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 mut files = BTreeMap::new();
158 for file in &self.files {
159 if let Some(path) = self.local_path(&file.name) {
160 verify_checksum(&path, file)?;
161 files.insert(file.name.clone(), path);
162 continue;
163 }
164 let repo = self.hf_repo.as_ref().ok_or_else(|| {
165 Error::Model(format!(
166 "model `{}` declares no hf_repo and `{}` is not present under {}",
167 self.name,
168 file.name,
169 self.cache_path().display()
170 ))
171 })?;
172 let path = hub_download(repo, &file.name, false)?;
173 verify_checksum(&path, file)?;
174 files.insert(file.name.clone(), path);
175 }
176 Ok(ResolvedModel {
177 name: self.name.clone(),
178 license: self.license.clone(),
179 files,
180 })
181 }
182}
183
184pub fn load_dir(dir: &Path) -> Result<Vec<ModelManifest>> {
186 let mut out = Vec::new();
187 for entry in std::fs::read_dir(dir)? {
188 let path = entry?.path();
189 if path.extension().and_then(|e| e.to_str()) == Some("toml") {
190 out.push(ModelManifest::load(&path)?);
191 }
192 }
193 out.sort_by(|a, b| a.task.cmp(&b.task).then_with(|| a.name.cmp(&b.name)));
194 Ok(out)
195}
196
197pub fn cache_dir() -> PathBuf {
199 if let Ok(dir) = std::env::var("FFAI_CACHE") {
200 return PathBuf::from(dir);
201 }
202 dirs::cache_dir()
203 .unwrap_or_else(std::env::temp_dir)
204 .join("ffai")
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn manifest_parses_and_surfaces_license() {
213 let m = ModelManifest::from_toml(
214 r#"
215 name = "whisper-tiny"
216 task = "asr"
217 license = "Apache-2.0"
218 hf_repo = "openai/whisper-tiny"
219
220 [[files]]
221 name = "model.safetensors"
222 "#,
223 )
224 .unwrap();
225 assert_eq!(m.name, "whisper-tiny");
226 assert_eq!(m.license, "Apache-2.0");
227 assert_eq!(m.files.len(), 1);
228 assert!(!m.is_cached());
229 }
230}