tldr_cli/commands/daemon/
cache_stats.rs1use 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#[derive(Debug, Clone, Args)]
32pub struct CacheStatsArgs {
33 #[arg(long, short = 'p', default_value = ".")]
35 pub project: PathBuf,
36}
37
38#[derive(Debug, Clone, Serialize)]
44pub struct CacheStatsOutput {
45 #[serde(skip_serializing_if = "Option::is_none")]
47 pub salsa_stats: Option<SalsaCacheStats>,
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub cache_files: Option<CacheFileInfo>,
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub message: Option<String>,
54}
55
56impl CacheStatsArgs {
61 pub fn run(&self, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
63 let runtime = tokio::runtime::Runtime::new()?;
65 runtime.block_on(self.run_async(format, quiet))
66 }
67
68 async fn run_async(&self, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
70 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 let cmd = DaemonCommand::Status { session: None };
79
80 match send_command(&project, &cmd).await {
81 Ok(DaemonResponse::FullStatus { salsa_stats, .. }) => {
82 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 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 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 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 let salsa_stats = self.load_salsa_stats(&cache_dir);
120
121 let cache_files = scan_cache_files(project)?;
123
124 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 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 match QueryCache::load_from_file(&salsa_cache_file) {
153 Ok(cache) => Some(cache.stats()),
154 Err(_) => None,
155 }
156 }
157
158 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
205fn 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 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
243fn 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
260fn 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#[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 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 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}