Skip to main content

morph_cli/core/
cache.rs

1use std::collections::hash_map::DefaultHasher;
2use std::fs;
3use std::hash::{Hash, Hasher};
4use std::path::{Path, PathBuf};
5use std::sync::atomic::{AtomicUsize, Ordering};
6
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9
10use crate::core::recipe::{DetectionFailure, FileAnalysis};
11
12const CACHE_DIR: &str = ".morph-cli/cache";
13
14static CACHE_HITS: AtomicUsize = AtomicUsize::new(0);
15static CACHE_MISSES: AtomicUsize = AtomicUsize::new(0);
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct CachedFileDetection {
19    pub hash: String,
20    pub metadata: CachedFileMetadata,
21    pub outcome: CachedDetectionOutcome,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct CachedFileMetadata {
26    pub path: PathBuf,
27    pub size: u64,
28    pub modified_secs: u64,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub enum CachedDetectionOutcome {
33    Analysis(FileAnalysis),
34    Skipped(PathBuf),
35    Failure(DetectionFailure),
36}
37
38#[derive(Debug, Clone, Copy)]
39pub struct CacheStats {
40    pub hits: usize,
41    pub misses: usize,
42}
43
44pub fn load_detection(recipe_name: &str, path: &Path) -> Option<CachedDetectionOutcome> {
45    let cache_path = cache_path(recipe_name, path);
46    if !cache_path.exists() {
47        return None;
48    }
49
50    let content = fs::read_to_string(&cache_path).ok()?;
51    let cached = serde_json::from_str::<CachedFileDetection>(&content).ok()?;
52
53    if let Ok(meta) = fs::metadata(path) {
54        let modified_secs = meta
55            .modified()
56            .ok()
57            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
58            .map(|d| d.as_secs())
59            .unwrap_or_default();
60
61        if meta.len() == cached.metadata.size && modified_secs == cached.metadata.modified_secs {
62            CACHE_HITS.fetch_add(1, Ordering::Relaxed);
63            return Some(cached.outcome);
64        }
65    }
66
67    let hash = file_hash(path).ok()?;
68    if cached.hash == hash {
69        CACHE_HITS.fetch_add(1, Ordering::Relaxed);
70        Some(cached.outcome)
71    } else {
72        CACHE_MISSES.fetch_add(1, Ordering::Relaxed);
73        None
74    }
75}
76
77pub fn save_detection(
78    recipe_name: &str,
79    path: &Path,
80    outcome: &CachedDetectionOutcome,
81) -> Result<()> {
82    let cache_path = cache_path(recipe_name, path);
83    if let Some(parent) = cache_path.parent() {
84        fs::create_dir_all(parent)?;
85    }
86
87    let cached = CachedFileDetection {
88        hash: file_hash(path)?,
89        metadata: file_metadata(path)?,
90        outcome: outcome.clone(),
91    };
92    let json = serde_json::to_string_pretty(&cached).context("failed to serialize cache entry")?;
93    fs::write(cache_path, json).context("failed to write cache entry")?;
94    Ok(())
95}
96
97pub fn record_miss() {
98    CACHE_MISSES.fetch_add(1, Ordering::Relaxed);
99}
100
101pub fn stats() -> CacheStats {
102    CacheStats {
103        hits: CACHE_HITS.load(Ordering::Relaxed),
104        misses: CACHE_MISSES.load(Ordering::Relaxed),
105    }
106}
107
108pub fn print_stats() {
109    let stats = stats();
110    println!("cache hits: {}", stats.hits);
111    println!("cache misses: {}", stats.misses);
112}
113
114fn cache_path(recipe_name: &str, path: &Path) -> PathBuf {
115    PathBuf::from(CACHE_DIR)
116        .join(sanitize(recipe_name))
117        .join(format!("{}.json", sanitize(&path.to_string_lossy())))
118}
119
120fn sanitize(value: &str) -> String {
121    value
122        .chars()
123        .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' })
124        .collect()
125}
126
127fn file_hash(path: &Path) -> Result<String> {
128    let content = fs::read(path)?;
129    let mut hasher = DefaultHasher::new();
130    content.hash(&mut hasher);
131    Ok(format!("{:016x}", hasher.finish()))
132}
133
134fn file_metadata(path: &Path) -> Result<CachedFileMetadata> {
135    let metadata = fs::metadata(path)?;
136    let modified_secs = metadata
137        .modified()
138        .ok()
139        .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
140        .map(|duration| duration.as_secs())
141        .unwrap_or_default();
142
143    Ok(CachedFileMetadata {
144        path: path.to_path_buf(),
145        size: metadata.len(),
146        modified_secs,
147    })
148}