1use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13
14use ffai_core::error::{Error, Result};
15use serde::Deserialize;
16use sha2::{Digest, Sha256};
17
18#[derive(Debug, Clone)]
20pub struct ResolvedModel {
21 pub name: String,
22 pub license: String,
24 pub files: BTreeMap<String, PathBuf>,
26}
27
28impl ResolvedModel {
29 pub fn file(&self, name: &str) -> Result<&Path> {
31 self.files
32 .get(name)
33 .map(PathBuf::as_path)
34 .ok_or_else(|| Error::Model(format!("model `{}` has no file `{name}`", self.name)))
35 }
36}
37
38#[cfg(feature = "fetch")]
42fn hub_download(repo: &str, filename: &str, cache_only: bool) -> Result<PathBuf> {
43 let (owner, name) = repo
44 .split_once('/')
45 .ok_or_else(|| Error::Model(format!("hf_repo `{repo}` is not in `owner/name` form")))?;
46 let client = hf_hub::HFClientSync::new()
47 .map_err(|e| Error::Model(format!("hugging face client init failed: {e}")))?;
48 client
49 .model(owner, name)
50 .download_file()
51 .filename(filename)
52 .local_files_only(cache_only)
53 .send()
54 .map_err(|e| Error::Model(format!("{repo}/{filename}: {e}")))
55}
56
57#[cfg(not(feature = "fetch"))]
60fn hub_download(repo: &str, filename: &str, _cache_only: bool) -> Result<PathBuf> {
61 Err(Error::Model(format!(
62 "{repo}/{filename} is not present locally and this build has the `fetch` feature disabled — place the file in the model directory, or enable `ffai-models/fetch` to download it"
63 )))
64}
65
66fn verify_checksum(path: &Path, file: &ModelFile) -> Result<()> {
70 let Some(expected) = &file.sha256 else {
71 return Ok(());
72 };
73 let bytes = std::fs::read(path)?;
74 let actual: String = Sha256::digest(&bytes)
75 .iter()
76 .map(|b| format!("{b:02x}"))
77 .collect();
78 if actual != expected.to_ascii_lowercase() {
79 return Err(Error::Model(format!(
80 "checksum mismatch for {}: manifest says {expected}, file is {actual}",
81 path.display()
82 )));
83 }
84 Ok(())
85}
86
87#[derive(Debug, Clone, Deserialize)]
89pub struct ModelFile {
90 pub name: String,
92 pub sha256: Option<String>,
94}
95
96#[derive(Debug, Clone, Deserialize)]
98pub struct ModelManifest {
99 pub name: String,
100 pub task: String,
102 pub description: Option<String>,
103 pub license: String,
106 pub hf_repo: Option<String>,
108 #[serde(default)]
109 pub files: Vec<ModelFile>,
110}
111
112impl ModelManifest {
113 pub fn from_toml(text: &str) -> Result<Self> {
114 toml::from_str(text).map_err(|e| Error::Model(format!("bad manifest: {e}")))
115 }
116
117 pub fn load(path: &Path) -> Result<Self> {
118 let text = std::fs::read_to_string(path)?;
119 Self::from_toml(&text)
120 }
121
122 #[must_use]
124 pub fn cache_path(&self) -> PathBuf {
125 cache_dir().join("models").join(&self.name)
126 }
127
128 #[must_use]
130 pub fn is_cached(&self) -> bool {
131 !self.files.is_empty()
132 && self
133 .files
134 .iter()
135 .all(|f| self.local_path(&f.name).is_some())
136 }
137
138 #[must_use]
143 pub fn local_path(&self, name: &str) -> Option<PathBuf> {
144 let manual = self.cache_path().join(name);
145 if manual.exists() {
146 return Some(manual);
147 }
148 hub_download(self.hf_repo.as_ref()?, name, true).ok()
149 }
150
151 pub fn fetch(&self) -> Result<ResolvedModel> {
157 let all: Vec<&str> = self.files.iter().map(|f| f.name.as_str()).collect();
158 self.fetch_only(&all)
159 }
160
161 pub fn fetch_only(&self, needed: &[&str]) -> Result<ResolvedModel> {
170 for name in needed {
171 if !self.files.iter().any(|f| f.name == *name) {
172 return Err(Error::Model(format!(
173 "model `{}` declares no file `{name}` — it has no checksum to verify against",
174 self.name
175 )));
176 }
177 }
178 let mut files = BTreeMap::new();
179 for file in self.files.iter().filter(|f| needed.contains(&f.name.as_str())) {
180 if let Some(path) = self.local_path(&file.name) {
181 verify_checksum(&path, file)?;
182 files.insert(file.name.clone(), path);
183 continue;
184 }
185 let repo = self.hf_repo.as_ref().ok_or_else(|| {
186 Error::Model(format!(
187 "model `{}` declares no hf_repo and `{}` is not present under {}",
188 self.name,
189 file.name,
190 self.cache_path().display()
191 ))
192 })?;
193 let path = hub_download(repo, &file.name, false)?;
194 verify_checksum(&path, file)?;
195 files.insert(file.name.clone(), path);
196 }
197 Ok(ResolvedModel {
198 name: self.name.clone(),
199 license: self.license.clone(),
200 files,
201 })
202 }
203
204 pub fn resolve_in(&self, root: &Path) -> Result<ResolvedModel> {
213 let all: Vec<&str> = self.files.iter().map(|f| f.name.as_str()).collect();
214 self.resolve_only_in(root, &all)
215 }
216
217 pub fn resolve_only_in(&self, root: &Path, needed: &[&str]) -> Result<ResolvedModel> {
226 let dir = root.join(&self.name);
227 let mut files = BTreeMap::new();
228 for name in needed {
229 if !self.files.iter().any(|f| f.name == *name) {
230 return Err(Error::Model(format!(
231 "model `{}` declares no file `{name}` — it has no checksum to verify against",
232 self.name
233 )));
234 }
235 }
236 for file in self
237 .files
238 .iter()
239 .filter(|f| needed.contains(&f.name.as_str()))
240 {
241 let path = dir.join(&file.name);
242 if !path.is_file() {
243 return Err(Error::Model(format!(
244 "model `{}` needs `{}`, expected at {} — not present (offline resolution \
245 never downloads)",
246 self.name,
247 file.name,
248 path.display()
249 )));
250 }
251 verify_checksum(&path, file)?;
252 files.insert(file.name.clone(), path);
253 }
254 Ok(ResolvedModel {
255 name: self.name.clone(),
256 license: self.license.clone(),
257 files,
258 })
259 }
260
261 pub fn verify(&self) -> Result<ResolvedModel> {
265 let mut files = BTreeMap::new();
266 for file in &self.files {
267 let path = self.local_path(&file.name).ok_or_else(|| {
268 Error::Model(format!(
269 "model `{}` is missing `{}` (looked under {}{})",
270 self.name,
271 file.name,
272 self.cache_path().display(),
273 if self.hf_repo.is_some() {
274 " and the Hugging Face cache"
275 } else {
276 ""
277 }
278 ))
279 })?;
280 verify_checksum(&path, file)?;
281 files.insert(file.name.clone(), path);
282 }
283 Ok(ResolvedModel {
284 name: self.name.clone(),
285 license: self.license.clone(),
286 files,
287 })
288 }
289}
290
291pub fn load_dir(dir: &Path) -> Result<Vec<ModelManifest>> {
298 let mut out = Vec::new();
299 let entries = std::fs::read_dir(dir).map_err(|e| {
300 Error::Model(format!(
301 "cannot read model manifest directory {}: {e}",
302 dir.display()
303 ))
304 })?;
305 for entry in entries {
306 let path = entry?.path();
307 if path.extension().and_then(|e| e.to_str()) == Some("toml") {
308 out.push(
309 ModelManifest::load(&path)
310 .map_err(|e| Error::Model(format!("{}: {e}", path.display())))?,
311 );
312 }
313 }
314 out.sort_by(|a, b| a.task.cmp(&b.task).then_with(|| a.name.cmp(&b.name)));
315 Ok(out)
316}
317
318pub fn cache_dir() -> PathBuf {
320 if let Ok(dir) = std::env::var("FFAI_CACHE") {
321 return PathBuf::from(dir);
322 }
323 os_cache_dir()
324 .unwrap_or_else(std::env::temp_dir)
325 .join("ffai")
326}
327
328fn os_cache_dir() -> Option<PathBuf> {
337 let abs = |v: std::ffi::OsString| Some(PathBuf::from(v)).filter(|p| p.is_absolute());
338 if cfg!(windows) {
339 std::env::var_os("LOCALAPPDATA").and_then(abs)
340 } else if cfg!(target_os = "macos") {
341 std::env::var_os("HOME").and_then(abs).map(|h| h.join("Library/Caches"))
342 } else {
343 std::env::var_os("XDG_CACHE_HOME")
344 .and_then(abs)
345 .or_else(|| std::env::var_os("HOME").and_then(abs).map(|h| h.join(".cache")))
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 #[test]
354 fn manifest_parses_and_surfaces_license() {
355 let m = ModelManifest::from_toml(
356 r#"
357 name = "whisper-tiny"
358 task = "asr"
359 license = "Apache-2.0"
360 hf_repo = "openai/whisper-tiny"
361
362 [[files]]
363 name = "model.safetensors"
364 "#,
365 )
366 .unwrap();
367 assert_eq!(m.name, "whisper-tiny");
368 assert_eq!(m.license, "Apache-2.0");
369 assert_eq!(m.files.len(), 1);
370 assert!(!m.is_cached());
371 }
372
373 fn scratch(tag: &str) -> PathBuf {
374 let d = std::env::temp_dir().join(format!("ffai-models-{tag}-{}", std::process::id()));
375 let _ = std::fs::remove_dir_all(&d);
376 std::fs::create_dir_all(&d).unwrap();
377 d
378 }
379
380 fn manifest_for(sha: &str) -> ModelManifest {
381 ModelManifest::from_toml(&format!(
382 r#"
383 name = "tiny-test"
384 task = "ocr"
385 license = "MIT"
386
387 [[files]]
388 name = "w.bin"
389 sha256 = "{sha}"
390 "#
391 ))
392 .unwrap()
393 }
394
395 const ABC: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
397
398 #[test]
399 fn resolve_in_names_the_exact_missing_path_and_never_fetches() {
400 let root = scratch("missing");
401 let err = manifest_for(ABC).resolve_in(&root).unwrap_err().to_string();
402 let want = root.join("tiny-test").join("w.bin");
403 assert!(
404 err.contains(&want.display().to_string()),
405 "error must name {}: {err}",
406 want.display()
407 );
408 assert!(err.contains("never downloads"), "{err}");
409 let _ = std::fs::remove_dir_all(&root);
410 }
411
412 #[test]
413 fn resolve_in_finds_and_checksums_a_placed_file() {
414 let root = scratch("present");
415 std::fs::create_dir_all(root.join("tiny-test")).unwrap();
416 std::fs::write(root.join("tiny-test").join("w.bin"), b"abc").unwrap();
417 let r = manifest_for(ABC).resolve_in(&root).unwrap();
418 assert_eq!(
419 r.file("w.bin").unwrap(),
420 root.join("tiny-test").join("w.bin")
421 );
422
423 let bad = manifest_for(&"0".repeat(64))
425 .resolve_in(&root)
426 .unwrap_err()
427 .to_string();
428 assert!(bad.contains("checksum mismatch"), "{bad}");
429 let _ = std::fs::remove_dir_all(&root);
430 }
431
432 #[test]
433 fn load_dir_names_a_missing_directory() {
434 let missing = std::env::temp_dir().join("ffai-models-definitely-absent-dir");
435 let err = load_dir(&missing).unwrap_err().to_string();
436 assert!(err.contains(&missing.display().to_string()), "{err}");
437 }
438
439 #[test]
443 fn resolve_only_in_needs_just_the_named_files_and_refuses_undeclared_ones() {
444 let root = scratch("subset");
445 std::fs::create_dir_all(root.join("tiny-test")).unwrap();
446 std::fs::write(root.join("tiny-test").join("w.bin"), b"abc").unwrap();
447 let m = ModelManifest::from_toml(&format!(
448 r#"
449 name = "tiny-test"
450 task = "ocr"
451 license = "MIT"
452
453 [[files]]
454 name = "w.bin"
455 sha256 = "{ABC}"
456
457 [[files]]
458 name = "train-only.bin"
459 "#
460 ))
461 .unwrap();
462 assert!(
463 m.resolve_in(&root).is_err(),
464 "the full set includes an absent file"
465 );
466 let r = m.resolve_only_in(&root, &["w.bin"]).unwrap();
467 assert_eq!(r.files.len(), 1);
468 let err = m
469 .resolve_only_in(&root, &["nope.bin"])
470 .unwrap_err()
471 .to_string();
472 assert!(err.contains("declares no file `nope.bin`"), "{err}");
473 let _ = std::fs::remove_dir_all(&root);
474 }
475
476 #[test]
479 fn fetch_only_refuses_undeclared_names_before_any_lookup() {
480 let m = ModelManifest::from_toml(
481 r#"
482 name = "tiny-test"
483 task = "ocr"
484 license = "MIT"
485 hf_repo = "example/does-not-matter"
486
487 [[files]]
488 name = "w.bin"
489 "#,
490 )
491 .unwrap();
492 let err = m.fetch_only(&["nope.bin"]).unwrap_err().to_string();
493 assert!(err.contains("declares no file `nope.bin`"), "{err}");
494 }
495}