Skip to main content

tldr_cli/commands/daemon/
cache_stats.rs

1//! Cache statistics command implementation
2//!
3//! CLI command: `tldr cache stats [--project PATH]`
4//!
5//! Displays cache statistics for a TLDR project:
6//! - If daemon is running: queries cache stats via IPC
7//! - If daemon is not running: reads cache files directly
8//!
9//! Statistics include:
10//! - Salsa-style query cache: hits, misses, hit rate, invalidations
11//! - Cache files: file count, total size on disk
12
13use std::fs;
14use std::path::{Path, PathBuf};
15
16use clap::Args;
17use serde::Serialize;
18
19use crate::output::OutputFormat;
20
21use super::error::{DaemonError, DaemonResult};
22use super::ipc::send_command;
23use super::salsa::QueryCache;
24use super::types::{CacheFileInfo, DaemonCommand, DaemonResponse, SalsaCacheStats};
25
26// =============================================================================
27// CLI Arguments
28// =============================================================================
29
30/// Arguments for the `cache stats` command.
31#[derive(Debug, Clone, Args)]
32pub struct CacheStatsArgs {
33    /// Project root directory (default: current directory)
34    #[arg(long, short = 'p', default_value = ".")]
35    pub project: PathBuf,
36}
37
38// =============================================================================
39// Output Types
40// =============================================================================
41
42/// Output structure for cache stats command.
43#[derive(Debug, Clone, Serialize)]
44pub struct CacheStatsOutput {
45    /// Salsa-style query cache statistics
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub salsa_stats: Option<SalsaCacheStats>,
48    /// Cache file information
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub cache_files: Option<CacheFileInfo>,
51    /// Optional message (e.g., "No cache statistics found")
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub message: Option<String>,
54}
55
56// =============================================================================
57// Command Implementation
58// =============================================================================
59
60impl CacheStatsArgs {
61    /// Run the cache stats command.
62    pub fn run(&self, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
63        // Create a new tokio runtime for the async operations
64        let runtime = tokio::runtime::Runtime::new()?;
65        runtime.block_on(self.run_async(format, quiet))
66    }
67
68    /// Async implementation of the cache stats command.
69    async fn run_async(&self, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
70        // Resolve project path to absolute
71        let project = self.project.canonicalize().unwrap_or_else(|_| {
72            std::env::current_dir()
73                .unwrap_or_else(|_| PathBuf::from("."))
74                .join(&self.project)
75        });
76
77        // Try to get stats from running daemon first
78        let cmd = DaemonCommand::Status { session: None };
79
80        match send_command(&project, &cmd).await {
81            Ok(DaemonResponse::FullStatus { salsa_stats, .. }) => {
82                // Daemon is running, use its stats
83                let cache_files = scan_cache_files(&project)?;
84                let output = CacheStatsOutput {
85                    salsa_stats: Some(salsa_stats),
86                    cache_files: Some(cache_files),
87                    message: None,
88                };
89                self.print_output(&output, format, quiet)
90            }
91            Ok(_) | Err(DaemonError::NotRunning) | Err(DaemonError::ConnectionRefused) => {
92                // Daemon not running, read from cache files directly
93                self.read_cache_from_files(&project, format, quiet)
94            }
95            Err(e) => Err(anyhow::anyhow!("Failed to get cache stats: {}", e)),
96        }
97    }
98
99    /// Read cache statistics from files when daemon is not running.
100    fn read_cache_from_files(
101        &self,
102        project: &Path,
103        format: OutputFormat,
104        quiet: bool,
105    ) -> anyhow::Result<()> {
106        let cache_dir = project.join(".tldr").join("cache");
107
108        // Check if cache directory exists
109        if !cache_dir.exists() {
110            let output = CacheStatsOutput {
111                salsa_stats: None,
112                cache_files: None,
113                message: Some("No cache directory found".to_string()),
114            };
115            return self.print_output(&output, format, quiet);
116        }
117
118        // Try to load salsa stats from file
119        let salsa_stats = self.load_salsa_stats(&cache_dir);
120
121        // Scan cache files
122        let cache_files = scan_cache_files(project)?;
123
124        // Check if we have any cache data
125        if salsa_stats.is_none() && cache_files.file_count == 0 {
126            let output = CacheStatsOutput {
127                salsa_stats: None,
128                cache_files: Some(cache_files),
129                message: Some("No cache statistics found".to_string()),
130            };
131            return self.print_output(&output, format, quiet);
132        }
133
134        let output = CacheStatsOutput {
135            salsa_stats,
136            cache_files: Some(cache_files),
137            message: None,
138        };
139
140        self.print_output(&output, format, quiet)
141    }
142
143    /// Try to load salsa cache stats from persisted file.
144    fn load_salsa_stats(&self, cache_dir: &Path) -> Option<SalsaCacheStats> {
145        let salsa_cache_file = cache_dir.join("salsa_cache.bin");
146
147        if !salsa_cache_file.exists() {
148            return None;
149        }
150
151        // Try to load the cache and extract stats
152        match QueryCache::load_from_file(&salsa_cache_file) {
153            Ok(cache) => Some(cache.stats()),
154            Err(_) => None,
155        }
156    }
157
158    /// Print output in the requested format.
159    fn print_output(
160        &self,
161        output: &CacheStatsOutput,
162        format: OutputFormat,
163        quiet: bool,
164    ) -> anyhow::Result<()> {
165        if quiet {
166            return Ok(());
167        }
168
169        match format {
170            OutputFormat::Json | OutputFormat::Compact => {
171                println!("{}", serde_json::to_string_pretty(output)?);
172            }
173            OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
174                if let Some(ref msg) = output.message {
175                    println!("{}", msg);
176                    return Ok(());
177                }
178
179                println!("Cache Statistics");
180                println!("================");
181
182                if let Some(ref stats) = output.salsa_stats {
183                    println!();
184                    println!("Salsa Cache:");
185                    println!("  Hits:          {}", format_number(stats.hits));
186                    println!("  Misses:        {}", format_number(stats.misses));
187                    println!("  Hit Rate:      {:.2}%", stats.hit_rate());
188                    println!("  Invalidations: {}", format_number(stats.invalidations));
189                    println!("  Recomputations: {}", format_number(stats.recomputations));
190                }
191
192                if let Some(ref files) = output.cache_files {
193                    println!();
194                    println!("Cache Files:");
195                    println!("  Count: {} files", files.file_count);
196                    println!("  Size:  {}", files.total_size_human);
197                }
198            }
199        }
200
201        Ok(())
202    }
203}
204
205// =============================================================================
206// Helper Functions
207// =============================================================================
208
209/// Scan cache files in the project's .tldr/cache/ directory.
210fn scan_cache_files(project: &Path) -> DaemonResult<CacheFileInfo> {
211    let cache_dir = project.join(".tldr").join("cache");
212
213    if !cache_dir.exists() {
214        return Ok(CacheFileInfo {
215            file_count: 0,
216            total_bytes: 0,
217            total_size_human: "0 B".to_string(),
218        });
219    }
220
221    let mut file_count = 0;
222    let mut total_bytes = 0u64;
223
224    // Count all files in cache directory
225    if let Ok(entries) = fs::read_dir(&cache_dir) {
226        for entry in entries.flatten() {
227            if let Ok(metadata) = entry.metadata() {
228                if metadata.is_file() {
229                    file_count += 1;
230                    total_bytes += metadata.len();
231                }
232            }
233        }
234    }
235
236    Ok(CacheFileInfo {
237        file_count,
238        total_bytes,
239        total_size_human: format_bytes(total_bytes),
240    })
241}
242
243/// Format bytes as human-readable string.
244fn format_bytes(bytes: u64) -> String {
245    const KB: u64 = 1024;
246    const MB: u64 = KB * 1024;
247    const GB: u64 = MB * 1024;
248
249    if bytes >= GB {
250        format!("{:.1} GB", bytes as f64 / GB as f64)
251    } else if bytes >= MB {
252        format!("{:.1} MB", bytes as f64 / MB as f64)
253    } else if bytes >= KB {
254        format!("{:.1} KB", bytes as f64 / KB as f64)
255    } else {
256        format!("{} B", bytes)
257    }
258}
259
260/// Format a number with thousands separators.
261fn format_number(n: u64) -> String {
262    let s = n.to_string();
263    let bytes = s.as_bytes();
264    let mut result = String::new();
265    let len = bytes.len();
266
267    for (i, &b) in bytes.iter().enumerate() {
268        if i > 0 && (len - i).is_multiple_of(3) {
269            result.push(',');
270        }
271        result.push(b as char);
272    }
273
274    result
275}
276
277// =============================================================================
278// Tests
279// =============================================================================
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use tempfile::TempDir;
285
286    #[test]
287    fn test_cache_stats_args_default() {
288        let args = CacheStatsArgs {
289            project: PathBuf::from("."),
290        };
291        assert_eq!(args.project, PathBuf::from("."));
292    }
293
294    #[test]
295    fn test_format_bytes() {
296        assert_eq!(format_bytes(0), "0 B");
297        assert_eq!(format_bytes(500), "500 B");
298        assert_eq!(format_bytes(1024), "1.0 KB");
299        assert_eq!(format_bytes(1536), "1.5 KB");
300        assert_eq!(format_bytes(1048576), "1.0 MB");
301        assert_eq!(format_bytes(1572864), "1.5 MB");
302        assert_eq!(format_bytes(1073741824), "1.0 GB");
303    }
304
305    #[test]
306    fn test_format_number() {
307        assert_eq!(format_number(0), "0");
308        assert_eq!(format_number(999), "999");
309        assert_eq!(format_number(1000), "1,000");
310        assert_eq!(format_number(1234567), "1,234,567");
311    }
312
313    #[test]
314    fn test_scan_cache_files_no_cache_dir() {
315        let temp = TempDir::new().unwrap();
316        let result = scan_cache_files(temp.path()).unwrap();
317
318        assert_eq!(result.file_count, 0);
319        assert_eq!(result.total_bytes, 0);
320        assert_eq!(result.total_size_human, "0 B");
321    }
322
323    #[test]
324    fn test_scan_cache_files_with_files() {
325        let temp = TempDir::new().unwrap();
326        let cache_dir = temp.path().join(".tldr").join("cache");
327        fs::create_dir_all(&cache_dir).unwrap();
328
329        // Create some test files
330        fs::write(cache_dir.join("file1.bin"), "hello").unwrap();
331        fs::write(cache_dir.join("file2.json"), "world").unwrap();
332        fs::write(cache_dir.join("call_graph.json"), r#"{"edges":[]}"#).unwrap();
333
334        let result = scan_cache_files(temp.path()).unwrap();
335
336        assert_eq!(result.file_count, 3);
337        assert!(result.total_bytes > 0);
338    }
339
340    #[test]
341    fn test_cache_stats_output_serialization() {
342        let output = CacheStatsOutput {
343            salsa_stats: Some(SalsaCacheStats {
344                hits: 100,
345                misses: 10,
346                invalidations: 5,
347                recomputations: 3,
348            }),
349            cache_files: Some(CacheFileInfo {
350                file_count: 25,
351                total_bytes: 1048576,
352                total_size_human: "1.0 MB".to_string(),
353            }),
354            message: None,
355        };
356
357        let json = serde_json::to_string(&output).unwrap();
358        assert!(json.contains("hits"));
359        assert!(json.contains("100"));
360        assert!(json.contains("file_count"));
361        assert!(json.contains("25"));
362    }
363
364    #[test]
365    fn test_cache_stats_output_empty() {
366        let output = CacheStatsOutput {
367            salsa_stats: None,
368            cache_files: None,
369            message: Some("No cache statistics found".to_string()),
370        };
371
372        let json = serde_json::to_string(&output).unwrap();
373        assert!(json.contains("No cache statistics found"));
374        assert!(!json.contains("salsa_stats"));
375        assert!(!json.contains("cache_files"));
376    }
377
378    #[tokio::test]
379    async fn test_cache_stats_no_cache() {
380        let temp = TempDir::new().unwrap();
381        let args = CacheStatsArgs {
382            project: temp.path().to_path_buf(),
383        };
384
385        // Should succeed even with no cache
386        let result = args.run_async(OutputFormat::Json, true).await;
387        assert!(result.is_ok());
388    }
389
390    #[tokio::test]
391    async fn test_cache_stats_with_cache_dir() {
392        let temp = TempDir::new().unwrap();
393        let cache_dir = temp.path().join(".tldr").join("cache");
394        fs::create_dir_all(&cache_dir).unwrap();
395        fs::write(cache_dir.join("test.bin"), "test data").unwrap();
396
397        let args = CacheStatsArgs {
398            project: temp.path().to_path_buf(),
399        };
400
401        let result = args.run_async(OutputFormat::Json, true).await;
402        assert!(result.is_ok());
403    }
404}