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
123fn parse_timestamp_epoch(ts: &str) -> Option<u64> {
125 if ts.len() < 19 {
127 return None;
128 }
129 let year: u64 = ts[0..4].parse().ok()?;
130 let month: u64 = ts[5..7].parse().ok()?;
131 let day: u64 = ts[8..10].parse().ok()?;
132 let hour: u64 = ts[11..13].parse().ok()?;
133 let min: u64 = ts[14..16].parse().ok()?;
134 let sec: u64 = ts[17..19].parse().ok()?;
135
136 let mut days: u64 = 0;
138 for y in 1970..year {
139 days += if is_leap(y) { 366 } else { 365 };
140 }
141 let month_days = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
142 for m in 1..month {
143 days += month_days[m as usize];
144 if m == 2 && is_leap(year) {
145 days += 1;
146 }
147 }
148 days += day - 1;
149
150 Some(days * 86400 + hour * 3600 + min * 60 + sec)
151}
152
153fn is_leap(y: u64) -> bool {
154 y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)
155}
156
157pub fn print_history(since: &str) {
159 let since_secs = parse_duration(since);
160 let records = load_history(since_secs);
161
162 if records.is_empty() {
163 println!("No session history found.");
164 if since_secs.is_some() {
165 println!(" (filtered to last {since})");
166 }
167 return;
168 }
169
170 println!(
171 "{:<22} {:<7} {:<20} {:<12} {:>10} {:>12} {:>12} {:>10}",
172 "Timestamp", "PID", "Project", "Model", "Duration", "Input", "Output", "Cost"
173 );
174 println!("{}", "-".repeat(110));
175
176 let mut total_cost = 0.0;
177 let mut total_duration = 0u64;
178 let mut total_input = 0u64;
179 let mut total_output = 0u64;
180
181 for r in &records {
182 let dur = format_duration(r.duration_secs);
183 let cost = if r.cost_usd < 1.0 {
184 format!("${:.2}", r.cost_usd)
185 } else {
186 format!("${:.1}", r.cost_usd)
187 };
188
189 println!(
190 "{:<22} {:<7} {:<20} {:<12} {:>10} {:>12} {:>12} {:>10}",
191 &r.timestamp[..19.min(r.timestamp.len())],
192 r.pid,
193 truncate(&r.project, 20),
194 truncate(&r.model, 12),
195 dur,
196 format_count(r.input_tokens),
197 format_count(r.output_tokens),
198 cost,
199 );
200
201 total_cost += r.cost_usd;
202 total_duration += r.duration_secs;
203 total_input += r.input_tokens;
204 total_output += r.output_tokens;
205 }
206
207 println!("{}", "-".repeat(110));
208 let total_cost_str = if total_cost < 1.0 {
209 format!("${:.2}", total_cost)
210 } else {
211 format!("${:.1}", total_cost)
212 };
213 println!(
214 "{:<22} {:<7} {:<20} {:<12} {:>10} {:>12} {:>12} {:>10}",
215 format!("{} sessions", records.len()),
216 "",
217 "",
218 "",
219 format_duration(total_duration),
220 format_count(total_input),
221 format_count(total_output),
222 total_cost_str,
223 );
224}
225
226pub fn print_stats(since: &str) {
228 let since_secs = parse_duration(since);
229 let records = load_history(since_secs);
230
231 if records.is_empty() {
232 println!("No session history found.");
233 return;
234 }
235
236 let total_cost: f64 = records.iter().map(|r| r.cost_usd).sum();
237 let total_duration: u64 = records.iter().map(|r| r.duration_secs).sum();
238 let total_input: u64 = records.iter().map(|r| r.input_tokens).sum();
239 let total_output: u64 = records.iter().map(|r| r.output_tokens).sum();
240 let avg_cost = total_cost / records.len() as f64;
241 let avg_duration = total_duration / records.len() as u64;
242
243 println!("Session Statistics (last {since})");
244 println!("{}", "=".repeat(45));
245 println!(" Sessions: {}", records.len());
246 println!(" Total cost: ${:.2}", total_cost);
247 println!(" Avg cost/session: ${:.2}", avg_cost);
248 println!(" Total duration: {}", format_duration(total_duration));
249 println!(" Avg duration: {}", format_duration(avg_duration));
250 println!(
251 " Total tokens: {} in / {} out",
252 format_count(total_input),
253 format_count(total_output)
254 );
255 println!();
256
257 let mut projects: std::collections::HashMap<String, (f64, u64, usize)> =
259 std::collections::HashMap::new();
260 for r in &records {
261 let entry = projects.entry(r.project.clone()).or_default();
262 entry.0 += r.cost_usd;
263 entry.1 += r.duration_secs;
264 entry.2 += 1;
265 }
266
267 let mut project_list: Vec<_> = projects.into_iter().collect();
268 project_list.sort_by(|a, b| b.1.0.partial_cmp(&a.1.0).unwrap());
269
270 println!(" Per-project breakdown:");
271 println!(
272 " {:<25} {:>8} {:>10} {:>10}",
273 "Project", "Sessions", "Duration", "Cost"
274 );
275 println!(" {}", "-".repeat(55));
276 for (name, (cost, dur, count)) in &project_list {
277 let cost_str = if *cost < 1.0 {
278 format!("${:.2}", cost)
279 } else {
280 format!("${:.1}", cost)
281 };
282 println!(
283 " {:<25} {:>8} {:>10} {:>10}",
284 truncate(name, 25),
285 count,
286 format_duration(*dur),
287 cost_str,
288 );
289 }
290
291 let mut models: std::collections::HashMap<String, (f64, usize)> =
293 std::collections::HashMap::new();
294 for r in &records {
295 let model = if r.model.is_empty() {
296 "unknown".to_string()
297 } else {
298 r.model.clone()
299 };
300 let entry = models.entry(model).or_default();
301 entry.0 += r.cost_usd;
302 entry.1 += 1;
303 }
304
305 let mut model_list: Vec<_> = models.into_iter().collect();
306 model_list.sort_by(|a, b| b.1.0.partial_cmp(&a.1.0).unwrap());
307
308 println!();
309 println!(" Per-model breakdown:");
310 println!(" {:<20} {:>8} {:>10}", "Model", "Sessions", "Cost");
311 println!(" {}", "-".repeat(40));
312 for (name, (cost, count)) in &model_list {
313 let cost_str = if *cost < 1.0 {
314 format!("${:.2}", cost)
315 } else {
316 format!("${:.1}", cost)
317 };
318 println!(" {:<20} {:>8} {:>10}", name, count, cost_str);
319 }
320}
321
322#[derive(Debug, Clone, Default)]
324pub struct WeeklySummary {
325 pub cost_usd: f64,
326 pub total_tokens: u64,
327 #[allow(dead_code)]
328 pub session_count: usize,
329 pub today_cost_usd: f64,
330}
331
332pub fn weekly_summary() -> WeeklySummary {
334 let week_secs = 7 * 86400;
335 let day_secs = 86400;
336 let week_records = load_history(Some(week_secs));
337 let day_records = load_history(Some(day_secs));
338
339 WeeklySummary {
340 cost_usd: week_records.iter().map(|r| r.cost_usd).sum(),
341 total_tokens: week_records
342 .iter()
343 .map(|r| r.input_tokens + r.output_tokens)
344 .sum(),
345 session_count: week_records.len(),
346 today_cost_usd: day_records.iter().map(|r| r.cost_usd).sum(),
347 }
348}
349
350pub fn parse_duration(s: &str) -> Option<u64> {
352 let s = s.trim();
353 if s.is_empty() {
354 return None;
355 }
356 let (num_str, unit) = s.split_at(s.len() - 1);
357 let num: u64 = num_str.parse().ok()?;
358 match unit {
359 "s" => Some(num),
360 "m" => Some(num * 60),
361 "h" => Some(num * 3600),
362 "d" => Some(num * 86400),
363 "w" => Some(num * 604800),
364 _ => None,
365 }
366}
367
368fn format_duration(secs: u64) -> String {
369 let h = secs / 3600;
370 let m = (secs % 3600) / 60;
371 let s = secs % 60;
372 if h > 0 {
373 format!("{h}h{m:02}m")
374 } else {
375 format!("{m}m{s:02}s")
376 }
377}
378
379fn format_count(n: u64) -> String {
380 if n >= 1_000_000 {
381 format!("{:.1}M", n as f64 / 1_000_000.0)
382 } else if n >= 1_000 {
383 format!("{:.1}k", n as f64 / 1_000.0)
384 } else {
385 n.to_string()
386 }
387}
388
389fn truncate(s: &str, max: usize) -> String {
390 if s.len() <= max {
391 s.to_string()
392 } else {
393 format!("{}...", &s[..max - 3])
394 }
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 #[test]
402 fn test_parse_duration() {
403 assert_eq!(parse_duration("24h"), Some(86400));
404 assert_eq!(parse_duration("30m"), Some(1800));
405 assert_eq!(parse_duration("7d"), Some(604800));
406 assert_eq!(parse_duration("1w"), Some(604800));
407 assert_eq!(parse_duration(""), None);
408 assert_eq!(parse_duration("abc"), None);
409 }
410
411 #[test]
412 fn test_format_duration() {
413 assert_eq!(format_duration(3661), "1h01m");
414 assert_eq!(format_duration(125), "2m05s");
415 assert_eq!(format_duration(0), "0m00s");
416 }
417
418 #[test]
419 fn test_truncate() {
420 assert_eq!(truncate("hello", 10), "hello");
421 assert_eq!(truncate("hello world!", 8), "hello...");
422 }
423
424 #[test]
425 fn test_parse_timestamp_epoch() {
426 let ts = parse_timestamp_epoch("2026-01-01T00:00:00Z").unwrap();
428 assert!(ts > 1735689600); assert!(ts < 1798761600); }
432
433 #[test]
434 fn test_is_leap() {
435 assert!(is_leap(2000));
436 assert!(is_leap(2024));
437 assert!(!is_leap(1900));
438 assert!(!is_leap(2023));
439 }
440}