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 = get_stats_path().map_err(|e| {
265        DaemonError::Io(std::io::Error::other(e.to_string()))
266    })?;
267
268    let stats = read_and_aggregate_stats(&stats_path).map_err(|e| {
269        DaemonError::Io(std::io::Error::other(e.to_string()))
270    })?;
271
272    match stats {
273        Some(stats) => Ok(StatsOutput {
274            total_invocations: stats.total_invocations,
275            estimated_tokens_saved: stats.estimated_tokens_saved,
276            raw_tokens_total: stats.raw_tokens_total,
277            tldr_tokens_total: stats.tldr_tokens_total,
278            savings_percent: stats.savings_percent,
279        }),
280        None => Ok(StatsOutput {
281            total_invocations: 0,
282            estimated_tokens_saved: 0,
283            raw_tokens_total: 0,
284            tldr_tokens_total: 0,
285            savings_percent: 0.0,
286        }),
287    }
288}
289
290/// Append a stats entry to the stats file.
291///
292/// Used by the daemon to record usage statistics.
293pub fn append_stats_entry(entry: &StatsEntry) -> anyhow::Result<()> {
294    let stats_path = get_stats_path()?;
295
296    // Ensure directory exists
297    if let Some(parent) = stats_path.parent() {
298        fs::create_dir_all(parent)?;
299    }
300
301    // Append entry as JSON line
302    let mut file = fs::OpenOptions::new()
303        .create(true)
304        .append(true)
305        .open(&stats_path)?;
306
307    use std::io::Write;
308    writeln!(file, "{}", serde_json::to_string(entry)?)?;
309
310    Ok(())
311}
312
313// =============================================================================
314// Tests
315// =============================================================================
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use tempfile::TempDir;
321
322    #[test]
323    fn test_stats_args_default() {
324        // StatsArgs has no fields - it uses global format from CLI
325        let _args = StatsArgs {};
326    }
327
328    #[test]
329    fn test_stats_entry_serialization() {
330        let entry = StatsEntry {
331            session_id: "test123".to_string(),
332            raw_tokens: 1000,
333            tldr_tokens: 100,
334            requests: 10,
335            timestamp: Some("2024-01-01T00:00:00Z".to_string()),
336        };
337
338        let json = serde_json::to_string(&entry).unwrap();
339        assert!(json.contains("test123"));
340        assert!(json.contains("1000"));
341        assert!(json.contains("100"));
342    }
343
344    #[test]
345    fn test_stats_entry_deserialization() {
346        let json = r#"{"session_id":"test1","raw_tokens":1000,"tldr_tokens":100,"requests":10}"#;
347        let entry: StatsEntry = serde_json::from_str(json).unwrap();
348
349        assert_eq!(entry.session_id, "test1");
350        assert_eq!(entry.raw_tokens, 1000);
351        assert_eq!(entry.tldr_tokens, 100);
352        assert_eq!(entry.requests, 10);
353    }
354
355    #[test]
356    fn test_stats_output_serialization() {
357        let output = StatsOutput {
358            total_invocations: 1500,
359            estimated_tokens_saved: 4500000,
360            raw_tokens_total: 5000000,
361            tldr_tokens_total: 500000,
362            savings_percent: 90.0,
363        };
364
365        let json = serde_json::to_string(&output).unwrap();
366        assert!(json.contains("1500"));
367        assert!(json.contains("4500000"));
368        assert!(json.contains("90"));
369    }
370
371    #[test]
372    fn test_format_number() {
373        assert_eq!(format_number(0), "0");
374        assert_eq!(format_number(100), "100");
375        assert_eq!(format_number(1000), "1,000");
376        assert_eq!(format_number(1234567), "1,234,567");
377    }
378
379    #[test]
380    fn test_format_number_signed() {
381        assert_eq!(format_number_signed(1000), "1,000");
382        assert_eq!(format_number_signed(-1000), "-1,000");
383        assert_eq!(format_number_signed(0), "0");
384    }
385
386    #[test]
387    fn test_read_and_aggregate_stats_empty() {
388        let temp = TempDir::new().unwrap();
389        let stats_path = temp.path().join("stats.jsonl");
390
391        // File doesn't exist
392        let result = read_and_aggregate_stats(&stats_path).unwrap();
393        assert!(result.is_none());
394
395        // Empty file
396        fs::write(&stats_path, "").unwrap();
397        let result = read_and_aggregate_stats(&stats_path).unwrap();
398        assert!(result.is_none());
399    }
400
401    #[test]
402    fn test_read_and_aggregate_stats_single_entry() {
403        let temp = TempDir::new().unwrap();
404        let stats_path = temp.path().join("stats.jsonl");
405
406        let data = r#"{"session_id":"test1","raw_tokens":1000,"tldr_tokens":100,"requests":10}"#;
407        fs::write(&stats_path, data).unwrap();
408
409        let result = read_and_aggregate_stats(&stats_path).unwrap().unwrap();
410        assert_eq!(result.total_invocations, 10);
411        assert_eq!(result.raw_tokens_total, 1000);
412        assert_eq!(result.tldr_tokens_total, 100);
413        assert_eq!(result.estimated_tokens_saved, 900);
414        assert!((result.savings_percent - 90.0).abs() < 0.01);
415    }
416
417    #[test]
418    fn test_read_and_aggregate_stats_multiple_entries() {
419        let temp = TempDir::new().unwrap();
420        let stats_path = temp.path().join("stats.jsonl");
421
422        let data = r#"{"session_id":"test1","raw_tokens":1000,"tldr_tokens":100,"requests":10}
423{"session_id":"test2","raw_tokens":2000,"tldr_tokens":200,"requests":20}"#;
424        fs::write(&stats_path, data).unwrap();
425
426        let result = read_and_aggregate_stats(&stats_path).unwrap().unwrap();
427        assert_eq!(result.total_invocations, 30);
428        assert_eq!(result.raw_tokens_total, 3000);
429        assert_eq!(result.tldr_tokens_total, 300);
430        assert_eq!(result.estimated_tokens_saved, 2700);
431    }
432
433    #[test]
434    fn test_read_and_aggregate_stats_with_blank_lines() {
435        let temp = TempDir::new().unwrap();
436        let stats_path = temp.path().join("stats.jsonl");
437
438        let data = r#"{"session_id":"test1","raw_tokens":1000,"tldr_tokens":100,"requests":10}
439
440{"session_id":"test2","raw_tokens":2000,"tldr_tokens":200,"requests":20}
441"#;
442        fs::write(&stats_path, data).unwrap();
443
444        let result = read_and_aggregate_stats(&stats_path).unwrap().unwrap();
445        assert_eq!(result.total_invocations, 30);
446    }
447
448    #[test]
449    fn test_append_stats_entry() {
450        let temp = TempDir::new().unwrap();
451        let tldr_dir = temp.path().join(".tldr");
452        fs::create_dir_all(&tldr_dir).unwrap();
453
454        // Override home dir for test - this is tricky, so we test the serialization
455        let entry = StatsEntry {
456            session_id: "test123".to_string(),
457            raw_tokens: 1000,
458            tldr_tokens: 100,
459            requests: 10,
460            timestamp: None,
461        };
462
463        let json = serde_json::to_string(&entry).unwrap();
464        assert!(json.contains("test123"));
465        assert!(json.contains("1000"));
466    }
467
468    #[test]
469    fn test_global_stats_calculation() {
470        let stats = GlobalStats {
471            total_invocations: 100,
472            estimated_tokens_saved: 9000,
473            raw_tokens_total: 10000,
474            tldr_tokens_total: 1000,
475            savings_percent: 90.0,
476        };
477
478        // Verify the calculation is correct
479        assert_eq!(
480            stats.estimated_tokens_saved,
481            (stats.raw_tokens_total - stats.tldr_tokens_total) as i64
482        );
483        assert!((stats.savings_percent - 90.0).abs() < 0.01);
484    }
485}