help_probe/
cache.rs

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/// Cache configuration.
11#[derive(Debug, Clone)]
12pub struct CacheConfig {
13    /// Cache directory path.
14    pub cache_dir: PathBuf,
15    /// Whether caching is enabled.
16    pub enabled: bool,
17    /// Maximum cache age in seconds (None = no expiration).
18    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), // 7 days default
27        }
28    }
29}
30
31/// Get the default cache directory.
32pub 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
37/// Generate a cache key from command and arguments.
38pub 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
45/// Get the cache file path for a given key.
46fn cache_file_path(cache_dir: &Path, key: &str) -> PathBuf {
47    cache_dir.join(format!("{}.json", key))
48}
49
50/// Cache entry metadata.
51#[derive(Debug, serde::Serialize, serde::Deserialize)]
52struct CacheEntry {
53    /// Timestamp when cached (Unix epoch seconds).
54    timestamp: u64,
55    /// Command version hash (from help text hash).
56    version_hash: String,
57    /// The cached probe result.
58    result: ProbeResult,
59}
60
61/// Read a cached result if available and valid.
62pub 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    // Ensure cache directory exists
72    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    // Read and parse cache file
85    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    // Check cache age
92    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            // Cache expired, remove it
99            let _ = fs::remove_file(&cache_file);
100            return Ok(None);
101        }
102    }
103
104    // Verify version hasn't changed by checking if help text hash matches
105    // We'll compute a simple hash of the help text to detect changes
106    let current_version_hash = compute_version_hash(&entry.result);
107    if current_version_hash != entry.version_hash {
108        // Command version changed, invalidate cache
109        let _ = fs::remove_file(&cache_file);
110        return Ok(None);
111    }
112
113    Ok(Some(entry.result))
114}
115
116/// Write a result to cache.
117pub 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    // Ensure cache directory exists
128    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    // Write to temporary file first, then rename (atomic operation)
153    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
162/// Compute a version hash from the probe result.
163/// This hash changes when the command's help text changes.
164fn 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
171/// Clear the cache for a specific command, or all cache if no command provided.
172pub 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        // Clear specific command cache
185        let key = if let Some(arg_list) = args {
186            generate_cache_key(prog, arg_list)
187        } else {
188            // If no args provided, clear all caches for this program
189            // This is a simplified approach - in practice, we'd need to track all keys
190            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        // Clear all cache
200        return clear_all_cache(config);
201    }
202
203    Ok(cleared)
204}
205
206/// Clear all cache entries.
207fn 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}