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