tldr_cli/commands/daemon/
stats.rs1use 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#[derive(Debug, Clone, Args)]
46pub struct StatsArgs {
47 }
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct StatsEntry {
57 pub session_id: String,
59
60 pub raw_tokens: u64,
62
63 pub tldr_tokens: u64,
65
66 pub requests: u64,
68
69 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub timestamp: Option<String>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct StatsOutput {
77 pub total_invocations: u64,
79
80 pub estimated_tokens_saved: i64,
82
83 pub raw_tokens_total: u64,
85
86 pub tldr_tokens_total: u64,
88
89 pub savings_percent: f64,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct EmptyStatsOutput {
102 pub message: String,
104 pub next_steps: Vec<String>,
106 pub requires: Vec<String>,
108}
109
110impl EmptyStatsOutput {
111 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
128impl StatsArgs {
133 pub fn run(&self, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
135 let stats_path = get_stats_path()?;
137
138 let stats = read_and_aggregate_stats(&stats_path)?;
140
141 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
203fn 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
210fn 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 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
261fn 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
284fn 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
301fn 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
310pub 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
336pub fn append_stats_entry(entry: &StatsEntry) -> anyhow::Result<()> {
340 let stats_path = get_stats_path()?;
341
342 if let Some(parent) = stats_path.parent() {
344 fs::create_dir_all(parent)?;
345 }
346
347 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#[cfg(test)]
364mod tests {
365 use super::*;
366 use tempfile::TempDir;
367
368 #[test]
369 fn test_stats_args_default() {
370 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 let result = read_and_aggregate_stats(&stats_path).unwrap();
439 assert!(result.is_none());
440
441 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 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 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}