Skip to main content

tldr_cli/commands/daemon/
stats.rs

1//! Stats command implementation
2//!
3//! CLI command: `tldr stats [--format json|text]`
4//!
5//! Reads usage statistics from `~/.tldr/stats.jsonl` and aggregates them.
6//!
7//! # Behavior
8//!
9//! 1. Read stats from JSONL file
10//! 2. Aggregate session stats
11//! 3. Calculate token savings
12//! 4. Output in requested format
13//!
14//! # Output
15//!
16//! JSON format:
17//! ```json
18//! {
19//!   "total_invocations": 1500,
20//!   "estimated_tokens_saved": 4500000,
21//!   "raw_tokens_total": 5000000,
22//!   "tldr_tokens_total": 500000,
23//!   "savings_percent": 90.0
24//! }
25//! ```
26
27use std::fs;
28use std::io::{BufRead, BufReader};
29use std::path::PathBuf;
30
31use clap::Args;
32use dirs;
33use serde::{Deserialize, Serialize};
34
35use crate::output::OutputFormat;
36
37use super::error::{DaemonError, DaemonResult};
38use super::types::GlobalStats;
39
40// =============================================================================
41// CLI Arguments
42// =============================================================================
43
44/// Arguments for the `stats` command.
45#[derive(Debug, Clone, Args)]
46pub struct StatsArgs {
47    // Stats command uses the global --format flag, no local format arg needed
48}
49
50// =============================================================================
51// Stats File Types
52// =============================================================================
53
54/// Entry in the stats.jsonl file.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct StatsEntry {
57    /// Session identifier
58    pub session_id: String,
59
60    /// Raw tokens processed
61    pub raw_tokens: u64,
62
63    /// TLDR tokens returned
64    pub tldr_tokens: u64,
65
66    /// Number of requests
67    pub requests: u64,
68
69    /// Optional timestamp
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub timestamp: Option<String>,
72}
73
74/// Output for the stats command.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct StatsOutput {
77    /// Total number of invocations across all sessions
78    pub total_invocations: u64,
79
80    /// Estimated tokens saved across all sessions
81    pub estimated_tokens_saved: i64,
82
83    /// Total raw tokens processed
84    pub raw_tokens_total: u64,
85
86    /// Total TLDR tokens returned
87    pub tldr_tokens_total: u64,
88
89    /// Savings percentage (0-100)
90    pub savings_percent: f64,
91}
92
93/// Message output for empty stats.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct EmptyStatsOutput {
96    /// Message indicating no usage
97    pub message: String,
98}
99
100// =============================================================================
101// Implementation
102// =============================================================================
103
104impl StatsArgs {
105    /// Run the stats command.
106    pub fn run(&self, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
107        // Get stats file path
108        let stats_path = get_stats_path()?;
109
110        // Read and aggregate stats
111        let stats = read_and_aggregate_stats(&stats_path)?;
112
113        // Output result
114        if !quiet {
115            // Use the global format from CLI
116            let output_format = format;
117
118            match stats {
119                Some(stats) => {
120                    let output = StatsOutput {
121                        total_invocations: stats.total_invocations,
122                        estimated_tokens_saved: stats.estimated_tokens_saved,
123                        raw_tokens_total: stats.raw_tokens_total,
124                        tldr_tokens_total: stats.tldr_tokens_total,
125                        savings_percent: stats.savings_percent,
126                    };
127
128                    match output_format {
129                        OutputFormat::Json | OutputFormat::Compact => {
130                            println!("{}", serde_json::to_string_pretty(&output)?);
131                        }
132                        OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
133                            print_text_stats(&output);
134                        }
135                    }
136                }
137                None => match output_format {
138                    OutputFormat::Json | OutputFormat::Compact => {
139                        let empty = EmptyStatsOutput {
140                            message: "No usage recorded yet".to_string(),
141                        };
142                        println!("{}", serde_json::to_string_pretty(&empty)?);
143                    }
144                    OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
145                        println!("No usage recorded yet");
146                    }
147                },
148            }
149        }
150
151        Ok(())
152    }
153}
154
155/// Get the path to the stats file.
156fn get_stats_path() -> anyhow::Result<PathBuf> {
157    let home =
158        dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?;
159    Ok(home.join(".tldr").join("stats.jsonl"))
160}
161
162/// Read and aggregate stats from the JSONL file.
163fn read_and_aggregate_stats(stats_path: &PathBuf) -> anyhow::Result<Option<GlobalStats>> {
164    if !stats_path.exists() {
165        return Ok(None);
166    }
167
168    let file = fs::File::open(stats_path)?;
169    let reader = BufReader::new(file);
170
171    let mut total_invocations: u64 = 0;
172    let mut raw_tokens_total: u64 = 0;
173    let mut tldr_tokens_total: u64 = 0;
174    let mut has_entries = false;
175
176    for line in reader.lines() {
177        let line = line?;
178        let line = line.trim();
179
180        if line.is_empty() {
181            continue;
182        }
183
184        // Parse each line as a stats entry
185        if let Ok(entry) = serde_json::from_str::<StatsEntry>(line) {
186            total_invocations += entry.requests;
187            raw_tokens_total += entry.raw_tokens;
188            tldr_tokens_total += entry.tldr_tokens;
189            has_entries = true;
190        }
191    }
192
193    if !has_entries {
194        return Ok(None);
195    }
196
197    let estimated_tokens_saved = raw_tokens_total as i64 - tldr_tokens_total as i64;
198    let savings_percent = if raw_tokens_total > 0 {
199        (estimated_tokens_saved as f64 / raw_tokens_total as f64) * 100.0
200    } else {
201        0.0
202    };
203
204    Ok(Some(GlobalStats {
205        total_invocations,
206        estimated_tokens_saved,
207        raw_tokens_total,
208        tldr_tokens_total,
209        savings_percent,
210    }))
211}
212
213/// Print stats in text format.
214fn print_text_stats(stats: &StatsOutput) {
215    println!("TLDR Usage Statistics");
216    println!("=====================");
217    println!(
218        "Total Invocations:     {}",
219        format_number(stats.total_invocations)
220    );
221    println!(
222        "Tokens Saved:          {} ({:.1}%)",
223        format_number_signed(stats.estimated_tokens_saved),
224        stats.savings_percent
225    );
226    println!(
227        "Raw Tokens Processed:  {}",
228        format_number(stats.raw_tokens_total)
229    );
230    println!(
231        "TLDR Tokens Returned:  {}",
232        format_number(stats.tldr_tokens_total)
233    );
234}
235
236/// Format a number with thousands separators.
237fn format_number(n: u64) -> String {
238    let s = n.to_string();
239    let mut result = String::new();
240    let chars: Vec<char> = s.chars().collect();
241    let len = chars.len();
242
243    for (i, c) in chars.iter().enumerate() {
244        if i > 0 && (len - i).is_multiple_of(3) {
245            result.push(',');
246        }
247        result.push(*c);
248    }
249
250    result
251}
252
253/// Format a signed number with thousands separators.
254fn format_number_signed(n: i64) -> String {
255    if n < 0 {
256        format!("-{}", format_number((-n) as u64))
257    } else {
258        format_number(n as u64)
259    }
260}
261
262/// Public function to run stats command (for daemon integration).
263pub async fn cmd_stats(_: StatsArgs) -> DaemonResult<StatsOutput> {
264    let stats_path =
265        get_stats_path().map_err(|e| DaemonError::Io(std::io::Error::other(e.to_string())))?;
266
267    let stats = read_and_aggregate_stats(&stats_path)
268        .map_err(|e| DaemonError::Io(std::io::Error::other(e.to_string())))?;
269
270    match stats {
271        Some(stats) => Ok(StatsOutput {
272            total_invocations: stats.total_invocations,
273            estimated_tokens_saved: stats.estimated_tokens_saved,
274            raw_tokens_total: stats.raw_tokens_total,
275            tldr_tokens_total: stats.tldr_tokens_total,
276            savings_percent: stats.savings_percent,
277        }),
278        None => Ok(StatsOutput {
279            total_invocations: 0,
280            estimated_tokens_saved: 0,
281            raw_tokens_total: 0,
282            tldr_tokens_total: 0,
283            savings_percent: 0.0,
284        }),
285    }
286}
287
288/// Append a stats entry to the stats file.
289///
290/// Used by the daemon to record usage statistics.
291pub fn append_stats_entry(entry: &StatsEntry) -> anyhow::Result<()> {
292    let stats_path = get_stats_path()?;
293
294    // Ensure directory exists
295    if let Some(parent) = stats_path.parent() {
296        fs::create_dir_all(parent)?;
297    }
298
299    // Append entry as JSON line
300    let mut file = fs::OpenOptions::new()
301        .create(true)
302        .append(true)
303        .open(&stats_path)?;
304
305    use std::io::Write;
306    writeln!(file, "{}", serde_json::to_string(entry)?)?;
307
308    Ok(())
309}
310
311// =============================================================================
312// Tests
313// =============================================================================
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use tempfile::TempDir;
319
320    #[test]
321    fn test_stats_args_default() {
322        // StatsArgs has no fields - it uses global format from CLI
323        let _args = StatsArgs {};
324    }
325
326    #[test]
327    fn test_stats_entry_serialization() {
328        let entry = StatsEntry {
329            session_id: "test123".to_string(),
330            raw_tokens: 1000,
331            tldr_tokens: 100,
332            requests: 10,
333            timestamp: Some("2024-01-01T00:00:00Z".to_string()),
334        };
335
336        let json = serde_json::to_string(&entry).unwrap();
337        assert!(json.contains("test123"));
338        assert!(json.contains("1000"));
339        assert!(json.contains("100"));
340    }
341
342    #[test]
343    fn test_stats_entry_deserialization() {
344        let json = r#"{"session_id":"test1","raw_tokens":1000,"tldr_tokens":100,"requests":10}"#;
345        let entry: StatsEntry = serde_json::from_str(json).unwrap();
346
347        assert_eq!(entry.session_id, "test1");
348        assert_eq!(entry.raw_tokens, 1000);
349        assert_eq!(entry.tldr_tokens, 100);
350        assert_eq!(entry.requests, 10);
351    }
352
353    #[test]
354    fn test_stats_output_serialization() {
355        let output = StatsOutput {
356            total_invocations: 1500,
357            estimated_tokens_saved: 4500000,
358            raw_tokens_total: 5000000,
359            tldr_tokens_total: 500000,
360            savings_percent: 90.0,
361        };
362
363        let json = serde_json::to_string(&output).unwrap();
364        assert!(json.contains("1500"));
365        assert!(json.contains("4500000"));
366        assert!(json.contains("90"));
367    }
368
369    #[test]
370    fn test_format_number() {
371        assert_eq!(format_number(0), "0");
372        assert_eq!(format_number(100), "100");
373        assert_eq!(format_number(1000), "1,000");
374        assert_eq!(format_number(1234567), "1,234,567");
375    }
376
377    #[test]
378    fn test_format_number_signed() {
379        assert_eq!(format_number_signed(1000), "1,000");
380        assert_eq!(format_number_signed(-1000), "-1,000");
381        assert_eq!(format_number_signed(0), "0");
382    }
383
384    #[test]
385    fn test_read_and_aggregate_stats_empty() {
386        let temp = TempDir::new().unwrap();
387        let stats_path = temp.path().join("stats.jsonl");
388
389        // File doesn't exist
390        let result = read_and_aggregate_stats(&stats_path).unwrap();
391        assert!(result.is_none());
392
393        // Empty file
394        fs::write(&stats_path, "").unwrap();
395        let result = read_and_aggregate_stats(&stats_path).unwrap();
396        assert!(result.is_none());
397    }
398
399    #[test]
400    fn test_read_and_aggregate_stats_single_entry() {
401        let temp = TempDir::new().unwrap();
402        let stats_path = temp.path().join("stats.jsonl");
403
404        let data = r#"{"session_id":"test1","raw_tokens":1000,"tldr_tokens":100,"requests":10}"#;
405        fs::write(&stats_path, data).unwrap();
406
407        let result = read_and_aggregate_stats(&stats_path).unwrap().unwrap();
408        assert_eq!(result.total_invocations, 10);
409        assert_eq!(result.raw_tokens_total, 1000);
410        assert_eq!(result.tldr_tokens_total, 100);
411        assert_eq!(result.estimated_tokens_saved, 900);
412        assert!((result.savings_percent - 90.0).abs() < 0.01);
413    }
414
415    #[test]
416    fn test_read_and_aggregate_stats_multiple_entries() {
417        let temp = TempDir::new().unwrap();
418        let stats_path = temp.path().join("stats.jsonl");
419
420        let data = r#"{"session_id":"test1","raw_tokens":1000,"tldr_tokens":100,"requests":10}
421{"session_id":"test2","raw_tokens":2000,"tldr_tokens":200,"requests":20}"#;
422        fs::write(&stats_path, data).unwrap();
423
424        let result = read_and_aggregate_stats(&stats_path).unwrap().unwrap();
425        assert_eq!(result.total_invocations, 30);
426        assert_eq!(result.raw_tokens_total, 3000);
427        assert_eq!(result.tldr_tokens_total, 300);
428        assert_eq!(result.estimated_tokens_saved, 2700);
429    }
430
431    #[test]
432    fn test_read_and_aggregate_stats_with_blank_lines() {
433        let temp = TempDir::new().unwrap();
434        let stats_path = temp.path().join("stats.jsonl");
435
436        let data = r#"{"session_id":"test1","raw_tokens":1000,"tldr_tokens":100,"requests":10}
437
438{"session_id":"test2","raw_tokens":2000,"tldr_tokens":200,"requests":20}
439"#;
440        fs::write(&stats_path, data).unwrap();
441
442        let result = read_and_aggregate_stats(&stats_path).unwrap().unwrap();
443        assert_eq!(result.total_invocations, 30);
444    }
445
446    #[test]
447    fn test_append_stats_entry() {
448        let temp = TempDir::new().unwrap();
449        let tldr_dir = temp.path().join(".tldr");
450        fs::create_dir_all(&tldr_dir).unwrap();
451
452        // Override home dir for test - this is tricky, so we test the serialization
453        let entry = StatsEntry {
454            session_id: "test123".to_string(),
455            raw_tokens: 1000,
456            tldr_tokens: 100,
457            requests: 10,
458            timestamp: None,
459        };
460
461        let json = serde_json::to_string(&entry).unwrap();
462        assert!(json.contains("test123"));
463        assert!(json.contains("1000"));
464    }
465
466    #[test]
467    fn test_global_stats_calculation() {
468        let stats = GlobalStats {
469            total_invocations: 100,
470            estimated_tokens_saved: 9000,
471            raw_tokens_total: 10000,
472            tldr_tokens_total: 1000,
473            savings_percent: 90.0,
474        };
475
476        // Verify the calculation is correct
477        assert_eq!(
478            stats.estimated_tokens_saved,
479            (stats.raw_tokens_total - stats.tldr_tokens_total) as i64
480        );
481        assert!((stats.savings_percent - 90.0).abs() < 0.01);
482    }
483}