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}