use chrono::NaiveDate;
use crate::data::models::{ModelUsage, ProjectDir, DailyActivity, DailyModelTokens, ModelTokenDetail, ViewData};
use crate::data::projects;
use crate::utils;
#[cfg(test)]
pub fn compute_view_data(
project_dirs: &[ProjectDir],
daily_activity: &[DailyActivity],
daily_model_tokens: &[DailyModelTokens],
start: NaiveDate,
end: NaiveDate,
) -> ViewData {
compute_view_data_with_usage(project_dirs, daily_activity, daily_model_tokens, &std::collections::HashMap::new(), start, end)
}
pub fn compute_view_data_with_usage(
project_dirs: &[ProjectDir],
daily_activity: &[DailyActivity],
daily_model_tokens: &[DailyModelTokens],
model_usage: &std::collections::HashMap<String, ModelUsage>,
start: NaiveDate,
end: NaiveDate,
) -> ViewData {
let mut all_sessions = Vec::new();
for proj in project_dirs {
let sessions = projects::parse_sessions_in_range(proj, start, end);
all_sessions.extend(sessions);
}
let (project_tokens, raw_session_model_tokens, raw_session_model_detail) =
projects::aggregate_sessions(&all_sessions);
let mut model_tokens: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
let mut model_token_detail: std::collections::HashMap<String, ModelTokenDetail> = std::collections::HashMap::new();
for (model, tokens) in raw_session_model_tokens {
let name = normalize_model_name(&model);
*model_tokens.entry(name.clone()).or_insert(0) += tokens;
}
for (model, detail) in raw_session_model_detail {
let name = normalize_model_name(&model);
let entry = model_token_detail.entry(name).or_default();
entry.total_tokens += detail.total_tokens;
entry.input_tokens += detail.input_tokens;
entry.output_tokens += detail.output_tokens;
entry.cache_read_input_tokens += detail.cache_read_input_tokens;
entry.cache_creation_input_tokens += detail.cache_creation_input_tokens;
entry.web_search_requests += detail.web_search_requests;
entry.web_fetch_requests += detail.web_fetch_requests;
entry.tool_call_count += detail.tool_call_count;
for (name, count) in &detail.tool_calls {
*entry.tool_calls.entry(name.clone()).or_insert(0) += *count;
}
}
let mut all_tool_calls: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
for detail in model_token_detail.values() {
for (name, count) in &detail.tool_calls {
*all_tool_calls.entry(name.clone()).or_insert(0) += *count;
}
}
let mut stats_model_tokens: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
for dmt in daily_model_tokens {
if utils::date_str_in_range(&dmt.date, start, end) {
for (model, tokens) in &dmt.tokens_by_model {
*stats_model_tokens.entry(normalize_model_name(model)).or_insert(0) += *tokens;
}
}
}
for (model, tokens) in stats_model_tokens {
if tokens > 0 {
let entry = model_tokens.entry(model.clone()).or_insert(0);
*entry = (*entry).max(tokens);
let detail = model_token_detail.entry(model).or_default();
detail.total_tokens = detail.total_tokens.max(tokens);
}
}
for (model, usage) in model_usage {
let name = normalize_model_name(model);
let total = usage.total_tokens();
let entry = model_tokens.entry(name.clone()).or_insert(0);
*entry = (*entry).max(total);
let detail = model_token_detail.entry(name).or_default();
detail.total_tokens = detail.total_tokens.max(total);
detail.input_tokens = detail.input_tokens.max(usage.input_tokens);
detail.output_tokens = detail.output_tokens.max(usage.output_tokens);
detail.cache_read_input_tokens = detail.cache_read_input_tokens.max(usage.cache_read_input_tokens);
detail.cache_creation_input_tokens = detail.cache_creation_input_tokens.max(usage.cache_creation_input_tokens);
detail.web_search_requests = detail.web_search_requests.max(usage.web_search_requests);
}
let total_tokens: u64 = model_tokens.values().sum();
let total_web_search_requests: u64 = model_token_detail.values().map(|d| d.web_search_requests).sum();
let total_web_fetch_requests: u64 = model_token_detail.values().map(|d| d.web_fetch_requests).sum();
let mut filtered_activity: Vec<_> = daily_activity
.iter()
.filter(|d| utils::date_str_in_range(&d.date, start, end))
.cloned()
.collect();
if filtered_activity.is_empty() && !all_sessions.is_empty() {
filtered_activity = generate_daily_activity(project_dirs, start, end);
} else if !all_sessions.is_empty() {
let existing: std::collections::HashSet<String> =
filtered_activity.iter().map(|d| d.date.clone()).collect();
let supplement = generate_daily_activity(project_dirs, start, end);
for a in supplement {
if !existing.contains(&a.date) {
filtered_activity.push(a);
}
}
filtered_activity.sort_by(|a, b| a.date.cmp(&b.date));
}
let total_messages: u64 = filtered_activity.iter().map(|d| d.message_count).sum();
let total_sessions: u64 = filtered_activity.iter().map(|d| d.session_count).sum();
let total_tool_calls: u64 = model_token_detail.values().map(|d| d.tool_call_count).sum();
let (longest_session_secs, total_session_secs, session_count_for_avg, hourly_activity) =
projects::compute_time_stats(project_dirs, start, end);
let avg_session_secs = if session_count_for_avg > 0 {
total_session_secs / session_count_for_avg
} else {
0
};
let peak_period = peak_period_name(&hourly_activity);
ViewData {
total_sessions,
total_messages,
total_tokens,
model_tokens,
model_token_detail,
project_tokens,
daily_activity: filtered_activity,
total_web_search_requests,
total_web_fetch_requests,
total_tool_calls,
tool_calls: all_tool_calls,
longest_session_secs,
avg_session_secs,
peak_period,
}
}
fn peak_period_name(hourly: &std::collections::HashMap<u32, u64>) -> String {
let (mut morning, mut afternoon, mut evening, mut night): (u64, u64, u64, u64) = (0, 0, 0, 0);
for (h, c) in hourly {
match *h {
6..=11 => morning += c,
12..=17 => afternoon += c,
18..=23 => evening += c,
_ => night += c,
}
}
let max = morning.max(afternoon).max(evening).max(night);
if max == 0 { return "N/A".into(); }
if max == morning { return format!("Morning ({:.0}%)", morning as f64 / (morning + afternoon + evening + night) as f64 * 100.0); }
if max == afternoon { return format!("Afternoon ({:.0}%)", afternoon as f64 / (morning + afternoon + evening + night) as f64 * 100.0); }
if max == evening { return format!("Evening ({:.0}%)", evening as f64 / (morning + afternoon + evening + night) as f64 * 100.0); }
format!("Night ({:.0}%)", night as f64 / (morning + afternoon + evening + night) as f64 * 100.0)
}
fn normalize_model_name(name: &str) -> String {
if let Some(idx) = name.find('[') {
name[..idx].to_string()
} else {
name.to_string()
}
}
pub fn generate_daily_activity(
project_dirs: &[ProjectDir],
start: NaiveDate,
end: NaiveDate,
) -> Vec<DailyActivity> {
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::fs::File;
use std::io::{BufRead, BufReader};
let mut day_counts: BTreeMap<String, (u64, BTreeSet<std::path::PathBuf>, u64)> = BTreeMap::new();
for proj in project_dirs {
for file_path in &proj.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;
}
let date = if let Some(idx) = line.find("\"timestamp\":\"") {
let idx = idx + 13;
if idx + 10 <= line.len() { Some(&line[idx..idx + 10]) } else { None }
} else if let Some(idx) = line.find("\"timestamp\": \"") {
let idx = idx + 14;
if idx + 10 <= line.len() { Some(&line[idx..idx + 10]) } else { None }
} else {
None
};
if let Some(date) = date {
let entry = day_counts.entry(date.to_string()).or_insert_with(|| (0, BTreeSet::new(), 0));
entry.0 += 1;
if line.contains("\"type\":\"assistant\"") {
entry.0 += 1;
}
entry.1.insert(file_path.clone());
if line.contains("tool_use") || line.contains("\"type\":\"tool\"") {
entry.2 += 1;
}
}
}
}
}
day_counts
.into_iter()
.map(|(date, (msgs, session_files, tools))| DailyActivity {
date,
message_count: msgs,
session_count: session_files.len() as u64,
tool_call_count: tools,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::models::{ProjectDir, ModelUsage};
use chrono::NaiveDate;
#[test]
fn test_compare_yearly_vs_alltime() {
let claude_dir = crate::utils::find_claude_dir(None);
if claude_dir.is_none() { eprintln!("Skipping: no .claude"); return; }
let cdir = claude_dir.unwrap();
let projects_dir = cdir.join("projects");
if !projects_dir.exists() { eprintln!("Skipping: no projects dir"); return; }
let stats = crate::data::stats::parse_stats_cache(&cdir.join("stats-cache.json"))
.unwrap_or_default();
let history = crate::data::history::parse_history(&cdir.join("history.jsonl"))
.unwrap_or_default();
let project_dirs = crate::data::projects::discover_projects(&projects_dir, &history)
.unwrap_or_default();
let start_all = stats.first_session_date.as_deref()
.and_then(crate::utils::parse_iso_timestamp)
.map(|dt| dt.date())
.unwrap_or_else(|| NaiveDate::from_ymd_opt(2025, 1, 1).unwrap());
let end = chrono::Local::now().naive_local().date();
let data_all = compute_view_data(&project_dirs, &stats.daily_activity, &stats.daily_model_tokens, start_all, end);
let (y2026_start, y2026_end) = crate::utils::year_bounds(2026);
let data_2026 = compute_view_data(&project_dirs, &stats.daily_activity, &stats.daily_model_tokens, y2026_start, y2026_end);
let mut data_merged = data_all.clone();
for (model, usage) in &stats.model_usage {
let total = usage.total_tokens();
let entry = data_merged.model_tokens.entry(model.clone()).or_insert(0);
*entry = (*entry).max(total);
}
data_merged.total_tokens = data_merged.model_tokens.values().sum();
eprintln!("=== COMPARISON ===");
eprintln!("all-time (compute): {:.1}M ({} models, {} sessions, {} msgs)",
data_all.total_tokens as f64 / 1e6, data_all.model_tokens.len(),
data_all.total_sessions, data_all.total_messages);
eprintln!("all-time (merged): {:.1}M ({} models)",
data_merged.total_tokens as f64 / 1e6, data_merged.model_tokens.len());
eprintln!("yearly 2026: {:.1}M ({} models, {} sessions, {} msgs)",
data_2026.total_tokens as f64 / 1e6, data_2026.model_tokens.len(),
data_2026.total_sessions, data_2026.total_messages);
for (model, tokens_all) in &data_all.model_tokens {
let tokens_2026 = data_2026.model_tokens.get(model).copied().unwrap_or(0);
let tokens_merged = data_merged.model_tokens.get(model).copied().unwrap_or(0);
let usage = stats.model_usage.get(model).map(|u| u.total_tokens()).unwrap_or(0);
eprintln!(" {:<20} all={:.1}M yr={:.1}M merged={:.1}M usage={:.1}M",
model,
*tokens_all as f64/1e6, tokens_2026 as f64/1e6,
tokens_merged as f64/1e6, usage as f64/1e6);
}
assert!(data_all.total_tokens > 0);
}
#[test]
fn test_compute_view_data_tool_calls_from_real_data() {
let claude_dir = crate::utils::find_claude_dir(None);
if claude_dir.is_none() { eprintln!("Skipping: no .claude"); return; }
let cdir = claude_dir.unwrap();
let projects_dir = cdir.join("projects");
if !projects_dir.exists() { eprintln!("Skipping: no projects dir"); return; }
let stats = crate::data::stats::parse_stats_cache(&cdir.join("stats-cache.json"))
.unwrap_or_default();
let history = crate::data::history::parse_history(&cdir.join("history.jsonl"))
.unwrap_or_default();
let project_dirs = crate::data::projects::discover_projects(&projects_dir, &history)
.unwrap_or_default();
let start = stats.first_session_date.as_deref()
.and_then(crate::utils::parse_iso_timestamp)
.map(|dt| dt.date())
.unwrap_or_else(|| NaiveDate::from_ymd_opt(2025, 1, 1).unwrap());
let end = chrono::Local::now().naive_local().date();
let data = compute_view_data(&project_dirs, &stats.daily_activity, &stats.daily_model_tokens, start, end);
let total_tc: u64 = data.model_token_detail.values().map(|d| d.tool_call_count).sum();
eprintln!("[TEST compute] projects={} models={} detail={} total_tc={}",
project_dirs.len(), data.model_tokens.len(), data.model_token_detail.len(), total_tc);
for (name, d) in &data.model_token_detail {
eprintln!(" [TEST compute] {}: tc={} tokens={}M ws={} wf={}",
name, d.tool_call_count, d.total_tokens as f64/1e6, d.web_search_requests, d.web_fetch_requests);
}
assert!(total_tc > 0, "Expected non-zero tool calls from real data, got 0");
}
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn make_daily_activity(date: &str, messages: u64, sessions: u64, tools: u64) -> DailyActivity {
DailyActivity { date: date.to_string(), message_count: messages, session_count: sessions, tool_call_count: tools }
}
fn make_daily_model_tokens(date: &str, tokens: Vec<(&str, u64)>) -> DailyModelTokens {
let mut map: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
for (model, count) in tokens {
map.insert(model.to_string(), count);
}
DailyModelTokens { date: date.to_string(), tokens_by_model: map }
}
#[test]
fn test_model_tokens_from_stats_cache() {
let project_dirs = vec![];
let daily_activity = vec![
make_daily_activity("2026-04-15", 10, 2, 5),
make_daily_activity("2026-04-16", 20, 3, 8),
];
let daily_model_tokens = vec![
make_daily_model_tokens("2026-04-15", vec![("opus", 1000), ("sonnet", 500)]),
make_daily_model_tokens("2026-04-16", vec![("opus", 2000), ("haiku", 300)]),
];
let data = compute_view_data(&project_dirs, &daily_activity, &daily_model_tokens, d(2026, 4, 1), d(2026, 4, 30));
assert_eq!(data.model_tokens.get("opus").unwrap(), &3000);
assert_eq!(data.model_tokens.get("sonnet").unwrap(), &500);
assert_eq!(data.model_tokens.get("haiku").unwrap(), &300);
}
#[test]
fn test_empty_range() {
let daily_activity = vec![make_daily_activity("2026-01-15", 10, 2, 0)];
let daily_model_tokens = vec![make_daily_model_tokens("2026-01-15", vec![("opus", 100)])];
let data = compute_view_data(&[], &daily_activity, &daily_model_tokens, d(2026, 6, 1), d(2026, 6, 30));
assert_eq!(data.total_tokens, 0);
assert!(data.model_tokens.is_empty());
}
#[test]
fn test_synthetic_model_preserved() {
let daily_activity = vec![make_daily_activity("2026-04-15", 5, 1, 0)];
let daily_model_tokens = vec![
make_daily_model_tokens("2026-04-15", vec![("<synthetic>", 5000), ("opus", 3000)]),
];
let data = compute_view_data(&[], &daily_activity, &daily_model_tokens, d(2026, 4, 1), d(2026, 4, 30));
assert_eq!(data.model_tokens.get("<synthetic>").unwrap(), &5000);
}
#[test]
fn test_aggregates_multiple_days_same_model() {
let daily_activity = vec![make_daily_activity("2026-01-01", 1, 1, 0)];
let daily_model_tokens = vec![
make_daily_model_tokens("2026-01-01", vec![("opus", 100)]),
make_daily_model_tokens("2026-01-02", vec![("opus", 200)]),
make_daily_model_tokens("2026-01-03", vec![("opus", 300)]),
];
let data = compute_view_data(&[], &daily_activity, &daily_model_tokens, d(2026, 1, 1), d(2026, 1, 31));
assert_eq!(data.model_tokens.get("opus").unwrap(), &600);
}
#[test]
fn test_session_primary_stats_gap_fill() {
let _project_dirs: Vec<ProjectDir> = vec![];
use std::io::Write;
let dir = std::env::temp_dir().join("claude-dash-test-gapfill");
let _ = std::fs::create_dir(&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:01Z","message":{{"model":"deepseek-v4-pro[1m]","usage":{{"input_tokens":5000,"output_tokens":2000}}}}}}"#).unwrap();
writeln!(f, r#"{{"type":"assistant","timestamp":"2026-05-12T11:00:01Z","message":{{"model":"deepseek-v4-flash","usage":{{"input_tokens":3000,"output_tokens":1000}}}}}}"#).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 daily_activity = vec![make_daily_activity("2026-05-12", 1, 1, 0)];
let daily_model_tokens = vec![
make_daily_model_tokens("2026-05-12", vec![("deepseek-v4-pro", 3000)]),
];
let data = compute_view_data(&[proj], &daily_activity, &daily_model_tokens, d(2026, 5, 1), d(2026, 5, 31));
assert_eq!(data.model_tokens.get("deepseek-v4-pro").unwrap(), &7000,
"session JSONL is primary source, should keep 7000 not stats 3000");
assert_eq!(data.model_tokens.get("deepseek-v4-flash").unwrap(), &4000,
"deepseek-v4-flash only in session, should be included");
let _ = std::fs::remove_file(&session_file);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn test_generate_daily_activity_from_session_files() {
use std::io::Write;
let dir = std::env::temp_dir().join("claude-dash-test-activity2");
let _ = std::fs::create_dir(&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:01Z","message":{{"model":"opus","usage":{{"input_tokens":100,"output_tokens":50}}}}}}"#).unwrap();
writeln!(f, r#"{{"type":"assistant","timestamp":"2026-05-13T09:00:00Z","message":{{"model":"sonnet","usage":{{"input_tokens":50,"output_tokens":25}}}}}}"#).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 activity = generate_daily_activity(&[proj], d(2026, 5, 1), d(2026, 5, 31));
assert_eq!(activity.len(), 2);
let _ = std::fs::remove_file(&session_file);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn test_generate_daily_activity_empty_range() {
let activity = generate_daily_activity(&[], d(2026, 6, 1), d(2026, 6, 30));
assert!(activity.is_empty());
}
#[test]
fn test_normalize_model_name_with_suffix() {
assert_eq!(normalize_model_name("opus[1m]"), "opus");
assert_eq!(normalize_model_name("sonnet[200k]"), "sonnet");
assert_eq!(normalize_model_name("haiku[128k]"), "haiku");
}
#[test]
fn test_normalize_model_name_without_suffix() {
assert_eq!(normalize_model_name("opus"), "opus");
assert_eq!(normalize_model_name("claude-opus-4"), "claude-opus-4");
assert_eq!(normalize_model_name("deepseek-v4-pro"), "deepseek-v4-pro");
}
#[test]
fn test_normalize_model_name_empty() {
assert_eq!(normalize_model_name(""), "");
}
#[test]
fn test_normalize_model_name_bracket_at_start() {
assert_eq!(normalize_model_name("[1m]model"), "");
}
#[test]
fn test_peak_period_name_morning() {
let mut hourly = std::collections::HashMap::new();
hourly.insert(8, 50);
hourly.insert(10, 30);
let result = peak_period_name(&hourly);
assert!(result.starts_with("Morning"));
assert!(result.contains("%"));
}
#[test]
fn test_peak_period_name_afternoon() {
let mut hourly = std::collections::HashMap::new();
hourly.insert(13, 60);
hourly.insert(15, 40);
let result = peak_period_name(&hourly);
assert!(result.starts_with("Afternoon"));
}
#[test]
fn test_peak_period_name_evening() {
let mut hourly = std::collections::HashMap::new();
hourly.insert(19, 70);
hourly.insert(21, 30);
let result = peak_period_name(&hourly);
assert!(result.starts_with("Evening"));
}
#[test]
fn test_peak_period_name_night() {
let mut hourly = std::collections::HashMap::new();
hourly.insert(1, 40);
hourly.insert(3, 60);
let result = peak_period_name(&hourly);
assert!(result.starts_with("Night"));
}
#[test]
fn test_peak_period_name_empty() {
let hourly = std::collections::HashMap::new();
assert_eq!(peak_period_name(&hourly), "N/A");
}
#[test]
fn test_peak_period_name_mixed() {
let mut hourly = std::collections::HashMap::new();
hourly.insert(9, 10); hourly.insert(14, 50); hourly.insert(20, 20); hourly.insert(2, 5); let result = peak_period_name(&hourly);
assert!(result.starts_with("Afternoon"));
}
#[test]
fn test_compute_view_data_with_usage_merges_model_usage() {
use std::collections::HashMap;
let mut model_usage = HashMap::new();
model_usage.insert("opus".to_string(), ModelUsage {
input_tokens: 5000,
output_tokens: 2000,
cache_read_input_tokens: 1000,
cache_creation_input_tokens: 500,
web_search_requests: 5,
cost_usd: 0.0,
context_window: 0,
max_output_tokens: 0,
});
let data = compute_view_data_with_usage(
&[], &[], &[],
&model_usage,
d(2026, 4, 1), d(2026, 4, 30),
);
assert!(data.model_tokens.contains_key("opus"));
let detail = data.model_token_detail.get("opus").unwrap();
assert_eq!(detail.input_tokens, 5000);
assert_eq!(detail.output_tokens, 2000);
assert_eq!(detail.cache_read_input_tokens, 1000);
assert_eq!(detail.web_search_requests, 5);
}
#[test]
fn test_compute_view_data_with_usage_session_wins_over_stats() {
use std::io::Write;
use std::collections::HashMap;
let dir = std::env::temp_dir().join("claude-dash-test-usage-merge");
let _ = std::fs::create_dir(&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:01Z","message":{{"model":"opus","usage":{{"input_tokens":10000,"output_tokens":5000}}}}}}"#).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 mut model_usage = HashMap::new();
model_usage.insert("opus".to_string(), ModelUsage {
input_tokens: 3000, output_tokens: 1000,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
web_search_requests: 0,
cost_usd: 0.0,
context_window: 0,
max_output_tokens: 0,
});
let daily_activity = vec![make_daily_activity("2026-05-12", 1, 1, 0)];
let data = compute_view_data_with_usage(
&[proj], &daily_activity, &[],
&model_usage,
d(2026, 5, 1), d(2026, 5, 31),
);
assert_eq!(data.model_tokens.get("opus").unwrap(), &15000);
let _ = std::fs::remove_file(&session_file);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn test_compute_view_data_tool_calls_aggregation() {
use std::io::Write;
let dir = std::env::temp_dir().join("claude-dash-test-tool-agg");
let _ = std::fs::create_dir(&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}},"content":[{{"type":"tool_use","name":"Bash","input":{{}}}},{{"type":"tool_use","name":"Bash","input":{{}}}},{{"type":"tool_use","name":"Read","input":{{}}}}]}}}}"#).unwrap();
writeln!(f, r#"{{"type":"assistant","timestamp":"2026-05-12T11:00:00Z","message":{{"model":"opus","usage":{{"input_tokens":200,"output_tokens":100}},"content":[{{"type":"tool_use","name":"Edit","input":{{}}}}]}}}}"#).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 daily_activity = vec![make_daily_activity("2026-05-12", 2, 1, 0)];
let data = compute_view_data(&[proj], &daily_activity, &[], d(2026, 5, 1), d(2026, 5, 31));
assert_eq!(data.tool_calls.get("Bash").unwrap(), &2);
assert_eq!(data.tool_calls.get("Read").unwrap(), &1);
assert_eq!(data.tool_calls.get("Edit").unwrap(), &1);
assert_eq!(data.total_tool_calls, 4);
let _ = std::fs::remove_file(&session_file);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn test_compute_view_data_supplements_daily_activity() {
use std::io::Write;
let dir = std::env::temp_dir().join("claude-dash-test-supplement");
let _ = std::fs::create_dir(&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}}}}}}"#).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 daily_activity = vec![make_daily_activity("2026-05-14", 5, 1, 2)];
let data = compute_view_data(&[proj], &daily_activity, &[], d(2026, 5, 1), d(2026, 5, 31));
assert!(data.daily_activity.len() >= 2);
let dates: Vec<&str> = data.daily_activity.iter().map(|d| d.date.as_str()).collect();
assert!(dates.contains(&"2026-05-14"));
assert!(dates.contains(&"2026-05-15"));
let _ = std::fs::remove_file(&session_file);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn test_different_months_produce_different_token_totals() {
let daily_activity = vec![
make_daily_activity("2026-01-15", 10, 2, 5),
make_daily_activity("2026-02-15", 20, 3, 8),
];
let daily_model_tokens = vec![
make_daily_model_tokens("2026-01-15", vec![("opus", 1000)]),
make_daily_model_tokens("2026-02-15", vec![("opus", 5000)]),
];
let jan = compute_view_data(&[], &daily_activity, &daily_model_tokens, d(2026, 1, 1), d(2026, 1, 31));
let feb = compute_view_data(&[], &daily_activity, &daily_model_tokens, d(2026, 2, 1), d(2026, 2, 28));
assert_eq!(jan.model_tokens.get("opus").copied().unwrap_or(0), 1000);
assert_eq!(feb.model_tokens.get("opus").copied().unwrap_or(0), 5000);
assert_ne!(jan.total_tokens, feb.total_tokens,
"Different months must produce different total tokens");
}
#[test]
fn test_different_months_with_model_usage_isolation() {
use std::collections::HashMap;
let daily_activity = vec![
make_daily_activity("2026-03-10", 5, 1, 2),
make_daily_activity("2026-04-10", 15, 2, 6),
];
let daily_model_tokens = vec![
make_daily_model_tokens("2026-03-10", vec![("sonnet", 2000)]),
make_daily_model_tokens("2026-04-10", vec![("sonnet", 8000)]),
];
let mut global_usage = HashMap::new();
global_usage.insert("sonnet".to_string(), ModelUsage {
input_tokens: 50000,
output_tokens: 20000,
cache_read_input_tokens: 10000,
cache_creation_input_tokens: 5000,
web_search_requests: 0,
cost_usd: 0.0,
context_window: 0,
max_output_tokens: 0,
});
let mar_buggy = compute_view_data_with_usage(
&[], &daily_activity, &daily_model_tokens, &global_usage,
d(2026, 3, 1), d(2026, 3, 31),
);
let apr_buggy = compute_view_data_with_usage(
&[], &daily_activity, &daily_model_tokens, &global_usage,
d(2026, 4, 1), d(2026, 4, 30),
);
assert_eq!(
mar_buggy.model_tokens.get("sonnet").copied().unwrap_or(0),
apr_buggy.model_tokens.get("sonnet").copied().unwrap_or(0),
"BUG reproduction: global model_usage makes months identical"
);
let empty_usage = HashMap::new();
let mar_fixed = compute_view_data_with_usage(
&[], &daily_activity, &daily_model_tokens, &empty_usage,
d(2026, 3, 1), d(2026, 3, 31),
);
let apr_fixed = compute_view_data_with_usage(
&[], &daily_activity, &daily_model_tokens, &empty_usage,
d(2026, 4, 1), d(2026, 4, 30),
);
assert_eq!(mar_fixed.model_tokens.get("sonnet").copied().unwrap_or(0), 2000);
assert_eq!(apr_fixed.model_tokens.get("sonnet").copied().unwrap_or(0), 8000);
assert_ne!(mar_fixed.total_tokens, apr_fixed.total_tokens,
"FIX: months must have different totals when model_usage is not passed");
}
#[test]
fn test_different_years_produce_different_token_totals() {
let daily_activity = vec![
make_daily_activity("2025-06-15", 10, 2, 5),
make_daily_activity("2026-06-15", 30, 4, 10),
];
let daily_model_tokens = vec![
make_daily_model_tokens("2025-06-15", vec![("opus", 3000)]),
make_daily_model_tokens("2026-06-15", vec![("opus", 9000)]),
];
let empty_usage = std::collections::HashMap::new();
let y2025 = compute_view_data_with_usage(
&[], &daily_activity, &daily_model_tokens, &empty_usage,
d(2025, 1, 1), d(2025, 12, 31),
);
let y2026 = compute_view_data_with_usage(
&[], &daily_activity, &daily_model_tokens, &empty_usage,
d(2026, 1, 1), d(2026, 12, 31),
);
assert_eq!(y2025.model_tokens.get("opus").copied().unwrap_or(0), 3000);
assert_eq!(y2026.model_tokens.get("opus").copied().unwrap_or(0), 9000);
assert_ne!(y2025.total_tokens, y2026.total_tokens);
}
#[test]
fn test_month_with_no_data_returns_zero_tokens() {
let daily_activity = vec![
make_daily_activity("2026-01-15", 10, 2, 5),
];
let daily_model_tokens = vec![
make_daily_model_tokens("2026-01-15", vec![("opus", 1000)]),
];
let empty_usage = std::collections::HashMap::new();
let feb = compute_view_data_with_usage(
&[], &daily_activity, &daily_model_tokens, &empty_usage,
d(2026, 2, 1), d(2026, 2, 28),
);
assert_eq!(feb.total_tokens, 0);
assert!(feb.model_tokens.is_empty());
}
#[test]
fn test_daily_activity_filtered_by_month() {
let daily_activity = vec![
make_daily_activity("2026-03-01", 5, 1, 2),
make_daily_activity("2026-03-15", 10, 2, 4),
make_daily_activity("2026-04-01", 20, 3, 8),
];
let daily_model_tokens = vec![];
let mar = compute_view_data(&[], &daily_activity, &daily_model_tokens, d(2026, 3, 1), d(2026, 3, 31));
assert_eq!(mar.daily_activity.len(), 2);
let total_msgs: u64 = mar.daily_activity.iter().map(|d| d.message_count).sum();
assert_eq!(total_msgs, 15);
let apr = compute_view_data(&[], &daily_activity, &daily_model_tokens, d(2026, 4, 1), d(2026, 4, 30));
assert_eq!(apr.daily_activity.len(), 1);
let total_msgs: u64 = apr.daily_activity.iter().map(|d| d.message_count).sum();
assert_eq!(total_msgs, 20);
}
}