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, OnceLock};
435
436 fn env_lock() -> &'static Mutex<()> {
437 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
438 LOCK.get_or_init(|| Mutex::new(()))
439 }
440
441 fn unique_temp_dir(name: &str) -> PathBuf {
442 let nanos = std::time::SystemTime::now()
443 .duration_since(std::time::UNIX_EPOCH)
444 .expect("system time before unix epoch")
445 .as_nanos();
446
447 std::env::temp_dir().join(format!("sigit-setup-tests-{name}-{nanos}"))
448 }
449
450 fn create_snapshot(
451 cache_root: &Path,
452 model_id: &str,
453 snapshot_name: &str,
454 gguf_file: &str,
455 complete: bool,
456 ) -> PathBuf {
457 let repo_dir = cache_root.join(format!("models--{}", model_id.replace('/', "--")));
458 let snapshot_dir = repo_dir.join("snapshots").join(snapshot_name);
459 std::fs::create_dir_all(&snapshot_dir).expect("create snapshot dir");
460
461 // Health is determined solely by the presence of a .gguf file.
462 // A complete snapshot has one; an incomplete snapshot has none
463 // (e.g. a partial download where only metadata files arrived).
464 if complete {
465 std::fs::write(snapshot_dir.join(gguf_file), b"gguf placeholder").expect("write gguf");
466 } else {
467 // Simulate a snapshot directory that exists but has no GGUF yet.
468 std::fs::write(snapshot_dir.join("config.json"), b"{}")
469 .expect("write config placeholder");
470 }
471
472 snapshot_dir
473 }
474
475 fn with_test_env<T>(hf_hub_cache: &Path, hf_home: &Path, f: impl FnOnce() -> T) -> T {
476 let _guard = env_lock().lock().expect("lock env");
477
478 let old_hf_hub_cache = std::env::var_os("HF_HUB_CACHE");
479 let old_hf_home = std::env::var_os("HF_HOME");
480 let old_home = std::env::var_os("HOME");
481
482 // SAFETY: tests serialize environment mutation with a process-wide mutex.
483 unsafe {
484 std::env::set_var("HF_HUB_CACHE", hf_hub_cache);
485 std::env::set_var("HF_HOME", hf_home);
486 std::env::set_var("HOME", hf_home);
487 }
488
489 let result = f();
490
491 // SAFETY: tests serialize environment mutation with a process-wide mutex.
492 unsafe {
493 match old_hf_hub_cache {
494 Some(value) => std::env::set_var("HF_HUB_CACHE", value),
495 None => std::env::remove_var("HF_HUB_CACHE"),
496 }
497 match old_hf_home {
498 Some(value) => std::env::set_var("HF_HOME", value),
499 None => std::env::remove_var("HF_HOME"),
500 }
501 match old_home {
502 Some(value) => std::env::set_var("HOME", value),
503 None => std::env::remove_var("HOME"),
504 }
505 }
506
507 result
508 }
509
510 #[test]
511 fn discover_local_models_marks_complete_and_incomplete_snapshots() {
512 let root = unique_temp_dir("discover-health");
513 let cache_root = root.join("hf-cache");
514 let hf_home = root.join("hf-home");
515 std::fs::create_dir_all(&cache_root).expect("create cache root");
516 std::fs::create_dir_all(&hf_home).expect("create hf home");
517
518 create_snapshot(
519 &cache_root,
520 "bartowski/Qwen_Qwen3-4B-GGUF",
521 "complete",
522 "Qwen_Qwen3-4B-Q4_K_M.gguf",
523 true,
524 );
525 create_snapshot(
526 &cache_root,
527 "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF",
528 "incomplete",
529 "Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf",
530 false,
531 );
532
533 let models = with_test_env(&cache_root, &hf_home, discover_local_models);
534
535 assert_eq!(models.len(), 2);
536
537 let complete = models
538 .iter()
539 .find(|model| model.model_id == "bartowski/Qwen_Qwen3-4B-GGUF")
540 .expect("complete model discovered");
541 assert_eq!(complete.cache_health, ModelCacheHealth::Complete);
542 assert!(!complete.from_app_group);
543
544 let incomplete = models
545 .iter()
546 .find(|model| model.model_id == "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF")
547 .expect("incomplete model discovered");
548 assert_eq!(incomplete.cache_health, ModelCacheHealth::Incomplete);
549 assert!(!incomplete.from_app_group);
550
551 std::fs::remove_dir_all(root).expect("remove temp dir");
552 }
553
554 #[test]
555 fn startup_model_selection_skips_saved_incomplete_model_and_picks_complete_one() {
556 let root = unique_temp_dir("startup-selection");
557 let cache_root = root.join("hf-cache");
558 let hf_home = root.join("hf-home");
559 std::fs::create_dir_all(&cache_root).expect("create cache root");
560 std::fs::create_dir_all(&hf_home).expect("create hf home");
561
562 create_snapshot(
563 &cache_root,
564 "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF",
565 "broken",
566 "Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf",
567 false,
568 );
569 create_snapshot(
570 &cache_root,
571 "bartowski/Qwen_Qwen3-4B-GGUF",
572 "ready",
573 "Qwen_Qwen3-4B-Q4_K_M.gguf",
574 true,
575 );
576
577 let selection = with_test_env(&cache_root, &hf_home, || {
578 let selected_path = selected_model_file_path().expect("selected model path");
579 if let Some(parent) = selected_path.parent() {
580 std::fs::create_dir_all(parent).expect("create selected model parent");
581 }
582
583 std::fs::write(
584 &selected_path,
585 "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF\nQwen2.5-Coder-3B-Instruct-Q4_K_M.gguf\n",
586 )
587 .expect("write selected model");
588
589 startup_model_selection().expect("startup selection")
590 });
591
592 assert_eq!(
593 selection.display_name,
594 "Qwen Qwen3-4B-GGUF — Qwen_Qwen3-4B-Q4_K_M"
595 );
596 let selected = selection.selected_model.expect("selected model");
597 assert_eq!(selected.model_id, "bartowski/Qwen_Qwen3-4B-GGUF");
598 assert_eq!(selected.gguf_file, "Qwen_Qwen3-4B-Q4_K_M.gguf");
599
600 std::fs::remove_dir_all(root).expect("remove temp dir");
601 }
602
603 #[test]
604 fn discover_empty_cache_returns_no_models() {
605 let root = unique_temp_dir("discover-empty");
606 let cache_root = root.join("hf-cache");
607 let hf_home = root.join("hf-home");
608 std::fs::create_dir_all(&cache_root).expect("create cache root");
609 std::fs::create_dir_all(&hf_home).expect("create hf home");
610
611 let models = with_test_env(&cache_root, &hf_home, discover_local_models);
612 assert!(models.is_empty());
613
614 std::fs::remove_dir_all(root).expect("remove temp dir");
615 }
616
617 #[test]
618 fn complete_models_sort_before_incomplete() {
619 let root = unique_temp_dir("sort-order");
620 let cache_root = root.join("hf-cache");
621 let hf_home = root.join("hf-home");
622 std::fs::create_dir_all(&cache_root).expect("create cache root");
623 std::fs::create_dir_all(&hf_home).expect("create hf home");
624
625 create_snapshot(
626 &cache_root,
627 "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF",
628 "snap1",
629 "Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf",
630 false,
631 );
632 create_snapshot(
633 &cache_root,
634 "bartowski/Qwen_Qwen3-4B-GGUF",
635 "snap2",
636 "Qwen_Qwen3-4B-Q4_K_M.gguf",
637 true,
638 );
639
640 let models = with_test_env(&cache_root, &hf_home, discover_local_models);
641 assert_eq!(models.len(), 2);
642 assert_eq!(models[0].cache_health, ModelCacheHealth::Complete);
643 assert_eq!(models[1].cache_health, ModelCacheHealth::Incomplete);
644
645 std::fs::remove_dir_all(root).expect("remove temp dir");
646 }
647
648 #[test]
649 fn load_selected_model_roundtrip() {
650 let root = unique_temp_dir("persistence-roundtrip");
651 let hf_home = root.join("hf-home");
652 std::fs::create_dir_all(&hf_home).expect("create hf home");
653
654 with_test_env(&hf_home, &hf_home, || {
655 let original = SelectedModel {
656 model_id: "bartowski/Qwen_Qwen3-4B-GGUF".to_string(),
657 gguf_file: "Qwen_Qwen3-4B-Q4_K_M.gguf".to_string(),
658 };
659
660 save_selected_model(&original).expect("save");
661 let loaded = load_selected_model().expect("load");
662
663 assert_eq!(loaded.model_id, original.model_id);
664 assert_eq!(loaded.gguf_file, original.gguf_file);
665 });
666
667 std::fs::remove_dir_all(root).expect("remove temp dir");
668 }
669
670 #[test]
671 fn load_selected_model_empty_file_returns_none() {
672 let root = unique_temp_dir("persistence-empty");
673 let hf_home = root.join("hf-home");
674 std::fs::create_dir_all(&hf_home).expect("create hf home");
675
676 with_test_env(&hf_home, &hf_home, || {
677 let path = selected_model_file_path().expect("path");
678 if let Some(parent) = path.parent() {
679 std::fs::create_dir_all(parent).expect("create parent");
680 }
681 std::fs::write(&path, "").expect("write empty");
682
683 assert!(load_selected_model().is_none());
684 });
685
686 std::fs::remove_dir_all(root).expect("remove temp dir");
687 }
688
689 #[test]
690 fn display_name_deduplicates_repo_and_file_name() {
691 // When the file name contains the repo name, only the repo name is shown.
692 let name = display_name_for_model(
693 "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF",
694 "Qwen2.5-Coder-3B-Instruct-GGUF-Q4_K_M.gguf",
695 );
696 assert_eq!(name, "Qwen2.5-Coder-3B-Instruct-GGUF");
697
698 // When the file name does NOT contain the repo name, both are shown.
699 let name = display_name_for_model(
700 "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF",
701 "Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf",
702 );
703 assert_eq!(
704 name,
705 "Qwen2.5-Coder-3B-Instruct-GGUF — Qwen2.5-Coder-3B-Instruct-Q4_K_M"
706 );
707 }
708
709 #[test]
710 fn display_name_includes_file_when_different() {
711 let name =
712 display_name_for_model("bartowski/Qwen_Qwen3-4B-GGUF", "Qwen_Qwen3-4B-Q4_K_M.gguf");
713 assert_eq!(name, "Qwen Qwen3-4B-GGUF — Qwen_Qwen3-4B-Q4_K_M");
714 }
715 }