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)]
95pub struct EmptyStatsOutput {
96 pub message: String,
98}
99
100impl StatsArgs {
105 pub fn run(&self, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
107 let stats_path = get_stats_path()?;
109
110 let stats = read_and_aggregate_stats(&stats_path)?;
112
113 if !quiet {
115 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
155fn 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
162fn 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 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
213fn 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
236fn 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
253fn 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
262pub 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
290pub fn append_stats_entry(entry: &StatsEntry) -> anyhow::Result<()> {
294 let stats_path = get_stats_path()?;
295
296 if let Some(parent) = stats_path.parent() {
298 fs::create_dir_all(parent)?;
299 }
300
301 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#[cfg(test)]
318mod tests {
319 use super::*;
320 use tempfile::TempDir;
321
322 #[test]
323 fn test_stats_args_default() {
324 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 let result = read_and_aggregate_stats(&stats_path).unwrap();
393 assert!(result.is_none());
394
395 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 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 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}