1use std::fs::{self, File, OpenOptions};
2use std::io::{BufRead, BufReader, Write};
3use std::path::PathBuf;
4
5#[derive(Debug, Clone)]
7pub struct SessionRecord {
8 pub timestamp: String, pub pid: u32,
10 pub project: String,
11 pub model: String,
12 pub duration_secs: u64,
13 pub input_tokens: u64,
14 pub output_tokens: u64,
15 pub cost_usd: f64,
16}
17
18fn history_dir() -> PathBuf {
19 std::env::var_os("HOME")
20 .map(PathBuf::from)
21 .unwrap_or_else(|| PathBuf::from("/tmp"))
22 .join(".local")
23 .join("share")
24 .join("claudectl")
25}
26
27fn history_path() -> PathBuf {
28 history_dir().join("history.csv")
29}
30
31pub fn record_session(session: &crate::session::ClaudeSession) {
33 let dir = history_dir();
34 if fs::create_dir_all(&dir).is_err() {
35 return;
36 }
37
38 let path = history_path();
39 let needs_header = !path.exists();
40
41 let file = OpenOptions::new().create(true).append(true).open(&path);
42
43 let Ok(mut file) = file else { return };
44
45 if needs_header {
46 let _ = writeln!(
47 file,
48 "timestamp,pid,project,model,duration_secs,input_tokens,output_tokens,cost_usd"
49 );
50 }
51
52 let ts = crate::logger::timestamp_now();
53 let project = session.display_name().replace(',', ";");
54 let model = session.model.replace(',', ";");
55
56 let _ = writeln!(
57 file,
58 "{},{},{},{},{},{},{},{:.4}",
59 ts,
60 session.pid,
61 project,
62 model,
63 session.elapsed.as_secs(),
64 session.total_input_tokens,
65 session.total_output_tokens,
66 session.cost_usd,
67 );
68}
69
70pub fn load_history(since_secs: Option<u64>) -> Vec<SessionRecord> {
72 let path = history_path();
73 let file = match File::open(&path) {
74 Ok(f) => f,
75 Err(_) => return Vec::new(),
76 };
77
78 let now_secs = std::time::SystemTime::now()
79 .duration_since(std::time::UNIX_EPOCH)
80 .unwrap_or_default()
81 .as_secs();
82
83 let reader = BufReader::new(file);
84 let mut records = Vec::new();
85
86 for (i, line) in reader.lines().enumerate() {
87 let Ok(line) = line else { continue };
88 if i == 0 && line.starts_with("timestamp") {
89 continue; }
91
92 let fields: Vec<&str> = line.splitn(8, ',').collect();
93 if fields.len() < 8 {
94 continue;
95 }
96
97 let record = SessionRecord {
98 timestamp: fields[0].to_string(),
99 pid: fields[1].parse().unwrap_or(0),
100 project: fields[2].to_string(),
101 model: fields[3].to_string(),
102 duration_secs: fields[4].parse().unwrap_or(0),
103 input_tokens: fields[5].parse().unwrap_or(0),
104 output_tokens: fields[6].parse().unwrap_or(0),
105 cost_usd: fields[7].parse().unwrap_or(0.0),
106 };
107
108 if let Some(window) = since_secs {
110 if let Some(record_secs) = parse_timestamp_epoch(&record.timestamp) {
111 if now_secs.saturating_sub(record_secs) > window {
112 continue;
113 }
114 }
115 }
116
117 records.push(record);
118 }
119
120 records
121}
122
123pub fn parse_timestamp_epoch(ts: &str) -> Option<u64> {
130 if ts.len() < 19 {
132 return None;
133 }
134 let year: u64 = ts[0..4].parse().ok()?;
135 let month: u64 = ts[5..7].parse().ok()?;
136 let day: u64 = ts[8..10].parse().ok()?;
137 let hour: u64 = ts[11..13].parse().ok()?;
138 let min: u64 = ts[14..16].parse().ok()?;
139 let sec: u64 = ts[17..19].parse().ok()?;
140
141 let mut days: u64 = 0;
143 for y in 1970..year {
144 days += if is_leap(y) { 366 } else { 365 };
145 }
146 let month_days = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
147 for m in 1..month {
148 days += month_days[m as usize];
149 if m == 2 && is_leap(year) {
150 days += 1;
151 }
152 }
153 days += day - 1;
154
155 Some(days * 86400 + hour * 3600 + min * 60 + sec)
156}
157
158fn is_leap(y: u64) -> bool {
159 y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)
160}
161
162pub fn print_history(since: &str) {
164 let since_secs = parse_duration(since);
165 let records = load_history(since_secs);
166
167 if records.is_empty() {
168 println!("No session history found.");
169 if since_secs.is_some() {
170 println!(" (filtered to last {since})");
171 }
172 return;
173 }
174
175 println!(
176 "{:<22} {:<7} {:<20} {:<12} {:>10} {:>12} {:>12} {:>10}",
177 "Timestamp", "PID", "Project", "Model", "Duration", "Input", "Output", "Cost"
178 );
179 println!("{}", "-".repeat(110));
180
181 let mut total_cost = 0.0;
182 let mut total_duration = 0u64;
183 let mut total_input = 0u64;
184 let mut total_output = 0u64;
185
186 for r in &records {
187 let dur = format_duration(r.duration_secs);
188 let cost = if r.cost_usd < 1.0 {
189 format!("${:.2}", r.cost_usd)
190 } else {
191 format!("${:.1}", r.cost_usd)
192 };
193
194 println!(
195 "{:<22} {:<7} {:<20} {:<12} {:>10} {:>12} {:>12} {:>10}",
196 &r.timestamp[..19.min(r.timestamp.len())],
197 r.pid,
198 truncate(&r.project, 20),
199 truncate(&r.model, 12),
200 dur,
201 format_count(r.input_tokens),
202 format_count(r.output_tokens),
203 cost,
204 );
205
206 total_cost += r.cost_usd;
207 total_duration += r.duration_secs;
208 total_input += r.input_tokens;
209 total_output += r.output_tokens;
210 }
211
212 println!("{}", "-".repeat(110));
213 let total_cost_str = if total_cost < 1.0 {
214 format!("${:.2}", total_cost)
215 } else {
216 format!("${:.1}", total_cost)
217 };
218 println!(
219 "{:<22} {:<7} {:<20} {:<12} {:>10} {:>12} {:>12} {:>10}",
220 format!("{} sessions", records.len()),
221 "",
222 "",
223 "",
224 format_duration(total_duration),
225 format_count(total_input),
226 format_count(total_output),
227 total_cost_str,
228 );
229}
230
231pub fn print_stats(since: &str) {
233 let since_secs = parse_duration(since);
234 let records = load_history(since_secs);
235
236 if records.is_empty() {
237 println!("No session history found.");
238 return;
239 }
240
241 let total_cost: f64 = records.iter().map(|r| r.cost_usd).sum();
242 let total_duration: u64 = records.iter().map(|r| r.duration_secs).sum();
243 let total_input: u64 = records.iter().map(|r| r.input_tokens).sum();
244 let total_output: u64 = records.iter().map(|r| r.output_tokens).sum();
245 let avg_cost = total_cost / records.len() as f64;
246 let avg_duration = total_duration / records.len() as u64;
247
248 println!("Session Statistics (last {since})");
249 println!("{}", "=".repeat(45));
250 println!(" Sessions: {}", records.len());
251 println!(" Total cost: ${:.2}", total_cost);
252 println!(" Avg cost/session: ${:.2}", avg_cost);
253 println!(" Total duration: {}", format_duration(total_duration));
254 println!(" Avg duration: {}", format_duration(avg_duration));
255 println!(
256 " Total tokens: {} in / {} out",
257 format_count(total_input),
258 format_count(total_output)
259 );
260 println!();
261
262 let mut projects: std::collections::HashMap<String, (f64, u64, usize)> =
264 std::collections::HashMap::new();
265 for r in &records {
266 let entry = projects.entry(r.project.clone()).or_default();
267 entry.0 += r.cost_usd;
268 entry.1 += r.duration_secs;
269 entry.2 += 1;
270 }
271
272 let mut project_list: Vec<_> = projects.into_iter().collect();
273 project_list.sort_by(|a, b| b.1.0.partial_cmp(&a.1.0).unwrap());
274
275 println!(" Per-project breakdown:");
276 println!(
277 " {:<25} {:>8} {:>10} {:>10}",
278 "Project", "Sessions", "Duration", "Cost"
279 );
280 println!(" {}", "-".repeat(55));
281 for (name, (cost, dur, count)) in &project_list {
282 let cost_str = if *cost < 1.0 {
283 format!("${:.2}", cost)
284 } else {
285 format!("${:.1}", cost)
286 };
287 println!(
288 " {:<25} {:>8} {:>10} {:>10}",
289 truncate(name, 25),
290 count,
291 format_duration(*dur),
292 cost_str,
293 );
294 }
295
296 let mut models: std::collections::HashMap<String, (f64, usize)> =
298 std::collections::HashMap::new();
299 for r in &records {
300 let model = if r.model.is_empty() {
301 "unknown".to_string()
302 } else {
303 r.model.clone()
304 };
305 let entry = models.entry(model).or_default();
306 entry.0 += r.cost_usd;
307 entry.1 += 1;
308 }
309
310 let mut model_list: Vec<_> = models.into_iter().collect();
311 model_list.sort_by(|a, b| b.1.0.partial_cmp(&a.1.0).unwrap());
312
313 println!();
314 println!(" Per-model breakdown:");
315 println!(" {:<20} {:>8} {:>10}", "Model", "Sessions", "Cost");
316 println!(" {}", "-".repeat(40));
317 for (name, (cost, count)) in &model_list {
318 let cost_str = if *cost < 1.0 {
319 format!("${:.2}", cost)
320 } else {
321 format!("${:.1}", cost)
322 };
323 println!(" {:<20} {:>8} {:>10}", name, count, cost_str);
324 }
325}
326
327#[derive(Debug, Clone, Default)]
329pub struct WeeklySummary {
330 pub cost_usd: f64,
331 pub total_tokens: u64,
332 #[allow(dead_code)]
333 pub session_count: usize,
334 pub today_cost_usd: f64,
335}
336
337pub fn weekly_summary() -> WeeklySummary {
339 let week_secs = 7 * 86400;
340 let day_secs = 86400;
341 let week_records = load_history(Some(week_secs));
342 let day_records = load_history(Some(day_secs));
343
344 WeeklySummary {
345 cost_usd: week_records.iter().map(|r| r.cost_usd).sum(),
346 total_tokens: week_records
347 .iter()
348 .map(|r| r.input_tokens + r.output_tokens)
349 .sum(),
350 session_count: week_records.len(),
351 today_cost_usd: day_records.iter().map(|r| r.cost_usd).sum(),
352 }
353}
354
355pub fn parse_duration(s: &str) -> Option<u64> {
357 let s = s.trim();
358 if s.is_empty() {
359 return None;
360 }
361 let (last_idx, unit_char) = s.char_indices().next_back()?;
365 let (num_str, unit) = s.split_at(last_idx);
366 let _ = unit_char;
367 let num: u64 = num_str.parse().ok()?;
368 match unit {
373 "s" => Some(num),
374 "m" => num.checked_mul(60),
375 "h" => num.checked_mul(3600),
376 "d" => num.checked_mul(86400),
377 "w" => num.checked_mul(604800),
378 _ => None,
379 }
380}
381
382fn format_duration(secs: u64) -> String {
383 let h = secs / 3600;
384 let m = (secs % 3600) / 60;
385 let s = secs % 60;
386 if h > 0 {
387 format!("{h}h{m:02}m")
388 } else {
389 format!("{m}m{s:02}s")
390 }
391}
392
393fn format_count(n: u64) -> String {
394 if n >= 1_000_000 {
395 format!("{:.1}M", n as f64 / 1_000_000.0)
396 } else if n >= 1_000 {
397 format!("{:.1}k", n as f64 / 1_000.0)
398 } else {
399 n.to_string()
400 }
401}
402
403fn truncate(s: &str, max: usize) -> String {
404 if s.len() <= max {
405 s.to_string()
406 } else {
407 format!("{}...", &s[..max - 3])
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 #[test]
416 fn test_parse_duration() {
417 assert_eq!(parse_duration("24h"), Some(86400));
418 assert_eq!(parse_duration("30m"), Some(1800));
419 assert_eq!(parse_duration("7d"), Some(604800));
420 assert_eq!(parse_duration("1w"), Some(604800));
421 assert_eq!(parse_duration(""), None);
422 assert_eq!(parse_duration("abc"), None);
423 }
424
425 #[test]
426 fn parse_duration_rejects_a_multibyte_suffix_instead_of_panicking() {
427 assert_eq!(parse_duration("30é"), None);
430 assert_eq!(parse_duration("é"), None);
431 assert_eq!(parse_duration("7日"), None);
432 assert_eq!(parse_duration("30🕐"), None);
433 }
434
435 #[test]
436 fn parse_duration_rejects_overflow_instead_of_panicking() {
437 assert_eq!(parse_duration(&format!("{}w", u64::MAX)), None);
440 assert_eq!(parse_duration(&format!("{}d", u64::MAX)), None);
441 assert_eq!(parse_duration(&format!("{}m", u64::MAX)), None);
442 assert_eq!(parse_duration(&format!("{}s", u64::MAX)), Some(u64::MAX));
444 }
445
446 #[test]
447 fn test_format_duration() {
448 assert_eq!(format_duration(3661), "1h01m");
449 assert_eq!(format_duration(125), "2m05s");
450 assert_eq!(format_duration(0), "0m00s");
451 }
452
453 #[test]
454 fn test_truncate() {
455 assert_eq!(truncate("hello", 10), "hello");
456 assert_eq!(truncate("hello world!", 8), "hello...");
457 }
458
459 #[test]
460 fn test_parse_timestamp_epoch() {
461 let ts = parse_timestamp_epoch("2026-01-01T00:00:00Z").unwrap();
463 assert!(ts > 1735689600); assert!(ts < 1798761600); }
467
468 #[test]
469 fn test_is_leap() {
470 assert!(is_leap(2000));
471 assert!(is_leap(2024));
472 assert!(!is_leap(1900));
473 assert!(!is_leap(2023));
474 }
475}