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///
95/// low-cleanup-bundle-v1 (L2): the previous shape `{"message": "No usage
96/// recorded"}` was opaque — users had no idea what "usage" meant or how to
97/// produce it. We now include a `next_steps` hint that names the daemon and
98/// the exact command to run, plus a `requires` field listing prerequisites
99/// for programmatic consumers.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct EmptyStatsOutput {
102    /// Message indicating no usage
103    pub message: String,
104    /// Concrete command(s) the user can run to populate stats.
105    pub next_steps: Vec<String>,
106    /// Prerequisites required for the stats command to produce data.
107    pub requires: Vec<String>,
108}
109
110impl EmptyStatsOutput {
111    /// Build the canonical empty-stats payload.
112    fn empty() -> Self {
113        Self {
114            message: "No usage recorded yet".to_string(),
115            next_steps: vec![
116                "tldr daemon start  # begin recording usage".to_string(),
117                "tldr <any-command> ...  # run a few commands while the daemon is up".to_string(),
118                "tldr stats  # rerun this command to see call counts and latencies".to_string(),
119            ],
120            requires: vec![
121                "tldr daemon (run `tldr daemon start`)".to_string(),
122                "at least one daemon-tracked invocation".to_string(),
123            ],
124        }
125    }
126}
127
128// =============================================================================
129// Implementation
130// =============================================================================
131
132impl StatsArgs {
133    /// Run the stats command.
134    pub fn run(&self, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
135        // Get stats file path
136        let stats_path = get_stats_path()?;
137
138        // Read and aggregate stats
139        let stats = read_and_aggregate_stats(&stats_path)?;
140
141        // Output result.
142        //
143        // high-bundle-progress-determinism-coverage-v1 (N1 follow-up):
144        // `quiet` is for *progress* suppression, not for total silence.
145        // For json/compact (now auto-quiet-on-json), we still need to
146        // emit the structured payload — otherwise pipelines see empty
147        // stdout. Only the human-readable text branches honor `quiet`
148        // for the verbose explanatory blurb.
149        let output_format = format;
150
151        match stats {
152            Some(stats) => {
153                let output = StatsOutput {
154                    total_invocations: stats.total_invocations,
155                    estimated_tokens_saved: stats.estimated_tokens_saved,
156                    raw_tokens_total: stats.raw_tokens_total,
157                    tldr_tokens_total: stats.tldr_tokens_total,
158                    savings_percent: stats.savings_percent,
159                };
160
161                match output_format {
162                    OutputFormat::Json | OutputFormat::Compact => {
163                        println!("{}", serde_json::to_string_pretty(&output)?);
164                    }
165                    OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
166                        if !quiet {
167                            print_text_stats(&output);
168                        }
169                    }
170                }
171            }
172            None => {
173                let empty = EmptyStatsOutput::empty();
174                match output_format {
175                    OutputFormat::Json | OutputFormat::Compact => {
176                        println!("{}", serde_json::to_string_pretty(&empty)?);
177                    }
178                    OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
179                        if !quiet {
180                            println!("{}", empty.message);
181                            println!();
182                            println!(
183                                "Usage tracking requires the tldr daemon. To begin recording:"
184                            );
185                            for step in &empty.next_steps {
186                                println!("  $ {}", step);
187                            }
188                            println!();
189                            println!(
190                                "Once the daemon has captured invocations, this command will \
191                                 display call counts, latencies, and most-used commands."
192                            );
193                        }
194                    }
195                }
196            }
197        }
198
199        Ok(())
200    }
201}
202
203/// Get the path to the stats file.
204fn get_stats_path() -> anyhow::Result<PathBuf> {
205    let home =
206        dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?;
207    Ok(home.join(".tldr").join("stats.jsonl"))
208}
209
210/// Read and aggregate stats from the JSONL file.
211fn read_and_aggregate_stats(stats_path: &PathBuf) -> anyhow::Result<Option<GlobalStats>> {
212    if !stats_path.exists() {
213        return Ok(None);
214    }
215
216    let file = fs::File::open(stats_path)?;
217    let reader = BufReader::new(file);
218
219    let mut total_invocations: u64 = 0;
220    let mut raw_tokens_total: u64 = 0;
221    let mut tldr_tokens_total: u64 = 0;
222    let mut has_entries = false;
223
224    for line in reader.lines() {
225        let line = line?;
226        let line = line.trim();
227
228        if line.is_empty() {
229            continue;
230        }
231
232        // Parse each line as a stats entry
233        if let Ok(entry) = serde_json::from_str::<StatsEntry>(line) {
234            total_invocations += entry.requests;
235            raw_tokens_total += entry.raw_tokens;
236            tldr_tokens_total += entry.tldr_tokens;
237            has_entries = true;
238        }
239    }
240
241    if !has_entries {
242        return Ok(None);
243    }
244
245    let estimated_tokens_saved = raw_tokens_total as i64 - tldr_tokens_total as i64;
246    let savings_percent = if raw_tokens_total > 0 {
247        (estimated_tokens_saved as f64 / raw_tokens_total as f64) * 100.0
248    } else {
249        0.0
250    };
251
252    Ok(Some(GlobalStats {
253        total_invocations,
254        estimated_tokens_saved,
255        raw_tokens_total,
256        tldr_tokens_total,
257        savings_percent,
258    }))
259}
260
261/// Print stats in text format.
262fn print_text_stats(stats: &StatsOutput) {
263    println!("TLDR Usage Statistics");
264    println!("=====================");
265    println!(
266        "Total Invocations:     {}",
267        format_number(stats.total_invocations)
268    );
269    println!(
270        "Tokens Saved:          {} ({:.1}%)",
271        format_number_signed(stats.estimated_tokens_saved),
272        stats.savings_percent
273    );
274    println!(
275        "Raw Tokens Processed:  {}",
276        format_number(stats.raw_tokens_total)
277    );
278    println!(
279        "TLDR Tokens Returned:  {}",
280        format_number(stats.tldr_tokens_total)
281    );
282}
283
284/// Format a number with thousands separators.
285fn format_number(n: u64) -> String {
286    let s = n.to_string();
287    let mut result = String::new();
288    let chars: Vec<char> = s.chars().collect();
289    let len = chars.len();
290
291    for (i, c) in chars.iter().enumerate() {
292        if i > 0 && (len - i).is_multiple_of(3) {
293            result.push(',');
294        }
295        result.push(*c);
296    }
297
298    result
299}
300
301/// Format a signed number with thousands separators.
302fn format_number_signed(n: i64) -> String {
303    if n < 0 {
304        format!("-{}", format_number((-n) as u64))
305    } else {
306        format_number(n as u64)
307    }
308}
309
310/// Public function to run stats command (for daemon integration).
311pub async fn cmd_stats(_: StatsArgs) -> DaemonResult<StatsOutput> {
312    let stats_path =
313        get_stats_path().map_err(|e| DaemonError::Io(std::io::Error::other(e.to_string())))?;
314
315    let stats = read_and_aggregate_stats(&stats_path)
316        .map_err(|e| DaemonError::Io(std::io::Error::other(e.to_string())))?;
317
318    match stats {
319        Some(stats) => Ok(StatsOutput {
320            total_invocations: stats.total_invocations,
321            estimated_tokens_saved: stats.estimated_tokens_saved,
322            raw_tokens_total: stats.raw_tokens_total,
323            tldr_tokens_total: stats.tldr_tokens_total,
324            savings_percent: stats.savings_percent,
325        }),
326        None => Ok(StatsOutput {
327            total_invocations: 0,
328            estimated_tokens_saved: 0,
329            raw_tokens_total: 0,
330            tldr_tokens_total: 0,
331            savings_percent: 0.0,
332        }),
333    }
334}
335
336/// Append a stats entry to the stats file.
337///
338/// Used by the daemon to record usage statistics.
339pub fn append_stats_entry(entry: &StatsEntry) -> anyhow::Result<()> {
340    let stats_path = get_stats_path()?;
341
342    // Ensure directory exists
343    if let Some(parent) = stats_path.parent() {
344        fs::create_dir_all(parent)?;
345    }
346
347    // Append entry as JSON line
348    let mut file = fs::OpenOptions::new()
349        .create(true)
350        .append(true)
351        .open(&stats_path)?;
352
353    use std::io::Write;
354    writeln!(file, "{}", serde_json::to_string(entry)?)?;
355
356    Ok(())
357}
358
359// =============================================================================
360// Tests
361// =============================================================================
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use tempfile::TempDir;
367
368    #[test]
369    fn test_stats_args_default() {
370        // StatsArgs has no fields - it uses global format from CLI
371        let _args = StatsArgs {};
372    }
373
374    #[test]
375    fn test_stats_entry_serialization() {
376        let entry = StatsEntry {
377            session_id: "test123".to_string(),
378            raw_tokens: 1000,
379            tldr_tokens: 100,
380            requests: 10,
381            timestamp: Some("2024-01-01T00:00:00Z".to_string()),
382        };
383
384        let json = serde_json::to_string(&entry).unwrap();
385        assert!(json.contains("test123"));
386        assert!(json.contains("1000"));
387        assert!(json.contains("100"));
388    }
389
390    #[test]
391    fn test_stats_entry_deserialization() {
392        let json = r#"{"session_id":"test1","raw_tokens":1000,"tldr_tokens":100,"requests":10}"#;
393        let entry: StatsEntry = serde_json::from_str(json).unwrap();
394
395        assert_eq!(entry.session_id, "test1");
396        assert_eq!(entry.raw_tokens, 1000);
397        assert_eq!(entry.tldr_tokens, 100);
398        assert_eq!(entry.requests, 10);
399    }
400
401    #[test]
402    fn test_stats_output_serialization() {
403        let output = StatsOutput {
404            total_invocations: 1500,
405            estimated_tokens_saved: 4500000,
406            raw_tokens_total: 5000000,
407            tldr_tokens_total: 500000,
408            savings_percent: 90.0,
409        };
410
411        let json = serde_json::to_string(&output).unwrap();
412        assert!(json.contains("1500"));
413        assert!(json.contains("4500000"));
414        assert!(json.contains("90"));
415    }
416
417    #[test]
418    fn test_format_number() {
419        assert_eq!(format_number(0), "0");
420        assert_eq!(format_number(100), "100");
421        assert_eq!(format_number(1000), "1,000");
422        assert_eq!(format_number(1234567), "1,234,567");
423    }
424
425    #[test]
426    fn test_format_number_signed() {
427        assert_eq!(format_number_signed(1000), "1,000");
428        assert_eq!(format_number_signed(-1000), "-1,000");
429        assert_eq!(format_number_signed(0), "0");
430    }
431
432    #[test]
433    fn test_read_and_aggregate_stats_empty() {
434        let temp = TempDir::new().unwrap();
435        let stats_path = temp.path().join("stats.jsonl");
436
437        // File doesn't exist
438        let result = read_and_aggregate_stats(&stats_path).unwrap();
439        assert!(result.is_none());
440
441        // Empty file
442        fs::write(&stats_path, "").unwrap();
443        let result = read_and_aggregate_stats(&stats_path).unwrap();
444        assert!(result.is_none());
445    }
446
447    #[test]
448    fn test_read_and_aggregate_stats_single_entry() {
449        let temp = TempDir::new().unwrap();
450        let stats_path = temp.path().join("stats.jsonl");
451
452        let data = r#"{"session_id":"test1","raw_tokens":1000,"tldr_tokens":100,"requests":10}"#;
453        fs::write(&stats_path, data).unwrap();
454
455        let result = read_and_aggregate_stats(&stats_path).unwrap().unwrap();
456        assert_eq!(result.total_invocations, 10);
457        assert_eq!(result.raw_tokens_total, 1000);
458        assert_eq!(result.tldr_tokens_total, 100);
459        assert_eq!(result.estimated_tokens_saved, 900);
460        assert!((result.savings_percent - 90.0).abs() < 0.01);
461    }
462
463    #[test]
464    fn test_read_and_aggregate_stats_multiple_entries() {
465        let temp = TempDir::new().unwrap();
466        let stats_path = temp.path().join("stats.jsonl");
467
468        let data = r#"{"session_id":"test1","raw_tokens":1000,"tldr_tokens":100,"requests":10}
469{"session_id":"test2","raw_tokens":2000,"tldr_tokens":200,"requests":20}"#;
470        fs::write(&stats_path, data).unwrap();
471
472        let result = read_and_aggregate_stats(&stats_path).unwrap().unwrap();
473        assert_eq!(result.total_invocations, 30);
474        assert_eq!(result.raw_tokens_total, 3000);
475        assert_eq!(result.tldr_tokens_total, 300);
476        assert_eq!(result.estimated_tokens_saved, 2700);
477    }
478
479    #[test]
480    fn test_read_and_aggregate_stats_with_blank_lines() {
481        let temp = TempDir::new().unwrap();
482        let stats_path = temp.path().join("stats.jsonl");
483
484        let data = r#"{"session_id":"test1","raw_tokens":1000,"tldr_tokens":100,"requests":10}
485
486{"session_id":"test2","raw_tokens":2000,"tldr_tokens":200,"requests":20}
487"#;
488        fs::write(&stats_path, data).unwrap();
489
490        let result = read_and_aggregate_stats(&stats_path).unwrap().unwrap();
491        assert_eq!(result.total_invocations, 30);
492    }
493
494    #[test]
495    fn test_append_stats_entry() {
496        let temp = TempDir::new().unwrap();
497        let tldr_dir = temp.path().join(".tldr");
498        fs::create_dir_all(&tldr_dir).unwrap();
499
500        // Override home dir for test - this is tricky, so we test the serialization
501        let entry = StatsEntry {
502            session_id: "test123".to_string(),
503            raw_tokens: 1000,
504            tldr_tokens: 100,
505            requests: 10,
506            timestamp: None,
507        };
508
509        let json = serde_json::to_string(&entry).unwrap();
510        assert!(json.contains("test123"));
511        assert!(json.contains("1000"));
512    }
513
514    #[test]
515    fn test_global_stats_calculation() {
516        let stats = GlobalStats {
517            total_invocations: 100,
518            estimated_tokens_saved: 9000,
519            raw_tokens_total: 10000,
520            tldr_tokens_total: 1000,
521            savings_percent: 90.0,
522        };
523
524        // Verify the calculation is correct
525        assert_eq!(
526            stats.estimated_tokens_saved,
527            (stats.raw_tokens_total - stats.tldr_tokens_total) as i64
528        );
529        assert!((stats.savings_percent - 90.0).abs() < 0.01);
530    }
531}