109
NotDownloaded,
110
}
111
112
+/// Where the model store lives, resolved once and handed to the readers.
113
+///
114
+/// Resolving it from the environment is a global decision, so a caller that
115
+/// needs a store of its own — a test pointing at a scratch directory — passes
116
+/// the roots in rather than mutating `HF_HUB_CACHE`, `HF_HOME` and `HOME` out
117
+/// from under whatever else is running in the same process.
118
+#[derive(Debug, Clone, Default)]
119
+struct ModelRoots {
120
+ /// Onde App Group model hub, when the signed app has run at least once.
121
+ app_group: Option<PathBuf>,
122
+ /// `HF_HUB_CACHE`, or `HF_HOME/hub`, when set and present.
123
+ configured_cache: Option<PathBuf>,
124
+ /// `~/.cache/huggingface/hub`, when present.
125
+ default_cache: Option<PathBuf>,
126
+ /// File holding the persisted selection.
127
+ selected_model_file: Option<PathBuf>,
128
+}
129
+
130
+impl ModelRoots {
131
+ fn from_env() -> Self {
132
+ Self {
133
+ app_group: app_group_models_root(),
134
+ configured_cache: hf_cache_root(),
135
+ default_cache: default_hf_cache_root(),
136
+ selected_model_file: selected_model_file_path(),
137
+ }
138
+ }
139
+
140
+ /// One cache dir and one selection file, with no environment lookups at all.
141
+ #[cfg(test)]
142
+ fn in_dir(cache_root: &Path, selected_model_file: &Path) -> Self {
143
+ Self {
144
+ configured_cache: Some(cache_root.to_path_buf()),
145
+ selected_model_file: Some(selected_model_file.to_path_buf()),
146
+ ..Self::default()
147
+ }
148
+ }
149
+}
150
+
151
/// find all GGUF models on disk. checks Onde app group first, then HF cache.
152
pub fn discover_local_models() -> Vec<DiscoveredModel> {
153
+ discover_local_models_in(&ModelRoots::from_env())
154
+}
155
+
156
+fn discover_local_models_in(roots: &ModelRoots) -> Vec<DiscoveredModel> {
157
let mut models = Vec::new();
158
let mut seen_roots = Vec::new();
159
117
- if let Some(app_group_models) = app_group_models_root() {
160
+ if let Some(app_group_models) = roots.app_group.as_ref() {
161
seen_roots.push(app_group_models.clone());
119
- collect_models_from_cache_root(&app_group_models, true, &mut models);
162
+ collect_models_from_cache_root(app_group_models, true, &mut models);
163
}
164
122
- if let Some(hf_cache) = hf_cache_root()
123
- && !seen_roots.iter().any(|root| root == &hf_cache)
165
+ if let Some(hf_cache) = roots.configured_cache.as_ref()
166
+ && !seen_roots.iter().any(|root| root == hf_cache)
167
{
168
seen_roots.push(hf_cache.clone());
126
- collect_models_from_cache_root(&hf_cache, false, &mut models);
169
+ collect_models_from_cache_root(hf_cache, false, &mut models);
170
}
171
129
- if let Some(default_hf_cache) = default_hf_cache_root()
130
- && !seen_roots.iter().any(|root| root == &default_hf_cache)
172
+ if let Some(default_hf_cache) = roots.default_cache.as_ref()
173
+ && !seen_roots.iter().any(|root| root == default_hf_cache)
174
{
132
- collect_models_from_cache_root(&default_hf_cache, false, &mut models);
175
+ collect_models_from_cache_root(default_hf_cache, false, &mut models);
176
}
177
178
models.sort_by(|left, right| {
343
}
344
345
pub fn load_selected_model() -> Option<SelectedModel> {
303
- let path = selected_model_file_path()?;
346
+ load_selected_model_from(&selected_model_file_path()?)
347
+}
348
+
349
+fn load_selected_model_from(path: &Path) -> Option<SelectedModel> {
350
let contents = std::fs::read_to_string(path).ok()?;
351
let trimmed = contents.trim();
352
if trimmed.is_empty() {
379
/// pick a model for startup: saved selection > first local model > none.
380
/// if we fall back to a local model, persist it so ACP and TUI agree next time.
381
pub fn startup_model_selection() -> Option<StartupModelSelection> {
336
- let discovered = discover_local_models();
382
+ startup_model_selection_in(&ModelRoots::from_env())
383
+}
384
338
- if let Some(saved_model) = load_selected_model()
385
+fn startup_model_selection_in(roots: &ModelRoots) -> Option<StartupModelSelection> {
386
+ let discovered = discover_local_models_in(roots);
387
+
388
+ if let Some(saved_model) = roots
389
+ .selected_model_file
390
+ .as_deref()
391
+ .and_then(load_selected_model_from)
392
&& let Some(model) = discovered.iter().find(|model| {
393
saved_model.matches(model) && model.cache_health == ModelCacheHealth::Complete
394
})
404
.find(|model| model.cache_health == ModelCacheHealth::Complete)
405
.map(|model| {
406
let selected_model = SelectedModel::from_discovered(&model);
354
- let _ = save_selected_model(&selected_model);
407
+ if let Some(path) = roots.selected_model_file.as_deref() {
408
+ let _ = save_selected_model_to(path, &selected_model);
409
+ }
410
StartupModelSelection {
411
display_name: model.display_name.clone(),
412
selected_model: Some(selected_model),
418
let path = selected_model_file_path()
419
.ok_or_else(|| "Could not determine where to store the selected model.".to_string())?;
420
421
+ save_selected_model_to(&path, selected_model)
422
+}
423
+
424
+fn save_selected_model_to(path: &Path, selected_model: &SelectedModel) -> Result<(), String> {
425
if let Some(parent) = path.parent()
426
&& !parent.exists()
427
{
434
selected_model.model_id, selected_model.gguf_file
435
);
436
378
- std::fs::write(&path, contents)
437
+ std::fs::write(path, contents)
438
.map_err(|error| format!("Could not save selected model: {error}"))
439
}
440
490
#[cfg(test)]
491
mod tests {
492
use super::*;
434
- use std::sync::Mutex;
493
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
494
+ /// A cache dir and selection file owned by this test. The model store is
495
+ /// resolved from `HF_HUB_CACHE`/`HF_HOME`/`HOME`, which are process-global
496
+ /// and read by the picker on other threads, so tests hand their roots in
497
+ /// instead of aiming those variables at a scratch directory.
498
+ fn test_roots(cache_root: &Path, selection_dir: &Path) -> ModelRoots {
499
+ ModelRoots::in_dir(cache_root, &selection_dir.join(SELECTED_MODEL_FILE_NAME))
500
}
501
502
+ /// A scratch directory no other test in this run can land on. The clock
503
+ /// alone is not enough: its resolution is coarse on some platforms and
504
+ /// parallel test threads can read the same tick, so the pid and a
505
+ /// process-local counter are mixed in.
506
fn unique_temp_dir(name: &str) -> PathBuf {
507
+ static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
508
+
509
let nanos = std::time::SystemTime::now()
510
.duration_since(std::time::UNIX_EPOCH)
511
.expect("system time before unix epoch")
512
.as_nanos();
513
+ let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
514
449
- std::env::temp_dir().join(format!("sigit-setup-tests-{name}-{nanos}"))
515
+ std::env::temp_dir().join(format!(
516
+ "sigit-setup-tests-{name}-{}-{seq}-{nanos}",
517
+ std::process::id()
518
+ ))
519
}
520
521
fn create_snapshot(
543
snapshot_dir
544
}
545
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
-
546
#[test]
547
fn discover_local_models_marks_complete_and_incomplete_snapshots() {
548
let root = unique_temp_dir("discover-health");
549
let cache_root = root.join("hf-cache");
516
- let hf_home = root.join("hf-home");
550
std::fs::create_dir_all(&cache_root).expect("create cache root");
518
- std::fs::create_dir_all(&hf_home).expect("create hf home");
551
552
create_snapshot(
553
&cache_root,
564
false,
565
);
566
535
- let models = with_test_env(&cache_root, &hf_home, discover_local_models);
567
+ let models = discover_local_models_in(&test_roots(&cache_root, &root));
568
569
assert_eq!(models.len(), 2);
570
589
fn startup_model_selection_skips_saved_incomplete_model_and_picks_complete_one() {
590
let root = unique_temp_dir("startup-selection");
591
let cache_root = root.join("hf-cache");
560
- let hf_home = root.join("hf-home");
592
std::fs::create_dir_all(&cache_root).expect("create cache root");
562
- std::fs::create_dir_all(&hf_home).expect("create hf home");
593
594
create_snapshot(
595
&cache_root,
606
true,
607
);
608
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
- }
609
+ let roots = test_roots(&cache_root, &root);
610
+ let selected_path = roots
611
+ .selected_model_file
612
+ .clone()
613
+ .expect("selected model path");
614
+ std::fs::write(
615
+ &selected_path,
616
+ "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF\nQwen2.5-Coder-3B-Instruct-Q4_K_M.gguf\n",
617
+ )
618
+ .expect("write selected model");
619
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
- });
620
+ let selection = startup_model_selection_in(&roots).expect("startup selection");
621
622
assert_eq!(
623
selection.display_name,
634
fn discover_empty_cache_returns_no_models() {
635
let root = unique_temp_dir("discover-empty");
636
let cache_root = root.join("hf-cache");
609
- let hf_home = root.join("hf-home");
637
std::fs::create_dir_all(&cache_root).expect("create cache root");
611
- std::fs::create_dir_all(&hf_home).expect("create hf home");
638
613
- let models = with_test_env(&cache_root, &hf_home, discover_local_models);
639
+ let models = discover_local_models_in(&test_roots(&cache_root, &root));
640
assert!(models.is_empty());
641
642
std::fs::remove_dir_all(root).expect("remove temp dir");
646
fn complete_models_sort_before_incomplete() {
647
let root = unique_temp_dir("sort-order");
648
let cache_root = root.join("hf-cache");
623
- let hf_home = root.join("hf-home");
649
std::fs::create_dir_all(&cache_root).expect("create cache root");
625
- std::fs::create_dir_all(&hf_home).expect("create hf home");
650
651
create_snapshot(
652
&cache_root,
663
true,
664
);
665
642
- let models = with_test_env(&cache_root, &hf_home, discover_local_models);
666
+ let models = discover_local_models_in(&test_roots(&cache_root, &root));
667
assert_eq!(models.len(), 2);
668
assert_eq!(models[0].cache_health, ModelCacheHealth::Complete);
669
assert_eq!(models[1].cache_health, ModelCacheHealth::Incomplete);
674
#[test]
675
fn load_selected_model_roundtrip() {
676
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");
677
+ let path = root.join(SELECTED_MODEL_FILE_NAME);
678
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
- };
679
+ let original = SelectedModel {
680
+ model_id: "bartowski/Qwen_Qwen3-4B-GGUF".to_string(),
681
+ gguf_file: "Qwen_Qwen3-4B-Q4_K_M.gguf".to_string(),
682
+ };
683
662
- save_selected_model(&original).expect("save");
663
- let loaded = load_selected_model().expect("load");
684
+ // The parent dir does not exist yet: saving has to create it.
685
+ save_selected_model_to(&path, &original).expect("save");
686
+ let loaded = load_selected_model_from(&path).expect("load");
687
665
- assert_eq!(loaded.model_id, original.model_id);
666
- assert_eq!(loaded.gguf_file, original.gguf_file);
667
- });
688
+ assert_eq!(loaded.model_id, original.model_id);
689
+ assert_eq!(loaded.gguf_file, original.gguf_file);
690
691
std::fs::remove_dir_all(root).expect("remove temp dir");
692
}
694
#[test]
695
fn load_selected_model_empty_file_returns_none() {
696
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");
697
+ let path = root.join(SELECTED_MODEL_FILE_NAME);
698
+ std::fs::create_dir_all(&root).expect("create root");
699
+ std::fs::write(&path, "").expect("write empty");
700
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
- });
701
+ assert!(load_selected_model_from(&path).is_none());
702
703
std::fs::remove_dir_all(root).expect("remove temp dir");
704
}