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 =
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
288pub fn append_stats_entry(entry: &StatsEntry) -> anyhow::Result<()> {
292 let stats_path = get_stats_path()?;
293
294 if let Some(parent) = stats_path.parent() {
296 fs::create_dir_all(parent)?;
297 }
298
299 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#[cfg(test)]
316mod tests {
317 use super::*;
318 use tempfile::TempDir;
319
320 #[test]
321 fn test_stats_args_default() {
322 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 let result = read_and_aggregate_stats(&stats_path).unwrap();
391 assert!(result.is_none());
392
393 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 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 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}