1use crate::error::{EnvironmentError, ProviderError, Result, UserError};
4use futures_util::StreamExt;
5use indicatif::{ProgressBar, ProgressStyle};
6use sha2::{Digest, Sha256};
7use std::fs::{self, File, OpenOptions};
8use std::io::{Read, Write};
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12const HF_BASE: &str = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main";
14
15#[derive(Debug, Clone, Copy)]
17pub struct ModelInfo {
18 pub name: &'static str,
19 pub filename: &'static str,
20 pub approx_bytes: u64,
22 pub notes: &'static str,
24}
25
26pub const MODELS: &[ModelInfo] = &[
28 ModelInfo {
30 name: "tiny",
31 filename: "ggml-tiny.bin",
32 approx_bytes: 75_000_000,
33 notes: "fastest full-precision",
34 },
35 ModelInfo {
36 name: "tiny-q5_1",
37 filename: "ggml-tiny-q5_1.bin",
38 approx_bytes: 32_000_000,
39 notes: "quantized ~32MB — best first-run trial",
40 },
41 ModelInfo {
42 name: "tiny-q8_0",
43 filename: "ggml-tiny-q8_0.bin",
44 approx_bytes: 44_000_000,
45 notes: "quantized",
46 },
47 ModelInfo {
48 name: "tiny.en",
49 filename: "ggml-tiny.en.bin",
50 approx_bytes: 75_000_000,
51 notes: "english-only",
52 },
53 ModelInfo {
54 name: "tiny.en-q5_1",
55 filename: "ggml-tiny.en-q5_1.bin",
56 approx_bytes: 32_000_000,
57 notes: "english-only quantized",
58 },
59 ModelInfo {
61 name: "base",
62 filename: "ggml-base.bin",
63 approx_bytes: 142_000_000,
64 notes: "default full-precision",
65 },
66 ModelInfo {
67 name: "base-q5_1",
68 filename: "ggml-base-q5_1.bin",
69 approx_bytes: 60_000_000,
70 notes: "quantized ~60MB",
71 },
72 ModelInfo {
73 name: "base-q8_0",
74 filename: "ggml-base-q8_0.bin",
75 approx_bytes: 82_000_000,
76 notes: "quantized",
77 },
78 ModelInfo {
79 name: "base.en",
80 filename: "ggml-base.en.bin",
81 approx_bytes: 142_000_000,
82 notes: "english-only",
83 },
84 ModelInfo {
85 name: "base.en-q5_1",
86 filename: "ggml-base.en-q5_1.bin",
87 approx_bytes: 60_000_000,
88 notes: "english-only quantized",
89 },
90 ModelInfo {
92 name: "small",
93 filename: "ggml-small.bin",
94 approx_bytes: 466_000_000,
95 notes: "higher accuracy",
96 },
97 ModelInfo {
98 name: "small-q5_1",
99 filename: "ggml-small-q5_1.bin",
100 approx_bytes: 190_000_000,
101 notes: "quantized",
102 },
103 ModelInfo {
104 name: "small-q8_0",
105 filename: "ggml-small-q8_0.bin",
106 approx_bytes: 264_000_000,
107 notes: "quantized",
108 },
109 ModelInfo {
110 name: "small.en",
111 filename: "ggml-small.en.bin",
112 approx_bytes: 466_000_000,
113 notes: "english-only",
114 },
115 ModelInfo {
116 name: "small.en-q5_1",
117 filename: "ggml-small.en-q5_1.bin",
118 approx_bytes: 190_000_000,
119 notes: "english-only quantized",
120 },
121 ModelInfo {
123 name: "medium",
124 filename: "ggml-medium.bin",
125 approx_bytes: 1_500_000_000,
126 notes: "large download",
127 },
128 ModelInfo {
129 name: "medium.en",
130 filename: "ggml-medium.en.bin",
131 approx_bytes: 1_500_000_000,
132 notes: "english-only",
133 },
134 ModelInfo {
136 name: "large-v3",
137 filename: "ggml-large-v3.bin",
138 approx_bytes: 3_100_000_000,
139 notes: "highest quality",
140 },
141 ModelInfo {
142 name: "large-v3-q5_0",
143 filename: "ggml-large-v3-q5_0.bin",
144 approx_bytes: 1_080_000_000,
145 notes: "quantized",
146 },
147 ModelInfo {
148 name: "large",
149 filename: "ggml-large-v3.bin",
150 approx_bytes: 3_100_000_000,
151 notes: "alias of large-v3",
152 },
153 ModelInfo {
154 name: "large-v3-turbo",
155 filename: "ggml-large-v3-turbo.bin",
156 approx_bytes: 1_600_000_000,
157 notes: "fast large",
158 },
159 ModelInfo {
160 name: "large-v3-turbo-q5_0",
161 filename: "ggml-large-v3-turbo-q5_0.bin",
162 approx_bytes: 574_000_000,
163 notes: "quantized turbo",
164 },
165 ModelInfo {
166 name: "turbo",
167 filename: "ggml-large-v3-turbo.bin",
168 approx_bytes: 1_600_000_000,
169 notes: "alias of large-v3-turbo",
170 },
171 ModelInfo {
172 name: "turbo-q5_0",
173 filename: "ggml-large-v3-turbo-q5_0.bin",
174 approx_bytes: 574_000_000,
175 notes: "alias of large-v3-turbo-q5_0",
176 },
177];
178
179pub fn available_model_names() -> String {
181 list_canonical_models()
182 .iter()
183 .map(|m| m.name)
184 .collect::<Vec<_>>()
185 .join(", ")
186}
187
188fn list_canonical_models() -> Vec<&'static ModelInfo> {
189 MODELS
190 .iter()
191 .filter(|m| !matches!(m.name, "large" | "turbo" | "turbo-q5_0"))
192 .collect()
193}
194
195pub fn lookup_model(name: &str) -> Result<&'static ModelInfo> {
196 let key = name.trim().to_ascii_lowercase();
197 MODELS.iter().find(|m| m.name == key).ok_or_else(|| {
198 UserError::InvalidModel {
199 model: name.to_string(),
200 available: available_model_names(),
201 }
202 .into()
203 })
204}
205
206pub fn models_dir(cache_dir: &Path) -> PathBuf {
208 cache_dir.join("models")
209}
210
211pub fn model_path(cache_dir: &Path, info: &ModelInfo) -> PathBuf {
213 models_dir(cache_dir).join(info.filename)
214}
215
216#[derive(Debug, Clone)]
218pub struct ModelStatus {
219 pub info: &'static ModelInfo,
220 pub cached: bool,
221 pub path: PathBuf,
222 pub size_bytes: Option<u64>,
223}
224
225pub fn list_models(cache_dir: &Path) -> Vec<ModelStatus> {
227 list_canonical_models()
228 .into_iter()
229 .map(|info| {
230 let path = model_path(cache_dir, info);
231 let (cached, size_bytes) = match fs::metadata(&path) {
232 Ok(m) if m.len() > 1_000_000 => (true, Some(m.len())),
233 _ => (false, None),
234 };
235 ModelStatus {
236 info,
237 cached,
238 path,
239 size_bytes,
240 }
241 })
242 .collect()
243}
244
245pub fn format_model_list(cache_dir: &Path) -> String {
247 let rows = list_models(cache_dir);
248 let mut out = String::from("Local whisper.cpp models (cache: ");
249 out.push_str(&models_dir(cache_dir).display().to_string());
250 out.push_str(")\n\n");
251 out.push_str(&format!(
252 "{:<22} {:>10} {:<8} {}\n",
253 "NAME", "SIZE", "STATUS", "NOTES"
254 ));
255 out.push_str(&format!(
256 "{:<22} {:>10} {:<8} {}\n",
257 "----", "----", "------", "-----"
258 ));
259 for row in rows {
260 let size = format_bytes(row.info.approx_bytes);
261 let status = if row.cached { "cached" } else { "—" };
262 out.push_str(&format!(
263 "{:<22} {:>10} {:<8} {}\n",
264 row.info.name, size, status, row.info.notes
265 ));
266 }
267 out.push_str(
268 "\nTip: first run downloads the selected model. Try `tiny-q5_1` (~32 MB) for a quick trial.\n",
269 );
270 out.push_str("Default model: `base` (~142 MB). Use --model <name> to choose.\n");
271 out
272}
273
274fn format_bytes(n: u64) -> String {
275 const KB: f64 = 1024.0;
276 const MB: f64 = KB * 1024.0;
277 const GB: f64 = MB * 1024.0;
278 let n = n as f64;
279 if n >= GB {
280 format!("{:.1} GB", n / GB)
281 } else if n >= MB {
282 format!("{:.0} MB", n / MB)
283 } else {
284 format!("{:.0} KB", n / KB)
285 }
286}
287
288#[derive(Debug, Clone)]
290pub struct DownloadProgress {
291 pub model: String,
292 pub downloaded_bytes: u64,
293 pub total_bytes: u64,
294}
295
296impl DownloadProgress {
297 pub fn fraction(&self) -> Option<f64> {
298 if self.total_bytes == 0 {
299 None
300 } else {
301 Some((self.downloaded_bytes as f64 / self.total_bytes as f64).clamp(0.0, 1.0))
302 }
303 }
304}
305
306pub type DownloadProgressCallback = Arc<dyn Fn(DownloadProgress) + Send + Sync>;
308
309#[derive(Clone, Default)]
311pub struct EnsureModelOptions {
312 pub local_only: bool,
314 pub show_progress: bool,
316 pub on_progress: Option<DownloadProgressCallback>,
318}
319
320impl EnsureModelOptions {
321 pub fn new() -> Self {
322 Self::default()
323 }
324
325 pub fn local_only(mut self, v: bool) -> Self {
326 self.local_only = v;
327 self
328 }
329
330 pub fn show_progress(mut self, v: bool) -> Self {
331 self.show_progress = v;
332 self
333 }
334
335 pub fn on_progress(mut self, cb: DownloadProgressCallback) -> Self {
336 self.on_progress = Some(cb);
337 self
338 }
339}
340
341pub fn is_model_cached(cache_dir: &Path, model_name: &str) -> bool {
343 let Ok(info) = lookup_model(model_name) else {
344 return false;
345 };
346 let path = model_path(cache_dir, info);
347 if !(path.exists()
348 && path
349 .metadata()
350 .map(|m| m.len() > 1_000_000)
351 .unwrap_or(false))
352 {
353 return false;
354 }
355 if pinned_sha256(info.filename).is_some() {
357 verify_model_basic(&path, info).is_ok()
358 } else {
359 verify_model_basic(&path, info).is_ok() && verify_cached_checksum(&path)
360 }
361}
362
363pub async fn ensure_model(
365 cache_dir: &Path,
366 model_name: &str,
367 show_progress: bool,
368) -> Result<PathBuf> {
369 ensure_model_with_options(
370 cache_dir,
371 model_name,
372 EnsureModelOptions {
373 show_progress,
374 ..EnsureModelOptions::default()
375 },
376 )
377 .await
378}
379
380pub async fn ensure_model_with_options(
382 cache_dir: &Path,
383 model_name: &str,
384 opts: EnsureModelOptions,
385) -> Result<PathBuf> {
386 let info = lookup_model(model_name)?;
387 let path = model_path(cache_dir, info);
388
389 if path.exists()
390 && path
391 .metadata()
392 .map(|m| m.len() > 1_000_000)
393 .unwrap_or(false)
394 {
395 let ok = if pinned_sha256(info.filename).is_some() {
397 verify_model_basic(&path, info).is_ok()
398 } else {
399 verify_model_basic(&path, info).is_ok() && verify_cached_checksum(&path)
400 };
401 if ok {
402 tracing::info!(model = info.name, path = %path.display(), "using cached model");
403 return Ok(path);
404 }
405 tracing::warn!(
406 model = info.name,
407 path = %path.display(),
408 "cached model failed integrity check; re-downloading"
409 );
410 let _ = fs::remove_file(&path);
411 }
412
413 if opts.local_only {
414 return Err(UserError::ModelNotCached {
415 model: model_name.to_string(),
416 }
417 .into());
418 }
419
420 fs::create_dir_all(models_dir(cache_dir)).map_err(|e| EnvironmentError::DirectoryAccess {
421 path: models_dir(cache_dir).display().to_string(),
422 reason: e.to_string(),
423 })?;
424
425 let lock_path = path.with_extension("bin.lock");
427 let lock_file = OpenOptions::new()
428 .create(true)
429 .read(true)
430 .write(true)
431 .truncate(false)
432 .open(&lock_path)
433 .map_err(|e| EnvironmentError::DirectoryAccess {
434 path: lock_path.display().to_string(),
435 reason: e.to_string(),
436 })?;
437
438 if opts.show_progress {
439 eprintln!("aurum: waiting for model download lock ({}) …", info.name);
440 }
441 lock_file.lock().map_err(|e| EnvironmentError::Other {
442 message: format!("failed to acquire model lock: {e}"),
443 })?;
444
445 if path.exists()
447 && path
448 .metadata()
449 .map(|m| m.len() > 1_000_000)
450 .unwrap_or(false)
451 && verify_model_basic(&path, info).is_ok()
452 && verify_cached_checksum(&path)
453 {
454 let _ = lock_file.unlock();
455 tracing::info!(model = info.name, "model appeared while waiting on lock");
456 return Ok(path);
457 }
458
459 if opts.local_only {
460 let _ = lock_file.unlock();
461 return Err(UserError::ModelNotCached {
462 model: model_name.to_string(),
463 }
464 .into());
465 }
466
467 if opts.show_progress {
468 eprintln!(
469 "aurum: downloading model `{}` ({}) — first run only …",
470 info.name,
471 format_bytes(info.approx_bytes)
472 );
473 }
474
475 let result = download_model(info, &path, opts.show_progress, opts.on_progress.as_ref()).await;
476 let _ = lock_file.unlock();
477 result?;
478 verify_model_basic(&path, info)?;
479 Ok(path)
480}
481
482fn verify_cached_checksum(path: &Path) -> bool {
484 let checksum_path = path.with_extension("bin.sha256");
485 let Ok(contents) = fs::read_to_string(&checksum_path) else {
486 return true;
487 };
488 let Some(expected) = contents.split_whitespace().next() else {
489 return true;
490 };
491 let Ok(mut file) = File::open(path) else {
492 return false;
493 };
494 let mut hasher = Sha256::new();
495 let mut buf = [0u8; 1024 * 64];
496 loop {
497 match file.read(&mut buf) {
498 Ok(0) => break,
499 Ok(n) => hasher.update(&buf[..n]),
500 Err(_) => return false,
501 }
502 }
503 let actual = hex::encode(hasher.finalize());
504 if actual != expected {
505 tracing::warn!(%expected, %actual, "model checksum mismatch");
506 return false;
507 }
508 true
509}
510
511async fn download_model(
512 info: &ModelInfo,
513 dest: &Path,
514 show_progress: bool,
515 on_progress: Option<&DownloadProgressCallback>,
516) -> Result<()> {
517 let url = format!("{HF_BASE}/{}?download=true", info.filename);
518 tracing::info!(model = info.name, %url, "downloading model");
519
520 let partial = dest.with_extension(format!(
521 "bin.partial.{}-{}",
522 std::process::id(),
523 std::time::SystemTime::now()
524 .duration_since(std::time::UNIX_EPOCH)
525 .map(|d| d.as_millis())
526 .unwrap_or(0)
527 ));
528 if partial.exists() {
529 let _ = fs::remove_file(&partial);
530 }
531
532 let client = reqwest::Client::builder()
534 .user_agent(concat!("aurum-core/", env!("CARGO_PKG_VERSION")))
535 .connect_timeout(std::time::Duration::from_secs(30))
536 .timeout(std::time::Duration::from_secs(30 * 60))
537 .build()
538 .map_err(|e| ProviderError::ModelDownload {
539 model: info.name.to_string(),
540 reason: format!("http client error: {e}"),
541 })?;
542
543 let response = client
544 .get(&url)
545 .send()
546 .await
547 .map_err(|e| ProviderError::ModelDownload {
548 model: info.name.to_string(),
549 reason: format!("request failed: {e}"),
550 })?;
551
552 if !response.status().is_success() {
553 return Err(ProviderError::ModelDownload {
554 model: info.name.to_string(),
555 reason: format!("HTTP {}", response.status()),
556 }
557 .into());
558 }
559
560 let total = response.content_length().unwrap_or(info.approx_bytes);
561
562 let pb = if show_progress {
563 let pb = ProgressBar::new(total);
564 pb.set_style(
565 ProgressStyle::with_template(
566 "{msg} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})",
567 )
568 .unwrap_or_else(|_| ProgressStyle::default_bar())
569 .progress_chars("=>-"),
570 );
571 pb.set_message(format!("Downloading {}", info.name));
572 Some(pb)
573 } else {
574 None
575 };
576
577 let mut file = File::create(&partial).map_err(|e| EnvironmentError::DirectoryAccess {
578 path: partial.display().to_string(),
579 reason: e.to_string(),
580 })?;
581
582 let mut stream = response.bytes_stream();
583 let mut hasher = Sha256::new();
584 let mut downloaded: u64 = 0;
585
586 while let Some(chunk) = stream.next().await {
587 let chunk = chunk.map_err(|e| ProviderError::ModelDownload {
588 model: info.name.to_string(),
589 reason: format!("stream error: {e}"),
590 })?;
591 file.write_all(&chunk)
592 .map_err(|e| EnvironmentError::DiskSpace {
593 path: partial.display().to_string(),
594 reason: e.to_string(),
595 })?;
596 hasher.update(&chunk);
597 downloaded += chunk.len() as u64;
598 let max_allowed = total
600 .saturating_mul(5)
601 .saturating_div(4)
602 .max(info.approx_bytes);
603 if downloaded > max_allowed {
604 let _ = fs::remove_file(&partial);
605 return Err(ProviderError::ModelDownload {
606 model: info.name.to_string(),
607 reason: format!(
608 "download exceeded size cap ({downloaded} > {max_allowed} bytes) — aborting"
609 ),
610 }
611 .into());
612 }
613 if let Some(pb) = &pb {
614 pb.set_position(downloaded.min(total));
615 }
616 if let Some(cb) = on_progress {
617 cb(DownloadProgress {
618 model: info.name.to_string(),
619 downloaded_bytes: downloaded,
620 total_bytes: total,
621 });
622 }
623 }
624
625 file.flush().ok();
626 drop(file);
627
628 fs::rename(&partial, dest).map_err(|e| EnvironmentError::DirectoryAccess {
629 path: dest.display().to_string(),
630 reason: e.to_string(),
631 })?;
632
633 if let Some(pb) = pb {
634 pb.finish_with_message(format!("Downloaded {} ({downloaded} bytes)", info.name));
635 }
636
637 let digest = hex::encode(hasher.finalize());
638 tracing::info!(
639 model = info.name,
640 sha256 = %digest,
641 bytes = downloaded,
642 "model download complete"
643 );
644
645 if let Some(expected) = pinned_sha256(info.filename) {
647 if digest != expected {
648 let _ = fs::remove_file(dest);
649 return Err(ProviderError::ModelDownload {
650 model: info.name.to_string(),
651 reason: format!(
652 "sha256 mismatch (got {digest}, expected {expected}) — refusing to cache"
653 ),
654 }
655 .into());
656 }
657 }
658
659 let checksum_path = dest.with_extension("bin.sha256");
660 let _ = fs::write(&checksum_path, format!("{digest} {}\n", info.filename));
661
662 sweep_stale_partials(dest.parent().unwrap_or_else(|| Path::new(".")));
664
665 Ok(())
666}
667
668fn pinned_sha256(filename: &str) -> Option<&'static str> {
672 match filename {
673 "ggml-tiny.bin" => Some("be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21"),
674 "ggml-tiny-q5_1.bin" => {
675 Some("818710568da3ca15689e31a743197b520007872ff9576237bda97bd1b469c3d7")
676 }
677 "ggml-tiny.en-q5_1.bin" => {
678 Some("c77c5766f1cef09b6b7d47f21b546cbddd4157886b3b5d6d4f709e91e66c7c2b")
679 }
680 "ggml-base.bin" => Some("60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe"),
681 "ggml-base-q5_1.bin" => {
682 Some("422f1ae452ade6f30a004d7e5c6a43195e4433bc370bf23fac9cc591f01a8898")
683 }
684 "ggml-base.en-q5_1.bin" => {
685 Some("4baf70dd0d7c4247ba2b81fafd9c01005ac77c2f9ef064e00dcf195d0e2fdd2f")
686 }
687 "ggml-small-q5_1.bin" => {
688 Some("ae85e4a935d7a567bd102fe55afc16bb595bdb618e11b2fc7591bc08120411bb")
689 }
690 _ => None,
691 }
692}
693
694fn sweep_stale_partials(dir: &Path) {
695 let Ok(entries) = fs::read_dir(dir) else {
696 return;
697 };
698 let stale_after = std::time::Duration::from_secs(6 * 3600);
699 let now = std::time::SystemTime::now();
700 for ent in entries.flatten() {
701 let name = ent.file_name();
702 let name = name.to_string_lossy();
703 if !name.contains(".bin.partial.") {
705 continue;
706 }
707 let Ok(meta) = ent.metadata() else {
708 continue;
709 };
710 let Ok(modified) = meta.modified() else {
711 continue;
712 };
713 if now.duration_since(modified).unwrap_or_default() > stale_after {
714 let _ = fs::remove_file(ent.path());
715 }
716 }
717}
718
719fn verify_model_basic(path: &Path, info: &ModelInfo) -> Result<()> {
721 let meta = fs::metadata(path).map_err(|e| ProviderError::ModelDownload {
722 model: info.name.to_string(),
723 reason: format!("missing after download: {e}"),
724 })?;
725
726 if meta.len() < 1_000_000 {
727 let _ = fs::remove_file(path);
728 return Err(ProviderError::ModelDownload {
729 model: info.name.to_string(),
730 reason: format!(
731 "downloaded file is only {} bytes — likely truncated or HTML error page",
732 meta.len()
733 ),
734 }
735 .into());
736 }
737
738 let mut hdr = [0u8; 4];
739 let mut f = File::open(path).map_err(|e| ProviderError::ModelDownload {
740 model: info.name.to_string(),
741 reason: e.to_string(),
742 })?;
743 f.read_exact(&mut hdr)
744 .map_err(|e| ProviderError::ModelDownload {
745 model: info.name.to_string(),
746 reason: format!("cannot read header: {e}"),
747 })?;
748
749 let magic_ok = matches!(
750 &hdr,
751 b"ggml"
752 | b"lmgg"
753 | b"ggmf"
754 | b"fmgg"
755 | b"ggjt"
756 | b"tjgg"
757 | b"ggjf"
758 | b"fjgg"
759 | b"gguf"
760 | b"fugg"
761 | b"GGUF"
762 );
763
764 if !magic_ok {
765 let _ = fs::remove_file(path);
766 return Err(ProviderError::ModelDownload {
767 model: info.name.to_string(),
768 reason: format!(
769 "model header {:?} is not a recognized ggml/gguf magic — refusing to use file",
770 hdr
771 ),
772 }
773 .into());
774 }
775
776 if let Some(expected) = pinned_sha256(info.filename) {
778 if !verify_against_expected(path, expected) {
779 let _ = fs::remove_file(path);
780 return Err(ProviderError::ModelDownload {
781 model: info.name.to_string(),
782 reason: format!("cached model failed pinned sha256 check ({expected})"),
783 }
784 .into());
785 }
786 }
787
788 Ok(())
789}
790
791fn verify_against_expected(path: &Path, expected: &str) -> bool {
792 let Ok(mut file) = File::open(path) else {
793 return false;
794 };
795 let mut hasher = Sha256::new();
796 let mut buf = [0u8; 64 * 1024];
797 loop {
798 match file.read(&mut buf) {
799 Ok(0) => break,
800 Ok(n) => hasher.update(&buf[..n]),
801 Err(_) => return false,
802 }
803 }
804 hex::encode(hasher.finalize()) == expected
805}
806
807#[cfg(test)]
808mod tests {
809 use super::*;
810
811 #[test]
812 fn lookup_known_models() {
813 assert_eq!(lookup_model("base").unwrap().filename, "ggml-base.bin");
814 assert_eq!(
815 lookup_model("tiny-q5_1").unwrap().filename,
816 "ggml-tiny-q5_1.bin"
817 );
818 assert_eq!(
819 lookup_model("large-v3-turbo").unwrap().filename,
820 "ggml-large-v3-turbo.bin"
821 );
822 assert_eq!(lookup_model("turbo").unwrap().name, "turbo");
823 assert!(lookup_model("nope").is_err());
824 }
825
826 #[test]
827 fn model_path_joins() {
828 let p = model_path(Path::new("/tmp/cache"), lookup_model("tiny").unwrap());
829 assert_eq!(p, PathBuf::from("/tmp/cache/models/ggml-tiny.bin"));
830 }
831
832 #[test]
833 fn list_includes_quantized() {
834 let list = format_model_list(Path::new("/tmp/aurum-cache-test"));
835 assert!(list.contains("tiny-q5_1"));
836 assert!(list.contains("base-q5_1"));
837 assert!(list.contains("first run"));
838 }
839}