Skip to main content

claudectl_core/
history.rs

1use std::fs::{self, File, OpenOptions};
2use std::io::{BufRead, BufReader, Write};
3use std::path::PathBuf;
4
5/// A completed session record persisted to CSV.
6#[derive(Debug, Clone)]
7pub struct SessionRecord {
8    pub timestamp: String, // ISO 8601
9    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
31/// Append a session record to the history CSV.
32pub 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
70/// Load all history records, optionally filtered by a time window.
71pub 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; // skip header
90        }
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        // Filter by time window if specified
109        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
123/// Parse an ISO 8601 timestamp to epoch seconds (simplified).
124///
125/// Public because Codex rollout events carry timestamps in exactly this
126/// shape (`2026-10-11T19:08:37.776Z`) and `agent::codex` needs them; see
127/// CLAUDE.md on not leaving `pub(crate)` on something downstream calls.
128/// Fractional seconds and offsets past position 19 are ignored.
129pub fn parse_timestamp_epoch(ts: &str) -> Option<u64> {
130    // Format: 2026-04-11T14:30:00Z
131    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    // Approximate days from epoch (good enough for filtering)
142    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
162/// Print a tabular history view.
163pub 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
231/// Print aggregate statistics.
232pub 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    // Per-project breakdown
263    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    // Per-model breakdown
297    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/// Weekly usage summary for the TUI title bar.
328#[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
337/// Compute weekly and daily cost/token summary from history.
338pub 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
355/// Parse a duration string like "24h", "30m", "7d" into seconds.
356pub fn parse_duration(s: &str) -> Option<u64> {
357    let s = s.trim();
358    if s.is_empty() {
359        return None;
360    }
361    // Split at the last *character*, not the last byte: `split_at(len - 1)`
362    // panics outright when the final char is multibyte, and this is reached
363    // from user input (`access grant --expires 30é`).
364    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    // checked_mul, not `*`: an absurd value like "99999999999999999999w" used
369    // to panic in debug builds rather than reading as invalid input. Anything
370    // that overflows is not a duration a caller can act on, so it is `None`
371    // like any other unparseable string.
372    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        // split_at(len-1) lands mid-codepoint when the last char is multibyte.
428        // Reachable from `claudectl access grant --expires 30é` (#427).
429        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        // These used to panic in debug builds. Reached from user input via
438        // `claudectl access grant --expires` (#427).
439        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        // Seconds have no multiplier, so the max is still a valid duration.
443        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        // 2026-01-01T00:00:00Z
462        let ts = parse_timestamp_epoch("2026-01-01T00:00:00Z").unwrap();
463        // Should be reasonable (after 2025)
464        assert!(ts > 1735689600); // 2025-01-01
465        assert!(ts < 1798761600); // 2027-01-01
466    }
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}