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).
124fn parse_timestamp_epoch(ts: &str) -> Option<u64> {
125    // Format: 2026-04-11T14:30:00Z
126    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    // Approximate days from epoch (good enough for filtering)
137    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
157/// Print a tabular history view.
158pub 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
226/// Print aggregate statistics.
227pub 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    // Per-project breakdown
258    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    // Per-model breakdown
292    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/// Weekly usage summary for the TUI title bar.
323#[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
332/// Compute weekly and daily cost/token summary from history.
333pub 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
350/// Parse a duration string like "24h", "30m", "7d" into seconds.
351pub 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        // 2026-01-01T00:00:00Z
427        let ts = parse_timestamp_epoch("2026-01-01T00:00:00Z").unwrap();
428        // Should be reasonable (after 2025)
429        assert!(ts > 1735689600); // 2025-01-01
430        assert!(ts < 1798761600); // 2027-01-01
431    }
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}