Skip to main content

model_hf/store/
usage.rs

1use super::local::{
2    gguf_metadata_cache_path, huggingface_hub_cache_dir, huggingface_identity_for_path,
3};
4use anyhow::{Context, Result};
5use chrono::{DateTime, Duration as ChronoDuration, Utc};
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8use std::collections::HashSet;
9use std::path::{Path, PathBuf};
10use std::time::Duration;
11
12#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
13pub struct ModelUsageRecord {
14    pub lookup_key: String,
15    pub display_name: String,
16    pub model_ref: Option<String>,
17    pub source: String,
18    pub mesh_managed: bool,
19    pub primary_path: PathBuf,
20    pub managed_paths: Vec<PathBuf>,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub hf_repo_id: Option<String>,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub hf_revision: Option<String>,
25    pub first_seen_at: String,
26    pub last_used_at: String,
27}
28
29#[derive(Clone, Debug, Serialize)]
30pub struct ModelCleanupCandidate {
31    pub display_name: String,
32    pub model_ref: Option<String>,
33    pub source: String,
34    pub primary_path: PathBuf,
35    pub mesh_managed: bool,
36    pub last_used_at: String,
37    pub file_count: usize,
38    pub total_bytes: u64,
39    pub stale_record_only: bool,
40}
41
42#[derive(Clone, Debug, Default, Serialize)]
43pub struct ModelCleanupPlan {
44    pub candidates: Vec<ModelCleanupCandidate>,
45    pub total_files: usize,
46    pub total_bytes: u64,
47    pub skipped_recent: usize,
48    pub stale_record_only: usize,
49}
50
51#[derive(Clone, Debug, Default, Serialize)]
52pub struct ModelCleanupResult {
53    pub removed_candidates: usize,
54    pub removed_files: usize,
55    pub removed_records: usize,
56    pub removed_metadata_files: usize,
57    pub reclaimed_bytes: u64,
58}
59
60#[derive(Clone, Debug)]
61struct CleanupEntry {
62    record: ModelUsageRecord,
63    record_path: PathBuf,
64    removable_paths: Vec<PathBuf>,
65    total_bytes: u64,
66    stale_record_only: bool,
67}
68
69#[derive(Clone, Debug, Eq, PartialEq)]
70struct RecordLocation {
71    lookup_key: String,
72    record_path: PathBuf,
73    record: Option<ModelUsageRecord>,
74}
75
76#[derive(Clone, Debug, Eq, PartialEq)]
77struct HuggingFaceRecordIdentity {
78    repo_id: String,
79    revision: String,
80}
81
82#[derive(Clone, Debug, Eq, PartialEq)]
83struct PathHuggingFaceIdentity {
84    repo_id: String,
85    revision: String,
86    canonical_ref: String,
87}
88
89pub fn model_usage_cache_dir() -> PathBuf {
90    super::mesh_llm_cache_dir().join("model-usage")
91}
92
93pub fn load_model_usage_record_for_path(path: &Path) -> Option<ModelUsageRecord> {
94    let usage_dir = model_usage_cache_dir();
95    let root = huggingface_hub_cache_dir();
96    let lookup_key = usage_lookup_key(path, &root)?;
97    resolve_record_location(&usage_dir, &lookup_key, &[normalize_path(path)]).record
98}
99
100pub fn track_model_usage(
101    path: &Path,
102    display_name: Option<&str>,
103    model_ref: Option<&str>,
104    source: Option<&str>,
105) -> Result<()> {
106    let usage_dir = model_usage_cache_dir();
107    let root = huggingface_hub_cache_dir();
108    record_model_usage_in_dir(
109        &usage_dir,
110        &root,
111        path,
112        &[],
113        display_name,
114        model_ref,
115        source,
116        false,
117    )
118}
119
120pub fn track_managed_model_usage(
121    primary_path: &Path,
122    managed_paths: &[PathBuf],
123    display_name: &str,
124    model_ref: Option<&str>,
125    source: &str,
126) -> Result<()> {
127    let usage_dir = model_usage_cache_dir();
128    let root = huggingface_hub_cache_dir();
129    record_model_usage_in_dir(
130        &usage_dir,
131        &root,
132        primary_path,
133        managed_paths,
134        Some(display_name),
135        model_ref,
136        Some(source),
137        true,
138    )
139}
140
141pub fn plan_model_cleanup(unused_since: Option<Duration>) -> Result<ModelCleanupPlan> {
142    let usage_dir = model_usage_cache_dir();
143    let root = huggingface_hub_cache_dir();
144    plan_model_cleanup_in_dir(&usage_dir, &root, unused_since)
145}
146
147pub fn execute_model_cleanup(unused_since: Option<Duration>) -> Result<ModelCleanupResult> {
148    let usage_dir = model_usage_cache_dir();
149    let root = huggingface_hub_cache_dir();
150    let records = load_model_usage_records_from_dir(&usage_dir);
151    let cutoff = unused_since
152        .map(ChronoDuration::from_std)
153        .transpose()?
154        .map(|age| Utc::now() - age);
155    let mut skipped_recent = 0usize;
156    let entries = plan_cleanup_entries(records, &usage_dir, &root, cutoff, &mut skipped_recent);
157    execute_model_cleanup_entries(entries)
158}
159
160fn load_model_usage_records_from_dir(dir: &Path) -> Vec<ModelUsageRecord> {
161    let mut records = Vec::new();
162    let Ok(entries) = std::fs::read_dir(dir) else {
163        return records;
164    };
165    for entry in entries.flatten() {
166        let path = entry.path();
167        if path.extension().and_then(|value| value.to_str()) != Some("json") {
168            continue;
169        }
170        if let Some(record) = read_usage_record(&path) {
171            records.push(record);
172        }
173    }
174    records
175}
176
177fn read_usage_record(path: &Path) -> Option<ModelUsageRecord> {
178    let bytes = std::fs::read(path).ok()?;
179    serde_json::from_slice(&bytes).ok()
180}
181
182#[allow(clippy::too_many_arguments)]
183fn record_model_usage_in_dir(
184    usage_dir: &Path,
185    track_root: &Path,
186    path: &Path,
187    managed_paths: &[PathBuf],
188    display_name: Option<&str>,
189    model_ref: Option<&str>,
190    source: Option<&str>,
191    mesh_managed: bool,
192) -> Result<()> {
193    let Some(lookup_key) = usage_lookup_key(path, track_root) else {
194        return Ok(());
195    };
196    let now = Utc::now().to_rfc3339();
197    let primary_path = normalize_path(path);
198    let normalized_managed_paths = unique_paths(managed_paths.to_vec());
199    let candidate_paths = usage_record_candidate_paths(&primary_path, &normalized_managed_paths);
200    let location = resolve_record_location(usage_dir, &lookup_key, &candidate_paths);
201    let record_path = location.record_path;
202    let existing = location.record;
203    let existing_display_name = existing
204        .as_ref()
205        .map(|record| record.display_name.as_str())
206        .filter(|value| !value.is_empty());
207    let existing_source = existing
208        .as_ref()
209        .map(|record| record.source.as_str())
210        .filter(|value| !value.is_empty());
211    let existing_model_ref = existing
212        .as_ref()
213        .and_then(|record| record.model_ref.as_deref());
214
215    let mut merged_paths = existing
216        .as_ref()
217        .map(|record| record.managed_paths.clone())
218        .unwrap_or_default();
219    if mesh_managed {
220        if normalized_managed_paths.is_empty() {
221            merged_paths.push(primary_path.clone());
222        } else {
223            merged_paths.extend(normalized_managed_paths.iter().cloned());
224        }
225    }
226    merged_paths = unique_paths(merged_paths);
227    let hf_identity = infer_record_hf_identity(&primary_path, &merged_paths, track_root)
228        .or_else(|| existing.as_ref().and_then(record_hf_identity));
229
230    let record = ModelUsageRecord {
231        lookup_key: location.lookup_key,
232        display_name: display_name
233            .or(existing_display_name)
234            .map(str::to_string)
235            .unwrap_or_else(|| default_display_name(&primary_path)),
236        model_ref: model_ref
237            .or(existing_model_ref)
238            .map(str::to_string)
239            .or_else(|| default_model_ref(&primary_path)),
240        source: source
241            .or(existing_source)
242            .map(str::to_string)
243            .unwrap_or_else(|| default_source(&primary_path)),
244        mesh_managed: mesh_managed || existing.as_ref().is_some_and(|record| record.mesh_managed),
245        primary_path,
246        managed_paths: merged_paths,
247        hf_repo_id: hf_identity
248            .as_ref()
249            .map(|identity| identity.repo_id.clone()),
250        hf_revision: hf_identity
251            .as_ref()
252            .map(|identity| identity.revision.clone()),
253        first_seen_at: existing
254            .as_ref()
255            .map(|record| record.first_seen_at.clone())
256            .unwrap_or_else(|| now.clone()),
257        last_used_at: now,
258    };
259
260    std::fs::create_dir_all(usage_dir)
261        .with_context(|| format!("Create {}", usage_dir.display()))?;
262    let bytes = serde_json::to_vec_pretty(&record)?;
263    std::fs::write(&record_path, bytes)
264        .with_context(|| format!("Write {}", record_path.display()))?;
265    Ok(())
266}
267
268fn plan_model_cleanup_in_dir(
269    usage_dir: &Path,
270    track_root: &Path,
271    unused_since: Option<Duration>,
272) -> Result<ModelCleanupPlan> {
273    let records = load_model_usage_records_from_dir(usage_dir);
274    let cutoff = unused_since
275        .map(ChronoDuration::from_std)
276        .transpose()?
277        .map(|age| Utc::now() - age);
278    let mut skipped_recent = 0usize;
279    let entries = plan_cleanup_entries(records, usage_dir, track_root, cutoff, &mut skipped_recent);
280    let mut plan = ModelCleanupPlan {
281        skipped_recent,
282        ..Default::default()
283    };
284    for entry in entries {
285        if entry.stale_record_only {
286            plan.stale_record_only += 1;
287        }
288        plan.total_files += entry.removable_paths.len();
289        plan.total_bytes += entry.total_bytes;
290        plan.candidates.push(ModelCleanupCandidate {
291            display_name: entry.record.display_name,
292            model_ref: entry.record.model_ref,
293            source: entry.record.source,
294            primary_path: entry.record.primary_path,
295            mesh_managed: entry.record.mesh_managed,
296            last_used_at: entry.record.last_used_at,
297            file_count: entry.removable_paths.len(),
298            total_bytes: entry.total_bytes,
299            stale_record_only: entry.stale_record_only,
300        });
301    }
302    plan.candidates.sort_by(|left, right| {
303        left.last_used_at
304            .cmp(&right.last_used_at)
305            .then_with(|| left.display_name.cmp(&right.display_name))
306    });
307    Ok(plan)
308}
309
310fn plan_cleanup_entries(
311    records: Vec<ModelUsageRecord>,
312    usage_dir: &Path,
313    track_root: &Path,
314    cutoff: Option<DateTime<Utc>>,
315    skipped_recent: &mut usize,
316) -> Vec<CleanupEntry> {
317    let mut entries = Vec::new();
318
319    for record in records {
320        if !record.mesh_managed {
321            continue;
322        }
323        let last_used =
324            parse_timestamp(&record.last_used_at).unwrap_or(DateTime::<Utc>::UNIX_EPOCH);
325        if let Some(cutoff) = cutoff
326            && last_used > cutoff
327        {
328            *skipped_recent += 1;
329            continue;
330        }
331
332        let removable_paths: Vec<PathBuf> = unique_paths(record.managed_paths.clone())
333            .into_iter()
334            .filter(|path| is_trackable_path(path, track_root))
335            .filter(|path| path_matches_record_identity(&record, path, track_root))
336            .filter(|path| path.exists())
337            .collect();
338        let total_bytes = removable_paths
339            .iter()
340            .filter_map(|path| std::fs::metadata(path).ok().map(|meta| meta.len()))
341            .sum();
342        let stale_record_only = removable_paths.is_empty();
343        let record_path = usage_record_path(usage_dir, &record.lookup_key);
344
345        entries.push(CleanupEntry {
346            record,
347            record_path,
348            removable_paths,
349            total_bytes,
350            stale_record_only,
351        });
352    }
353
354    entries.sort_by(|left, right| {
355        left.record
356            .last_used_at
357            .cmp(&right.record.last_used_at)
358            .then_with(|| left.record.display_name.cmp(&right.record.display_name))
359    });
360    entries
361}
362
363fn execute_model_cleanup_entries(entries: Vec<CleanupEntry>) -> Result<ModelCleanupResult> {
364    let mut result = ModelCleanupResult::default();
365    for entry in entries {
366        for path in &entry.removable_paths {
367            if let Ok(meta) = std::fs::metadata(path) {
368                result.reclaimed_bytes += meta.len();
369            }
370            if path.exists() {
371                std::fs::remove_file(path).with_context(|| format!("Remove {}", path.display()))?;
372                result.removed_files += 1;
373            }
374            if let Some(cache_path) = gguf_metadata_cache_path(path)
375                && cache_path.exists()
376            {
377                std::fs::remove_file(&cache_path)
378                    .with_context(|| format!("Remove metadata cache {}", cache_path.display()))?;
379                result.removed_metadata_files += 1;
380            }
381            prune_empty_ancestors(path, &huggingface_hub_cache_dir());
382        }
383        if entry.record_path.exists() {
384            std::fs::remove_file(&entry.record_path)
385                .with_context(|| format!("Remove {}", entry.record_path.display()))?;
386            result.removed_records += 1;
387        }
388        result.removed_candidates += 1;
389    }
390    Ok(result)
391}
392
393fn usage_lookup_key(path: &Path, track_root: &Path) -> Option<String> {
394    if !is_trackable_path(path, track_root) {
395        return None;
396    }
397    if let Some(identity) = hf_identity_for_path_in_root(path, track_root) {
398        return Some(format!("hf:{}", identity.canonical_ref));
399    }
400    if let Some(identity) = huggingface_identity_for_path(path) {
401        return Some(format!("hf:{}", identity.canonical_ref));
402    }
403    Some(format!(
404        "path:{}",
405        normalize_path(path).to_string_lossy().replace('\\', "/")
406    ))
407}
408
409fn resolve_record_location(
410    usage_dir: &Path,
411    direct_lookup_key: &str,
412    candidate_paths: &[PathBuf],
413) -> RecordLocation {
414    let direct_record_path = usage_record_path(usage_dir, direct_lookup_key);
415    if let Some(record) = read_usage_record(&direct_record_path) {
416        return RecordLocation {
417            lookup_key: direct_lookup_key.to_string(),
418            record_path: direct_record_path,
419            record: Some(record),
420        };
421    }
422
423    let Some(existing) = find_usage_record_by_paths(usage_dir, candidate_paths) else {
424        return RecordLocation {
425            lookup_key: direct_lookup_key.to_string(),
426            record_path: direct_record_path,
427            record: None,
428        };
429    };
430
431    let record_path = usage_record_path(usage_dir, &existing.lookup_key);
432    RecordLocation {
433        lookup_key: existing.lookup_key.clone(),
434        record_path,
435        record: Some(existing),
436    }
437}
438
439fn find_usage_record_by_paths(
440    usage_dir: &Path,
441    candidate_paths: &[PathBuf],
442) -> Option<ModelUsageRecord> {
443    let candidate_paths = unique_paths(candidate_paths.to_vec());
444    if candidate_paths.is_empty() {
445        return None;
446    }
447    let candidate_set: HashSet<PathBuf> = candidate_paths.iter().cloned().collect();
448    load_model_usage_records_from_dir(usage_dir)
449        .into_iter()
450        .find(|record| record_matches_any_path(record, &candidate_set))
451}
452
453fn record_matches_any_path(record: &ModelUsageRecord, candidate_paths: &HashSet<PathBuf>) -> bool {
454    let primary_path = normalize_path(&record.primary_path);
455    if candidate_paths.contains(&primary_path) {
456        return true;
457    }
458    if record
459        .managed_paths
460        .iter()
461        .map(|path| normalize_path(path))
462        .any(|path| candidate_paths.contains(&path))
463    {
464        return true;
465    }
466    false
467}
468
469fn usage_record_candidate_paths(primary_path: &Path, managed_paths: &[PathBuf]) -> Vec<PathBuf> {
470    let mut paths = vec![primary_path.to_path_buf()];
471    paths.extend(managed_paths.iter().cloned());
472    unique_paths(paths)
473}
474
475fn infer_record_hf_identity(
476    primary_path: &Path,
477    managed_paths: &[PathBuf],
478    track_root: &Path,
479) -> Option<HuggingFaceRecordIdentity> {
480    let mut paths = usage_record_candidate_paths(primary_path, managed_paths).into_iter();
481    let first = paths
482        .find_map(|path| hf_identity_for_path_in_root(&path, track_root))
483        .map(|identity| HuggingFaceRecordIdentity {
484            repo_id: identity.repo_id,
485            revision: identity.revision,
486        })?;
487
488    for path in usage_record_candidate_paths(primary_path, managed_paths) {
489        let Some(identity) = hf_identity_for_path_in_root(&path, track_root) else {
490            continue;
491        };
492        if identity.repo_id != first.repo_id || identity.revision != first.revision {
493            return None;
494        }
495    }
496    Some(first)
497}
498
499fn record_hf_identity(record: &ModelUsageRecord) -> Option<HuggingFaceRecordIdentity> {
500    Some(HuggingFaceRecordIdentity {
501        repo_id: record.hf_repo_id.clone()?,
502        revision: record.hf_revision.clone()?,
503    })
504}
505
506fn path_matches_record_identity(record: &ModelUsageRecord, path: &Path, track_root: &Path) -> bool {
507    let Some(record_identity) = record_hf_identity(record) else {
508        return true;
509    };
510    hf_identity_for_path_in_root(path, track_root).is_some_and(|identity| {
511        identity.repo_id == record_identity.repo_id && identity.revision == record_identity.revision
512    })
513}
514
515fn hf_identity_for_path_in_root(path: &Path, track_root: &Path) -> Option<PathHuggingFaceIdentity> {
516    let path = normalize_path(path);
517    let root = normalize_path(track_root);
518    let relative = path.strip_prefix(&root).ok()?;
519    let mut components = relative.components();
520    let repo_dir = components.next()?.as_os_str().to_str()?;
521    let repo_id = repo_dir.strip_prefix("models--")?.replace("--", "/");
522    if components.next()?.as_os_str() != "snapshots" {
523        return None;
524    }
525    let revision = components.next()?.as_os_str().to_str()?.to_string();
526    let relative_file = components
527        .map(|component| component.as_os_str().to_str())
528        .collect::<Option<Vec<_>>>()?
529        .join("/");
530    if relative_file.is_empty() {
531        return None;
532    }
533    Some(PathHuggingFaceIdentity {
534        repo_id: repo_id.clone(),
535        revision: revision.clone(),
536        canonical_ref: format!("{repo_id}@{revision}/{relative_file}"),
537    })
538}
539
540pub(crate) fn usage_record_path(usage_dir: &Path, lookup_key: &str) -> PathBuf {
541    let digest = Sha256::digest(lookup_key.as_bytes());
542    usage_dir.join(format!("{digest:x}.json"))
543}
544
545fn normalize_path(path: &Path) -> PathBuf {
546    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
547}
548
549fn unique_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
550    let mut seen = HashSet::new();
551    let mut unique = Vec::new();
552    for path in paths {
553        let normalized = normalize_path(&path);
554        if seen.insert(normalized.clone()) {
555            unique.push(normalized);
556        }
557    }
558    unique.sort();
559    unique
560}
561
562fn parse_timestamp(value: &str) -> Option<DateTime<Utc>> {
563    DateTime::parse_from_rfc3339(value)
564        .ok()
565        .map(|parsed| parsed.with_timezone(&Utc))
566}
567
568fn default_display_name(path: &Path) -> String {
569    path.file_stem()
570        .and_then(|value| value.to_str())
571        .or_else(|| path.file_name().and_then(|value| value.to_str()))
572        .unwrap_or("model")
573        .to_string()
574}
575
576fn default_model_ref(path: &Path) -> Option<String> {
577    huggingface_identity_for_path(path).map(|identity| identity.canonical_ref)
578}
579
580fn default_source(path: &Path) -> String {
581    if huggingface_identity_for_path(path).is_some() {
582        "huggingface-cache".to_string()
583    } else {
584        "local-cache".to_string()
585    }
586}
587
588fn is_trackable_path(path: &Path, track_root: &Path) -> bool {
589    let path = normalize_path(path);
590    let root = normalize_path(track_root);
591    path.starts_with(&root)
592}
593
594fn prune_empty_ancestors(path: &Path, stop_at: &Path) {
595    let stop_at = normalize_path(stop_at);
596    let mut current = path.parent().map(normalize_path);
597    while let Some(dir) = current {
598        if dir == stop_at {
599            break;
600        }
601        let Ok(mut entries) = std::fs::read_dir(&dir) else {
602            break;
603        };
604        if entries.next().is_some() {
605            break;
606        }
607        if std::fs::remove_dir(&dir).is_err() {
608            break;
609        }
610        current = dir.parent().map(normalize_path);
611    }
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617    use std::sync::atomic::{AtomicU64, Ordering};
618
619    static TEMP_DIR_COUNTER: AtomicU64 = AtomicU64::new(0);
620
621    fn temp_dir(prefix: &str) -> PathBuf {
622        let sequence = TEMP_DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
623        std::env::temp_dir().join(format!(
624            "{prefix}-{}-{}-{}",
625            std::process::id(),
626            sequence,
627            std::time::SystemTime::now()
628                .duration_since(std::time::UNIX_EPOCH)
629                .expect("system time should be after epoch")
630                .as_nanos()
631        ))
632    }
633
634    fn write_record(dir: &Path, record: &ModelUsageRecord) {
635        std::fs::create_dir_all(dir).expect("usage dir should be created");
636        let path = usage_record_path(dir, &record.lookup_key);
637        std::fs::write(
638            path,
639            serde_json::to_vec_pretty(record).expect("record JSON should serialize"),
640        )
641        .expect("record should be written");
642    }
643
644    #[test]
645    fn record_model_usage_merges_managed_paths() {
646        let usage_dir = temp_dir("mesh-llm-usage-dir");
647        let cache_root = temp_dir("mesh-llm-hf-cache");
648        let primary = cache_root
649            .join("models--Org--Demo")
650            .join("snapshots")
651            .join("rev1")
652            .join("Demo-Q4_K_M.gguf");
653        let shard = cache_root
654            .join("models--Org--Demo")
655            .join("snapshots")
656            .join("rev1")
657            .join("Demo-Q4_K_M-00002-of-00002.gguf");
658        std::fs::create_dir_all(primary.parent().expect("primary path should have parent"))
659            .expect("primary parent should exist");
660        std::fs::write(&primary, b"primary").expect("primary model should be written");
661        std::fs::write(&shard, b"shard").expect("shard model should be written");
662
663        record_model_usage_in_dir(
664            &usage_dir,
665            &cache_root,
666            &primary,
667            &[primary.clone(), shard.clone()],
668            Some("Demo-Q4_K_M"),
669            Some("Org/Demo@rev1/Demo-Q4_K_M.gguf"),
670            Some("catalog"),
671            true,
672        )
673        .expect("managed usage should be recorded");
674
675        let records = load_model_usage_records_from_dir(&usage_dir);
676        assert_eq!(records.len(), 1);
677        assert!(records[0].mesh_managed);
678        assert_eq!(records[0].managed_paths.len(), 2);
679        assert_eq!(records[0].display_name, "Demo-Q4_K_M");
680        assert_eq!(records[0].hf_repo_id.as_deref(), Some("Org/Demo"));
681        assert_eq!(records[0].hf_revision.as_deref(), Some("rev1"));
682
683        let _ = std::fs::remove_dir_all(&usage_dir);
684        let _ = std::fs::remove_dir_all(&cache_root);
685    }
686
687    #[test]
688    fn load_model_usage_record_for_split_shard_returns_bundle_record() {
689        let usage_dir = temp_dir("mesh-llm-usage-dir");
690        let cache_root = temp_dir("mesh-llm-hf-cache");
691        let primary = cache_root
692            .join("models--Org--Bundle")
693            .join("snapshots")
694            .join("rev1")
695            .join("Bundle-Q4_K_M-00001-of-00002.gguf");
696        let shard = cache_root
697            .join("models--Org--Bundle")
698            .join("snapshots")
699            .join("rev1")
700            .join("Bundle-Q4_K_M-00002-of-00002.gguf");
701        std::fs::create_dir_all(primary.parent().expect("primary path should have parent"))
702            .expect("primary parent should exist");
703        std::fs::write(&primary, b"primary").expect("primary model should be written");
704        std::fs::write(&shard, b"shard").expect("shard model should be written");
705
706        record_model_usage_in_dir(
707            &usage_dir,
708            &cache_root,
709            &primary,
710            &[primary.clone(), shard.clone()],
711            Some("Bundle-Q4_K_M"),
712            Some("Org/Bundle@rev1/Bundle-Q4_K_M-00001-of-00002.gguf"),
713            Some("catalog"),
714            true,
715        )
716        .expect("managed usage should be recorded");
717
718        let record = find_usage_record_by_paths(&usage_dir, std::slice::from_ref(&shard))
719            .expect("split shard should resolve back to the bundle record");
720        assert_eq!(
721            record.lookup_key,
722            usage_lookup_key(&primary, &cache_root).expect("primary path should key")
723        );
724        assert_eq!(record.managed_paths.len(), 2);
725
726        let _ = std::fs::remove_dir_all(&usage_dir);
727        let _ = std::fs::remove_dir_all(&cache_root);
728    }
729
730    #[test]
731    fn record_model_usage_updates_last_used_at_by_managed_identity() {
732        let usage_dir = temp_dir("mesh-llm-usage-dir");
733        let cache_root = temp_dir("mesh-llm-hf-cache");
734        let primary = cache_root
735            .join("models--Org--Bundle")
736            .join("snapshots")
737            .join("rev1")
738            .join("Bundle-Q4_K_M-00001-of-00002.gguf");
739        let shard = cache_root
740            .join("models--Org--Bundle")
741            .join("snapshots")
742            .join("rev1")
743            .join("Bundle-Q4_K_M-00002-of-00002.gguf");
744        std::fs::create_dir_all(primary.parent().expect("primary path should have parent"))
745            .expect("primary parent should exist");
746        std::fs::write(&primary, b"primary").expect("primary model should be written");
747        std::fs::write(&shard, b"shard").expect("shard model should be written");
748
749        let lookup_key = usage_lookup_key(&primary, &cache_root).expect("primary path should key");
750        write_record(
751            &usage_dir,
752            &ModelUsageRecord {
753                lookup_key: lookup_key.clone(),
754                display_name: "Bundle-Q4_K_M".to_string(),
755                model_ref: Some("Org/Bundle@rev1/Bundle-Q4_K_M-00001-of-00002.gguf".to_string()),
756                source: "catalog".to_string(),
757                mesh_managed: true,
758                primary_path: primary.clone(),
759                managed_paths: vec![primary.clone(), shard.clone()],
760                hf_repo_id: Some("Org/Bundle".to_string()),
761                hf_revision: Some("rev1".to_string()),
762                first_seen_at: "2026-04-01T00:00:00Z".to_string(),
763                last_used_at: "2026-04-01T00:00:00Z".to_string(),
764            },
765        );
766
767        record_model_usage_in_dir(
768            &usage_dir,
769            &cache_root,
770            &shard,
771            &[],
772            None,
773            None,
774            Some("resolve"),
775            false,
776        )
777        .expect("usage refresh should succeed");
778
779        let records = load_model_usage_records_from_dir(&usage_dir);
780        assert_eq!(records.len(), 1);
781        assert_eq!(records[0].lookup_key, lookup_key);
782        assert_eq!(records[0].managed_paths.len(), 2);
783        assert_ne!(records[0].last_used_at, "2026-04-01T00:00:00Z");
784
785        let _ = std::fs::remove_dir_all(&usage_dir);
786        let _ = std::fs::remove_dir_all(&cache_root);
787    }
788
789    #[test]
790    fn cleanup_plan_filters_recent_and_external_records() {
791        let usage_dir = temp_dir("mesh-llm-usage-dir");
792        let cache_root = temp_dir("mesh-llm-hf-cache");
793        let old_path = cache_root
794            .join("models--Org--Old")
795            .join("snapshots")
796            .join("rev1")
797            .join("Old-Q4_K_M.gguf");
798        let recent_path = cache_root
799            .join("models--Org--Recent")
800            .join("snapshots")
801            .join("rev1")
802            .join("Recent-Q4_K_M.gguf");
803        let external_path = cache_root
804            .join("models--Org--External")
805            .join("snapshots")
806            .join("rev1")
807            .join("External-Q4_K_M.gguf");
808
809        for path in [&old_path, &recent_path, &external_path] {
810            std::fs::create_dir_all(path.parent().expect("test path should have parent"))
811                .expect("test parent should exist");
812            std::fs::write(path, vec![0_u8; 16]).expect("test model should be written");
813        }
814
815        write_record(
816            &usage_dir,
817            &ModelUsageRecord {
818                lookup_key: usage_lookup_key(&old_path, &cache_root).expect("old path should key"),
819                display_name: "Old".to_string(),
820                model_ref: None,
821                source: "catalog".to_string(),
822                mesh_managed: true,
823                primary_path: old_path.clone(),
824                managed_paths: vec![old_path.clone()],
825                hf_repo_id: Some("Org/Old".to_string()),
826                hf_revision: Some("rev1".to_string()),
827                first_seen_at: "2026-04-01T00:00:00Z".to_string(),
828                last_used_at: "2026-04-01T00:00:00Z".to_string(),
829            },
830        );
831        write_record(
832            &usage_dir,
833            &ModelUsageRecord {
834                lookup_key: usage_lookup_key(&recent_path, &cache_root)
835                    .expect("recent path should key"),
836                display_name: "Recent".to_string(),
837                model_ref: None,
838                source: "catalog".to_string(),
839                mesh_managed: true,
840                primary_path: recent_path.clone(),
841                managed_paths: vec![recent_path.clone()],
842                hf_repo_id: Some("Org/Recent".to_string()),
843                hf_revision: Some("rev1".to_string()),
844                first_seen_at: Utc::now().to_rfc3339(),
845                last_used_at: Utc::now().to_rfc3339(),
846            },
847        );
848        write_record(
849            &usage_dir,
850            &ModelUsageRecord {
851                lookup_key: usage_lookup_key(&external_path, &cache_root)
852                    .expect("external path should key"),
853                display_name: "External".to_string(),
854                model_ref: None,
855                source: "local-cache".to_string(),
856                mesh_managed: false,
857                primary_path: external_path.clone(),
858                managed_paths: vec![],
859                hf_repo_id: Some("Org/External".to_string()),
860                hf_revision: Some("rev1".to_string()),
861                first_seen_at: "2026-04-01T00:00:00Z".to_string(),
862                last_used_at: "2026-04-01T00:00:00Z".to_string(),
863            },
864        );
865
866        let plan =
867            plan_model_cleanup_in_dir(&usage_dir, &cache_root, Some(Duration::from_secs(60)))
868                .expect("cleanup plan should succeed");
869        assert_eq!(plan.candidates.len(), 1);
870        assert_eq!(plan.candidates[0].display_name, "Old");
871        assert_eq!(plan.skipped_recent, 1);
872
873        let _ = std::fs::remove_dir_all(&usage_dir);
874        let _ = std::fs::remove_dir_all(&cache_root);
875    }
876
877    #[test]
878    fn execute_cleanup_removes_files_and_records() {
879        let usage_dir = temp_dir("mesh-llm-usage-dir");
880        let cache_root = temp_dir("mesh-llm-hf-cache");
881        let primary = cache_root
882            .join("models--Org--Cleanup")
883            .join("snapshots")
884            .join("rev1")
885            .join("Cleanup-Q4_K_M.gguf");
886        std::fs::create_dir_all(primary.parent().expect("cleanup path should have parent"))
887            .expect("cleanup parent should exist");
888        std::fs::write(&primary, vec![0_u8; 32]).expect("cleanup model should be written");
889
890        let record = ModelUsageRecord {
891            lookup_key: usage_lookup_key(&primary, &cache_root).expect("cleanup path should key"),
892            display_name: "Cleanup".to_string(),
893            model_ref: Some("Org/Cleanup@rev1/Cleanup-Q4_K_M.gguf".to_string()),
894            source: "catalog".to_string(),
895            mesh_managed: true,
896            primary_path: primary.clone(),
897            managed_paths: vec![primary.clone()],
898            hf_repo_id: Some("Org/Cleanup".to_string()),
899            hf_revision: Some("rev1".to_string()),
900            first_seen_at: "2026-04-01T00:00:00Z".to_string(),
901            last_used_at: "2026-04-01T00:00:00Z".to_string(),
902        };
903        write_record(&usage_dir, &record);
904
905        let mut skipped_recent = 0usize;
906        let entries = plan_cleanup_entries(
907            vec![record],
908            &usage_dir,
909            &cache_root,
910            None,
911            &mut skipped_recent,
912        );
913        let result = execute_model_cleanup_entries(entries).expect("cleanup should succeed");
914        assert_eq!(result.removed_candidates, 1);
915        assert_eq!(result.removed_files, 1);
916        assert_eq!(result.removed_records, 1);
917        assert!(!primary.exists());
918        assert!(load_model_usage_records_from_dir(&usage_dir).is_empty());
919
920        let _ = std::fs::remove_dir_all(&usage_dir);
921        let _ = std::fs::remove_dir_all(&cache_root);
922    }
923
924    #[test]
925    fn cleanup_skips_paths_that_no_longer_match_record_identity() {
926        let usage_dir = temp_dir("mesh-llm-usage-dir");
927        let cache_root = temp_dir("mesh-llm-hf-cache");
928        let old_path = cache_root
929            .join("models--Org--Actual")
930            .join("snapshots")
931            .join("rev2")
932            .join("Actual-Q4_K_M.gguf");
933        std::fs::create_dir_all(old_path.parent().expect("cleanup path should have parent"))
934            .expect("cleanup parent should exist");
935        std::fs::write(&old_path, vec![0_u8; 32]).expect("cleanup model should be written");
936
937        let record = ModelUsageRecord {
938            lookup_key: "hf:Org/Expected@rev1/Expected-Q4_K_M.gguf".to_string(),
939            display_name: "Expected".to_string(),
940            model_ref: Some("Org/Expected@rev1/Expected-Q4_K_M.gguf".to_string()),
941            source: "catalog".to_string(),
942            mesh_managed: true,
943            primary_path: old_path.clone(),
944            managed_paths: vec![old_path.clone()],
945            hf_repo_id: Some("Org/Expected".to_string()),
946            hf_revision: Some("rev1".to_string()),
947            first_seen_at: "2026-04-01T00:00:00Z".to_string(),
948            last_used_at: "2026-04-01T00:00:00Z".to_string(),
949        };
950        write_record(&usage_dir, &record);
951
952        let mut skipped_recent = 0usize;
953        let entries = plan_cleanup_entries(
954            vec![record],
955            &usage_dir,
956            &cache_root,
957            None,
958            &mut skipped_recent,
959        );
960        assert_eq!(entries.len(), 1);
961        assert!(entries[0].removable_paths.is_empty());
962        assert!(entries[0].stale_record_only);
963        assert!(old_path.exists());
964
965        let _ = std::fs::remove_dir_all(&usage_dir);
966        let _ = std::fs::remove_dir_all(&cache_root);
967    }
968}