claude/sigit-acp-local-chat-cx6380
claude/code-feature-parity-q003hm
claude/elegant-carson-l1menh
claude/sigit-acp-local-chat-cx6380
claude/sigit-cloud-agent-expansion-reox0a
claude/tool-permission-system
claude/zen-feynman-0u78dk
development
feature/agent-tools-multiedit-glob-todos-remember
feature/background-commands
feature/commit-coauthor-attribution
feature/headless-mode
feature/init-command
feature/load-local-model-explicitly
feature/session-persistence-compaction
feature/sigit-code-cloud
feature/subagent-tool
feature/tool-permission-system
feature/tui-repo-tabs
feature/tui-tabs
main
release/v1.3.1
| 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 | } |