gglib_core/domain/
model_naming.rs1use super::gguf::GgufMetadata;
8use std::path::Path;
9
10pub const UNKNOWN_MODEL_NAME: &str = "Unknown Model";
12
13#[derive(Debug, Clone, Copy)]
18pub enum NameSource<'a> {
19 LocalFile,
20 HuggingFace { repo_id: &'a str },
21}
22
23#[must_use]
32pub fn repo_short_name(repo_id: &str) -> &str {
33 repo_id.rsplit('/').next().unwrap_or(repo_id)
34}
35
36#[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#[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#[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}