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}