1use crate::model::ProbeResult;
2use anyhow::{Context, Result};
3use serde_json;
4use std::collections::hash_map::DefaultHasher;
5use std::fs;
6use std::hash::{Hash, Hasher};
7use std::path::{Path, PathBuf};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10#[derive(Debug, Clone)]
12pub struct CacheConfig {
13 pub cache_dir: PathBuf,
15 pub enabled: bool,
17 pub max_age_secs: Option<u64>,
19}
20
21impl Default for CacheConfig {
22 fn default() -> Self {
23 Self {
24 cache_dir: default_cache_dir(),
25 enabled: true,
26 max_age_secs: Some(86400 * 7), }
28 }
29}
30
31pub fn default_cache_dir() -> PathBuf {
33 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
34 PathBuf::from(home).join(".cache").join("help-probe")
35}
36
37pub fn generate_cache_key(program: &str, args: &[String]) -> String {
39 let mut hasher = DefaultHasher::new();
40 program.hash(&mut hasher);
41 args.hash(&mut hasher);
42 format!("{:x}", hasher.finish())
43}
44
45fn cache_file_path(cache_dir: &Path, key: &str) -> PathBuf {
47 cache_dir.join(format!("{}.json", key))
48}
49
50#[derive(Debug, serde::Serialize, serde::Deserialize)]
52struct CacheEntry {
53 timestamp: u64,
55 version_hash: String,
57 result: ProbeResult,
59}
60
61pub fn read_cache(
63 program: &str,
64 args: &[String],
65 config: &CacheConfig,
66) -> Result<Option<ProbeResult>> {
67 if !config.enabled {
68 return Ok(None);
69 }
70
71 if !config.cache_dir.exists() {
73 fs::create_dir_all(&config.cache_dir)
74 .with_context(|| format!("Failed to create cache directory: {:?}", config.cache_dir))?;
75 }
76
77 let key = generate_cache_key(program, args);
78 let cache_file = cache_file_path(&config.cache_dir, &key);
79
80 if !cache_file.exists() {
81 return Ok(None);
82 }
83
84 let content = fs::read_to_string(&cache_file)
86 .with_context(|| format!("Failed to read cache file: {:?}", cache_file))?;
87
88 let entry: CacheEntry = serde_json::from_str(&content)
89 .with_context(|| format!("Failed to parse cache file: {:?}", cache_file))?;
90
91 if let Some(max_age) = config.max_age_secs {
93 let now = SystemTime::now()
94 .duration_since(UNIX_EPOCH)
95 .unwrap()
96 .as_secs();
97 if now.saturating_sub(entry.timestamp) > max_age {
98 let _ = fs::remove_file(&cache_file);
100 return Ok(None);
101 }
102 }
103
104 let current_version_hash = compute_version_hash(&entry.result);
107 if current_version_hash != entry.version_hash {
108 let _ = fs::remove_file(&cache_file);
110 return Ok(None);
111 }
112
113 Ok(Some(entry.result))
114}
115
116pub fn write_cache(
118 program: &str,
119 args: &[String],
120 result: &ProbeResult,
121 config: &CacheConfig,
122) -> Result<()> {
123 if !config.enabled {
124 return Ok(());
125 }
126
127 if !config.cache_dir.exists() {
129 fs::create_dir_all(&config.cache_dir)
130 .with_context(|| format!("Failed to create cache directory: {:?}", config.cache_dir))?;
131 }
132
133 let key = generate_cache_key(program, args);
134 let cache_file = cache_file_path(&config.cache_dir, &key);
135
136 let timestamp = SystemTime::now()
137 .duration_since(UNIX_EPOCH)
138 .unwrap()
139 .as_secs();
140
141 let version_hash = compute_version_hash(result);
142
143 let entry = CacheEntry {
144 timestamp,
145 version_hash,
146 result: result.clone(),
147 };
148
149 let content =
150 serde_json::to_string_pretty(&entry).context("Failed to serialize cache entry")?;
151
152 let temp_file = cache_file.with_extension("tmp");
154 fs::write(&temp_file, content)
155 .with_context(|| format!("Failed to write cache file: {:?}", temp_file))?;
156 fs::rename(&temp_file, &cache_file)
157 .with_context(|| format!("Failed to rename cache file: {:?}", cache_file))?;
158
159 Ok(())
160}
161
162fn compute_version_hash(result: &ProbeResult) -> String {
165 let mut hasher = DefaultHasher::new();
166 result.raw_stdout.hash(&mut hasher);
167 result.raw_stderr.hash(&mut hasher);
168 format!("{:x}", hasher.finish())
169}
170
171pub fn clear_cache(
173 program: Option<&str>,
174 args: Option<&[String]>,
175 config: &CacheConfig,
176) -> Result<usize> {
177 if !config.cache_dir.exists() {
178 return Ok(0);
179 }
180
181 let mut cleared = 0;
182
183 if let Some(prog) = program {
184 let key = if let Some(arg_list) = args {
186 generate_cache_key(prog, arg_list)
187 } else {
188 return clear_all_cache(config);
191 };
192 let cache_file = cache_file_path(&config.cache_dir, &key);
193 if cache_file.exists() {
194 fs::remove_file(&cache_file)
195 .with_context(|| format!("Failed to remove cache file: {:?}", cache_file))?;
196 cleared = 1;
197 }
198 } else {
199 return clear_all_cache(config);
201 }
202
203 Ok(cleared)
204}
205
206fn clear_all_cache(config: &CacheConfig) -> Result<usize> {
208 if !config.cache_dir.exists() {
209 return Ok(0);
210 }
211
212 let mut cleared = 0;
213 let entries = fs::read_dir(&config.cache_dir)
214 .with_context(|| format!("Failed to read cache directory: {:?}", config.cache_dir))?;
215
216 for entry in entries {
217 let entry = entry.context("Failed to read cache directory entry")?;
218 let path = entry.path();
219 if path.extension().and_then(|s| s.to_str()) == Some("json") {
220 fs::remove_file(&path)
221 .with_context(|| format!("Failed to remove cache file: {:?}", path))?;
222 cleared += 1;
223 }
224 }
225
226 Ok(cleared)
227}