Skip to main content

sqry_cli/commands/
cache.rs

1//! Cache command implementation
2
3use crate::args::{CacheAction, Cli};
4use anyhow::Result;
5use sqry_core::cache::{CacheConfig, CacheManager, PruneOptions, PruneOutputMode, PruneReport};
6use std::path::PathBuf;
7use std::time::Duration;
8
9/// Run cache management command
10///
11/// # Errors
12/// Returns an error if cache operations fail or stats cannot be collected.
13pub fn run_cache(cli: &Cli, action: &CacheAction) -> Result<()> {
14    match action {
15        CacheAction::Stats { path } => {
16            let search_path = path.as_deref().unwrap_or(".");
17            show_cache_stats(cli, search_path)
18        }
19        CacheAction::Clear { path, confirm } => {
20            let search_path = path.as_deref().unwrap_or(".");
21            clear_cache(cli, search_path, *confirm);
22            Ok(())
23        }
24        CacheAction::Prune {
25            days,
26            size,
27            dry_run,
28            path,
29        } => prune_cache(cli, *days, size.as_deref(), *dry_run, path.as_deref()),
30    }
31}
32
33/// Show cache statistics
34fn show_cache_stats(cli: &Cli, _path: &str) -> Result<()> {
35    // Create cache manager with default config
36    let config = CacheConfig::from_env();
37    let cache = CacheManager::new(config);
38    let stats = cache.stats();
39
40    if cli.json {
41        // JSON output
42        let json_stats = serde_json::json!({
43            "ast_cache": {
44                "hits": stats.hits,
45                "misses": stats.misses,
46                "evictions": stats.evictions,
47                "entry_count": stats.entry_count,
48                "total_bytes": stats.total_bytes,
49                "total_mb": bytes_to_mb_lossy(stats.total_bytes),
50                "hit_rate": stats.hit_rate(),
51            },
52        });
53        println!("{}", serde_json::to_string_pretty(&json_stats)?);
54    } else {
55        // Human-readable output
56        println!("AST Cache Statistics");
57        println!("====================");
58        println!();
59        println!("Performance:");
60        println!("  Hit rate:    {:.1}%", stats.hit_rate() * 100.0);
61        println!("  Hits:        {}", stats.hits);
62        println!("  Misses:      {}", stats.misses);
63        println!("  Evictions:   {}", stats.evictions);
64        println!();
65        println!("Storage:");
66        println!("  Entries:     {}", stats.entry_count);
67        println!(
68            "  Memory:      {:.2} MB",
69            bytes_to_mb_lossy(stats.total_bytes)
70        );
71        println!();
72
73        // Calculate effectiveness
74        print_cache_effectiveness(stats.hits, stats.misses);
75
76        // Show cache location and disk usage
77        let cache_root =
78            std::env::var("SQRY_CACHE_ROOT").unwrap_or_else(|_| ".sqry-cache".to_string());
79        println!("Cache location: {cache_root}");
80
81        // Show disk usage
82        let disk_usage = get_disk_usage(&cache_root);
83        println!();
84        println!("Disk Usage:");
85        println!("  Files:       {}", disk_usage.file_count);
86        println!(
87            "  Total size:  {:.2} MB",
88            bytes_to_mb_lossy(disk_usage.bytes)
89        );
90    }
91
92    Ok(())
93}
94
95/// Print estimated cache effectiveness metrics (time savings from cache hits).
96fn print_cache_effectiveness(hits: usize, misses: usize) {
97    if hits + misses > 0 {
98        let total_accesses = hits + misses;
99        let avg_savings_ms = 50; // Conservative estimate: parsing takes ~50ms
100        let time_saved_ms = hits * avg_savings_ms;
101        let time_saved_sec = time_saved_ms / 1000;
102
103        println!("Estimated Impact:");
104        println!("  Total accesses:  {total_accesses}");
105        println!("  Time saved:      ~{time_saved_sec} seconds ({time_saved_ms} ms)");
106        println!();
107    }
108}
109
110struct DiskUsage {
111    file_count: usize,
112    bytes: u64,
113}
114
115fn get_disk_usage(cache_root: &str) -> DiskUsage {
116    use walkdir::WalkDir;
117
118    let mut file_count = 0;
119    let mut total_bytes = 0u64;
120
121    for entry in WalkDir::new(cache_root)
122        .into_iter()
123        .filter_map(std::result::Result::ok)
124        .filter(|e| e.file_type().is_file())
125    {
126        if let Ok(metadata) = entry.metadata() {
127            total_bytes += metadata.len();
128            file_count += 1;
129        }
130    }
131
132    DiskUsage {
133        file_count,
134        bytes: total_bytes,
135    }
136}
137
138fn u64_to_f64_lossy(value: u64) -> f64 {
139    let narrowed = u32::try_from(value).unwrap_or(u32::MAX);
140    f64::from(narrowed)
141}
142
143fn bytes_to_mb_lossy(bytes: u64) -> f64 {
144    u64_to_f64_lossy(bytes) / 1_048_576.0
145}
146
147/// Clear the cache
148fn clear_cache(_cli: &Cli, _path: &str, confirm: bool) {
149    if !confirm {
150        eprintln!("Error: Cache clear requires --confirm flag for safety");
151        eprintln!();
152        eprintln!("This will delete all cached AST data. Next queries will re-parse files.");
153        eprintln!();
154        eprintln!("To proceed, run:");
155        eprintln!("  sqry cache clear --confirm");
156        std::process::exit(1);
157    }
158
159    // Create cache manager and clear it
160    let config = CacheConfig::from_env();
161    let cache = CacheManager::new(config);
162
163    // Get stats before clearing
164    let stats_before = cache.stats();
165
166    cache.clear();
167
168    // Verify it's cleared
169    let stats_after = cache.stats();
170
171    println!("Cache cleared successfully");
172    println!();
173    println!("Removed:");
174    println!("  Entries:     {}", stats_before.entry_count);
175    println!(
176        "  Memory:      {:.2} MB",
177        bytes_to_mb_lossy(stats_before.total_bytes)
178    );
179    println!();
180    println!("Current stats:");
181    println!("  Entries:     {}", stats_after.entry_count);
182    println!(
183        "  Memory:      {:.2} MB",
184        bytes_to_mb_lossy(stats_after.total_bytes)
185    );
186}
187
188/// Prune the cache based on retention policies
189fn prune_cache(
190    cli: &Cli,
191    days: Option<u64>,
192    size_str: Option<&str>,
193    dry_run: bool,
194    path: Option<&str>,
195) -> Result<()> {
196    let options = build_prune_options(cli, days, size_str, dry_run, path)?;
197    let report = execute_cache_prune(&options)?;
198    write_prune_report(cli, dry_run, &report)?;
199
200    Ok(())
201}
202
203/// Parse byte size from string (e.g., "1GB", "500MB")
204fn parse_byte_size(s: &str) -> Result<u64> {
205    let s = s.trim().to_uppercase();
206
207    // Extract number and unit
208    let (num_str, unit) = if s.ends_with("GB") {
209        (&s[..s.len() - 2], 1024 * 1024 * 1024)
210    } else if s.ends_with("MB") {
211        (&s[..s.len() - 2], 1024 * 1024)
212    } else if s.ends_with("KB") {
213        (&s[..s.len() - 2], 1024)
214    } else if s.ends_with('B') {
215        (&s[..s.len() - 1], 1)
216    } else {
217        // Assume bytes if no unit
218        (&s[..], 1)
219    };
220
221    let num: u64 = num_str.trim().parse().map_err(|_| {
222        anyhow::anyhow!("Invalid size format {s}. Expected formats: 1GB, 500MB, 100KB")
223    })?;
224
225    Ok(num * unit)
226}
227
228fn build_prune_options(
229    cli: &Cli,
230    days: Option<u64>,
231    size_str: Option<&str>,
232    dry_run: bool,
233    path: Option<&str>,
234) -> Result<PruneOptions> {
235    // Parse size if provided
236    let max_size = size_str.map(parse_byte_size).transpose()?;
237
238    // Convert days to Duration
239    let max_age = days.map(|d| Duration::from_secs(d * 24 * 3600));
240
241    // Build prune options
242    let mut options = PruneOptions::new();
243
244    if let Some(age) = max_age {
245        options = options.with_max_age(age);
246    }
247
248    if let Some(size) = max_size {
249        options = options.with_max_size(size);
250    }
251
252    options = options.with_dry_run(dry_run);
253
254    let output_mode = if cli.json {
255        PruneOutputMode::Json
256    } else {
257        PruneOutputMode::Human
258    };
259    options = options.with_output_mode(output_mode);
260
261    if let Some(p) = path {
262        options = options.with_target_dir(PathBuf::from(p));
263    }
264
265    Ok(options)
266}
267
268fn execute_cache_prune(options: &PruneOptions) -> Result<PruneReport> {
269    let config = CacheConfig::from_env();
270    let cache = CacheManager::new(config);
271    cache.prune(options)
272}
273
274fn write_prune_report(cli: &Cli, dry_run: bool, report: &PruneReport) -> Result<()> {
275    if cli.json {
276        println!("{}", serde_json::to_string_pretty(report)?);
277        return Ok(());
278    }
279
280    let header = if dry_run {
281        "Cache Prune Preview (Dry Run)"
282    } else {
283        "Cache Prune Report"
284    };
285    println!("{header}");
286    println!("====================");
287    println!();
288
289    if report.entries_removed == 0 {
290        println!("No entries removed");
291        println!("Cache is within configured limits");
292        return Ok(());
293    }
294
295    println!("Entries:");
296    println!("  Considered:  {}", report.entries_considered);
297    println!("  Removed:     {}", report.entries_removed);
298    println!("  Remaining:   {}", report.remaining_entries);
299    println!();
300    println!("Space:");
301    println!(
302        "  Reclaimed:   {:.2} MB",
303        bytes_to_mb_lossy(report.bytes_removed)
304    );
305    println!(
306        "  Remaining:   {:.2} MB",
307        bytes_to_mb_lossy(report.remaining_bytes)
308    );
309
310    if dry_run {
311        println!();
312        println!("Run without --dry-run to actually delete files");
313    }
314
315    Ok(())
316}