use std::collections::HashMap;
use std::fs::{self, File};
use std::io::{BufRead, BufReader};
use std::path::Path;
use chrono::NaiveDate;
use crate::data::models::{ModelTokenDetail, ProjectDir, SessionEvent, SessionSummary};
use crate::utils;
fn build_session_project_map(
history: &[crate::data::models::HistoryEntry],
) -> HashMap<String, String> {
let mut map = HashMap::new();
for entry in history {
if !entry.session_id.is_empty() && !entry.project.is_empty() {
map.entry(entry.session_id.clone())
.or_insert_with(|| entry.project.clone());
}
}
map
}
pub fn discover_projects(
projects_dir: &Path,
history: &[crate::data::models::HistoryEntry],
) -> Result<Vec<ProjectDir>, String> {
if !projects_dir.exists() {
return Ok(Vec::new());
}
let session_to_project = build_session_project_map(history);
let mut projects = Vec::new();
let entries = fs::read_dir(projects_dir)
.map_err(|e| format!("Failed to read projects directory: {}", e))?;
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read project entry: {}", e))?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let mut session_files = Vec::new();
let mut session_ids: Vec<String> = Vec::new();
if let Ok(dir_entries) = fs::read_dir(&path) {
for e in dir_entries {
if let Ok(e) = e {
let p = e.path();
if p.extension().map(|ex| ex == "jsonl").unwrap_or(false) && p.is_file() {
if let Some(stem) = p.file_stem() {
session_ids.push(stem.to_string_lossy().to_string());
}
session_files.push(p);
}
}
}
}
let encoded_name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let real_path = session_ids
.iter()
.find_map(|sid| session_to_project.get(sid))
.cloned();
let (display_name, full_path, path_resolved) = if let Some(rp) = real_path {
let display = Path::new(&rp)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| rp.clone());
(display, rp, true)
} else if let Some((display, full)) = utils::decode_project_name(&encoded_name) {
(display, full, true)
} else {
let label = if encoded_name.len() > 3 {
let body = &encoded_name[3..];
if body.len() > 24 {
format!("...{}", &body[body.len().saturating_sub(21)..])
} else {
body.to_string()
}
} else {
encoded_name.clone()
};
(label, String::new(), false)
};
projects.push(ProjectDir {
display_name,
full_path,
session_files,
path_resolved,
});
}
projects.sort_by(|a, b| a.display_name.cmp(&b.display_name));
Ok(projects)
}
pub fn parse_sessions_in_range(
project_dir: &ProjectDir,
start: NaiveDate,
end: NaiveDate,
) -> Vec<SessionSummary> {
let mut results = Vec::new();
for file_path in &project_dir.session_files {
let file = match File::open(file_path) {
Ok(f) => f,
Err(_) => continue,
};
let reader = BufReader::new(file);
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => continue,
};
if line.trim().is_empty() {
continue;
}
if !utils::timestamp_in_range_fast(&line, start, end) {
continue;
}
if let Ok(SessionEvent::Assistant { timestamp, message, .. }) =
serde_json::from_str::<SessionEvent>(&line)
{
let ts = timestamp
.as_deref()
.and_then(utils::parse_iso_timestamp)
.unwrap_or(chrono::DateTime::UNIX_EPOCH.naive_utc());
if ts.date() < start || ts.date() > end {
continue;
}
if let Some(ref msg) = message {
let model = msg.model.clone();
let mut tool_count: u64 = 0;
let mut tool_map: HashMap<String, u64> = HashMap::new();
if let Some(ref blocks) = msg.content {
for b in blocks {
if b.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
tool_count += 1;
if let Some(name) = b.get("name").and_then(|n| n.as_str()) {
*tool_map.entry(name.to_string()).or_insert(0) += 1;
}
}
}
}
let wsr = msg.usage.as_ref().and_then(|u| u.server_tool_use.as_ref()).map(|t| t.web_search_requests).unwrap_or(0);
let wfr = msg.usage.as_ref().and_then(|u| u.server_tool_use.as_ref()).map(|t| t.web_fetch_requests).unwrap_or(0);
let tokens = msg.usage.as_ref().map(|u| u.total_tokens()).unwrap_or(0);
results.push(SessionSummary {
project_name: project_dir.display_name.clone(),
model,
total_tokens: tokens,
input_tokens: msg.usage.as_ref().map(|u| u.input_tokens).unwrap_or(0),
output_tokens: msg.usage.as_ref().map(|u| u.output_tokens).unwrap_or(0),
cache_read_input_tokens: msg.usage.as_ref().map(|u| u.cache_read_input_tokens).unwrap_or(0),
cache_creation_input_tokens: msg.usage.as_ref().map(|u| u.cache_creation_input_tokens).unwrap_or(0),
web_search_requests: wsr,
web_fetch_requests: wfr,
tool_call_count: tool_count,
tool_calls: tool_map,
});
}
}
}
}
results
}
pub fn compute_time_stats(
project_dirs: &[ProjectDir],
start: NaiveDate,
end: NaiveDate,
) -> (u64, u64, u64, HashMap<u32, u64>) {
let mut longest_secs: u64 = 0;
let mut total_secs: u64 = 0;
let mut session_count: u64 = 0;
let mut hourly: HashMap<u32, u64> = HashMap::new();
for proj in project_dirs {
for file_path in &proj.session_files {
let file = match std::fs::File::open(file_path) {
Ok(f) => f,
Err(_) => continue,
};
let reader = std::io::BufReader::new(file);
let mut first_ts: Option<chrono::NaiveDateTime> = None;
let mut last_ts: Option<chrono::NaiveDateTime> = None;
for line in reader.lines() {
let line = match line { Ok(l) => l, Err(_) => continue };
if line.trim().is_empty() { continue; }
let (date, hour) = extract_date_hour_from_line(&line);
if date.is_none() { continue; }
let d = date.unwrap();
if d < start || d > end { continue; }
let dt = utils::parse_iso_timestamp(line
.split("\"timestamp\":\"")
.nth(1)
.and_then(|s| s.split('\"').next())
.unwrap_or(""))
.unwrap_or(chrono::DateTime::UNIX_EPOCH.naive_utc());
if dt == chrono::DateTime::UNIX_EPOCH.naive_utc() { continue; }
if first_ts.is_none() || dt < first_ts.unwrap() {
first_ts = Some(dt);
}
if last_ts.is_none() || dt > last_ts.unwrap() {
last_ts = Some(dt);
}
*hourly.entry(hour.unwrap_or(0)).or_insert(0) += 1;
}
if let (Some(first), Some(last)) = (first_ts, last_ts) {
let dur = (last - first).num_seconds() as u64;
total_secs += dur;
session_count += 1;
if dur > longest_secs {
longest_secs = dur;
}
}
}
}
(longest_secs, total_secs, session_count, hourly)
}
fn extract_date_hour_from_line(line: &str) -> (Option<NaiveDate>, Option<u32>) {
let ts_str = match line.find("\"timestamp\":\"") {
Some(idx) => {
let start = idx + 13;
if start + 19 <= line.len() {
&line[start..start + 19] } else { return (None, None); }
}
None => match line.find("\"timestamp\": \"") {
Some(idx) => {
let start = idx + 14;
if start + 19 <= line.len() {
&line[start..start + 19]
} else { return (None, None); }
}
None => return (None, None),
},
};
let date = NaiveDate::parse_from_str(&ts_str[..10], "%Y-%m-%d").ok();
let hour = ts_str[11..13].parse::<u32>().ok();
(date, hour)
}
pub fn aggregate_sessions(
sessions: &[SessionSummary],
) -> (HashMap<String, u64>, HashMap<String, u64>, HashMap<String, ModelTokenDetail>) {
let mut project_tokens: HashMap<String, u64> = HashMap::new();
let mut model_tokens: HashMap<String, u64> = HashMap::new();
let mut model_detail: HashMap<String, ModelTokenDetail> = HashMap::new();
for s in sessions {
*project_tokens.entry(s.project_name.clone()).or_insert(0) += s.total_tokens;
if !s.model.is_empty() {
*model_tokens.entry(s.model.clone()).or_insert(0) += s.total_tokens;
let detail = model_detail.entry(s.model.clone()).or_default();
detail.total_tokens += s.total_tokens;
detail.input_tokens += s.input_tokens;
detail.output_tokens += s.output_tokens;
detail.cache_read_input_tokens += s.cache_read_input_tokens;
detail.cache_creation_input_tokens += s.cache_creation_input_tokens;
detail.web_search_requests += s.web_search_requests;
detail.web_fetch_requests += s.web_fetch_requests;
detail.tool_call_count += s.tool_call_count;
for (name, count) in &s.tool_calls {
*detail.tool_calls.entry(name.clone()).or_insert(0) += *count;
}
}
}
(project_tokens, model_tokens, model_detail)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::models::HistoryEntry;
#[test]
fn test_build_session_project_map() {
let entries = vec![
HistoryEntry {
display: "test".into(),
timestamp: 0,
project: "D:\\dev\\foo".into(),
session_id: "sid1".into(),
},
HistoryEntry {
display: "test".into(),
timestamp: 0,
project: "D:\\dev\\bar".into(),
session_id: "sid2".into(),
},
];
let map = build_session_project_map(&entries);
assert_eq!(map.get("sid1").unwrap(), "D:\\dev\\foo");
assert_eq!(map.get("sid2").unwrap(), "D:\\dev\\bar");
}
#[test]
fn test_aggregate_sessions() {
let sessions = vec![
SessionSummary {
project_name: "proj_a".into(), model: "opus".into(),
total_tokens: 100, input_tokens: 60, output_tokens: 30,
cache_read_input_tokens: 10, cache_creation_input_tokens: 0,
web_search_requests: 2, web_fetch_requests: 1,
tool_call_count: 3,
tool_calls: HashMap::new(),
},
SessionSummary {
project_name: "proj_a".into(), model: "sonnet".into(),
total_tokens: 50, input_tokens: 30, output_tokens: 15,
cache_read_input_tokens: 5, cache_creation_input_tokens: 0,
web_search_requests: 0, web_fetch_requests: 0,
tool_call_count: 1,
tool_calls: HashMap::new(),
},
SessionSummary {
project_name: "proj_b".into(), model: "opus".into(),
total_tokens: 200, input_tokens: 120, output_tokens: 60,
cache_read_input_tokens: 20, cache_creation_input_tokens: 0,
web_search_requests: 1, web_fetch_requests: 0,
tool_call_count: 5,
tool_calls: HashMap::new(),
},
];
let (proj_tokens, model_tokens, model_detail) = aggregate_sessions(&sessions);
assert_eq!(proj_tokens.get("proj_a").unwrap(), &150);
assert_eq!(proj_tokens.get("proj_b").unwrap(), &200);
assert_eq!(model_tokens.get("opus").unwrap(), &300);
assert_eq!(model_tokens.get("sonnet").unwrap(), &50);
let opus_detail = model_detail.get("opus").unwrap();
assert_eq!(opus_detail.input_tokens, 180);
assert_eq!(opus_detail.output_tokens, 90);
assert_eq!(opus_detail.cache_read_input_tokens, 30);
assert_eq!(opus_detail.web_search_requests, 3);
assert_eq!(opus_detail.web_fetch_requests, 1);
let sonnet_detail = model_detail.get("sonnet").unwrap();
assert_eq!(sonnet_detail.input_tokens, 30);
assert_eq!(sonnet_detail.output_tokens, 15);
}
#[test]
fn test_aggregate_sessions_empty() {
let (proj_tokens, model_tokens, model_detail) = aggregate_sessions(&[]);
assert!(proj_tokens.is_empty());
assert!(model_tokens.is_empty());
assert!(model_detail.is_empty());
}
#[test]
fn test_aggregate_sessions_empty_model_skipped() {
let sessions = vec![
SessionSummary {
project_name: "proj".into(), model: "".into(),
total_tokens: 100, input_tokens: 60, output_tokens: 30,
cache_read_input_tokens: 10, cache_creation_input_tokens: 0,
web_search_requests: 0, web_fetch_requests: 0,
tool_call_count: 0,
tool_calls: HashMap::new(),
},
];
let (proj_tokens, model_tokens, _model_detail) = aggregate_sessions(&sessions);
assert_eq!(proj_tokens.get("proj").unwrap(), &100);
assert!(model_tokens.is_empty());
}
#[test]
fn test_aggregate_sessions_tool_call_grouping() {
let mut tool_calls_a = HashMap::new();
tool_calls_a.insert("Bash".to_string(), 3);
tool_calls_a.insert("Read".to_string(), 2);
let mut tool_calls_b = HashMap::new();
tool_calls_b.insert("Bash".to_string(), 5);
tool_calls_b.insert("Edit".to_string(), 1);
let sessions = vec![
SessionSummary {
project_name: "proj".into(), model: "opus".into(),
total_tokens: 100, input_tokens: 60, output_tokens: 30,
cache_read_input_tokens: 10, cache_creation_input_tokens: 0,
web_search_requests: 0, web_fetch_requests: 0,
tool_call_count: 5,
tool_calls: tool_calls_a,
},
SessionSummary {
project_name: "proj".into(), model: "opus".into(),
total_tokens: 200, input_tokens: 120, output_tokens: 60,
cache_read_input_tokens: 20, cache_creation_input_tokens: 0,
web_search_requests: 0, web_fetch_requests: 0,
tool_call_count: 6,
tool_calls: tool_calls_b,
},
];
let (_, _, model_detail) = aggregate_sessions(&sessions);
let detail = model_detail.get("opus").unwrap();
assert_eq!(detail.tool_calls.get("Bash").unwrap(), &8);
assert_eq!(detail.tool_calls.get("Read").unwrap(), &2);
assert_eq!(detail.tool_calls.get("Edit").unwrap(), &1);
assert_eq!(detail.tool_call_count, 11);
}
#[test]
fn test_parse_sessions_in_range_with_file() {
use std::io::Write;
let dir = std::env::temp_dir().join("claude-dash-test-parse-sessions");
let _ = std::fs::create_dir_all(&dir);
let session_file = dir.join("test-session.jsonl");
let mut f = std::fs::File::create(&session_file).unwrap();
writeln!(f, r#"{{"type":"assistant","timestamp":"2026-05-12T10:30:00Z","message":{{"model":"opus","usage":{{"input_tokens":5000,"output_tokens":2000,"cache_read_input_tokens":1000,"cache_creation_input_tokens":500}},"content":[{{"type":"tool_use","name":"Bash","input":{{}}}},{{"type":"text","text":"hello"}}]}}}}"#).unwrap();
writeln!(f, r#"{{"type":"assistant","timestamp":"2026-05-13T09:00:00Z","message":{{"model":"sonnet","usage":{{"input_tokens":3000,"output_tokens":1000}},"content":[{{"type":"tool_use","name":"Read","input":{{}}}}]}}}}"#).unwrap();
writeln!(f, r#"{{"type":"user","timestamp":"2026-05-12T10:29:00Z","message":{{"role":"user","content":"hi"}}}}"#).unwrap();
writeln!(f, r#"{{"type":"assistant","timestamp":"2026-06-01T10:00:00Z","message":{{"model":"haiku","usage":{{"input_tokens":10}}}}}}"#).unwrap();
let proj = ProjectDir {
display_name: "test".into(),
full_path: dir.to_string_lossy().to_string(),
session_files: vec![session_file.clone()],
path_resolved: true,
};
let start = NaiveDate::from_ymd_opt(2026, 5, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap();
let sessions = parse_sessions_in_range(&proj, start, end);
assert_eq!(sessions.len(), 2);
let s0 = sessions.iter().find(|s| s.model == "opus").unwrap();
assert_eq!(s0.total_tokens, 8500); assert_eq!(s0.tool_call_count, 1);
assert_eq!(s0.tool_calls.get("Bash").unwrap(), &1);
assert_eq!(s0.project_name, "test");
let s1 = sessions.iter().find(|s| s.model == "sonnet").unwrap();
assert_eq!(s1.total_tokens, 4000);
assert_eq!(s1.tool_call_count, 1);
assert_eq!(s1.tool_calls.get("Read").unwrap(), &1);
let _ = std::fs::remove_file(&session_file);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn test_parse_sessions_in_range_server_tool_use() {
use std::io::Write;
let dir = std::env::temp_dir().join("claude-dash-test-server-tools");
let _ = std::fs::create_dir_all(&dir);
let session_file = dir.join("test-session.jsonl");
let mut f = std::fs::File::create(&session_file).unwrap();
writeln!(f, r#"{{"type":"assistant","timestamp":"2026-05-15T10:00:00Z","message":{{"model":"opus","usage":{{"input_tokens":100,"output_tokens":50,"server_tool_use":{{"web_search_requests":3,"web_fetch_requests":2}}}}}}}}"#).unwrap();
let proj = ProjectDir {
display_name: "test".into(),
full_path: dir.to_string_lossy().to_string(),
session_files: vec![session_file.clone()],
path_resolved: true,
};
let start = NaiveDate::from_ymd_opt(2026, 5, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap();
let sessions = parse_sessions_in_range(&proj, start, end);
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].web_search_requests, 3);
assert_eq!(sessions[0].web_fetch_requests, 2);
let _ = std::fs::remove_file(&session_file);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn test_parse_sessions_in_range_empty_project() {
let proj = ProjectDir {
display_name: "test".into(),
full_path: String::new(),
session_files: vec![],
path_resolved: true,
};
let start = NaiveDate::from_ymd_opt(2026, 5, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap();
let sessions = parse_sessions_in_range(&proj, start, end);
assert!(sessions.is_empty());
}
#[test]
fn test_compute_time_stats_with_file() {
use std::io::Write;
let dir = std::env::temp_dir().join("claude-dash-test-time-stats");
let _ = std::fs::create_dir_all(&dir);
let session_file = dir.join("test-session.jsonl");
let mut f = std::fs::File::create(&session_file).unwrap();
writeln!(f, r#"{{"type":"assistant","timestamp":"2026-05-12T10:00:00Z","message":{{"model":"opus","usage":{{"input_tokens":100,"output_tokens":50}}}}}}"#).unwrap();
writeln!(f, r#"{{"type":"assistant","timestamp":"2026-05-12T11:00:00Z","message":{{"model":"opus","usage":{{"input_tokens":100,"output_tokens":50}}}}}}"#).unwrap();
writeln!(f, r#"{{"type":"assistant","timestamp":"2026-05-12T12:00:00Z","message":{{"model":"opus","usage":{{"input_tokens":100,"output_tokens":50}}}}}}"#).unwrap();
let proj = ProjectDir {
display_name: "test".into(),
full_path: dir.to_string_lossy().to_string(),
session_files: vec![session_file.clone()],
path_resolved: true,
};
let start = NaiveDate::from_ymd_opt(2026, 5, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap();
let (longest, total, count, hourly) = compute_time_stats(&[proj], start, end);
assert_eq!(longest, 7200); assert_eq!(total, 7200);
assert_eq!(count, 1); assert!(!hourly.is_empty());
assert!(hourly.contains_key(&10));
assert!(hourly.contains_key(&11));
assert!(hourly.contains_key(&12));
let _ = std::fs::remove_file(&session_file);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn test_compute_time_stats_empty() {
let start = NaiveDate::from_ymd_opt(2026, 5, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap();
let (longest, total, count, hourly) = compute_time_stats(&[], start, end);
assert_eq!(longest, 0);
assert_eq!(total, 0);
assert_eq!(count, 0);
assert!(hourly.is_empty());
}
#[test]
fn test_compute_time_stats_out_of_range() {
use std::io::Write;
let dir = std::env::temp_dir().join("claude-dash-test-time-oor");
let _ = std::fs::create_dir_all(&dir);
let session_file = dir.join("test-session.jsonl");
let mut f = std::fs::File::create(&session_file).unwrap();
writeln!(f, r#"{{"type":"assistant","timestamp":"2026-06-15T10:00:00Z","message":{{"model":"opus","usage":{{"input_tokens":100,"output_tokens":50}}}}}}"#).unwrap();
let proj = ProjectDir {
display_name: "test".into(),
full_path: dir.to_string_lossy().to_string(),
session_files: vec![session_file.clone()],
path_resolved: true,
};
let start = NaiveDate::from_ymd_opt(2026, 5, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2026, 5, 31).unwrap();
let (longest, total, count, hourly) = compute_time_stats(&[proj], start, end);
assert_eq!(longest, 0);
assert_eq!(total, 0);
assert_eq!(count, 0);
assert!(hourly.is_empty());
let _ = std::fs::remove_file(&session_file);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn test_extract_date_hour_from_line() {
let line = r#"{"type":"assistant","timestamp":"2026-05-12T14:30:00Z","message":{}}"#;
let (date, hour) = extract_date_hour_from_line(line);
assert_eq!(date.unwrap().to_string(), "2026-05-12");
assert_eq!(hour.unwrap(), 14);
}
#[test]
fn test_extract_date_hour_from_line_with_space() {
let line = r#"{"type": "assistant", "timestamp": "2026-05-12T09:00:00Z"}"#;
let (date, hour) = extract_date_hour_from_line(line);
assert_eq!(date.unwrap().to_string(), "2026-05-12");
assert_eq!(hour.unwrap(), 9);
}
#[test]
fn test_extract_date_hour_from_line_no_timestamp() {
let line = r#"{"type":"assistant","data":"no timestamp here"}"#;
let (date, hour) = extract_date_hour_from_line(line);
assert!(date.is_none());
assert!(hour.is_none());
}
#[test]
fn test_extract_date_hour_from_line_short_line() {
let line = r#"{"timestamp":"2026"}"#;
let (date, hour) = extract_date_hour_from_line(line);
assert!(date.is_none());
assert!(hour.is_none());
}
#[test]
fn test_build_session_project_map_empty() {
let map = build_session_project_map(&[]);
assert!(map.is_empty());
}
#[test]
fn test_build_session_project_map_empty_session_id() {
let entries = vec![
HistoryEntry {
display: "test".into(),
timestamp: 0,
project: "D:\\dev\\foo".into(),
session_id: "".into(), },
];
let map = build_session_project_map(&entries);
assert!(map.is_empty());
}
#[test]
fn test_build_session_project_map_first_wins() {
let entries = vec![
HistoryEntry {
display: "first".into(),
timestamp: 0,
project: "D:\\dev\\first".into(),
session_id: "sid1".into(),
},
HistoryEntry {
display: "second".into(),
timestamp: 1,
project: "D:\\dev\\second".into(),
session_id: "sid1".into(),
},
];
let map = build_session_project_map(&entries);
assert_eq!(map.get("sid1").unwrap(), "D:\\dev\\first");
}
#[test]
fn test_discover_projects_nonexistent_dir() {
let result = discover_projects(
std::path::Path::new("/nonexistent/path"),
&[],
);
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
}