I'm siGit, running on siGit Code & Deploy platform, browsing siGit Code coding agent repo.
fix/zed-commands-after-session-response
| 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 | /// 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 | |
| 160 | if let Some(app_group_models) = roots.app_group.as_ref() { |
| 161 | seen_roots.push(app_group_models.clone()); |
| 162 | collect_models_from_cache_root(app_group_models, true, &mut models); |
| 163 | } |
| 164 | |
| 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()); |
| 169 | collect_models_from_cache_root(hf_cache, false, &mut models); |
| 170 | } |
| 171 | |
| 172 | if let Some(default_hf_cache) = roots.default_cache.as_ref() |
| 173 | && !seen_roots.iter().any(|root| root == default_hf_cache) |
| 174 | { |
| 175 | collect_models_from_cache_root(default_hf_cache, false, &mut models); |
| 176 | } |
| 177 | |
| 178 | models.sort_by(|left, right| { |
| 179 | right |
| 180 | .from_app_group |
| 181 | .cmp(&left.from_app_group) |
| 182 | .then_with(|| { |
| 183 | left.display_name |
| 184 | .to_lowercase() |
| 185 | .cmp(&right.display_name.to_lowercase()) |
| 186 | }) |
| 187 | .then_with(|| left.model_id.cmp(&right.model_id)) |
| 188 | .then_with(|| left.gguf_file.cmp(&right.gguf_file)) |
| 189 | }); |
| 190 | |
| 191 | models.dedup_by(|left, right| left.gguf_path == right.gguf_path); |
| 192 | models |
| 193 | } |
| 194 | |
| 195 | fn collect_models_from_cache_root( |
| 196 | cache_root: &Path, |
| 197 | from_app_group: bool, |
| 198 | models: &mut Vec<DiscoveredModel>, |
| 199 | ) { |
| 200 | let entries = match std::fs::read_dir(cache_root) { |
| 201 | Ok(entries) => entries, |
| 202 | Err(error) => { |
| 203 | log::debug!( |
| 204 | "Skipping unreadable model cache root {}: {error}", |
| 205 | cache_root.display() |
| 206 | ); |
| 207 | return; |
| 208 | } |
| 209 | }; |
| 210 | |
| 211 | for entry in entries.flatten() { |
| 212 | let repo_dir = entry.path(); |
| 213 | if !repo_dir.is_dir() { |
| 214 | continue; |
| 215 | } |
| 216 | |
| 217 | let dir_name = match entry.file_name().to_str() { |
| 218 | Some(name) => name.to_string(), |
| 219 | None => continue, |
| 220 | }; |
| 221 | |
| 222 | if !dir_name.starts_with("models--") { |
| 223 | continue; |
| 224 | } |
| 225 | |
| 226 | let model_id = dir_name["models--".len()..].replace("--", "/"); |
| 227 | let snapshots_dir = repo_dir.join("snapshots"); |
| 228 | let snapshots = match std::fs::read_dir(&snapshots_dir) { |
| 229 | Ok(entries) => entries, |
| 230 | Err(_) => continue, |
| 231 | }; |
| 232 | |
| 233 | for snapshot in snapshots.flatten() { |
| 234 | let snapshot_path = snapshot.path(); |
| 235 | if !snapshot_path.is_dir() { |
| 236 | continue; |
| 237 | } |
| 238 | |
| 239 | let files = match std::fs::read_dir(&snapshot_path) { |
| 240 | Ok(entries) => entries, |
| 241 | Err(_) => continue, |
| 242 | }; |
| 243 | |
| 244 | let mut gguf_files = Vec::new(); |
| 245 | |
| 246 | for file in files.flatten() { |
| 247 | let file_path = file.path(); |
| 248 | if !file_path.is_file() { |
| 249 | continue; |
| 250 | } |
| 251 | |
| 252 | let file_name = match file.file_name().to_str() { |
| 253 | Some(name) => name.to_string(), |
| 254 | None => continue, |
| 255 | }; |
| 256 | |
| 257 | let extension = file_path |
| 258 | .extension() |
| 259 | .and_then(|ext| ext.to_str()) |
| 260 | .unwrap_or_default(); |
| 261 | |
| 262 | if extension.eq_ignore_ascii_case("gguf") { |
| 263 | gguf_files.push((file_name, file_path)); |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | if gguf_files.is_empty() { |
| 268 | // snapshot dir exists but no .gguf yet (download in progress or |
| 269 | // only metadata). mark incomplete so the picker can show it disabled. |
| 270 | models.push(DiscoveredModel { |
| 271 | display_name: display_name_for_model(&model_id, ""), |
| 272 | model_id: model_id.clone(), |
| 273 | gguf_file: String::new(), |
| 274 | snapshot_path: snapshot_path.clone(), |
| 275 | // unused for loading; incomplete models are filtered out before config |
| 276 | gguf_path: snapshot_path.clone(), |
| 277 | from_app_group, |
| 278 | cache_health: ModelCacheHealth::Incomplete, |
| 279 | }); |
| 280 | } else { |
| 281 | for (gguf_file, file_path) in gguf_files { |
| 282 | models.push(DiscoveredModel { |
| 283 | display_name: display_name_for_model(&model_id, &gguf_file), |
| 284 | model_id: model_id.clone(), |
| 285 | gguf_file, |
| 286 | snapshot_path: snapshot_path.clone(), |
| 287 | gguf_path: file_path, |
| 288 | from_app_group, |
| 289 | cache_health: ModelCacheHealth::Complete, |
| 290 | }); |
| 291 | } |
| 292 | } |
| 293 | } |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | fn display_name_for_model(model_id: &str, gguf_file: &str) -> String { |
| 298 | let repo_name = model_id |
| 299 | .rsplit('/') |
| 300 | .next() |
| 301 | .unwrap_or(model_id) |
| 302 | .replace('_', " "); |
| 303 | |
| 304 | let file_name = gguf_file.strip_suffix(".gguf").unwrap_or(gguf_file); |
| 305 | |
| 306 | if file_name.contains(&repo_name.replace(' ', "_")) || file_name.contains(&repo_name) { |
| 307 | repo_name |
| 308 | } else { |
| 309 | format!("{repo_name} — {file_name}") |
| 310 | } |
| 311 | } |
| 312 | |
| 313 | fn app_group_models_root() -> Option<PathBuf> { |
| 314 | resolve_shared_container().map(|dir| dir.join("models").join("hub")) |
| 315 | } |
| 316 | |
| 317 | fn hf_cache_root() -> Option<PathBuf> { |
| 318 | if let Ok(cache) = std::env::var("HF_HUB_CACHE") { |
| 319 | let path = PathBuf::from(cache); |
| 320 | if path.is_dir() { |
| 321 | return Some(path); |
| 322 | } |
| 323 | } |
| 324 | |
| 325 | if let Ok(home) = std::env::var("HF_HOME") { |
| 326 | let path = PathBuf::from(home).join("hub"); |
| 327 | if path.is_dir() { |
| 328 | return Some(path); |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | None |
| 333 | } |
| 334 | |
| 335 | fn default_hf_cache_root() -> Option<PathBuf> { |
| 336 | let home = std::env::var("HOME").ok()?; |
| 337 | let path = PathBuf::from(home) |
| 338 | .join(".cache") |
| 339 | .join("huggingface") |
| 340 | .join("hub"); |
| 341 | |
| 342 | path.is_dir().then_some(path) |
| 343 | } |
| 344 | |
| 345 | pub fn load_selected_model() -> Option<SelectedModel> { |
| 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() { |
| 353 | return None; |
| 354 | } |
| 355 | |
| 356 | let mut parts = trimmed.splitn(2, '\n'); |
| 357 | let model_id = parts.next()?.trim(); |
| 358 | let gguf_file = parts.next()?.trim(); |
| 359 | |
| 360 | if model_id.is_empty() || gguf_file.is_empty() { |
| 361 | return None; |
| 362 | } |
| 363 | |
| 364 | Some(SelectedModel { |
| 365 | model_id: model_id.to_string(), |
| 366 | gguf_file: gguf_file.to_string(), |
| 367 | }) |
| 368 | } |
| 369 | |
| 370 | #[allow(dead_code)] |
| 371 | pub fn load_selected_model_name() -> Option<String> { |
| 372 | let selected = load_selected_model()?; |
| 373 | discover_local_models() |
| 374 | .into_iter() |
| 375 | .find(|model| selected.matches(model)) |
| 376 | .map(|model| model.display_name) |
| 377 | } |
| 378 | |
| 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> { |
| 382 | startup_model_selection_in(&ModelRoots::from_env()) |
| 383 | } |
| 384 | |
| 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 | }) |
| 395 | { |
| 396 | return Some(StartupModelSelection { |
| 397 | display_name: model.display_name.clone(), |
| 398 | selected_model: Some(saved_model), |
| 399 | }); |
| 400 | } |
| 401 | |
| 402 | discovered |
| 403 | .into_iter() |
| 404 | .find(|model| model.cache_health == ModelCacheHealth::Complete) |
| 405 | .map(|model| { |
| 406 | let selected_model = SelectedModel::from_discovered(&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), |
| 413 | } |
| 414 | }) |
| 415 | } |
| 416 | |
| 417 | pub fn save_selected_model(selected_model: &SelectedModel) -> Result<(), String> { |
| 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 | { |
| 428 | std::fs::create_dir_all(parent) |
| 429 | .map_err(|error| format!("Could not create preferences directory: {error}"))?; |
| 430 | } |
| 431 | |
| 432 | let contents = format!( |
| 433 | "{}\n{}\n", |
| 434 | selected_model.model_id, selected_model.gguf_file |
| 435 | ); |
| 436 | |
| 437 | std::fs::write(path, contents) |
| 438 | .map_err(|error| format!("Could not save selected model: {error}")) |
| 439 | } |
| 440 | |
| 441 | fn selected_model_file_path() -> Option<PathBuf> { |
| 442 | if let Some(shared_dir) = resolve_shared_container() { |
| 443 | return Some(shared_dir.join(SELECTED_MODEL_FILE_NAME)); |
| 444 | } |
| 445 | |
| 446 | if let Ok(home) = std::env::var("HF_HOME") { |
| 447 | let path = PathBuf::from(home); |
| 448 | if path.is_dir() || path.parent().is_some() { |
| 449 | return Some(path.join(SELECTED_MODEL_FILE_NAME)); |
| 450 | } |
| 451 | } |
| 452 | |
| 453 | let home = std::env::var("HOME").ok()?; |
| 454 | Some( |
| 455 | PathBuf::from(home) |
| 456 | .join(".cache") |
| 457 | .join("sigit") |
| 458 | .join(SELECTED_MODEL_FILE_NAME), |
| 459 | ) |
| 460 | } |
| 461 | |
| 462 | /// macOS only creates this dir when a signed app in the group first runs, |
| 463 | /// so it won't exist until the user has launched siGit desktop or another Onde app. |
| 464 | #[cfg(target_os = "macos")] |
| 465 | fn resolve_shared_container() -> Option<PathBuf> { |
| 466 | let home = std::env::var("HOME").ok()?; |
| 467 | let container = PathBuf::from(home) |
| 468 | .join("Library") |
| 469 | .join("Group Containers") |
| 470 | .join(APP_GROUP_IDENTIFIER); |
| 471 | |
| 472 | if container.is_dir() { |
| 473 | log::debug!("App Group container found: {}", container.display()); |
| 474 | Some(container) |
| 475 | } else { |
| 476 | log::debug!( |
| 477 | "App Group container does not exist at {} — \ |
| 478 | has siGit desktop been launched at least once?", |
| 479 | container.display() |
| 480 | ); |
| 481 | None |
| 482 | } |
| 483 | } |
| 484 | |
| 485 | #[cfg(not(target_os = "macos"))] |
| 486 | fn resolve_shared_container() -> Option<PathBuf> { |
| 487 | None |
| 488 | } |
| 489 | |
| 490 | #[cfg(test)] |
| 491 | mod tests { |
| 492 | use super::*; |
| 493 | |
| 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 | |
| 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( |
| 522 | cache_root: &Path, |
| 523 | model_id: &str, |
| 524 | snapshot_name: &str, |
| 525 | gguf_file: &str, |
| 526 | complete: bool, |
| 527 | ) -> PathBuf { |
| 528 | let repo_dir = cache_root.join(format!("models--{}", model_id.replace('/', "--"))); |
| 529 | let snapshot_dir = repo_dir.join("snapshots").join(snapshot_name); |
| 530 | std::fs::create_dir_all(&snapshot_dir).expect("create snapshot dir"); |
| 531 | |
| 532 | // Health is determined solely by the presence of a .gguf file. |
| 533 | // A complete snapshot has one; an incomplete snapshot has none |
| 534 | // (e.g. a partial download where only metadata files arrived). |
| 535 | if complete { |
| 536 | std::fs::write(snapshot_dir.join(gguf_file), b"gguf placeholder").expect("write gguf"); |
| 537 | } else { |
| 538 | // Simulate a snapshot directory that exists but has no GGUF yet. |
| 539 | std::fs::write(snapshot_dir.join("config.json"), b"{}") |
| 540 | .expect("write config placeholder"); |
| 541 | } |
| 542 | |
| 543 | snapshot_dir |
| 544 | } |
| 545 | |
| 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"); |
| 550 | std::fs::create_dir_all(&cache_root).expect("create cache root"); |
| 551 | |
| 552 | create_snapshot( |
| 553 | &cache_root, |
| 554 | "bartowski/Qwen_Qwen3-4B-GGUF", |
| 555 | "complete", |
| 556 | "Qwen_Qwen3-4B-Q4_K_M.gguf", |
| 557 | true, |
| 558 | ); |
| 559 | create_snapshot( |
| 560 | &cache_root, |
| 561 | "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF", |
| 562 | "incomplete", |
| 563 | "Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf", |
| 564 | false, |
| 565 | ); |
| 566 | |
| 567 | let models = discover_local_models_in(&test_roots(&cache_root, &root)); |
| 568 | |
| 569 | assert_eq!(models.len(), 2); |
| 570 | |
| 571 | let complete = models |
| 572 | .iter() |
| 573 | .find(|model| model.model_id == "bartowski/Qwen_Qwen3-4B-GGUF") |
| 574 | .expect("complete model discovered"); |
| 575 | assert_eq!(complete.cache_health, ModelCacheHealth::Complete); |
| 576 | assert!(!complete.from_app_group); |
| 577 | |
| 578 | let incomplete = models |
| 579 | .iter() |
| 580 | .find(|model| model.model_id == "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF") |
| 581 | .expect("incomplete model discovered"); |
| 582 | assert_eq!(incomplete.cache_health, ModelCacheHealth::Incomplete); |
| 583 | assert!(!incomplete.from_app_group); |
| 584 | |
| 585 | std::fs::remove_dir_all(root).expect("remove temp dir"); |
| 586 | } |
| 587 | |
| 588 | #[test] |
| 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"); |
| 592 | std::fs::create_dir_all(&cache_root).expect("create cache root"); |
| 593 | |
| 594 | create_snapshot( |
| 595 | &cache_root, |
| 596 | "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF", |
| 597 | "broken", |
| 598 | "Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf", |
| 599 | false, |
| 600 | ); |
| 601 | create_snapshot( |
| 602 | &cache_root, |
| 603 | "bartowski/Qwen_Qwen3-4B-GGUF", |
| 604 | "ready", |
| 605 | "Qwen_Qwen3-4B-Q4_K_M.gguf", |
| 606 | true, |
| 607 | ); |
| 608 | |
| 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 | |
| 620 | let selection = startup_model_selection_in(&roots).expect("startup selection"); |
| 621 | |
| 622 | assert_eq!( |
| 623 | selection.display_name, |
| 624 | "Qwen Qwen3-4B-GGUF — Qwen_Qwen3-4B-Q4_K_M" |
| 625 | ); |
| 626 | let selected = selection.selected_model.expect("selected model"); |
| 627 | assert_eq!(selected.model_id, "bartowski/Qwen_Qwen3-4B-GGUF"); |
| 628 | assert_eq!(selected.gguf_file, "Qwen_Qwen3-4B-Q4_K_M.gguf"); |
| 629 | |
| 630 | std::fs::remove_dir_all(root).expect("remove temp dir"); |
| 631 | } |
| 632 | |
| 633 | #[test] |
| 634 | fn discover_empty_cache_returns_no_models() { |
| 635 | let root = unique_temp_dir("discover-empty"); |
| 636 | let cache_root = root.join("hf-cache"); |
| 637 | std::fs::create_dir_all(&cache_root).expect("create cache root"); |
| 638 | |
| 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"); |
| 643 | } |
| 644 | |
| 645 | #[test] |
| 646 | fn complete_models_sort_before_incomplete() { |
| 647 | let root = unique_temp_dir("sort-order"); |
| 648 | let cache_root = root.join("hf-cache"); |
| 649 | std::fs::create_dir_all(&cache_root).expect("create cache root"); |
| 650 | |
| 651 | create_snapshot( |
| 652 | &cache_root, |
| 653 | "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF", |
| 654 | "snap1", |
| 655 | "Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf", |
| 656 | false, |
| 657 | ); |
| 658 | create_snapshot( |
| 659 | &cache_root, |
| 660 | "bartowski/Qwen_Qwen3-4B-GGUF", |
| 661 | "snap2", |
| 662 | "Qwen_Qwen3-4B-Q4_K_M.gguf", |
| 663 | true, |
| 664 | ); |
| 665 | |
| 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); |
| 670 | |
| 671 | std::fs::remove_dir_all(root).expect("remove temp dir"); |
| 672 | } |
| 673 | |
| 674 | #[test] |
| 675 | fn load_selected_model_roundtrip() { |
| 676 | let root = unique_temp_dir("persistence-roundtrip"); |
| 677 | let path = root.join(SELECTED_MODEL_FILE_NAME); |
| 678 | |
| 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 | |
| 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 | |
| 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 | } |
| 693 | |
| 694 | #[test] |
| 695 | fn load_selected_model_empty_file_returns_none() { |
| 696 | let root = unique_temp_dir("persistence-empty"); |
| 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 | |
| 701 | assert!(load_selected_model_from(&path).is_none()); |
| 702 | |
| 703 | std::fs::remove_dir_all(root).expect("remove temp dir"); |
| 704 | } |
| 705 | |
| 706 | #[test] |
| 707 | fn display_name_deduplicates_repo_and_file_name() { |
| 708 | // When the file name contains the repo name, only the repo name is shown. |
| 709 | let name = display_name_for_model( |
| 710 | "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF", |
| 711 | "Qwen2.5-Coder-3B-Instruct-GGUF-Q4_K_M.gguf", |
| 712 | ); |
| 713 | assert_eq!(name, "Qwen2.5-Coder-3B-Instruct-GGUF"); |
| 714 | |
| 715 | // When the file name does NOT contain the repo name, both are shown. |
| 716 | let name = display_name_for_model( |
| 717 | "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF", |
| 718 | "Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf", |
| 719 | ); |
| 720 | assert_eq!( |
| 721 | name, |
| 722 | "Qwen2.5-Coder-3B-Instruct-GGUF — Qwen2.5-Coder-3B-Instruct-Q4_K_M" |
| 723 | ); |
| 724 | } |
| 725 | |
| 726 | #[test] |
| 727 | fn display_name_includes_file_when_different() { |
| 728 | let name = |
| 729 | display_name_for_model("bartowski/Qwen_Qwen3-4B-GGUF", "Qwen_Qwen3-4B-Q4_K_M.gguf"); |
| 730 | assert_eq!(name, "Qwen Qwen3-4B-GGUF — Qwen_Qwen3-4B-Q4_K_M"); |
| 731 | } |
| 732 | } |