Skip to main content

tldr_cli/commands/daemon/
cache_clear.rs

1//! Cache clear command implementation
2//!
3//! CLI command: `tldr cache clear [--project PATH]`
4//!
5//! Clears the cache for a TLDR project:
6//! 1. If daemon is running, stops it first (or sends Clear command)
7//! 2. Deletes cache files in `.tldr/cache/`
8//! 3. Reports cleared size
9//!
10//! Files removed:
11//! - salsa_cache.bin (Salsa query cache)
12//! - salsa_stats.json (legacy stats file)
13//! - call_graph.json (call graph cache)
14//! - *.pkl files (pickle files, if any)
15//! - Any other files in the cache directory
16
17use std::fs;
18use std::path::{Path, PathBuf};
19
20use clap::Args;
21use serde::Serialize;
22
23use crate::output::OutputFormat;
24
25use super::error::DaemonResult;
26use super::ipc::send_command;
27use super::types::DaemonCommand;
28
29// =============================================================================
30// CLI Arguments
31// =============================================================================
32
33/// Arguments for the `cache clear` command.
34#[derive(Debug, Clone, Args)]
35pub struct CacheClearArgs {
36    /// Project root directory (default: current directory)
37    #[arg(long, short = 'p', default_value = ".")]
38    pub project: PathBuf,
39}
40
41// =============================================================================
42// Output Types
43// =============================================================================
44
45/// Output structure for cache clear command.
46#[derive(Debug, Clone, Serialize)]
47pub struct CacheClearOutput {
48    /// Status of the operation
49    pub status: String,
50    /// Number of files removed
51    pub files_removed: usize,
52    /// Bytes freed
53    pub bytes_freed: u64,
54    /// Human-readable size freed
55    pub size_freed_human: String,
56    /// Optional message
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub message: Option<String>,
59}
60
61// =============================================================================
62// Command Implementation
63// =============================================================================
64
65impl CacheClearArgs {
66    /// Run the cache clear command.
67    pub fn run(&self, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
68        // Create a new tokio runtime for the async operations
69        let runtime = tokio::runtime::Runtime::new()?;
70        runtime.block_on(self.run_async(format, quiet))
71    }
72
73    /// Async implementation of the cache clear command.
74    async fn run_async(&self, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
75        // Resolve project path to absolute
76        let project = self.project.canonicalize().unwrap_or_else(|_| {
77            std::env::current_dir()
78                .unwrap_or_else(|_| PathBuf::from("."))
79                .join(&self.project)
80        });
81
82        // Try to stop daemon first if it's running
83        // This ensures the daemon doesn't continue writing to cache files
84        self.try_stop_daemon(&project).await;
85
86        // Clear cache files
87        let (files_removed, bytes_freed) = self.clear_cache_files(&project)?;
88
89        let output = if files_removed == 0 {
90            CacheClearOutput {
91                status: "ok".to_string(),
92                files_removed: 0,
93                bytes_freed: 0,
94                size_freed_human: "0 B".to_string(),
95                message: Some("No cache directory found".to_string()),
96            }
97        } else {
98            CacheClearOutput {
99                status: "ok".to_string(),
100                files_removed,
101                bytes_freed,
102                size_freed_human: format_bytes(bytes_freed),
103                message: Some(format!("Cache cleared: {} file(s) removed", files_removed)),
104            }
105        };
106
107        self.print_output(&output, format, quiet)
108    }
109
110    /// Try to stop the daemon if it's running.
111    async fn try_stop_daemon(&self, project: &Path) {
112        let cmd = DaemonCommand::Shutdown;
113        // Ignore errors - daemon might not be running
114        let _ = send_command(project, &cmd).await;
115    }
116
117    /// Clear all cache files in the project's .tldr/cache/ directory.
118    fn clear_cache_files(&self, project: &Path) -> DaemonResult<(usize, u64)> {
119        let cache_dir = project.join(".tldr").join("cache");
120
121        if !cache_dir.exists() {
122            return Ok((0, 0));
123        }
124
125        let mut files_removed = 0;
126        let mut bytes_freed = 0u64;
127
128        // Collect files to remove
129        let entries: Vec<_> = fs::read_dir(&cache_dir)?
130            .filter_map(|e| e.ok())
131            .filter(|e| e.metadata().map(|m| m.is_file()).unwrap_or(false))
132            .collect();
133
134        // Remove each file
135        for entry in entries {
136            let path = entry.path();
137            if let Ok(metadata) = entry.metadata() {
138                bytes_freed += metadata.len();
139            }
140            if fs::remove_file(&path).is_ok() {
141                files_removed += 1;
142            }
143        }
144
145        Ok((files_removed, bytes_freed))
146    }
147
148    /// Print output in the requested format.
149    fn print_output(
150        &self,
151        output: &CacheClearOutput,
152        format: OutputFormat,
153        quiet: bool,
154    ) -> anyhow::Result<()> {
155        if quiet {
156            return Ok(());
157        }
158
159        match format {
160            OutputFormat::Json | OutputFormat::Compact => {
161                println!("{}", serde_json::to_string_pretty(output)?);
162            }
163            OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
164                if output.files_removed == 0 {
165                    println!("No cache directory found");
166                } else {
167                    println!(
168                        "Cache cleared: {} file(s) removed ({})",
169                        output.files_removed, output.size_freed_human
170                    );
171                }
172            }
173        }
174
175        Ok(())
176    }
177}
178
179// =============================================================================
180// Helper Functions
181// =============================================================================
182
183/// Format bytes as human-readable string.
184fn format_bytes(bytes: u64) -> String {
185    const KB: u64 = 1024;
186    const MB: u64 = KB * 1024;
187    const GB: u64 = MB * 1024;
188
189    if bytes >= GB {
190        format!("{:.1} GB", bytes as f64 / GB as f64)
191    } else if bytes >= MB {
192        format!("{:.1} MB", bytes as f64 / MB as f64)
193    } else if bytes >= KB {
194        format!("{:.1} KB", bytes as f64 / KB as f64)
195    } else {
196        format!("{} B", bytes)
197    }
198}
199
200// =============================================================================
201// Tests
202// =============================================================================
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use tempfile::TempDir;
208
209    #[test]
210    fn test_cache_clear_args_default() {
211        let args = CacheClearArgs {
212            project: PathBuf::from("."),
213        };
214        assert_eq!(args.project, PathBuf::from("."));
215    }
216
217    #[test]
218    fn test_format_bytes() {
219        assert_eq!(format_bytes(0), "0 B");
220        assert_eq!(format_bytes(500), "500 B");
221        assert_eq!(format_bytes(1024), "1.0 KB");
222        assert_eq!(format_bytes(1536), "1.5 KB");
223        assert_eq!(format_bytes(1048576), "1.0 MB");
224        assert_eq!(format_bytes(1073741824), "1.0 GB");
225    }
226
227    #[test]
228    fn test_cache_clear_output_serialization() {
229        let output = CacheClearOutput {
230            status: "ok".to_string(),
231            files_removed: 26,
232            bytes_freed: 1048576,
233            size_freed_human: "1.0 MB".to_string(),
234            message: Some("Cache cleared: 26 file(s) removed".to_string()),
235        };
236
237        let json = serde_json::to_string(&output).unwrap();
238        assert!(json.contains("ok"));
239        assert!(json.contains("26"));
240        assert!(json.contains("1048576"));
241        assert!(json.contains("1.0 MB"));
242    }
243
244    #[test]
245    fn test_cache_clear_output_empty() {
246        let output = CacheClearOutput {
247            status: "ok".to_string(),
248            files_removed: 0,
249            bytes_freed: 0,
250            size_freed_human: "0 B".to_string(),
251            message: Some("No cache directory found".to_string()),
252        };
253
254        let json = serde_json::to_string(&output).unwrap();
255        assert!(json.contains("No cache directory found"));
256    }
257
258    #[test]
259    fn test_clear_cache_files_no_cache_dir() {
260        let temp = TempDir::new().unwrap();
261        let args = CacheClearArgs {
262            project: temp.path().to_path_buf(),
263        };
264
265        let result = args.clear_cache_files(temp.path());
266        assert!(result.is_ok());
267        let (files, bytes) = result.unwrap();
268        assert_eq!(files, 0);
269        assert_eq!(bytes, 0);
270    }
271
272    #[test]
273    fn test_clear_cache_files_with_files() {
274        let temp = TempDir::new().unwrap();
275        let cache_dir = temp.path().join(".tldr").join("cache");
276        fs::create_dir_all(&cache_dir).unwrap();
277
278        // Create some test files
279        fs::write(cache_dir.join("salsa_cache.bin"), "test data 1").unwrap();
280        fs::write(cache_dir.join("call_graph.json"), r#"{"edges":[]}"#).unwrap();
281        fs::write(cache_dir.join("test.pkl"), "pickle data").unwrap();
282
283        let args = CacheClearArgs {
284            project: temp.path().to_path_buf(),
285        };
286
287        let result = args.clear_cache_files(temp.path());
288        assert!(result.is_ok());
289        let (files, bytes) = result.unwrap();
290        assert_eq!(files, 3);
291        assert!(bytes > 0);
292
293        // Verify files are gone
294        assert!(!cache_dir.join("salsa_cache.bin").exists());
295        assert!(!cache_dir.join("call_graph.json").exists());
296        assert!(!cache_dir.join("test.pkl").exists());
297    }
298
299    #[test]
300    fn test_clear_cache_files_preserves_directory() {
301        let temp = TempDir::new().unwrap();
302        let cache_dir = temp.path().join(".tldr").join("cache");
303        fs::create_dir_all(&cache_dir).unwrap();
304        fs::write(cache_dir.join("test.bin"), "data").unwrap();
305
306        let args = CacheClearArgs {
307            project: temp.path().to_path_buf(),
308        };
309
310        args.clear_cache_files(temp.path()).unwrap();
311
312        // Cache directory should still exist (only files removed)
313        assert!(cache_dir.exists());
314    }
315
316    #[tokio::test]
317    async fn test_cache_clear_no_cache() {
318        let temp = TempDir::new().unwrap();
319        let args = CacheClearArgs {
320            project: temp.path().to_path_buf(),
321        };
322
323        // Should succeed even with no cache
324        let result = args.run_async(OutputFormat::Json, true).await;
325        assert!(result.is_ok());
326    }
327
328    #[tokio::test]
329    async fn test_cache_clear_with_files() {
330        let temp = TempDir::new().unwrap();
331        let cache_dir = temp.path().join(".tldr").join("cache");
332        fs::create_dir_all(&cache_dir).unwrap();
333        fs::write(cache_dir.join("test.bin"), "test data").unwrap();
334
335        let args = CacheClearArgs {
336            project: temp.path().to_path_buf(),
337        };
338
339        let result = args.run_async(OutputFormat::Json, true).await;
340        assert!(result.is_ok());
341
342        // File should be removed
343        assert!(!cache_dir.join("test.bin").exists());
344    }
345}