1 //! Model cache setup, local model discovery, and selected-model persistence.
2 //!
3 //! On macOS the CLI shares a HuggingFace cache with Onde desktop apps via an
4 //! App Group container (`~/Library/Group Containers/group.com.ondeinference.apps/models/`).
5 //! On other platforms it falls back to `~/.cache/huggingface/`.
6 //!
7 //! Must run before anything touches `ChatEngine` or `hf-hub` because they
8 //! read the env vars once at init.
9
10 use std::path::{Path, PathBuf};
11
12 /// shared across siGit, Rumi, GT8, etc.
13 #[cfg(target_os = "macos")]
14 const APP_GROUP_IDENTIFIER: &str = "group.com.ondeinference.apps";
15
16 /// point `HF_HOME` / `HF_HUB_CACHE` at the shared container. no-ops if
17 /// the user already set them.
18 pub fn setup_shared_model_cache() {
19 if let Some(shared_dir) = resolve_shared_container() {
20 let models_home = shared_dir.join("models");
21 let model_hub = models_home.join("hub");
22
23 if let Err(error) = std::fs::create_dir_all(&model_hub) {
24 log::warn!(
25 "Failed to create shared model cache at {}: {error} — falling back to default",
26 model_hub.display()
27 );
28 return;
29 }
30
31 if std::env::var("HF_HOME").is_err() {
32 // SAFETY: called once at startup before any threads are spawned.
33 unsafe { std::env::set_var("HF_HOME", &models_home) };
34 log::info!("HF_HOME → shared App Group: {}", models_home.display());
35 } else {
36 log::debug!(
37 "HF_HOME already set by user: {}",
38 std::env::var("HF_HOME").unwrap_or_default()
39 );
40 }
41
42 // mistral.rs reads HF_HUB_CACHE directly instead of deriving from HF_HOME
43 if std::env::var("HF_HUB_CACHE").is_err() {
44 // SAFETY: called once at startup before any threads are spawned.
45 unsafe { std::env::set_var("HF_HUB_CACHE", &model_hub) };
46 log::info!("HF_HUB_CACHE → shared App Group: {}", model_hub.display());
47 }
48 } else {
49 log::debug!("Shared App Group container not available — using default HF cache");
50 }
51 }
52
53 const SELECTED_MODEL_FILE_NAME: &str = "selected-model.txt";
54
55 /// persisted identifier for a selected model (model_id + gguf filename).
56 #[derive(Debug, Clone, PartialEq, Eq)]
57 pub struct SelectedModel {
58 /// e.g. `bartowski/Qwen_Qwen3-4B-GGUF`
59 pub model_id: String,
60
61 pub gguf_file: String,
62 }
63
64 impl SelectedModel {
65 fn from_discovered(model: &DiscoveredModel) -> Self {
66 Self {
67 model_id: model.model_id.clone(),
68 gguf_file: model.gguf_file.clone(),
69 }
70 }
71
72 fn matches(&self, model: &DiscoveredModel) -> bool {
73 self.model_id == model.model_id && self.gguf_file == model.gguf_file
74 }
75 }
76
77 /// what we know about the model before the full UI is up.
78 #[derive(Debug, Clone, PartialEq, Eq)]
79 pub struct StartupModelSelection {
80 /// shown in the loading screen
81 pub display_name: String,
82
83 pub selected_model: Option<SelectedModel>,
84 }
85
86 /// a GGUF model found on disk.
87 #[derive(Debug, Clone, PartialEq, Eq)]
88 pub struct DiscoveredModel {
89 /// e.g. `bartowski/Qwen_Qwen3-4B-GGUF`
90 pub model_id: String,
91 /// filename inside the snapshot dir
92 pub gguf_file: String,
93
94 pub display_name: String,
95
96 pub snapshot_path: PathBuf,
97
98 pub gguf_path: PathBuf,
99
100 pub from_app_group: bool,
101
102 pub cache_health: ModelCacheHealth,
103 }
104
105 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
106 pub enum ModelCacheHealth {
107 Complete,
108 Incomplete,
109 NotDownloaded,
110 }
111
112 /// find all GGUF models on disk. checks Onde app group first, then HF cache.
113 pub fn discover_local_models() -> Vec<DiscoveredModel> {
114 let mut models = Vec::new();
115 let mut seen_roots = Vec::new();
116
117 if let Some(app_group_models) = app_group_models_root() {
118 seen_roots.push(app_group_models.clone());
119 collect_models_from_cache_root(&app_group_models, true, &mut models);
120 }
121
122 if let Some(hf_cache) = hf_cache_root()
123 && !seen_roots.iter().any(|root| root == &hf_cache)
124 {
125 seen_roots.push(hf_cache.clone());
126 collect_models_from_cache_root(&hf_cache, false, &mut models);
127 }
128
129 if let Some(default_hf_cache) = default_hf_cache_root()
130 && !seen_roots.iter().any(|root| root == &default_hf_cache)
131 {
132 collect_models_from_cache_root(&default_hf_cache, false, &mut models);
133 }
134
135 models.sort_by(|left, right| {
136 right
137 .from_app_group
138 .cmp(&left.from_app_group)
139 .then_with(|| {
140 left.display_name
141 .to_lowercase()
142 .cmp(&right.display_name.to_lowercase())
143 })
144 .then_with(|| left.model_id.cmp(&right.model_id))
145 .then_with(|| left.gguf_file.cmp(&right.gguf_file))
146 });
147
148 models.dedup_by(|left, right| left.gguf_path == right.gguf_path);
149 models
150 }
151
152 fn collect_models_from_cache_root(
153 cache_root: &Path,
154 from_app_group: bool,
155 models: &mut Vec<DiscoveredModel>,
156 ) {
157 let entries = match std::fs::read_dir(cache_root) {
158 Ok(entries) => entries,
159 Err(error) => {
160 log::debug!(
161 "Skipping unreadable model cache root {}: {error}",
162 cache_root.display()
163 );
164 return;
165 }
166 };
167
168 for entry in entries.flatten() {
169 let repo_dir = entry.path();
170 if !repo_dir.is_dir() {
171 continue;
172 }
173
174 let dir_name = match entry.file_name().to_str() {
175 Some(name) => name.to_string(),
176 None => continue,
177 };
178
179 if !dir_name.starts_with("models--") {
180 continue;
181 }
182
183 let model_id = dir_name["models--".len()..].replace("--", "/");
184 let snapshots_dir = repo_dir.join("snapshots");
185 let snapshots = match std::fs::read_dir(&snapshots_dir) {
186 Ok(entries) => entries,
187 Err(_) => continue,
188 };
189
190 for snapshot in snapshots.flatten() {
191 let snapshot_path = snapshot.path();
192 if !snapshot_path.is_dir() {
193 continue;
194 }
195
196 let files = match std::fs::read_dir(&snapshot_path) {
197 Ok(entries) => entries,
198 Err(_) => continue,
199 };
200
201 let mut gguf_files = Vec::new();
202
203 for file in files.flatten() {
204 let file_path = file.path();
205 if !file_path.is_file() {
206 continue;
207 }
208
209 let file_name = match file.file_name().to_str() {
210 Some(name) => name.to_string(),
211 None => continue,
212 };
213
214 let extension = file_path
215 .extension()
216 .and_then(|ext| ext.to_str())
217 .unwrap_or_default();
218
219 if extension.eq_ignore_ascii_case("gguf") {
220 gguf_files.push((file_name, file_path));
221 }
222 }
223
224 if gguf_files.is_empty() {
225 // snapshot dir exists but no .gguf yet (download in progress or
226 // only metadata). mark incomplete so the picker can show it disabled.
227 models.push(DiscoveredModel {
228 display_name: display_name_for_model(&model_id, ""),
229 model_id: model_id.clone(),
230 gguf_file: String::new(),
231 snapshot_path: snapshot_path.clone(),
232 // unused for loading; incomplete models are filtered out before config
233 gguf_path: snapshot_path.clone(),
234 from_app_group,
235 cache_health: ModelCacheHealth::Incomplete,
236 });
237 } else {
238 for (gguf_file, file_path) in gguf_files {
239 models.push(DiscoveredModel {
240 display_name: display_name_for_model(&model_id, &gguf_file),
241 model_id: model_id.clone(),
242 gguf_file,
243 snapshot_path: snapshot_path.clone(),
244 gguf_path: file_path,
245 from_app_group,
246 cache_health: ModelCacheHealth::Complete,
247 });
248 }
249 }
250 }
251 }
252 }
253
254 fn display_name_for_model(model_id: &str, gguf_file: &str) -> String {
255 let repo_name = model_id
256 .rsplit('/')
257 .next()
258 .unwrap_or(model_id)
259 .replace('_', " ");
260
261 let file_name = gguf_file.strip_suffix(".gguf").unwrap_or(gguf_file);
262
263 if file_name.contains(&repo_name.replace(' ', "_")) || file_name.contains(&repo_name) {
264 repo_name
265 } else {
266 format!("{repo_name} — {file_name}")
267 }
268 }
269
270 fn app_group_models_root() -> Option<PathBuf> {
271 resolve_shared_container().map(|dir| dir.join("models").join("hub"))
272 }
273
274 fn hf_cache_root() -> Option<PathBuf> {
275 if let Ok(cache) = std::env::var("HF_HUB_CACHE") {
276 let path = PathBuf::from(cache);
277 if path.is_dir() {
278 return Some(path);
279 }
280 }
281
282 if let Ok(home) = std::env::var("HF_HOME") {
283 let path = PathBuf::from(home).join("hub");
284 if path.is_dir() {
285 return Some(path);
286 }
287 }
288
289 None
290 }
291
292 fn default_hf_cache_root() -> Option<PathBuf> {
293 let home = std::env::var("HOME").ok()?;
294 let path = PathBuf::from(home)
295 .join(".cache")
296 .join("huggingface")
297 .join("hub");
298
299 path.is_dir().then_some(path)
300 }
301
302 pub fn load_selected_model() -> Option<SelectedModel> {
303 let path = selected_model_file_path()?;
304 let contents = std::fs::read_to_string(path).ok()?;
305 let trimmed = contents.trim();
306 if trimmed.is_empty() {
307 return None;
308 }
309
310 let mut parts = trimmed.splitn(2, '\n');
311 let model_id = parts.next()?.trim();
312 let gguf_file = parts.next()?.trim();
313
314 if model_id.is_empty() || gguf_file.is_empty() {
315 return None;
316 }
317
318 Some(SelectedModel {
319 model_id: model_id.to_string(),
320 gguf_file: gguf_file.to_string(),
321 })
322 }
323
324 #[allow(dead_code)]
325 pub fn load_selected_model_name() -> Option<String> {
326 let selected = load_selected_model()?;
327 discover_local_models()
328 .into_iter()
329 .find(|model| selected.matches(model))
330 .map(|model| model.display_name)
331 }
332
333 /// pick a model for startup: saved selection > first local model > none.
334 /// if we fall back to a local model, persist it so ACP and TUI agree next time.
335 pub fn startup_model_selection() -> Option<StartupModelSelection> {
336 let discovered = discover_local_models();
337
338 if let Some(saved_model) = load_selected_model()
339 && let Some(model) = discovered.iter().find(|model| {
340 saved_model.matches(model) && model.cache_health == ModelCacheHealth::Complete
341 })
342 {
343 return Some(StartupModelSelection {
344 display_name: model.display_name.clone(),
345 selected_model: Some(saved_model),
346 });
347 }
348
349 discovered
350 .into_iter()
351 .find(|model| model.cache_health == ModelCacheHealth::Complete)
352 .map(|model| {
353 let selected_model = SelectedModel::from_discovered(&model);
354 let _ = save_selected_model(&selected_model);
355 StartupModelSelection {
356 display_name: model.display_name.clone(),
357 selected_model: Some(selected_model),
358 }
359 })
360 }
361
362 pub fn save_selected_model(selected_model: &SelectedModel) -> Result<(), String> {
363 let path = selected_model_file_path()
364 .ok_or_else(|| "Could not determine where to store the selected model.".to_string())?;
365
366 if let Some(parent) = path.parent()
367 && !parent.exists()
368 {
369 std::fs::create_dir_all(parent)
370 .map_err(|error| format!("Could not create preferences directory: {error}"))?;
371 }
372
373 let contents = format!(
374 "{}\n{}\n",
375 selected_model.model_id, selected_model.gguf_file
376 );
377
378 std::fs::write(&path, contents)
379 .map_err(|error| format!("Could not save selected model: {error}"))
380 }
381
382 fn selected_model_file_path() -> Option<PathBuf> {
383 if let Some(shared_dir) = resolve_shared_container() {
384 return Some(shared_dir.join(SELECTED_MODEL_FILE_NAME));
385 }
386
387 if let Ok(home) = std::env::var("HF_HOME") {
388 let path = PathBuf::from(home);
389 if path.is_dir() || path.parent().is_some() {
390 return Some(path.join(SELECTED_MODEL_FILE_NAME));
391 }
392 }
393
394 let home = std::env::var("HOME").ok()?;
395 Some(
396 PathBuf::from(home)
397 .join(".cache")
398 .join("sigit")
399 .join(SELECTED_MODEL_FILE_NAME),
400 )
401 }
402
403 /// macOS only creates this dir when a signed app in the group first runs,
404 /// so it won't exist until the user has launched siGit desktop or another Onde app.
405 #[cfg(target_os = "macos")]
406 fn resolve_shared_container() -> Option<PathBuf> {
407 let home = std::env::var("HOME").ok()?;
408 let container = PathBuf::from(home)
409 .join("Library")
410 .join("Group Containers")
411 .join(APP_GROUP_IDENTIFIER);
412
413 if container.is_dir() {
414 log::debug!("App Group container found: {}", container.display());
415 Some(container)
416 } else {
417 log::debug!(
418 "App Group container does not exist at {} — \
419 has siGit desktop been launched at least once?",
420 container.display()
421 );
422 None
423 }
424 }
425
426 #[cfg(not(target_os = "macos"))]
427 fn resolve_shared_container() -> Option<PathBuf> {
428 None
429 }
430
431 #[cfg(test)]
432 mod tests {
433 use super::*;
434 use std::sync::Mutex;
435
436 /// The crate-wide lock, not one of this module's own: the model picker
437 /// reads this cache from other modules' tests, and they must not see it
438 /// pointed at a scratch directory halfway through.
439 fn env_lock() -> &'static Mutex<()> {
440 &crate::ENV_TEST_LOCK
441 }
442
443 fn unique_temp_dir(name: &str) -> PathBuf {
444 let nanos = std::time::SystemTime::now()
445 .duration_since(std::time::UNIX_EPOCH)
446 .expect("system time before unix epoch")
447 .as_nanos();
448
449 std::env::temp_dir().join(format!("sigit-setup-tests-{name}-{nanos}"))
450 }
451
452 fn create_snapshot(
453 cache_root: &Path,
454 model_id: &str,
455 snapshot_name: &str,
456 gguf_file: &str,
457 complete: bool,
458 ) -> PathBuf {
459 let repo_dir = cache_root.join(format!("models--{}", model_id.replace('/', "--")));
460 let snapshot_dir = repo_dir.join("snapshots").join(snapshot_name);
461 std::fs::create_dir_all(&snapshot_dir).expect("create snapshot dir");
462
463 // Health is determined solely by the presence of a .gguf file.
464 // A complete snapshot has one; an incomplete snapshot has none
465 // (e.g. a partial download where only metadata files arrived).
466 if complete {
467 std::fs::write(snapshot_dir.join(gguf_file), b"gguf placeholder").expect("write gguf");
468 } else {
469 // Simulate a snapshot directory that exists but has no GGUF yet.
470 std::fs::write(snapshot_dir.join("config.json"), b"{}")
471 .expect("write config placeholder");
472 }
473
474 snapshot_dir
475 }
476
477 fn with_test_env<T>(hf_hub_cache: &Path, hf_home: &Path, f: impl FnOnce() -> T) -> T {
478 let _guard = env_lock().lock().expect("lock env");
479
480 let old_hf_hub_cache = std::env::var_os("HF_HUB_CACHE");
481 let old_hf_home = std::env::var_os("HF_HOME");
482 let old_home = std::env::var_os("HOME");
483
484 // SAFETY: tests serialize environment mutation with a process-wide mutex.
485 unsafe {
486 std::env::set_var("HF_HUB_CACHE", hf_hub_cache);
487 std::env::set_var("HF_HOME", hf_home);
488 std::env::set_var("HOME", hf_home);
489 }
490
491 let result = f();
492
493 // SAFETY: tests serialize environment mutation with a process-wide mutex.
494 unsafe {
495 match old_hf_hub_cache {
496 Some(value) => std::env::set_var("HF_HUB_CACHE", value),
497 None => std::env::remove_var("HF_HUB_CACHE"),
498 }
499 match old_hf_home {
500 Some(value) => std::env::set_var("HF_HOME", value),
501 None => std::env::remove_var("HF_HOME"),
502 }
503 match old_home {
504 Some(value) => std::env::set_var("HOME", value),
505 None => std::env::remove_var("HOME"),
506 }
507 }
508
509 result
510 }
511
512 #[test]
513 fn discover_local_models_marks_complete_and_incomplete_snapshots() {
514 let root = unique_temp_dir("discover-health");
515 let cache_root = root.join("hf-cache");
516 let hf_home = root.join("hf-home");
517 std::fs::create_dir_all(&cache_root).expect("create cache root");
518 std::fs::create_dir_all(&hf_home).expect("create hf home");
519
520 create_snapshot(
521 &cache_root,
522 "bartowski/Qwen_Qwen3-4B-GGUF",
523 "complete",
524 "Qwen_Qwen3-4B-Q4_K_M.gguf",
525 true,
526 );
527 create_snapshot(
528 &cache_root,
529 "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF",
530 "incomplete",
531 "Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf",
532 false,
533 );
534
535 let models = with_test_env(&cache_root, &hf_home, discover_local_models);
536
537 assert_eq!(models.len(), 2);
538
539 let complete = models
540 .iter()
541 .find(|model| model.model_id == "bartowski/Qwen_Qwen3-4B-GGUF")
542 .expect("complete model discovered");
543 assert_eq!(complete.cache_health, ModelCacheHealth::Complete);
544 assert!(!complete.from_app_group);
545
546 let incomplete = models
547 .iter()
548 .find(|model| model.model_id == "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF")
549 .expect("incomplete model discovered");
550 assert_eq!(incomplete.cache_health, ModelCacheHealth::Incomplete);
551 assert!(!incomplete.from_app_group);
552
553 std::fs::remove_dir_all(root).expect("remove temp dir");
554 }
555
556 #[test]
557 fn startup_model_selection_skips_saved_incomplete_model_and_picks_complete_one() {
558 let root = unique_temp_dir("startup-selection");
559 let cache_root = root.join("hf-cache");
560 let hf_home = root.join("hf-home");
561 std::fs::create_dir_all(&cache_root).expect("create cache root");
562 std::fs::create_dir_all(&hf_home).expect("create hf home");
563
564 create_snapshot(
565 &cache_root,
566 "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF",
567 "broken",
568 "Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf",
569 false,
570 );
571 create_snapshot(
572 &cache_root,
573 "bartowski/Qwen_Qwen3-4B-GGUF",
574 "ready",
575 "Qwen_Qwen3-4B-Q4_K_M.gguf",
576 true,
577 );
578
579 let selection = with_test_env(&cache_root, &hf_home, || {
580 let selected_path = selected_model_file_path().expect("selected model path");
581 if let Some(parent) = selected_path.parent() {
582 std::fs::create_dir_all(parent).expect("create selected model parent");
583 }
584
585 std::fs::write(
586 &selected_path,
587 "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF\nQwen2.5-Coder-3B-Instruct-Q4_K_M.gguf\n",
588 )
589 .expect("write selected model");
590
591 startup_model_selection().expect("startup selection")
592 });
593
594 assert_eq!(
595 selection.display_name,
596 "Qwen Qwen3-4B-GGUF — Qwen_Qwen3-4B-Q4_K_M"
597 );
598 let selected = selection.selected_model.expect("selected model");
599 assert_eq!(selected.model_id, "bartowski/Qwen_Qwen3-4B-GGUF");
600 assert_eq!(selected.gguf_file, "Qwen_Qwen3-4B-Q4_K_M.gguf");
601
602 std::fs::remove_dir_all(root).expect("remove temp dir");
603 }
604
605 #[test]
606 fn discover_empty_cache_returns_no_models() {
607 let root = unique_temp_dir("discover-empty");
608 let cache_root = root.join("hf-cache");
609 let hf_home = root.join("hf-home");
610 std::fs::create_dir_all(&cache_root).expect("create cache root");
611 std::fs::create_dir_all(&hf_home).expect("create hf home");
612
613 let models = with_test_env(&cache_root, &hf_home, discover_local_models);
614 assert!(models.is_empty());
615
616 std::fs::remove_dir_all(root).expect("remove temp dir");
617 }
618
619 #[test]
620 fn complete_models_sort_before_incomplete() {
621 let root = unique_temp_dir("sort-order");
622 let cache_root = root.join("hf-cache");
623 let hf_home = root.join("hf-home");
624 std::fs::create_dir_all(&cache_root).expect("create cache root");
625 std::fs::create_dir_all(&hf_home).expect("create hf home");
626
627 create_snapshot(
628 &cache_root,
629 "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF",
630 "snap1",
631 "Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf",
632 false,
633 );
634 create_snapshot(
635 &cache_root,
636 "bartowski/Qwen_Qwen3-4B-GGUF",
637 "snap2",
638 "Qwen_Qwen3-4B-Q4_K_M.gguf",
639 true,
640 );
641
642 let models = with_test_env(&cache_root, &hf_home, discover_local_models);
643 assert_eq!(models.len(), 2);
644 assert_eq!(models[0].cache_health, ModelCacheHealth::Complete);
645 assert_eq!(models[1].cache_health, ModelCacheHealth::Incomplete);
646
647 std::fs::remove_dir_all(root).expect("remove temp dir");
648 }
649
650 #[test]
651 fn load_selected_model_roundtrip() {
652 let root = unique_temp_dir("persistence-roundtrip");
653 let hf_home = root.join("hf-home");
654 std::fs::create_dir_all(&hf_home).expect("create hf home");
655
656 with_test_env(&hf_home, &hf_home, || {
657 let original = SelectedModel {
658 model_id: "bartowski/Qwen_Qwen3-4B-GGUF".to_string(),
659 gguf_file: "Qwen_Qwen3-4B-Q4_K_M.gguf".to_string(),
660 };
661
662 save_selected_model(&original).expect("save");
663 let loaded = load_selected_model().expect("load");
664
665 assert_eq!(loaded.model_id, original.model_id);
666 assert_eq!(loaded.gguf_file, original.gguf_file);
667 });
668
669 std::fs::remove_dir_all(root).expect("remove temp dir");
670 }
671
672 #[test]
673 fn load_selected_model_empty_file_returns_none() {
674 let root = unique_temp_dir("persistence-empty");
675 let hf_home = root.join("hf-home");
676 std::fs::create_dir_all(&hf_home).expect("create hf home");
677
678 with_test_env(&hf_home, &hf_home, || {
679 let path = selected_model_file_path().expect("path");
680 if let Some(parent) = path.parent() {
681 std::fs::create_dir_all(parent).expect("create parent");
682 }
683 std::fs::write(&path, "").expect("write empty");
684
685 assert!(load_selected_model().is_none());
686 });
687
688 std::fs::remove_dir_all(root).expect("remove temp dir");
689 }
690
691 #[test]
692 fn display_name_deduplicates_repo_and_file_name() {
693 // When the file name contains the repo name, only the repo name is shown.
694 let name = display_name_for_model(
695 "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF",
696 "Qwen2.5-Coder-3B-Instruct-GGUF-Q4_K_M.gguf",
697 );
698 assert_eq!(name, "Qwen2.5-Coder-3B-Instruct-GGUF");
699
700 // When the file name does NOT contain the repo name, both are shown.
701 let name = display_name_for_model(
702 "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF",
703 "Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf",
704 );
705 assert_eq!(
706 name,
707 "Qwen2.5-Coder-3B-Instruct-GGUF — Qwen2.5-Coder-3B-Instruct-Q4_K_M"
708 );
709 }
710
711 #[test]
712 fn display_name_includes_file_when_different() {
713 let name =
714 display_name_for_model("bartowski/Qwen_Qwen3-4B-GGUF", "Qwen_Qwen3-4B-Q4_K_M.gguf");
715 assert_eq!(name, "Qwen Qwen3-4B-GGUF — Qwen_Qwen3-4B-Q4_K_M");
716 }
717 }