main
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 | /// 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 | } |