Skip to main content

model_hf/store/
local.rs

1use hf_hub::cache::{CachedFileInfo, CachedRepoInfo, CachedRevisionInfo, HFCacheInfo};
2use hf_hub::{HFClientBuilder, RepoType, RepoTypeModel};
3use model_ref::{format_model_ref, gguf_matches_quant_selector, normalize_gguf_distribution_id};
4use sha2::{Digest, Sha256};
5use std::collections::{HashMap, HashSet};
6use std::ffi::OsStr;
7use std::path::{Path, PathBuf};
8use std::sync::{Mutex, OnceLock};
9use std::time::UNIX_EPOCH;
10
11mod mmproj;
12
13pub use mmproj::find_mmproj_path;
14
15#[derive(Clone, Debug, Eq, PartialEq)]
16pub struct HuggingFaceModelIdentity {
17    pub repo_id: String,
18    pub revision: String,
19    pub file: String,
20    pub canonical_ref: String,
21    pub local_file_name: String,
22}
23
24static MODEL_REF_PATHS: OnceLock<Mutex<HashMap<String, PathBuf>>> = OnceLock::new();
25
26fn model_ref_paths() -> &'static Mutex<HashMap<String, PathBuf>> {
27    MODEL_REF_PATHS.get_or_init(|| Mutex::new(HashMap::new()))
28}
29
30fn remember_model_ref_path(model_ref: &str, path: &Path) {
31    if let Ok(mut paths) = model_ref_paths().lock() {
32        match paths.get(model_ref) {
33            Some(existing)
34                if model_ref_path_preference_key(existing)
35                    <= model_ref_path_preference_key(path) => {}
36            _ => {
37                paths.insert(model_ref.to_string(), path.to_path_buf());
38            }
39        }
40    }
41}
42
43fn remembered_model_ref_path(model_ref: &str) -> Option<PathBuf> {
44    model_ref_paths()
45        .lock()
46        .ok()
47        .and_then(|paths| paths.get(model_ref).cloned())
48        .filter(|path| path.exists())
49}
50
51impl HuggingFaceModelIdentity {
52    #[cfg(test)]
53    pub fn distribution_ref(&self) -> String {
54        format!(
55            "{}@{}/{}",
56            self.repo_id,
57            self.revision,
58            distribution_ref_file(&self.file)
59        )
60    }
61}
62
63#[cfg(test)]
64fn distribution_ref_file(file: &str) -> String {
65    let path = Path::new(file);
66    let file_name = path.file_name().and_then(|value| value.to_str());
67    let Some(file_name) = file_name else {
68        return file.to_string();
69    };
70    let Some(stem) = file_name.strip_suffix(".gguf") else {
71        return file.to_string();
72    };
73    let Some((prefix, suffix)) = stem.rsplit_once("-of-") else {
74        return file.to_string();
75    };
76    let Some((prefix, shard_no)) = prefix.rsplit_once('-') else {
77        return file.to_string();
78    };
79    if shard_no.len() != 5
80        || suffix.len() != 5
81        || !shard_no.chars().all(|ch| ch.is_ascii_digit())
82        || !suffix.chars().all(|ch| ch.is_ascii_digit())
83    {
84        return file.to_string();
85    }
86    path.with_file_name(prefix)
87        .to_string_lossy()
88        .replace('\\', "/")
89}
90
91pub fn huggingface_hub_cache() -> PathBuf {
92    crate::huggingface_hub_cache_dir()
93}
94
95pub fn huggingface_hub_cache_dir() -> PathBuf {
96    crate::huggingface_hub_cache_dir()
97}
98
99pub fn huggingface_repo_folder_name(repo_id: &str, repo_type: impl RepoType) -> String {
100    let type_plural = repo_type.plural();
101    std::iter::once(type_plural)
102        .chain(repo_id.split('/'))
103        .collect::<Vec<_>>()
104        .join("--")
105}
106
107pub fn huggingface_snapshot_path(
108    repo_id: &str,
109    repo_type: impl RepoType,
110    revision: &str,
111) -> PathBuf {
112    huggingface_hub_cache_dir()
113        .join(huggingface_repo_folder_name(repo_id, repo_type))
114        .join("snapshots")
115        .join(revision)
116}
117
118pub fn scan_hf_cache_info(cache_root: &Path) -> Option<HFCacheInfo> {
119    let cache_root = cache_root.to_path_buf();
120    let scan = move || {
121        let runtime = tokio::runtime::Builder::new_current_thread()
122            .enable_all()
123            .build()
124            .ok()?;
125        runtime
126            .block_on(
127                HFClientBuilder::new()
128                    .cache_dir(cache_root)
129                    .build()
130                    .ok()?
131                    .scan_cache()
132                    .send(),
133            )
134            .ok()
135    };
136
137    if tokio::runtime::Handle::try_current().is_ok() {
138        std::thread::spawn(scan).join().ok().flatten()
139    } else {
140        scan()
141    }
142}
143
144fn cache_repo_id(repo: &CachedRepoInfo) -> Option<&str> {
145    (repo.repo_type == RepoTypeModel.singular()).then_some(repo.repo_id.as_str())
146}
147
148pub fn mesh_llm_cache_dir() -> PathBuf {
149    crate::mesh_llm_cache_dir()
150}
151
152pub fn model_metadata_cache_dir() -> PathBuf {
153    mesh_llm_cache_dir().join("model-meta")
154}
155
156fn parse_model_repo_folder_name(folder: &str) -> Option<String> {
157    folder
158        .strip_prefix("models--")
159        .map(|value| value.replace("--", "/"))
160}
161
162fn identity_from_cache_snapshot_path(
163    path: &Path,
164    cache_root: &Path,
165) -> Option<HuggingFaceModelIdentity> {
166    let relative = path.strip_prefix(cache_root).ok()?;
167    let mut components = relative.components();
168    let repo_folder = components.next()?.as_os_str().to_str()?;
169    let repo_id = parse_model_repo_folder_name(repo_folder)?;
170    if components.next()?.as_os_str() != OsStr::new("snapshots") {
171        return None;
172    }
173    let revision = components.next()?.as_os_str().to_str()?.to_string();
174    let relative_file = components
175        .map(|component| component.as_os_str().to_str())
176        .collect::<Option<Vec<_>>>()?
177        .join("/");
178    if relative_file.is_empty() {
179        return None;
180    }
181    let local_file_name = Path::new(&relative_file)
182        .file_name()
183        .and_then(|value| value.to_str())?
184        .to_string();
185    let canonical_ref = format!("{repo_id}@{revision}/{relative_file}");
186    Some(HuggingFaceModelIdentity {
187        repo_id,
188        revision,
189        file: relative_file,
190        canonical_ref,
191        local_file_name,
192    })
193}
194
195fn identity_from_snapshot_layout_ancestors(path: &Path) -> Option<HuggingFaceModelIdentity> {
196    for revision_dir in path.ancestors() {
197        let Some(snapshots_dir) = revision_dir.parent() else {
198            continue;
199        };
200        if snapshots_dir.file_name()? != OsStr::new("snapshots") {
201            continue;
202        }
203        let repo_dir = snapshots_dir.parent()?;
204        let repo_folder = repo_dir.file_name()?.to_str()?;
205        let repo_id = parse_model_repo_folder_name(repo_folder)?;
206        let revision = revision_dir.file_name()?.to_str()?.to_string();
207        let relative_file = path
208            .strip_prefix(revision_dir)
209            .ok()?
210            .components()
211            .map(|component| component.as_os_str().to_str())
212            .collect::<Option<Vec<_>>>()?
213            .join("/");
214        if relative_file.is_empty() {
215            continue;
216        }
217        let local_file_name = Path::new(&relative_file)
218            .file_name()
219            .and_then(|value| value.to_str())?
220            .to_string();
221        let canonical_ref = format!("{repo_id}@{revision}/{relative_file}");
222        return Some(HuggingFaceModelIdentity {
223            repo_id,
224            revision,
225            file: relative_file,
226            canonical_ref,
227            local_file_name,
228        });
229    }
230    None
231}
232
233fn scan_hf_cache_identity_for_path(
234    path: &Path,
235    cache_root: &Path,
236) -> Option<HuggingFaceModelIdentity> {
237    let cache_info = scan_hf_cache_info(cache_root)?;
238    let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
239
240    for repo in &cache_info.repos {
241        let Some(repo_id) = cache_repo_id(repo) else {
242            continue;
243        };
244        for revision in &repo.revisions {
245            for file in &revision.files {
246                let candidate = file
247                    .file_path
248                    .canonicalize()
249                    .unwrap_or_else(|_| file.file_path.clone());
250                if file.file_path != path && candidate != resolved {
251                    continue;
252                }
253
254                let relative_path = file
255                    .file_path
256                    .strip_prefix(&revision.snapshot_path)
257                    .ok()?
258                    .to_string_lossy()
259                    .replace('\\', "/");
260                if relative_path.is_empty() {
261                    return None;
262                }
263
264                let canonical_ref = format!(
265                    "{repo_id}@{revision}/{relative_path}",
266                    revision = revision.commit_hash
267                );
268
269                return Some(HuggingFaceModelIdentity {
270                    repo_id: repo_id.to_string(),
271                    revision: revision.commit_hash.clone(),
272                    file: relative_path,
273                    canonical_ref,
274                    local_file_name: file.file_name.clone(),
275                });
276            }
277        }
278    }
279
280    None
281}
282
283pub fn huggingface_identity_for_path(path: &Path) -> Option<HuggingFaceModelIdentity> {
284    let cache_root = huggingface_hub_cache_dir();
285    if let Some(identity) = identity_from_cache_snapshot_path(path, &cache_root) {
286        return Some(identity);
287    }
288    let resolved_cache_root = cache_root
289        .canonicalize()
290        .unwrap_or_else(|_| cache_root.clone());
291    if resolved_cache_root != *cache_root
292        && let Some(identity) = identity_from_cache_snapshot_path(path, &resolved_cache_root)
293    {
294        return Some(identity);
295    }
296    let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
297    if resolved != path {
298        if let Some(identity) = identity_from_cache_snapshot_path(&resolved, &cache_root) {
299            return Some(identity);
300        }
301        if resolved_cache_root != *cache_root
302            && let Some(identity) =
303                identity_from_cache_snapshot_path(&resolved, &resolved_cache_root)
304        {
305            return Some(identity);
306        }
307    }
308    if let Some(identity) = identity_from_snapshot_layout_ancestors(path) {
309        return Some(identity);
310    }
311    if resolved != path
312        && let Some(identity) = identity_from_snapshot_layout_ancestors(&resolved)
313    {
314        return Some(identity);
315    }
316    scan_hf_cache_identity_for_path(path, &cache_root)
317}
318
319pub fn gguf_metadata_cache_path(path: &Path) -> Option<PathBuf> {
320    let key = if let Some(identity) = huggingface_identity_for_path(path) {
321        format!("hf:{}", identity.canonical_ref)
322    } else {
323        let metadata = std::fs::metadata(path).ok()?;
324        let modified = metadata
325            .modified()
326            .ok()?
327            .duration_since(UNIX_EPOCH)
328            .ok()?
329            .as_nanos();
330        format!(
331            "local:{}:{}:{}",
332            path.to_string_lossy(),
333            metadata.len(),
334            modified
335        )
336    };
337    let digest = Sha256::digest(key.as_bytes());
338    Some(model_metadata_cache_dir().join(format!("{digest:x}.json")))
339}
340
341pub fn direct_hf_cache_root_gguf_paths(root: &Path) -> Vec<PathBuf> {
342    let mut out = Vec::new();
343    let Ok(entries) = std::fs::read_dir(root) else {
344        return out;
345    };
346    for entry in entries.flatten() {
347        let path = entry.path();
348        let Ok(file_type) = entry.file_type() else {
349            continue;
350        };
351        if !(file_type.is_file() || file_type.is_symlink()) {
352            continue;
353        }
354        if path
355            .extension()
356            .and_then(|ext| ext.to_str())
357            .map(|ext| ext.eq_ignore_ascii_case("gguf"))
358            != Some(true)
359        {
360            continue;
361        }
362        out.push(path);
363    }
364    out.sort();
365    out
366}
367
368fn cache_scanned_file_path(
369    cache_root: &Path,
370    repo: &CachedRepoInfo,
371    revision: &CachedRevisionInfo,
372    file: &CachedFileInfo,
373) -> PathBuf {
374    let relative = file
375        .file_path
376        .strip_prefix(&revision.snapshot_path)
377        .unwrap_or(file.file_path.as_path());
378    repo.repo_path
379        .strip_prefix(cache_root)
380        .map_or_else(
381            |_| repo.repo_path.clone(),
382            |relative| cache_root.join(relative),
383        )
384        .join("snapshots")
385        .join(&revision.commit_hash)
386        .join(relative)
387}
388
389fn cached_relative_file(revision: &CachedRevisionInfo, file: &CachedFileInfo) -> String {
390    file.file_path
391        .strip_prefix(&revision.snapshot_path)
392        .unwrap_or(file.file_path.as_path())
393        .to_string_lossy()
394        .replace('\\', "/")
395}
396
397fn layered_package_relative_preference(relative_file: &str) -> u8 {
398    if relative_file == "shared/output.gguf" {
399        0
400    } else if is_layered_package_direct_shared_relative_file(relative_file) {
401        1
402    } else if layered_package_layer_index(relative_file).is_some() {
403        2
404    } else if is_layered_package_gguf_relative_file(relative_file) {
405        3
406    } else {
407        4
408    }
409}
410
411fn model_ref_path_preference_key(path: &Path) -> (u8, String) {
412    let rank = huggingface_identity_for_path(path)
413        .filter(|identity| identity.repo_id.ends_with("-layers"))
414        .map(|identity| layered_package_relative_preference(&identity.file))
415        .unwrap_or(0);
416    (rank, path.to_string_lossy().to_string())
417}
418
419fn model_ref_path_preference_key_for_cache_root(root: &Path, path: &Path) -> (u8, String) {
420    let rank = identity_from_cache_snapshot_path(path, root)
421        .filter(|identity| identity.repo_id.ends_with("-layers"))
422        .map(|identity| layered_package_relative_preference(&identity.file))
423        .unwrap_or(0);
424    (rank, path.to_string_lossy().to_string())
425}
426
427fn push_model_name(
428    path: &Path,
429    names: &mut Vec<String>,
430    seen: &mut HashSet<String>,
431    min_size_bytes: u64,
432) {
433    if path.extension().and_then(|ext| ext.to_str()) != Some("gguf") {
434        return;
435    }
436    let Some(stem) = path.file_stem().and_then(|value| value.to_str()) else {
437        return;
438    };
439    if stem.contains("mmproj") {
440        return;
441    }
442    let size = std::fs::metadata(path).map(|meta| meta.len()).unwrap_or(0);
443    if size <= min_size_bytes {
444        return;
445    }
446    let name = model_ref_for_path(path);
447    if seen.insert(name.clone()) {
448        names.push(name);
449    }
450}
451
452fn scan_hf_cache_models(
453    cache_root: &Path,
454    names: &mut Vec<String>,
455    seen: &mut HashSet<String>,
456    min_size_bytes: u64,
457) {
458    for path in direct_hf_cache_root_gguf_paths(cache_root) {
459        push_model_name(&path, names, seen, min_size_bytes);
460    }
461
462    if std::env::var("MESH_LLM_ALLOW_FULL_HF_CACHE_SCAN").unwrap_or_default() == "1" {
463        let Some(cache_info) = scan_hf_cache_info(cache_root) else {
464            return;
465        };
466        for repo in &cache_info.repos {
467            if repo.repo_type != RepoTypeModel.singular() {
468                continue;
469            }
470            for revision in &repo.revisions {
471                let mut files = revision.files.iter().collect::<Vec<_>>();
472                files.sort_by(|left, right| {
473                    let left_relative = cached_relative_file(revision, left);
474                    let right_relative = cached_relative_file(revision, right);
475                    layered_package_relative_preference(&left_relative)
476                        .cmp(&layered_package_relative_preference(&right_relative))
477                        .then_with(|| left_relative.cmp(&right_relative))
478                });
479                for file in files {
480                    if !file.file_name.ends_with(".gguf") {
481                        continue;
482                    }
483                    let path = cache_scanned_file_path(cache_root, repo, revision, file);
484                    push_model_name(&path, names, seen, min_size_bytes);
485                }
486            }
487        }
488    } else {
489        for path in scan_hf_cache_fast(cache_root) {
490            push_model_name(&path, names, seen, min_size_bytes);
491        }
492    }
493}
494
495fn scan_models_with_min_size(cache_root: &Path, min_size_bytes: u64) -> Vec<String> {
496    let mut names = Vec::new();
497    let mut seen = HashSet::new();
498    if cache_root.exists() {
499        scan_hf_cache_models(cache_root, &mut names, &mut seen, min_size_bytes);
500    }
501    names.sort();
502    names
503}
504
505/// Scan model directories for GGUF files and return canonical model refs.
506pub fn scan_local_models() -> Vec<String> {
507    scan_models_with_min_size(&huggingface_hub_cache_dir(), 500_000_000)
508}
509
510/// Scan installed GGUF models, including small draft models, and return canonical model refs.
511pub fn scan_installed_models() -> Vec<String> {
512    scan_installed_models_in(&huggingface_hub_cache_dir())
513}
514
515/// Scan an explicit Hugging Face cache for installed GGUF models.
516pub fn scan_installed_models_in(cache_root: &Path) -> Vec<String> {
517    scan_models_with_min_size(cache_root, 0)
518}
519
520fn hf_identity_model_ref(identity: &HuggingFaceModelIdentity) -> String {
521    if let Some(model_ref) = layered_package_model_ref(identity) {
522        return model_ref;
523    }
524    let selector = model_ref::quant_selector_from_gguf_file(&identity.file)
525        .or_else(|| normalize_gguf_distribution_id(&identity.file));
526    format_model_ref(&identity.repo_id, None, selector.as_deref())
527}
528
529fn layered_package_model_ref(identity: &HuggingFaceModelIdentity) -> Option<String> {
530    if identity.repo_id.ends_with("-layers")
531        && is_layered_package_gguf_relative_file(&identity.file)
532    {
533        Some(format_model_ref(&identity.repo_id, None, None))
534    } else {
535        None
536    }
537}
538
539fn is_layered_package_gguf_relative_file(relative_file: &str) -> bool {
540    (relative_file.starts_with("shared/") || relative_file.starts_with("layers/"))
541        && relative_file.ends_with(".gguf")
542        && Path::new(relative_file).file_name().is_some()
543}
544
545fn is_layered_package_direct_shared_relative_file(relative_file: &str) -> bool {
546    let Some(file_name) = relative_file.strip_prefix("shared/") else {
547        return false;
548    };
549    !file_name.is_empty() && !file_name.contains('/') && file_name.ends_with(".gguf")
550}
551
552fn layered_package_layer_index(relative_file: &str) -> Option<usize> {
553    let relative = relative_file.strip_prefix("layers/")?;
554    let file_name = Path::new(relative).file_name()?.to_str()?;
555    let index = file_name.strip_prefix("layer-")?.strip_suffix(".gguf")?;
556    if index.is_empty() || !index.chars().all(|ch| ch.is_ascii_digit()) {
557        return None;
558    }
559    index.parse().ok()
560}
561
562fn layered_package_snapshot_root(
563    path: &Path,
564    identity: &HuggingFaceModelIdentity,
565) -> Option<PathBuf> {
566    let mut root = path.to_path_buf();
567    for _ in Path::new(&identity.file).components() {
568        if !root.pop() {
569            return None;
570        }
571    }
572    Some(root)
573}
574
575fn layered_package_gguf_paths(path: &Path) -> Option<(PathBuf, Vec<PathBuf>)> {
576    let identity = huggingface_identity_for_path(path)?;
577    layered_package_model_ref(&identity)?;
578    let root = layered_package_snapshot_root(path, &identity)?;
579    let mut paths = Vec::new();
580    for subdir in ["shared", "layers"] {
581        collect_gguf_paths_recursive(&root.join(subdir), &mut paths);
582    }
583    paths.sort();
584    Some((root, paths))
585}
586
587fn collect_gguf_paths_recursive(dir: &Path, paths: &mut Vec<PathBuf>) {
588    let Ok(entries) = std::fs::read_dir(dir) else {
589        return;
590    };
591    for entry in entries.flatten() {
592        let path = entry.path();
593        if path.is_dir() {
594            collect_gguf_paths_recursive(&path, paths);
595            continue;
596        }
597        if path
598            .extension()
599            .and_then(|ext| ext.to_str())
600            .is_some_and(|ext| ext.eq_ignore_ascii_case("gguf"))
601        {
602            paths.push(path);
603        }
604    }
605}
606
607pub fn scan_hf_cache_fast(cache_root: &Path) -> Vec<PathBuf> {
608    let mut gguf_paths = Vec::new();
609    let Ok(entries) = std::fs::read_dir(cache_root) else {
610        return gguf_paths;
611    };
612    for entry in entries.flatten() {
613        let path = entry.path();
614        if path.is_dir() {
615            let snapshots = path.join("snapshots");
616            if snapshots.exists() {
617                collect_gguf_paths_recursive(&snapshots, &mut gguf_paths);
618            }
619        }
620    }
621    gguf_paths
622}
623
624pub fn layered_package_layer_count_for_path(path: &Path) -> Option<usize> {
625    let (root, paths) = layered_package_gguf_paths(path)?;
626    let layers = paths
627        .iter()
628        .filter(|path| {
629            path.strip_prefix(&root)
630                .ok()
631                .map(|relative| relative.to_string_lossy().replace('\\', "/"))
632                .as_deref()
633                .and_then(layered_package_layer_index)
634                .is_some()
635        })
636        .count();
637    (layers > 0).then_some(layers)
638}
639
640pub fn layered_package_total_bytes_for_path(path: &Path) -> Option<u64> {
641    let (_root, paths) = layered_package_gguf_paths(path)?;
642    let total = paths
643        .iter()
644        .map(|path| std::fs::metadata(path).map(|meta| meta.len()).unwrap_or(0))
645        .sum();
646    Some(total)
647}
648
649fn synthetic_local_gguf_model_ref(path: &Path) -> String {
650    let filename = path
651        .file_name()
652        .and_then(|value| value.to_str())
653        .unwrap_or("model.gguf");
654    let metadata = std::fs::metadata(path).ok();
655    let len = metadata.as_ref().map(std::fs::Metadata::len).unwrap_or(0);
656    let modified = metadata
657        .and_then(|metadata| metadata.modified().ok())
658        .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
659        .map(|duration| duration.as_nanos())
660        .unwrap_or(0);
661    let mut hasher = Sha256::new();
662    hasher.update(path.to_string_lossy().as_bytes());
663    hasher.update(b"\0");
664    hasher.update(filename.as_bytes());
665    hasher.update(b"\0");
666    hasher.update(len.to_le_bytes());
667    hasher.update(modified.to_le_bytes());
668    let digest = format!("{:x}", hasher.finalize());
669    format_model_ref(&format!("local-gguf/sha256-{}", &digest[..16]), None, None)
670}
671
672pub fn model_ref_for_path(path: &Path) -> String {
673    let model_ref = huggingface_identity_for_path(path)
674        .map(|identity| hf_identity_model_ref(&identity))
675        .unwrap_or_else(|| synthetic_local_gguf_model_ref(path));
676    remember_model_ref_path(&model_ref, path);
677    model_ref
678}
679
680fn find_hf_cache_model_ref_path(root: &Path, model: &model_ref::ModelRef) -> Option<PathBuf> {
681    if model.repo.starts_with("local-gguf/") {
682        return find_synthetic_local_gguf_path(root, model);
683    }
684    let cache_info = scan_hf_cache_info(root)?;
685    let mut candidates = Vec::new();
686    for repo in &cache_info.repos {
687        if repo.repo_type != RepoTypeModel.singular() || repo.repo_id != model.repo {
688            continue;
689        }
690        for revision in &repo.revisions {
691            if let Some(wanted_revision) = model.revision.as_deref()
692                && revision.commit_hash != wanted_revision
693            {
694                continue;
695            }
696            for file in &revision.files {
697                if !file.file_name.ends_with(".gguf") {
698                    continue;
699                }
700                let matches = match model.selector.as_deref() {
701                    Some(selector) => {
702                        gguf_matches_quant_selector(&file.file_name, selector)
703                            || normalize_gguf_distribution_id(&file.file_name).as_deref()
704                                == Some(selector)
705                    }
706                    None => true,
707                };
708                if matches {
709                    candidates.push(cache_scanned_file_path(root, repo, revision, file));
710                }
711            }
712        }
713    }
714    candidates.sort_by_key(|path| model_ref_path_preference_key_for_cache_root(root, path));
715    candidates.into_iter().next()
716}
717
718fn find_synthetic_local_gguf_path(root: &Path, model: &model_ref::ModelRef) -> Option<PathBuf> {
719    let wanted = model.display_id();
720    let mut candidates = direct_hf_cache_root_gguf_paths(root);
721    let cache_info = scan_hf_cache_info(root);
722    if let Some(cache_info) = cache_info {
723        for repo in &cache_info.repos {
724            if repo.repo_type != RepoTypeModel.singular() {
725                continue;
726            }
727            for revision in &repo.revisions {
728                for file in &revision.files {
729                    if file.file_name.ends_with(".gguf") {
730                        candidates.push(cache_scanned_file_path(root, repo, revision, file));
731                    }
732                }
733            }
734        }
735    }
736    candidates.sort();
737    candidates
738        .into_iter()
739        .find(|path| model_ref_for_path(path) == wanted)
740}
741
742fn find_hf_cache_model_path(root: &Path, stem: &str) -> Option<PathBuf> {
743    let filename = format!("{stem}.gguf");
744    let direct = root.join(&filename);
745    if direct.exists() {
746        return Some(direct);
747    }
748
749    let split_prefix = format!("{stem}-00001-of-");
750    let cache_root = huggingface_hub_cache_dir();
751    let cache_info = scan_hf_cache_info(&cache_root)?;
752    for repo in &cache_info.repos {
753        if repo.repo_type != RepoTypeModel.singular() {
754            continue;
755        }
756        for revision in &repo.revisions {
757            for file in &revision.files {
758                let Some(name) = Path::new(&file.file_name)
759                    .file_name()
760                    .and_then(|value| value.to_str())
761                else {
762                    continue;
763                };
764                if name == filename || (name.starts_with(&split_prefix) && name.ends_with(".gguf"))
765                {
766                    return Some(cache_scanned_file_path(&cache_root, repo, revision, file));
767                }
768            }
769        }
770    }
771    None
772}
773
774/// Extract the base model name from a split GGUF stem.
775/// "GLM-5-UD-IQ2_XXS-00001-of-00006" → Some("GLM-5-UD-IQ2_XXS")
776/// "Qwen3-8B-Q4_K_M" → None (not a split file)
777pub fn split_gguf_base_name(stem: &str) -> Option<&str> {
778    let suffix = stem.rfind("-of-")?;
779    let part_num = &stem[suffix + 4..];
780    if part_num.len() != 5 || !part_num.chars().all(|c| c.is_ascii_digit()) {
781        return None;
782    }
783    let dash = stem[..suffix].rfind('-')?;
784    let seq = &stem[dash + 1..suffix];
785    if seq.len() != 5 || !seq.chars().all(|c| c.is_ascii_digit()) {
786        return None;
787    }
788    Some(&stem[..dash])
789}
790
791/// Find a GGUF model file by canonical model ref in the Hugging Face cache.
792/// For split GGUFs, finds the first part (name-00001-of-NNNNN.gguf).
793pub fn find_model_path(model_ref: &str) -> PathBuf {
794    if let Some(path) = remembered_model_ref_path(model_ref) {
795        return path;
796    }
797    let canonical_dir = huggingface_hub_cache_dir();
798    if let Ok(parsed) = model_ref::ModelRef::parse(model_ref)
799        && let Some(found) = find_hf_cache_model_ref_path(&canonical_dir, &parsed)
800    {
801        return found;
802    }
803
804    if let Some(found) = find_hf_cache_model_path(&canonical_dir, model_ref) {
805        return found;
806    }
807
808    canonical_dir.join(format!("{model_ref}.gguf"))
809}
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814    use serial_test::serial;
815
816    #[test]
817    #[serial]
818    fn huggingface_cache_prefers_explicit_hub_cache() {
819        let prev_hub_cache = std::env::var_os("HF_HUB_CACHE");
820        let prev_huggingface_hub_cache = std::env::var_os("HUGGINGFACE_HUB_CACHE");
821        let prev_hf_home = std::env::var_os("HF_HOME");
822        let prev_xdg = std::env::var_os("XDG_CACHE_HOME");
823        // TODO: Audit that the environment access only happens in single-threaded code.
824        unsafe { std::env::set_var("HF_HUB_CACHE", "/tmp/mesh-llm-hub-cache") };
825        // TODO: Audit that the environment access only happens in single-threaded code.
826        unsafe { std::env::set_var("HUGGINGFACE_HUB_CACHE", "/tmp/mesh-llm-alt-hub-cache") };
827        // TODO: Audit that the environment access only happens in single-threaded code.
828        unsafe { std::env::set_var("HF_HOME", "/tmp/mesh-llm-hf-home") };
829        // TODO: Audit that the environment access only happens in single-threaded code.
830        unsafe { std::env::set_var("XDG_CACHE_HOME", "/tmp/mesh-llm-xdg") };
831
832        assert_eq!(
833            huggingface_hub_cache_dir(),
834            PathBuf::from("/tmp/mesh-llm-hub-cache")
835        );
836
837        restore_env("HF_HUB_CACHE", prev_hub_cache);
838        restore_env("HUGGINGFACE_HUB_CACHE", prev_huggingface_hub_cache);
839        restore_env("HF_HOME", prev_hf_home);
840        restore_env("XDG_CACHE_HOME", prev_xdg);
841    }
842
843    #[test]
844    #[serial]
845    fn huggingface_cache_accepts_huggingface_hub_cache_alias() {
846        let prev_hub_cache = std::env::var_os("HF_HUB_CACHE");
847        let prev_huggingface_hub_cache = std::env::var_os("HUGGINGFACE_HUB_CACHE");
848        let prev_hf_home = std::env::var_os("HF_HOME");
849        let prev_xdg = std::env::var_os("XDG_CACHE_HOME");
850        // TODO: Audit that the environment access only happens in single-threaded code.
851        unsafe { std::env::remove_var("HF_HUB_CACHE") };
852        // TODO: Audit that the environment access only happens in single-threaded code.
853        unsafe { std::env::set_var("HUGGINGFACE_HUB_CACHE", "/tmp/mesh-llm-alt-hub-cache") };
854        // TODO: Audit that the environment access only happens in single-threaded code.
855        unsafe { std::env::set_var("HF_HOME", "/tmp/mesh-llm-hf-home") };
856        // TODO: Audit that the environment access only happens in single-threaded code.
857        unsafe { std::env::set_var("XDG_CACHE_HOME", "/tmp/mesh-llm-xdg") };
858
859        assert_eq!(
860            huggingface_hub_cache_dir(),
861            PathBuf::from("/tmp/mesh-llm-alt-hub-cache")
862        );
863
864        restore_env("HF_HUB_CACHE", prev_hub_cache);
865        restore_env("HUGGINGFACE_HUB_CACHE", prev_huggingface_hub_cache);
866        restore_env("HF_HOME", prev_hf_home);
867        restore_env("XDG_CACHE_HOME", prev_xdg);
868    }
869
870    #[test]
871    #[serial]
872    fn huggingface_cache_falls_back_to_hf_home() {
873        let prev_hub_cache = std::env::var_os("HF_HUB_CACHE");
874        let prev_huggingface_hub_cache = std::env::var_os("HUGGINGFACE_HUB_CACHE");
875        let prev_hf_home = std::env::var_os("HF_HOME");
876        let prev_xdg = std::env::var_os("XDG_CACHE_HOME");
877        // TODO: Audit that the environment access only happens in single-threaded code.
878        unsafe { std::env::remove_var("HF_HUB_CACHE") };
879        // TODO: Audit that the environment access only happens in single-threaded code.
880        unsafe { std::env::remove_var("HUGGINGFACE_HUB_CACHE") };
881        // TODO: Audit that the environment access only happens in single-threaded code.
882        unsafe { std::env::set_var("HF_HOME", "/tmp/mesh-llm-hf-home") };
883        // TODO: Audit that the environment access only happens in single-threaded code.
884        unsafe { std::env::set_var("XDG_CACHE_HOME", "/tmp/mesh-llm-xdg") };
885
886        assert_eq!(
887            huggingface_hub_cache_dir(),
888            PathBuf::from("/tmp/mesh-llm-hf-home").join("hub")
889        );
890
891        restore_env("HF_HUB_CACHE", prev_hub_cache);
892        restore_env("HUGGINGFACE_HUB_CACHE", prev_huggingface_hub_cache);
893        restore_env("HF_HOME", prev_hf_home);
894        restore_env("XDG_CACHE_HOME", prev_xdg);
895    }
896
897    #[test]
898    fn test_split_gguf_base_name() {
899        assert_eq!(
900            split_gguf_base_name("GLM-5-UD-IQ2_XXS-00001-of-00006"),
901            Some("GLM-5-UD-IQ2_XXS")
902        );
903        assert_eq!(
904            split_gguf_base_name("GLM-5-UD-IQ2_XXS-00006-of-00006"),
905            Some("GLM-5-UD-IQ2_XXS")
906        );
907        assert_eq!(split_gguf_base_name("Qwen3-8B-Q4_K_M"), None);
908        assert_eq!(split_gguf_base_name("model-001-of-003"), None);
909        assert_eq!(split_gguf_base_name("model-00001-of-00003"), Some("model"));
910    }
911
912    #[test]
913    #[serial]
914    fn huggingface_identity_for_path_parses_snapshot_path_directly() {
915        let prev_hub_cache = std::env::var_os("HF_HUB_CACHE");
916        let prev_hf_home = std::env::var_os("HF_HOME");
917        let prev_xdg = std::env::var_os("XDG_CACHE_HOME");
918
919        let temp = std::env::temp_dir().join(format!(
920            "mesh-llm-hf-identity-{}",
921            std::time::SystemTime::now()
922                .duration_since(std::time::UNIX_EPOCH)
923                .unwrap()
924                .as_nanos()
925        ));
926        let snapshot_path = temp
927            .join("models--bartowski--Llama-3.2-1B-Instruct-GGUF")
928            .join("snapshots")
929            .join("abcdef1234567890")
930            .join("nested")
931            .join("Llama-3.2-1B-Instruct-Q4_K_M.gguf");
932        std::fs::create_dir_all(snapshot_path.parent().unwrap()).unwrap();
933        std::fs::write(&snapshot_path, b"gguf").unwrap();
934
935        // TODO: Audit that the environment access only happens in single-threaded code.
936        unsafe { std::env::set_var("HF_HUB_CACHE", &temp) };
937        // TODO: Audit that the environment access only happens in single-threaded code.
938        unsafe { std::env::remove_var("HF_HOME") };
939        // TODO: Audit that the environment access only happens in single-threaded code.
940        unsafe { std::env::remove_var("XDG_CACHE_HOME") };
941
942        let identity = huggingface_identity_for_path(&snapshot_path).unwrap();
943        assert_eq!(identity.repo_id, "bartowski/Llama-3.2-1B-Instruct-GGUF");
944        assert_eq!(identity.revision, "abcdef1234567890");
945        assert_eq!(identity.file, "nested/Llama-3.2-1B-Instruct-Q4_K_M.gguf");
946        assert_eq!(
947            identity.canonical_ref,
948            "bartowski/Llama-3.2-1B-Instruct-GGUF@abcdef1234567890/nested/Llama-3.2-1B-Instruct-Q4_K_M.gguf"
949        );
950        assert_eq!(
951            identity.local_file_name,
952            "Llama-3.2-1B-Instruct-Q4_K_M.gguf"
953        );
954
955        let _ = std::fs::remove_dir_all(&temp);
956        restore_env("HF_HUB_CACHE", prev_hub_cache);
957        restore_env("HF_HOME", prev_hf_home);
958        restore_env("XDG_CACHE_HOME", prev_xdg);
959    }
960
961    #[test]
962    #[serial]
963    fn huggingface_identity_for_path_falls_back_to_snapshot_layout_ancestors() {
964        let prev_hub_cache = std::env::var_os("HF_HUB_CACHE");
965        let prev_hf_home = std::env::var_os("HF_HOME");
966        let prev_xdg = std::env::var_os("XDG_CACHE_HOME");
967
968        let temp = std::env::temp_dir().join(format!(
969            "mesh-llm-hf-ancestor-{}",
970            std::time::SystemTime::now()
971                .duration_since(std::time::UNIX_EPOCH)
972                .unwrap()
973                .as_nanos()
974        ));
975        let snapshot_path = temp
976            .join("nested")
977            .join("cache-root")
978            .join("models--bartowski--Llama-3.2-1B-Instruct-GGUF")
979            .join("snapshots")
980            .join("abcdef1234567890")
981            .join("nested")
982            .join("Llama-3.2-1B-Instruct-Q4_K_M.gguf");
983        std::fs::create_dir_all(snapshot_path.parent().unwrap()).unwrap();
984        std::fs::write(&snapshot_path, b"gguf").unwrap();
985
986        // TODO: Audit that the environment access only happens in single-threaded code.
987        unsafe { std::env::set_var("HF_HUB_CACHE", temp.join("some-other-cache-root")) };
988        // TODO: Audit that the environment access only happens in single-threaded code.
989        unsafe { std::env::remove_var("HF_HOME") };
990        // TODO: Audit that the environment access only happens in single-threaded code.
991        unsafe { std::env::remove_var("XDG_CACHE_HOME") };
992
993        let identity = huggingface_identity_for_path(&snapshot_path).unwrap();
994        assert_eq!(identity.repo_id, "bartowski/Llama-3.2-1B-Instruct-GGUF");
995        assert_eq!(identity.revision, "abcdef1234567890");
996        assert_eq!(identity.file, "nested/Llama-3.2-1B-Instruct-Q4_K_M.gguf");
997
998        let _ = std::fs::remove_dir_all(&temp);
999        restore_env("HF_HUB_CACHE", prev_hub_cache);
1000        restore_env("HF_HOME", prev_hf_home);
1001        restore_env("XDG_CACHE_HOME", prev_xdg);
1002    }
1003
1004    #[test]
1005    #[serial]
1006    fn scan_installed_models_includes_direct_hf_cache_root_files() {
1007        let prev_hub_cache = std::env::var_os("HF_HUB_CACHE");
1008        let prev_hf_home = std::env::var_os("HF_HOME");
1009        let prev_xdg = std::env::var_os("XDG_CACHE_HOME");
1010
1011        let temp = std::env::temp_dir().join(format!(
1012            "mesh-llm-direct-cache-root-{}",
1013            std::time::SystemTime::now()
1014                .duration_since(std::time::UNIX_EPOCH)
1015                .unwrap()
1016                .as_nanos()
1017        ));
1018        std::fs::create_dir_all(&temp).unwrap();
1019        std::fs::write(temp.join("Direct-Root-Q4_K_M.gguf"), b"gguf").unwrap();
1020
1021        // TODO: Audit that the environment access only happens in single-threaded code.
1022        unsafe { std::env::set_var("HF_HUB_CACHE", &temp) };
1023        // TODO: Audit that the environment access only happens in single-threaded code.
1024        unsafe { std::env::remove_var("HF_HOME") };
1025        // TODO: Audit that the environment access only happens in single-threaded code.
1026        unsafe { std::env::remove_var("XDG_CACHE_HOME") };
1027
1028        let installed = scan_installed_models();
1029        assert!(
1030            installed
1031                .iter()
1032                .any(|name| name.starts_with("local-gguf/sha256-"))
1033        );
1034
1035        let _ = std::fs::remove_dir_all(&temp);
1036        restore_env("HF_HUB_CACHE", prev_hub_cache);
1037        restore_env("HF_HOME", prev_hf_home);
1038        restore_env("XDG_CACHE_HOME", prev_xdg);
1039    }
1040
1041    #[test]
1042    #[serial]
1043    fn scan_installed_models_collapses_layered_package_files() {
1044        if let Ok(mut paths) = model_ref_paths().lock() {
1045            paths.clear();
1046        }
1047        let prev_hub_cache = std::env::var_os("HF_HUB_CACHE");
1048        let prev_hf_home = std::env::var_os("HF_HOME");
1049        let prev_xdg = std::env::var_os("XDG_CACHE_HOME");
1050
1051        let temp = std::env::temp_dir().join(format!(
1052            "mesh-llm-layered-cache-{}",
1053            std::time::SystemTime::now()
1054                .duration_since(std::time::UNIX_EPOCH)
1055                .unwrap()
1056                .as_nanos()
1057        ));
1058        let repo_dir = temp.join("models--meshllm--DeepSeek-V3.2-UD-Q4_K_XL-layers");
1059        let revision = "abcdef1234567890";
1060        let snapshot = repo_dir.join("snapshots").join(revision);
1061        let shared = snapshot.join("shared").join("embeddings.gguf");
1062        let layer_000 = snapshot.join("layers").join("layer-000.gguf");
1063        let layer_001 = snapshot.join("layers").join("layer-001.gguf");
1064        let nested_layer_002 = snapshot
1065            .join("layers")
1066            .join("blocks")
1067            .join("layer-002.gguf");
1068        let nested_shared = snapshot.join("shared").join("nested").join("extra.gguf");
1069        std::fs::create_dir_all(shared.parent().unwrap()).unwrap();
1070        std::fs::create_dir_all(layer_000.parent().unwrap()).unwrap();
1071        std::fs::create_dir_all(nested_layer_002.parent().unwrap()).unwrap();
1072        std::fs::create_dir_all(nested_shared.parent().unwrap()).unwrap();
1073        std::fs::create_dir_all(repo_dir.join("refs")).unwrap();
1074        std::fs::write(repo_dir.join("refs").join("main"), revision).unwrap();
1075        std::fs::write(&shared, b"shared").unwrap();
1076        std::fs::write(&layer_000, b"layer-000").unwrap();
1077        std::fs::write(&layer_001, b"layer-001").unwrap();
1078        std::fs::write(&nested_layer_002, b"layer-002").unwrap();
1079        std::fs::write(&nested_shared, b"nested").unwrap();
1080
1081        // TODO: Audit that the environment access only happens in single-threaded code.
1082        unsafe { std::env::set_var("HF_HUB_CACHE", &temp) };
1083        // TODO: Audit that the environment access only happens in single-threaded code.
1084        unsafe { std::env::remove_var("HF_HOME") };
1085        // TODO: Audit that the environment access only happens in single-threaded code.
1086        unsafe { std::env::remove_var("XDG_CACHE_HOME") };
1087
1088        let installed = scan_installed_models();
1089
1090        assert_eq!(
1091            installed,
1092            vec!["meshllm/DeepSeek-V3.2-UD-Q4_K_XL-layers".to_string()]
1093        );
1094        let parsed_ref =
1095            model_ref::ModelRef::parse("meshllm/DeepSeek-V3.2-UD-Q4_K_XL-layers").unwrap();
1096        assert_eq!(
1097            find_hf_cache_model_ref_path(&temp, &parsed_ref),
1098            Some(shared.clone())
1099        );
1100        assert_eq!(layered_package_layer_count_for_path(&layer_000), Some(3));
1101        assert_eq!(
1102            layered_package_total_bytes_for_path(&layer_000),
1103            Some(6 + 9 + 9 + 9 + 6)
1104        );
1105
1106        let _ = std::fs::remove_dir_all(&temp);
1107        restore_env("HF_HUB_CACHE", prev_hub_cache);
1108        restore_env("HF_HOME", prev_hf_home);
1109        restore_env("XDG_CACHE_HOME", prev_xdg);
1110    }
1111
1112    #[test]
1113    fn hf_identity_model_ref_preserves_layer_selector_outside_layered_packages() {
1114        let identity = HuggingFaceModelIdentity {
1115            repo_id: "example/Regular-GGUF".to_string(),
1116            revision: "deadbeef".to_string(),
1117            file: "layers/layer-000.gguf".to_string(),
1118            canonical_ref: "example/Regular-GGUF@deadbeef/layers/layer-000.gguf".to_string(),
1119            local_file_name: "layer-000.gguf".to_string(),
1120        };
1121
1122        assert_eq!(
1123            hf_identity_model_ref(&identity),
1124            "example/Regular-GGUF:layer-000"
1125        );
1126    }
1127
1128    #[test]
1129    fn layered_package_shared_matching_accepts_nested_package_artifacts() {
1130        let direct = HuggingFaceModelIdentity {
1131            repo_id: "meshllm/Demo-layers".to_string(),
1132            revision: "deadbeef".to_string(),
1133            file: "shared/embeddings.gguf".to_string(),
1134            canonical_ref: "meshllm/Demo-layers@deadbeef/shared/embeddings.gguf".to_string(),
1135            local_file_name: "embeddings.gguf".to_string(),
1136        };
1137        let nested = HuggingFaceModelIdentity {
1138            file: "shared/nested/embeddings.gguf".to_string(),
1139            canonical_ref: "meshllm/Demo-layers@deadbeef/shared/nested/embeddings.gguf".to_string(),
1140            ..direct.clone()
1141        };
1142
1143        assert_eq!(
1144            layered_package_model_ref(&direct),
1145            Some("meshllm/Demo-layers".to_string())
1146        );
1147        assert_eq!(
1148            layered_package_model_ref(&nested),
1149            Some("meshllm/Demo-layers".to_string())
1150        );
1151    }
1152
1153    #[test]
1154    fn layered_package_layer_matching_is_separator_safe_after_normalization() {
1155        let relative = "layers\\block-0\\layer-000.gguf".replace('\\', "/");
1156
1157        assert_eq!(layered_package_layer_index(&relative), Some(0));
1158    }
1159
1160    #[test]
1161    fn distribution_ref_preserves_unsplit_exact_file() {
1162        let identity = HuggingFaceModelIdentity {
1163            repo_id: "unsloth/Qwen3.6-35B-A3B-GGUF".to_string(),
1164            revision: "deadbeef".to_string(),
1165            file: "BF16/Qwen3.6-35B-A3B-BF16.gguf".to_string(),
1166            canonical_ref: "unsloth/Qwen3.6-35B-A3B-GGUF@deadbeef/BF16/Qwen3.6-35B-A3B-BF16.gguf"
1167                .to_string(),
1168            local_file_name: "Qwen3.6-35B-A3B-BF16.gguf".to_string(),
1169        };
1170
1171        assert_eq!(
1172            identity.distribution_ref(),
1173            "unsloth/Qwen3.6-35B-A3B-GGUF@deadbeef/BF16/Qwen3.6-35B-A3B-BF16.gguf"
1174        );
1175    }
1176
1177    #[test]
1178    fn distribution_ref_strips_split_suffix_for_split_gguf() {
1179        let identity = HuggingFaceModelIdentity {
1180            repo_id: "unsloth/Qwen3.6-35B-A3B-GGUF".to_string(),
1181            revision: "deadbeef".to_string(),
1182            file: "BF16/Qwen3.6-35B-A3B-BF16-00001-of-00002.gguf".to_string(),
1183            canonical_ref: "unsloth/Qwen3.6-35B-A3B-GGUF@deadbeef/BF16/Qwen3.6-35B-A3B-BF16-00001-of-00002.gguf".to_string(),
1184            local_file_name: "Qwen3.6-35B-A3B-BF16-00001-of-00002.gguf".to_string(),
1185        };
1186
1187        assert_eq!(
1188            identity.distribution_ref(),
1189            "unsloth/Qwen3.6-35B-A3B-GGUF@deadbeef/BF16/Qwen3.6-35B-A3B-BF16"
1190        );
1191    }
1192
1193    #[test]
1194    fn distribution_ref_strips_non_first_split_suffix_for_split_gguf() {
1195        let identity = HuggingFaceModelIdentity {
1196            repo_id: "unsloth/Qwen3.6-35B-A3B-GGUF".to_string(),
1197            revision: "deadbeef".to_string(),
1198            file: "BF16/Qwen3.6-35B-A3B-BF16-00002-of-00002.gguf".to_string(),
1199            canonical_ref: "unsloth/Qwen3.6-35B-A3B-GGUF@deadbeef/BF16/Qwen3.6-35B-A3B-BF16-00002-of-00002.gguf".to_string(),
1200            local_file_name: "Qwen3.6-35B-A3B-BF16-00002-of-00002.gguf".to_string(),
1201        };
1202
1203        assert_eq!(
1204            identity.distribution_ref(),
1205            "unsloth/Qwen3.6-35B-A3B-GGUF@deadbeef/BF16/Qwen3.6-35B-A3B-BF16"
1206        );
1207    }
1208
1209    fn restore_env(key: &str, value: Option<std::ffi::OsString>) {
1210        if let Some(value) = value {
1211            // TODO: Audit that the environment access only happens in single-threaded code.
1212            unsafe { std::env::set_var(key, value) };
1213        } else {
1214            // TODO: Audit that the environment access only happens in single-threaded code.
1215            unsafe { std::env::remove_var(key) };
1216        }
1217    }
1218}