Skip to main content

gglib_core/domain/
model_naming.rs

1//! Shared model-naming policy.
2//!
3//! Both the local-file import path and the `HuggingFace` download path must
4//! resolve the same `models.name` for the same underlying GGUF file. This
5//! module is the single place that decision is made.
6
7use super::gguf::GgufMetadata;
8use std::path::Path;
9
10/// Stored when no naming signal is available at all.
11pub const UNKNOWN_MODEL_NAME: &str = "Unknown Model";
12
13/// Which naming signals are available for a model being added.
14///
15/// Local imports have no repository id, so they skip the repo rung of the
16/// ladder in [`resolve_model_name`] entirely rather than passing a sentinel.
17#[derive(Debug, Clone, Copy)]
18pub enum NameSource<'a> {
19    LocalFile,
20    HuggingFace { repo_id: &'a str },
21}
22
23/// Strip a `HuggingFace` repo id down to its final path segment.
24///
25/// `"unsloth/Qwen3-8B-GGUF"` -> `"Qwen3-8B-GGUF"`. A bare id with no `/` is
26/// returned unchanged. A trailing `/` yields an empty string, matching
27/// `str::split('/').next_back()` semantics. Does **not** strip a `-GGUF`
28/// suffix — callers that need the repository name as-is (e.g. search
29/// results) should use this directly; [`resolve_model_name`] layers
30/// [`strip_gguf_suffix`] on top.
31#[must_use]
32pub fn repo_short_name(repo_id: &str) -> &str {
33    repo_id.rsplit('/').next().unwrap_or(repo_id)
34}
35
36/// Strip a trailing `-GGUF` marker, case-insensitively.
37#[must_use]
38pub fn strip_gguf_suffix(name: &str) -> &str {
39    if name.len() > 5 && name[name.len() - 5..].eq_ignore_ascii_case("-gguf") {
40        &name[..name.len() - 5]
41    } else {
42        name
43    }
44}
45
46/// The `general.name` declared in the GGUF header, or `None` if absent or
47/// blank.
48#[must_use]
49pub fn declared_name(gguf: Option<&GgufMetadata>) -> Option<&str> {
50    gguf.and_then(|g| g.metadata.get("general.name"))
51        .map(String::as_str)
52        .map(str::trim)
53        .filter(|s| !s.is_empty())
54}
55
56fn file_stem_name(file_path: &Path) -> Option<&str> {
57    file_path
58        .file_stem()
59        .and_then(|s| s.to_str())
60        .map(str::trim)
61        .filter(|s| !s.is_empty())
62}
63
64/// Resolve the `models.name` for a model being added.
65///
66/// The first non-blank rung wins:
67/// 1. `general.name` from the GGUF header
68/// 2. the `HuggingFace` repo's short name, with the owner prefix and a
69///    trailing `-GGUF` stripped (only when `source` carries a repo id)
70/// 3. the file stem
71/// 4. [`UNKNOWN_MODEL_NAME`]
72#[must_use]
73pub fn resolve_model_name(
74    gguf: Option<&GgufMetadata>,
75    file_path: &Path,
76    source: NameSource<'_>,
77) -> String {
78    if let Some(name) = declared_name(gguf) {
79        return name.to_string();
80    }
81
82    if let NameSource::HuggingFace { repo_id } = source {
83        let short = strip_gguf_suffix(repo_short_name(repo_id)).trim();
84        if !short.is_empty() {
85            return short.to_string();
86        }
87    }
88
89    if let Some(stem) = file_stem_name(file_path) {
90        return stem.to_string();
91    }
92
93    UNKNOWN_MODEL_NAME.to_string()
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use std::collections::HashMap;
100    use std::path::PathBuf;
101
102    fn gguf_with_name(name: &str) -> GgufMetadata {
103        let mut metadata = HashMap::new();
104        metadata.insert("general.name".to_string(), name.to_string());
105        GgufMetadata {
106            metadata,
107            ..Default::default()
108        }
109    }
110
111    #[test]
112    fn repo_short_name_strips_owner() {
113        assert_eq!(repo_short_name("unsloth/Qwen3-8B-GGUF"), "Qwen3-8B-GGUF");
114    }
115
116    #[test]
117    fn repo_short_name_bare_id_unchanged() {
118        assert_eq!(repo_short_name("Qwen3-8B-GGUF"), "Qwen3-8B-GGUF");
119    }
120
121    #[test]
122    fn repo_short_name_trailing_slash_is_empty() {
123        assert_eq!(repo_short_name("unsloth/"), "");
124    }
125
126    #[test]
127    fn strip_gguf_suffix_removes_uppercase() {
128        assert_eq!(strip_gguf_suffix("Qwen3-8B-GGUF"), "Qwen3-8B");
129    }
130
131    #[test]
132    fn strip_gguf_suffix_case_insensitive() {
133        assert_eq!(strip_gguf_suffix("Qwen3-8B-gguf"), "Qwen3-8B");
134        assert_eq!(strip_gguf_suffix("Qwen3-8B-Gguf"), "Qwen3-8B");
135    }
136
137    #[test]
138    fn strip_gguf_suffix_no_match_unchanged() {
139        assert_eq!(strip_gguf_suffix("Qwen3-8B"), "Qwen3-8B");
140    }
141
142    #[test]
143    fn strip_gguf_suffix_panic_safety() {
144        assert_eq!(strip_gguf_suffix(""), "");
145        assert_eq!(strip_gguf_suffix("a"), "a");
146        assert_eq!(strip_gguf_suffix("-GGUF"), "-GGUF");
147        assert_eq!(strip_gguf_suffix("模型-GGUF"), "模型");
148    }
149
150    #[test]
151    fn declared_name_reads_general_name() {
152        let gguf = gguf_with_name("Qwen3-8B");
153        assert_eq!(declared_name(Some(&gguf)), Some("Qwen3-8B"));
154    }
155
156    #[test]
157    fn declared_name_blank_is_none() {
158        let gguf = gguf_with_name("   ");
159        assert_eq!(declared_name(Some(&gguf)), None);
160    }
161
162    #[test]
163    fn declared_name_absent_metadata_is_none() {
164        assert_eq!(declared_name(None), None);
165        assert_eq!(declared_name(Some(&GgufMetadata::default())), None);
166    }
167
168    #[test]
169    fn resolve_prefers_declared_name_over_repo_and_stem() {
170        let gguf = gguf_with_name("Qwen3-8B");
171        let name = resolve_model_name(
172            Some(&gguf),
173            &PathBuf::from("/models/other-file.gguf"),
174            NameSource::HuggingFace {
175                repo_id: "unsloth/Qwen3.6-27B-MTP-GGUF",
176            },
177        );
178        assert_eq!(name, "Qwen3-8B");
179    }
180
181    #[test]
182    fn resolve_falls_back_to_repo_short_name_stripped() {
183        let name = resolve_model_name(
184            None,
185            &PathBuf::from("/models/some-file.gguf"),
186            NameSource::HuggingFace {
187                repo_id: "unsloth/Qwen3.6-27B-MTP-GGUF",
188            },
189        );
190        assert_eq!(name, "Qwen3.6-27B-MTP");
191    }
192
193    #[test]
194    fn resolve_local_file_skips_repo_rung() {
195        let name = resolve_model_name(
196            None,
197            &PathBuf::from("/models/qwen3-8b-q4_k_m.gguf"),
198            NameSource::LocalFile,
199        );
200        assert_eq!(name, "qwen3-8b-q4_k_m");
201    }
202
203    #[test]
204    fn resolve_blank_declared_name_falls_through() {
205        let gguf = gguf_with_name("  ");
206        let name = resolve_model_name(
207            Some(&gguf),
208            &PathBuf::from("/models/qwen3-8b.gguf"),
209            NameSource::LocalFile,
210        );
211        assert_eq!(name, "qwen3-8b");
212    }
213
214    #[test]
215    fn resolve_nothing_available_is_unknown() {
216        let name = resolve_model_name(None, &PathBuf::from("/"), NameSource::LocalFile);
217        assert_eq!(name, UNKNOWN_MODEL_NAME);
218    }
219
220    #[test]
221    fn resolve_hf_repo_id_ending_in_slash_falls_to_stem() {
222        let name = resolve_model_name(
223            None,
224            &PathBuf::from("/models/qwen3-8b.gguf"),
225            NameSource::HuggingFace {
226                repo_id: "unsloth/",
227            },
228        );
229        assert_eq!(name, "qwen3-8b");
230    }
231}