diff --git a/src/analytics/cc_economics.rs b/src/analytics/cc_economics.rs index d66e018f97..9ef6b188ca 100644 --- a/src/analytics/cc_economics.rs +++ b/src/analytics/cc_economics.rs @@ -231,7 +231,7 @@ fn merge_daily(cc: Option>, rtk: Vec) -> Vec>, rtk: Vec) -> Vec { let mut map: HashMap = HashMap::new(); - // Insert ccusage data (key = ISO Monday "2026-01-20") + // ccusage::fetch and Tracker::get_by_week both return Monday week starts. if let Some(cc_data) = cc { for entry in cc_data { let super::ccusage::CcusagePeriod { key, metrics } = entry; @@ -241,12 +241,10 @@ fn merge_weekly(cc: Option>, rtk: Vec) -> Vec m, - None => { + let monday_key = match NaiveDate::parse_from_str(&entry.week_start, "%Y-%m-%d") { + Ok(date) => date.format("%Y-%m-%d").to_string(), + Err(_) => { eprintln!("[warn] Invalid week_start format: {}", entry.week_start); continue; } @@ -297,18 +295,6 @@ fn merge_monthly(cc: Option>, rtk: Vec) -> Vec "2026-01-20" (Mon) -fn convert_saturday_to_monday(saturday: &str) -> Option { - let sat_date = NaiveDate::parse_from_str(saturday, "%Y-%m-%d").ok()?; - - // rtk uses Saturday as week start, ISO uses Monday - // Saturday + 2 days = Monday - let monday = sat_date + chrono::TimeDelta::try_days(2)?; - - Some(monday.format("%Y-%m-%d").to_string()) -} - fn compute_totals(periods: &[PeriodEconomics]) -> Totals { let mut totals = Totals { cc_cost: 0.0, @@ -830,16 +816,168 @@ fn print_csv_row(p: &PeriodEconomics) { mod tests { use super::*; + fn weekly_stats(start: &str, end: &str) -> WeekStats { + WeekStats { + week_start: start.to_string(), + week_end: end.to_string(), + commands: 3, + input_tokens: 1000, + output_tokens: 400, + saved_tokens: 600, + savings_pct: 60.0, + total_time_ms: 30, + avg_time_ms: 10, + timed_commands: 3, + } + } + + fn weekly_usage(start: &str) -> CcusagePeriod { + CcusagePeriod { + key: start.to_string(), + metrics: ccusage::CcusageMetrics { + input_tokens: 1000, + output_tokens: 500, + cache_creation_tokens: 200, + cache_read_tokens: 5000, + total_tokens: 6700, + total_cost: 42.5, + }, + } + } + + #[test] + fn test_merge_weekly_same_monday_joins_all_metrics() { + let merged = merge_weekly( + Some(vec![weekly_usage("2026-09-28")]), + vec![weekly_stats("2026-09-28", "2026-10-04")], + ); + assert_eq!(merged.len(), 1, "same Monday must join into one row"); + let p = &merged[0]; + assert_eq!(p.label, "2026-09-28"); + assert_eq!(p.cc_cost, Some(42.5)); + assert_eq!(p.cc_input_tokens, Some(1000)); + assert_eq!(p.cc_output_tokens, Some(500)); + assert_eq!(p.cc_cache_create_tokens, Some(200)); + assert_eq!(p.cc_cache_read_tokens, Some(5000)); + assert_eq!(p.cc_total_tokens, Some(6700)); + assert_eq!(p.cc_active_tokens, Some(1500)); + assert_eq!(p.rtk_commands, Some(3)); + assert_eq!(p.rtk_saved_tokens, Some(600)); + assert_eq!(p.rtk_savings_pct, Some(60.0)); + assert_eq!(p.weighted_input_cpt, Some(0.01)); + assert_eq!(p.savings_weighted, Some(6.0)); + assert_eq!(p.blended_cpt, Some(42.5 / 6700.0)); + assert_eq!(p.active_cpt, Some(42.5 / 1500.0)); + assert_eq!(p.savings_blended, Some(600.0 * (42.5 / 6700.0))); + assert_eq!(p.savings_active, Some(600.0 * (42.5 / 1500.0))); + } + #[test] - fn test_convert_saturday_to_monday() { - // Saturday Jan 18 -> Monday Jan 20 + fn test_merge_weekly_calendar_boundaries() { + for (start, end) in [ + ("2020-12-28", "2021-01-03"), + ("2024-02-26", "2024-03-03"), + ("2025-12-29", "2026-01-04"), + ("2026-09-28", "2026-10-04"), + ] { + let merged = merge_weekly( + Some(vec![weekly_usage(start)]), + vec![weekly_stats(start, end)], + ); + assert_eq!(merged.len(), 1, "{start}"); + assert_eq!(merged[0].label, start); + assert_eq!(merged[0].savings_weighted, Some(6.0)); + } + } + + #[test] + fn test_merge_weekly_preserves_missing_sides_and_empty_reports() { + let only_cc = merge_weekly(Some(vec![weekly_usage("2026-09-28")]), vec![]); + assert_eq!(only_cc.len(), 1); + assert_eq!(only_cc[0].label, "2026-09-28"); + assert_eq!(only_cc[0].cc_cost, Some(42.5)); + assert_eq!(only_cc[0].rtk_commands, None); + assert_eq!(only_cc[0].weighted_input_cpt, None); + assert_eq!(only_cc[0].savings_weighted, None); + for cc in [None, Some(vec![])] { + let only_rtk = merge_weekly(cc, vec![weekly_stats("2026-09-28", "2026-10-04")]); + assert_eq!(only_rtk.len(), 1); + assert_eq!(only_rtk[0].label, "2026-09-28"); + assert_eq!(only_rtk[0].rtk_commands, Some(3)); + assert_eq!(only_rtk[0].rtk_saved_tokens, Some(600)); + assert_eq!(only_rtk[0].rtk_savings_pct, Some(60.0)); + assert_eq!(only_rtk[0].cc_cost, None); + assert_eq!(only_rtk[0].weighted_input_cpt, None); + assert_eq!(only_rtk[0].savings_weighted, None); + assert_eq!(only_rtk[0].blended_cpt, None); + assert_eq!(only_rtk[0].active_cpt, None); + } + assert!(merge_weekly(None, vec![]).is_empty()); + assert!(merge_weekly(Some(vec![]), vec![]).is_empty()); + } + + #[test] + fn test_merge_weekly_sorts_union_and_preserves_totals() { + let merged = merge_weekly( + Some(vec![weekly_usage("2026-10-05"), weekly_usage("2026-09-28")]), + vec![ + weekly_stats("2026-09-28", "2026-10-04"), + weekly_stats("2026-09-21", "2026-09-27"), + ], + ); assert_eq!( - convert_saturday_to_monday("2026-01-18"), - Some("2026-01-20".to_string()) + merged.iter().map(|p| p.label.as_str()).collect::>(), + ["2026-09-21", "2026-09-28", "2026-10-05"] + ); + assert_eq!(merged[0].cc_cost, None); + assert_eq!(merged[0].rtk_saved_tokens, Some(600)); + assert_eq!(merged[1].savings_weighted, Some(6.0)); + assert_eq!(merged[2].cc_cost, Some(42.5)); + assert_eq!(merged[2].rtk_saved_tokens, None); + let totals = compute_totals(&merged); + assert_eq!(totals.cc_cost, 85.0); + assert_eq!(totals.cc_total_tokens, 13400); + assert_eq!(totals.rtk_commands, 6); + assert_eq!(totals.rtk_saved_tokens, 1200); + } + + #[test] + fn test_merge_weekly_zero_tokens_preserves_absent_cpt() { + let cc = CcusagePeriod { + key: "2026-09-28".to_string(), + metrics: ccusage::CcusageMetrics { + input_tokens: 0, + output_tokens: 0, + cache_creation_tokens: 0, + cache_read_tokens: 0, + total_tokens: 0, + total_cost: 0.0, + }, + }; + let merged = merge_weekly( + Some(vec![cc]), + vec![weekly_stats("2026-09-28", "2026-10-04")], ); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].cc_cost, Some(0.0)); + assert_eq!(merged[0].rtk_saved_tokens, Some(600)); + assert_eq!(merged[0].weighted_input_cpt, None); + assert_eq!(merged[0].savings_weighted, None); + assert_eq!(merged[0].blended_cpt, None); + assert_eq!(merged[0].active_cpt, None); + } - // Invalid format - assert_eq!(convert_saturday_to_monday("invalid"), None); + #[test] + fn test_merge_weekly_canonicalizes_dates_and_skips_invalid_rtk() { + let mut rtk = vec![weekly_stats("2026-9-28", "2026-10-04")]; + for invalid in ["invalid", "", "2026-02-29", "2026-13-01"] { + rtk.push(weekly_stats(invalid, "2026-10-04")); + } + let merged = merge_weekly(Some(vec![weekly_usage("2026-09-28")]), rtk); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].label, "2026-09-28"); + assert_eq!(merged[0].rtk_commands, Some(3)); + assert_eq!(merged[0].savings_weighted, Some(6.0)); } #[test] diff --git a/src/analytics/ccusage.rs b/src/analytics/ccusage.rs index 01aa1de67b..67be9151a9 100644 --- a/src/analytics/ccusage.rs +++ b/src/analytics/ccusage.rs @@ -7,13 +7,15 @@ use crate::core::stream::exec_capture; use crate::core::utils::{resolved_command, tool_exists}; use anyhow::{Context, Result}; +use chrono::{Datelike, NaiveDate, Weekday}; use serde::Deserialize; +use std::collections::BTreeMap; use std::process::Command; // ── Public Types ── /// Metrics from ccusage for a single period (day/week/month) -#[derive(Debug, Deserialize)] +#[derive(Debug, Default, Deserialize)] pub struct CcusageMetrics { #[serde(rename = "inputTokens")] pub input_tokens: u64, @@ -32,7 +34,7 @@ pub struct CcusageMetrics { /// Period data with key (date/month/week) and metrics #[derive(Debug)] pub struct CcusagePeriod { - pub key: String, // "2026-01-30" (daily), "2026-01" (monthly), "2026-01-20" (weekly ISO monday) + pub key: String, // "2026-01-30" (daily), "2026-01" (monthly), "2026-09-28" (weekly Monday) pub metrics: CcusageMetrics, } @@ -69,7 +71,7 @@ struct WeeklyResponse { struct WeeklyEntry { // Older ccusage emits "week"; current ccusage emits "period". Accept both. #[serde(alias = "period")] - week: String, // ISO week start (Monday) + week: String, // Calendar week start; legacy ccusage defaults to Sunday. #[serde(flatten)] metrics: CcusageMetrics, } @@ -146,30 +148,113 @@ pub fn fetch(granularity: Granularity) -> Result>> { .arg("--since") .arg("20250101"); // 90 days back approx - let result = match exec_capture(&mut cmd) { - Err(e) => { - eprintln!("[warn] ccusage execution failed: {}", e); + let mut requested_monday = false; + loop { + let result = match exec_capture(&mut cmd) { + Err(e) => { + eprintln!("[warn] ccusage execution failed: {}", e); + return Ok(None); + } + Ok(r) => r, + }; + + if !result.success() { + eprintln!( + "[warn] ccusage exited with {}: {}", + result.exit_code, + result.stderr.trim() + ); return Ok(None); } - Ok(r) => r, - }; - if !result.success() { - eprintln!( - "[warn] ccusage exited with {}: {}", - result.exit_code, - result.stderr.trim() - ); - return Ok(None); + let mut periods = parse_json(&result.stdout, granularity) + .context("Failed to parse ccusage JSON output")?; + + if matches!(granularity, Granularity::Weekly) { + if !canonicalize_weekly_keys(&mut periods)? { + if requested_monday { + // ccusage 18 can ignore CLI week-start flags when a config + // file is present. Do not join incompatible buckets or let + // that take down the daily/monthly sections of --all. + eprintln!( + "[warn] ccusage returned non-Monday weeks after requesting --start-of-week monday; showing RTK-only weekly data" + ); + return Ok(None); + } + // Legacy ccusage has configurable (default Sunday) weeks. + // Current root ccusage uses Monday and rejects this flag. + // Preserve weekly-specific config and re-fetch every bucket: + // shifting labels cannot reallocate Sunday's spending. + cmd.args(["--start-of-week", "monday"]); + requested_monday = true; + continue; + } + periods = aggregate_weekly_periods(periods)?; + } + + return Ok(Some(periods)); } +} - let periods = - parse_json(&result.stdout, granularity).context("Failed to parse ccusage JSON output")?; +// ── Internal Helpers ── - Ok(Some(periods)) +/// Validate every bucket's start date and canonicalize its spelling without +/// moving it. False means the whole report must be requested again for Monday. +fn canonicalize_weekly_keys(periods: &mut [CcusagePeriod]) -> Result { + let mut all_mondays = true; + for period in periods { + let date = NaiveDate::parse_from_str(&period.key, "%Y-%m-%d") + .with_context(|| format!("Invalid ccusage week start: {}", period.key))?; + all_mondays &= date.weekday() == Weekday::Mon; + period.key = date.format("%Y-%m-%d").to_string(); + } + Ok(all_mondays) } -// ── Internal Helpers ── +/// Combine the final Monday report's disjoint project rows. Legacy ccusage can +/// emit multiple rows per week while omitting project identity from JSON. +/// Sum totalTokens independently; some sources count cache tokens differently. +fn aggregate_weekly_periods(periods: Vec) -> Result> { + let mut weeks: BTreeMap = BTreeMap::new(); + for period in periods { + let week = weeks.entry(period.key).or_default(); + let metrics = period.metrics; + week.input_tokens = week + .input_tokens + .checked_add(metrics.input_tokens) + .context("ccusage weekly input tokens overflow")?; + week.output_tokens = week + .output_tokens + .checked_add(metrics.output_tokens) + .context("ccusage weekly output tokens overflow")?; + week.cache_creation_tokens = week + .cache_creation_tokens + .checked_add(metrics.cache_creation_tokens) + .context("ccusage weekly cache creation tokens overflow")?; + week.cache_read_tokens = week + .cache_read_tokens + .checked_add(metrics.cache_read_tokens) + .context("ccusage weekly cache read tokens overflow")?; + week.total_tokens = week + .total_tokens + .checked_add(metrics.total_tokens) + .context("ccusage weekly total tokens overflow")?; + week.total_cost += metrics.total_cost; + anyhow::ensure!( + week.total_cost.is_finite(), + "ccusage weekly total cost overflow" + ); + } + for week in weeks.values() { + week.input_tokens + .checked_add(week.output_tokens) + .context("ccusage weekly active tokens overflow")?; + } + Ok(weeks + .into_iter() + .map(|(key, metrics)| CcusagePeriod { key, metrics }) + .collect()) +} fn parse_json(json: &str, granularity: Granularity) -> Result> { match granularity { @@ -216,6 +301,220 @@ fn parse_json(json: &str, granularity: Granularity) -> Result mod tests { use super::*; + fn weekly_payload(field: &str, dates: &[&str]) -> String { + let weekly: Vec<_> = dates + .iter() + .map(|date| { + serde_json::json!({ + field: date, + "inputTokens": 100, + "outputTokens": 20, + "cacheCreationTokens": 0, + "cacheReadTokens": 10, + "totalTokens": 120, + "totalCost": 0.125 + }) + }) + .collect(); + serde_json::json!({"weekly": weekly}).to_string() + } + + #[test] + fn test_weekly_keys_validate_every_weekday_without_relabeling() { + for field in ["week", "period"] { + for (date, is_monday) in [ + ("2026-09-28", true), + ("2026-09-29", false), + ("2026-09-30", false), + ("2026-10-01", false), + ("2026-10-02", false), + ("2026-10-03", false), + ("2026-10-04", false), + ("2020-12-28", true), + ("2021-01-03", false), + ("2024-02-26", true), + ("2024-02-29", false), + ("2025-12-29", true), + ("2026-01-04", false), + ] { + let mut periods = parse_json(&weekly_payload(field, &[date]), Granularity::Weekly) + .expect("valid weekly payload"); + assert_eq!( + canonicalize_weekly_keys(&mut periods).expect("valid date"), + is_monday, + "{field}: {date}" + ); + assert_eq!(periods[0].key, date, "never move pre-aggregated spending"); + assert_eq!(periods[0].metrics.total_tokens, 120); + assert_eq!(periods[0].metrics.total_cost, 0.125); + } + } + } + + #[test] + fn test_weekly_keys_canonical_mixed_and_empty_cases() { + let mut periods = parse_json( + &weekly_payload("period", &["2026-9-28", "2025-12-29"]), + Granularity::Weekly, + ) + .expect("valid weekly payload"); + assert!(canonicalize_weekly_keys(&mut periods).expect("Monday dates")); + assert_eq!(periods[0].key, "2026-09-28"); + assert_eq!(periods[1].key, "2025-12-29"); + for dates in [["2026-09-28", "2026-09-27"], ["2026-09-27", "2026-09-28"]] { + let mut mixed = parse_json(&weekly_payload("week", &dates), Granularity::Weekly) + .expect("valid weekly payload"); + assert!(!canonicalize_weekly_keys(&mut mixed).expect("valid dates")); + assert_eq!(mixed[0].key, dates[0]); + assert_eq!(mixed[1].key, dates[1]); + } + let mut empty = parse_json(r#"{"weekly": []}"#, Granularity::Weekly).expect("empty weekly"); + assert!(canonicalize_weekly_keys(&mut empty).expect("empty keys")); + assert!( + aggregate_weekly_periods(empty) + .expect("empty metrics") + .is_empty() + ); + } + + #[test] + fn test_weekly_keys_validate_invalid_dates_even_after_sunday() { + for invalid in ["invalid", "", "2026-02-29", "2026-13-01", "2026-W40"] { + let mut periods = parse_json( + &weekly_payload("week", &["2026-09-27", invalid]), + Granularity::Weekly, + ) + .expect("syntactically valid JSON"); + let error = + canonicalize_weekly_keys(&mut periods).expect_err("invalid date must error"); + assert!( + error.to_string().contains("Invalid ccusage week start"), + "{invalid}: {error}" + ); + } + } + + #[test] + fn test_weekly_aggregation_sums_duplicate_project_rows_and_sorts() { + for field in ["week", "period"] { + let mut json: serde_json::Value = serde_json::from_str(&weekly_payload( + field, + &["2026-10-05", "2026-9-28", "2026-09-28"], + )) + .expect("payload"); + json["weekly"][0]["totalCost"] = serde_json::json!(3.0); + json["weekly"][1]["totalCost"] = serde_json::json!(11.0); + json["weekly"][2]["totalCost"] = serde_json::json!(29.0); + let mut periods = + parse_json(&json.to_string(), Granularity::Weekly).expect("weekly rows"); + assert!(canonicalize_weekly_keys(&mut periods).expect("Monday dates")); + let result = aggregate_weekly_periods(periods).expect("safe weekly sums"); + assert_eq!(result.len(), 2); + assert_eq!(result[0].key, "2026-09-28"); + assert_eq!(result[0].metrics.input_tokens, 200); + assert_eq!(result[0].metrics.output_tokens, 40); + assert_eq!(result[0].metrics.cache_creation_tokens, 0); + assert_eq!(result[0].metrics.cache_read_tokens, 20); + assert_eq!( + result[0].metrics.total_tokens, 240, + "reported total is independent of categories" + ); + assert_eq!(result[0].metrics.total_cost, 40.0); + assert_eq!(result[1].key, "2026-10-05"); + assert_eq!(result[1].metrics.total_tokens, 120); + assert_eq!(result[1].metrics.total_cost, 3.0); + } + } + + #[test] + fn test_weekly_aggregation_rejects_overflow_without_partial_results() { + for (field, label) in [ + ("inputTokens", "input tokens"), + ("outputTokens", "output tokens"), + ("cacheCreationTokens", "cache creation tokens"), + ("cacheReadTokens", "cache read tokens"), + ("totalTokens", "total tokens"), + ] { + let mut json: serde_json::Value = + serde_json::from_str(&weekly_payload("week", &["2026-09-28", "2026-09-28"])) + .expect("payload"); + json["weekly"][0][field] = serde_json::json!(u64::MAX); + json["weekly"][1][field] = serde_json::json!(1); + let periods = + parse_json(&json.to_string(), Granularity::Weekly).expect("valid u64 values"); + let error = aggregate_weekly_periods(periods).expect_err("sum must not overflow"); + assert!( + error.to_string().contains(&format!("{label} overflow")), + "{field}: {error}" + ); + } + let mut json: serde_json::Value = + serde_json::from_str(&weekly_payload("week", &["2026-09-28", "2026-09-28"])) + .expect("payload"); + json["weekly"][0]["totalCost"] = serde_json::json!(f64::MAX); + json["weekly"][1]["totalCost"] = serde_json::json!(f64::MAX); + let periods = + parse_json(&json.to_string(), Granularity::Weekly).expect("finite source costs"); + assert!( + aggregate_weekly_periods(periods) + .expect_err("finite sum required") + .to_string() + .contains("total cost overflow") + ); + + let mut json: serde_json::Value = + serde_json::from_str(&weekly_payload("week", &["2026-09-28"])).expect("payload"); + json["weekly"][0]["inputTokens"] = serde_json::json!(u64::MAX); + json["weekly"][0]["outputTokens"] = serde_json::json!(1); + let periods = + parse_json(&json.to_string(), Granularity::Weekly).expect("valid source counters"); + assert!( + aggregate_weekly_periods(periods) + .expect_err("active sum must fit") + .to_string() + .contains("active tokens overflow") + ); + } + + #[test] + fn test_weekly_upstream_focused_snapshot_requires_regrouping() { + let json = include_str!("../../tests/fixtures/ccusage_weekly_focused_v20_snapshot.json"); + let mut periods = + parse_json(json, Granularity::Weekly).expect("upstream renderer snapshot"); + assert!(!canonicalize_weekly_keys(&mut periods).expect("valid Sunday dates")); + assert_eq!(periods.len(), 3); + for (p, (date, input, output, creation, read, total, cost)) in periods.iter().zip([ + ("2098-12-28", 60, 10, 15, 25, 110, 0.0001555), + ("2099-01-11", 130, 20, 30, 40, 220, 0.0003224), + ("2099-02-01", 40, 5, 0, 10, 55, 0.0000806), + ]) { + assert_eq!(p.key, date); + assert_eq!(p.metrics.input_tokens, input); + assert_eq!(p.metrics.output_tokens, output); + assert_eq!(p.metrics.cache_creation_tokens, creation); + assert_eq!(p.metrics.cache_read_tokens, read); + assert_eq!(p.metrics.total_tokens, total); + assert!((p.metrics.total_cost - cost).abs() < 1e-12); + } + } + + #[test] + fn test_weekly_root_fixture_preserves_independent_total_tokens() { + let json = include_str!("../../tests/fixtures/ccusage_weekly_root_v20_synthetic.json"); + let mut periods = + parse_json(json, Granularity::Weekly).expect("source-derived synthetic fixture"); + assert!(canonicalize_weekly_keys(&mut periods).expect("valid Monday")); + let periods = aggregate_weekly_periods(periods).expect("valid metrics"); + assert_eq!(periods.len(), 1); + assert_eq!(periods[0].key, "2025-12-29"); + assert_eq!(periods[0].metrics.input_tokens, 100); + assert_eq!(periods[0].metrics.output_tokens, 20); + assert_eq!(periods[0].metrics.cache_creation_tokens, 0); + assert_eq!(periods[0].metrics.cache_read_tokens, 10); + assert_eq!(periods[0].metrics.total_tokens, 120); + assert_eq!(periods[0].metrics.total_cost, 0.01); + } + #[test] fn test_parse_monthly_valid() { let json = r#"{ diff --git a/src/core/tracking.rs b/src/core/tracking.rs index a861089169..f69eb71ea9 100644 --- a/src/core/tracking.rs +++ b/src/core/tracking.rs @@ -295,12 +295,12 @@ pub struct DayStats { /// Weekly statistics for token savings and execution metrics. /// /// Serializable to JSON for export via `rtk gain --weekly --format json`. -/// Weeks start on Sunday (SQLite default). +/// Weeks run from Monday through Sunday. #[derive(Debug, Serialize)] pub struct WeekStats { - /// Week start date (YYYY-MM-DD) + /// Monday's date (YYYY-MM-DD) pub week_start: String, - /// Week end date (YYYY-MM-DD) + /// Sunday's date (YYYY-MM-DD) pub week_end: String, /// Number of commands executed this week pub commands: usize, @@ -1395,7 +1395,7 @@ impl Tracker { /// Get weekly statistics grouped by week. /// /// Returns one [`WeekStats`] per week with aggregated metrics. - /// Weeks start on Sunday (SQLite default). Results ordered chronologically. + /// Weeks run from Monday through Sunday. Results ordered chronologically. /// /// # Examples /// @@ -2656,6 +2656,285 @@ mod tests { use super::*; use crate::core::test_isolation; + // Fixed timestamps bypass record(), which uses the wall clock and prunes old + // rows. The in-memory tracker still creates the real production schema. + fn insert_weekly_command( + tracker: &Tracker, + timestamp: &str, + project_path: &str, + rtk_cmd: &str, + input: i64, + output: i64, + exec_time_ms: i64, + ) { + let saved = input - output; + let savings_pct = if input > 0 { + saved as f64 / input as f64 * 100.0 + } else { + 0.0 + }; + tracker + .conn + .execute( + "INSERT INTO commands (timestamp, original_cmd, rtk_cmd, project_path, + input_tokens, output_tokens, saved_tokens, savings_pct, exec_time_ms) + VALUES (?1, 'fixture command', ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + timestamp, + rtk_cmd, + project_path, + input, + output, + saved, + savings_pct, + exec_time_ms + ], + ) + .expect("Failed to insert weekly command fixture"); + } + + #[test] + fn test_get_by_week_groups_all_weekdays_monday_through_sunday() { + for dates in [ + [ + "2026-09-28", + "2026-09-29", + "2026-09-30", + "2026-10-01", + "2026-10-02", + "2026-10-03", + "2026-10-04", + ], + // ISO week 2020-W53 crosses the calendar-year boundary. + [ + "2020-12-28", + "2020-12-29", + "2020-12-30", + "2020-12-31", + "2021-01-01", + "2021-01-02", + "2021-01-03", + ], + // The leap day belongs to the same Monday-to-Sunday week. + [ + "2024-02-26", + "2024-02-27", + "2024-02-28", + "2024-02-29", + "2024-03-01", + "2024-03-02", + "2024-03-03", + ], + ] { + let tracker = Tracker::new_in_memory().expect("Failed to create tracker"); + for date in dates { + insert_weekly_command( + &tracker, + &format!("{date}T12:00:00Z"), + "", + "rtk git status", + 100, + 25, + 10, + ); + } + + let weeks = tracker.get_by_week().expect("Failed to get weeks"); + assert_eq!( + weeks.len(), + 1, + "All seven dates must share a week: {dates:?}" + ); + let week = &weeks[0]; + assert_eq!(week.week_start, dates[0]); + assert_eq!(week.week_end, dates[6]); + assert_eq!(week.commands, 7); + assert_eq!(week.input_tokens, 700); + assert_eq!(week.output_tokens, 175); + assert_eq!(week.saved_tokens, 525); + } + } + + #[test] + fn test_get_by_week_sunday_monday_boundaries_are_chronological() { + let tracker = Tracker::new_in_memory().expect("Failed to create tracker"); + // Deliberately insert out of order, one second either side of both + // boundaries of the week beginning 2026-09-28. + for (timestamp, input) in [ + ("2026-10-05T00:00:00Z", 40), + ("2026-09-28T00:00:00Z", 20), + ("2026-10-04T23:59:59Z", 30), + ("2026-09-27T23:59:59Z", 10), + ] { + insert_weekly_command(&tracker, timestamp, "", "rtk git status", input, 1, input); + } + + let weeks = tracker.get_by_week().expect("Failed to get weeks"); + let actual: Vec<_> = weeks + .iter() + .map(|week| { + ( + week.week_start.as_str(), + week.week_end.as_str(), + week.commands, + week.input_tokens, + week.total_time_ms, + ) + }) + .collect(); + assert_eq!( + actual, + vec![ + ("2026-09-21", "2026-09-27", 1, 10, 10), + ("2026-09-28", "2026-10-04", 2, 50, 50), + ("2026-10-05", "2026-10-11", 1, 40, 40), + ] + ); + } + + #[test] + fn test_get_by_week_aggregates_weighted_savings_and_execution_time() { + let tracker = Tracker::new_in_memory().expect("Failed to create tracker"); + for (timestamp, command, input, output, time) in [ + ("2026-09-28T09:00:00Z", "rtk git status", 900, 90, 101), + ("2026-10-02T12:00:00Z", "rtk ls", 100, 90, 202), + ("2026-10-04T23:59:59Z", "rtk proxy true", 0, 0, 8), + ] { + insert_weekly_command(&tracker, timestamp, "", command, input, output, time); + } + + let weeks = tracker.get_by_week().expect("Failed to get weeks"); + assert_eq!(weeks.len(), 1); + let week = &weeks[0]; + assert_eq!(week.commands, 3); + assert_eq!(week.input_tokens, 1000); + assert_eq!(week.output_tokens, 180); + assert_eq!(week.saved_tokens, 820); + // A mean of the individual rates (90%, 10%, 0%) would be incorrect. + assert!((week.savings_pct - 82.0).abs() < 1e-9); + assert_eq!(week.total_time_ms, 303); + assert_eq!(week.avg_time_ms, 151); + assert_eq!(week.timed_commands, 2); + } + + #[test] + fn test_get_by_week_zero_input_has_zero_savings_rate() { + let tracker = Tracker::new_in_memory().expect("Failed to create tracker"); + for (timestamp, time) in [("2026-09-28T00:00:00Z", 7), ("2026-10-04T23:59:59Z", 12)] { + insert_weekly_command(&tracker, timestamp, "", "rtk proxy true", 0, 0, time); + } + + let weeks = tracker.get_by_week().expect("Failed to get weeks"); + assert_eq!(weeks.len(), 1); + let week = &weeks[0]; + assert_eq!(week.commands, 2); + assert_eq!(week.input_tokens, 0); + assert_eq!(week.output_tokens, 0); + assert_eq!(week.saved_tokens, 0); + assert_eq!(week.savings_pct, 0.0); + assert_eq!(week.total_time_ms, 0); + assert_eq!(week.avg_time_ms, 0); + assert_eq!(week.timed_commands, 0); + } + + #[test] + fn test_get_by_week_project_filter_includes_exact_and_child_not_sibling() { + let tracker = Tracker::new_in_memory().expect("Failed to create tracker"); + let project = PathBuf::from("projects").join("demo"); + let child = project.join("child"); + let sibling = PathBuf::from("projects").join("demo-sibling"); + let unrelated = PathBuf::from("elsewhere"); + for (path, input, output, time) in [ + (&project, 100, 20, 10), + (&child, 200, 50, 20), + (&sibling, 400, 0, 40), + (&unrelated, 800, 0, 80), + ] { + insert_weekly_command( + &tracker, + "2026-09-28T12:00:00Z", + &path.to_string_lossy(), + "rtk git status", + input, + output, + time, + ); + } + + let weeks = tracker + .get_by_week_filtered(Some(&project.to_string_lossy())) + .expect("Failed to get project weeks"); + assert_eq!(weeks.len(), 1); + let week = &weeks[0]; + assert_eq!(week.week_start, "2026-09-28"); + assert_eq!(week.week_end, "2026-10-04"); + assert_eq!(week.commands, 2); + assert_eq!(week.input_tokens, 300); + assert_eq!(week.output_tokens, 70); + assert_eq!(week.saved_tokens, 230); + assert!((week.savings_pct - 230.0 / 300.0 * 100.0).abs() < 1e-9); + assert_eq!(week.total_time_ms, 30); + assert_eq!(week.avg_time_ms, 15); + assert_eq!( + tracker.get_by_week().expect("Failed to get all weeks")[0].commands, + 4 + ); + assert!( + tracker + .get_by_week_filtered(Some("missing-project")) + .expect("Failed to query missing project") + .is_empty() + ); + } + + #[test] + fn monday_week_join_preserves_input_eligibility_boundary() { + let tracker = Tracker::new_in_memory().unwrap(); + insert_weekly_command( + &tracker, + "2026-09-28T12:00:00Z", + "", + "rtk eligible", + 7500, + 1500, + 5, + ); + insert_weekly_command( + &tracker, + "2026-10-04T23:59:59Z", + "", + "rtk oversized", + 7501, + 0, + 20, + ); + let weeks = tracker.get_by_week().unwrap(); + assert_eq!(weeks.len(), 1); + assert_eq!(weeks[0].week_start, "2026-09-28"); + assert_eq!(weeks[0].week_end, "2026-10-04"); + assert_eq!(weeks[0].commands, 1); + assert_eq!(weeks[0].input_tokens, 7500); + assert_eq!(weeks[0].saved_tokens, 6000); + assert_eq!(weeks[0].total_time_ms, 5); + } + + #[test] + fn test_get_by_week_empty_database() { + let tracker = Tracker::new_in_memory().expect("Failed to create tracker"); + assert!( + tracker + .get_by_week() + .expect("Failed to get weeks") + .is_empty() + ); + assert!( + tracker + .get_by_week_filtered(Some("project")) + .expect("Failed to get project weeks") + .is_empty() + ); + } + // 1. estimate_tokens — verify ~4 chars/token ratio #[test] fn test_estimate_tokens() { diff --git a/tests/cc_economics_weekly_test.rs b/tests/cc_economics_weekly_test.rs new file mode 100644 index 0000000000..9040641db6 --- /dev/null +++ b/tests/cc_economics_weekly_test.rs @@ -0,0 +1,671 @@ +//! Exercise the compiled CLI against a real, isolated tracker database and a +//! deterministic ccusage subprocess. The fake-only PATH prevents npx, network +//! access, and reads of the contributor's real Claude usage/configuration. + +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::process::{Command, Output}; + +use rusqlite::{Connection, params}; +use serde_json::{Value, json}; + +mod common; + +const INITIAL_ARGS: &str = "<--json><--since><20250101>\n"; +const RETRY_ARGS: &str = "<--json><--since><20250101><--start-of-week>\n"; +const CSV_HEADER: &str = "period,spent,input_tokens,output_tokens,cache_create,cache_read,active_tokens,total_tokens,saved_tokens,weighted_savings,active_savings,blended_savings,rtk_commands\n"; + +struct Sandbox { + root: tempfile::TempDir, +} + +impl Sandbox { + fn new() -> Self { + let root = tempfile::tempdir().expect("create isolated economics test directory"); + for dir in ["bin", "home", "config", "data", "cwd"] { + fs::create_dir(root.path().join(dir)).expect("create test subdirectory"); + } + let sandbox = Self { root }; + // Let the compiled CLI create the production schema, rather than + // duplicating it in this test or borrowing a real user's database. + let init = sandbox + .command() + .args(["gain", "--format", "json"]) + .output() + .expect("initialize tracker through rtk gain"); + assert_success(&init); + assert!(sandbox.root.path().join("data/history.db").is_file()); + sandbox + } + + fn command(&self) -> Command { + let root = self.root.path(); + let mut cmd = common::rtk_command(); + cmd.current_dir(root.join("cwd")) + .env("HOME", root.join("home")) + .env("XDG_CONFIG_HOME", root.join("config")) + .env("XDG_DATA_HOME", root.join("data")) + .env("RTK_DB_PATH", root.join("data/history.db")) + .env("RTK_TEE_DIR", root.join("data/tee")) + .env("RTK_RECALL_DB", root.join("data/recall.db")) + .env("CCUSAGE_TEST_ROOT", root) + // Do not append the host PATH: even the missing-tool case must + // never launch a real ccusage or npx installation. + .env("PATH", root.join("bin")) + .env("NO_COLOR", "1") + .env("LC_ALL", "C"); + cmd + } + + fn seed(&self, rows: &[(&str, u64)]) { + let conn = Connection::open(self.root.path().join("data/history.db")) + .expect("open isolated production tracker schema"); + for &(date, multiplier) in rows { + conn.execute( + "INSERT INTO commands + (timestamp, original_cmd, rtk_cmd, input_tokens, output_tokens, + saved_tokens, savings_pct, exec_time_ms, project_path) + VALUES (?1, 'synthetic command', 'rtk synthetic', ?2, ?3, ?4, + 80.0, 5, '')", + params![ + format!("{date}T12:00:00Z"), + 1000 * multiplier, + 200 * multiplier, + 800 * multiplier + ], + ) + .expect("seed isolated tracker row"); + } + } + + fn stub(&self, responses: &[(&str, i32)]) { + // All commands in this script are /bin/sh builtins. Logging each argv + // separately also catches extra probes, daily fetches and third retries. + let mut script = String::from( + "#!/bin/sh\n\ + set -eu\n\ + printf '<%s>' \"$@\" >> \"$CCUSAGE_TEST_ROOT/calls\"\n\ + printf '\\n' >> \"$CCUSAGE_TEST_ROOT/calls\"\n\ + count=0\n\ + if [ -f \"$CCUSAGE_TEST_ROOT/count\" ]; then\n\ + read -r count < \"$CCUSAGE_TEST_ROOT/count\"\n\ + fi\n\ + count=$((count + 1))\n\ + printf '%s\\n' \"$count\" > \"$CCUSAGE_TEST_ROOT/count\"\n\ + case \"$count\" in\n", + ); + for (index, &(body, status)) in responses.iter().enumerate() { + let quoted = body.replace('\'', "'\\''"); + let redirect = if status == 0 { "" } else { " >&2" }; + script.push_str(&format!( + "{}) printf '%s\\n' '{}'{}; exit {} ;;\n", + index + 1, + quoted, + redirect, + status + )); + } + script.push_str("*) printf 'unexpected ccusage invocation\\n' >&2; exit 99 ;;\nesac\n"); + let path = self.root.path().join("bin/ccusage"); + fs::write(&path, script).expect("write fake ccusage"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)) + .expect("make fake ccusage executable"); + } + + fn run(&self, format: &str) -> Output { + self.command() + .args(["cc-economics", "--weekly", "--format", format]) + .output() + .expect("run compiled cc-economics CLI") + } + + fn assert_calls(&self, count: usize) { + let path = self.root.path().join("calls"); + let actual = if path.exists() { + fs::read_to_string(path).expect("read fake ccusage argv log") + } else { + String::new() + }; + let expected = match count { + 0 => String::new(), + 1 => INITIAL_ARGS.to_string(), + 2 => format!("{INITIAL_ARGS}{RETRY_ARGS}"), + _ => panic!("weekly fetch must never need more than two calls"), + }; + assert_eq!(actual, expected, "ccusage argv and call count"); + } +} + +fn fixture(key: &str, rows: &[(&str, u64)]) -> String { + let weekly: Vec<_> = rows + .iter() + .map(|&(date, multiplier)| { + json!({ + (key): date, + "inputTokens": 100 * multiplier, + "outputTokens": 20 * multiplier, + "cacheCreationTokens": 40 * multiplier, + "cacheReadTokens": 200 * multiplier, + "totalTokens": 360 * multiplier, + "totalCost": 2.7 * multiplier as f64 + }) + }) + .collect(); + json!({"weekly": weekly}).to_string() +} + +fn assert_success(out: &Output) { + assert!( + out.status.success(), + "CLI failed: status={}\nstdout={}\nstderr={}", + out.status, + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); +} + +fn weekly_rows(out: &Output) -> Vec { + assert_success(out); + let report: Value = serde_json::from_slice(&out.stdout).expect("valid economics JSON"); + assert!(report["daily"].is_null()); + assert!(report["monthly"].is_null()); + assert!(report["totals"].is_null()); + report["weekly"].as_array().expect("weekly rows").clone() +} + +fn assert_metric(row: &Value, key: &str, expected: f64) { + let actual = row[key] + .as_f64() + .unwrap_or_else(|| panic!("missing numeric metric {key}: {row}")); + assert!( + (actual - expected).abs() < 1e-10, + "{key}: expected {expected}, got {actual}; row={row}" + ); +} + +fn assert_joined(row: &Value, label: &str, cc: u64, rtk: u64, commands: u64) { + assert_eq!(row.as_object().expect("period object").len(), 17); + assert_eq!(row["label"], label); + for (key, expected) in [ + ("cc_input_tokens", 100 * cc), + ("cc_output_tokens", 20 * cc), + ("cc_cache_create_tokens", 40 * cc), + ("cc_cache_read_tokens", 200 * cc), + ("cc_active_tokens", 120 * cc), + ("cc_total_tokens", 360 * cc), + ("rtk_commands", commands), + ("rtk_saved_tokens", 800 * rtk), + ] { + assert_eq!(row[key], expected, "{key}: {row}"); + } + for (key, expected) in [ + ("cc_cost", 2.7 * cc as f64), + ("rtk_savings_pct", 80.0), + ("weighted_input_cpt", 0.01), + ("savings_weighted", 8.0 * rtk as f64), + ("blended_cpt", 0.0075), + ("active_cpt", 0.0225), + ("savings_blended", 6.0 * rtk as f64), + ("savings_active", 18.0 * rtk as f64), + ] { + assert_metric(row, key, expected); + } +} + +fn assert_rtk_only(out: &Output) { + assert_eq!( + weekly_rows(out), + vec![json!({ + "label": "2026-09-28", + "cc_cost": null, + "cc_total_tokens": null, + "cc_active_tokens": null, + "cc_input_tokens": null, + "cc_output_tokens": null, + "cc_cache_create_tokens": null, + "cc_cache_read_tokens": null, + "rtk_commands": 1, + "rtk_saved_tokens": 800, + "rtk_savings_pct": 80.0, + "weighted_input_cpt": null, + "savings_weighted": null, + "blended_cpt": null, + "active_cpt": null, + "savings_blended": null, + "savings_active": null + })] + ); +} + +fn assert_fetch_error(out: &Output) { + assert!(!out.status.success(), "malformed weekly data must fail"); + assert!(out.stdout.is_empty(), "must not emit a partial JSON report"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("ccusage"), + "missing fetch context: {stderr}" + ); +} + +fn monday_join(key: &str, date: &str) { + let sandbox = Sandbox::new(); + sandbox.seed(&[("2026-09-28", 1)]); + let monday = fixture(key, &[(date, 1)]); + sandbox.stub(&[(&monday, 0)]); + let rows = weekly_rows(&sandbox.run("json")); + sandbox.assert_calls(1); + assert_eq!(rows.len(), 1, "same Monday must produce one joined row"); + assert_joined(&rows[0], "2026-09-28", 1, 1, 1); +} + +#[test] +fn legacy_week_key_joins_the_tracker_monday_without_retry() { + monday_join("week", "2026-09-28"); +} + +#[test] +fn current_period_key_joins_the_tracker_monday_without_retry() { + monday_join("period", "2026-09-28"); +} + +#[test] +fn monday_dates_are_canonicalized_without_rebucketing() { + monday_join("period", "2026-9-28"); +} + +#[test] +fn csv_contains_exactly_one_complete_joined_row() { + let sandbox = Sandbox::new(); + sandbox.seed(&[("2026-09-28", 1)]); + let monday = fixture("period", &[("2026-09-28", 1)]); + sandbox.stub(&[(&monday, 0)]); + let out = sandbox.run("csv"); + assert_success(&out); + sandbox.assert_calls(1); + assert_eq!( + String::from_utf8(out.stdout).expect("UTF-8 CSV"), + format!( + "{CSV_HEADER}2026-09-28,2.7000,100,20,40,200,120,360,800,8.0000,18.0000,6.0000,1\n" + ) + ); +} + +#[test] +fn text_contains_one_monday_row_with_spending_and_savings() { + let sandbox = Sandbox::new(); + sandbox.seed(&[("2026-09-28", 1)]); + let monday = fixture("week", &[("2026-09-28", 1)]); + sandbox.stub(&[(&monday, 0)]); + let out = sandbox.run("text"); + assert_success(&out); + sandbox.assert_calls(1); + let stdout = String::from_utf8(out.stdout).expect("UTF-8 text"); + assert!(stdout.contains("Weekly Economics")); + let rows: Vec<_> = stdout + .lines() + .filter(|line| line.starts_with("2026-")) + .collect(); + assert_eq!(rows.len(), 1, "same Monday must not split: {stdout}"); + assert_eq!( + rows[0].split_whitespace().collect::>(), + ["2026-09-28", "$2.70", "800", "$8.00", "1"] + ); +} + +#[test] +fn sunday_buckets_are_replaced_by_genuinely_regrouped_monday_buckets() { + let sandbox = Sandbox::new(); + // Daily contributions 1, 3, 2, 4 produce Sunday buckets 4, 6 but + // Monday buckets 1, 5, 4. Relabeling or adding either response is wrong. + sandbox.seed(&[ + ("2026-09-27", 1), + ("2026-09-28", 3), + ("2026-10-04", 2), + ("2026-10-05", 4), + ]); + let sunday = fixture("period", &[("2026-09-27", 4), ("2026-10-04", 6)]); + let monday = fixture( + "period", + &[("2026-09-21", 1), ("2026-09-28", 5), ("2026-10-05", 4)], + ); + sandbox.stub(&[(&sunday, 0), (&monday, 0)]); + let rows = weekly_rows(&sandbox.run("json")); + sandbox.assert_calls(2); + assert_eq!(rows.len(), 3, "the first response must be discarded"); + assert_joined(&rows[0], "2026-09-21", 1, 1, 1); + assert_joined(&rows[1], "2026-09-28", 5, 5, 2); + assert_joined(&rows[2], "2026-10-05", 4, 4, 1); +} + +#[test] +fn mixed_week_starts_replace_even_initial_monday_rows() { + let sandbox = Sandbox::new(); + sandbox.seed(&[("2026-09-28", 1)]); + let mixed = fixture("week", &[("2026-09-28", 99), ("2026-10-04", 100)]); + let monday = fixture("week", &[("2026-09-28", 1)]); + sandbox.stub(&[(&mixed, 0), (&monday, 0)]); + let rows = weekly_rows(&sandbox.run("json")); + sandbox.assert_calls(2); + assert_eq!(rows.len(), 1, "discard the entire mixed response"); + assert_joined(&rows[0], "2026-09-28", 1, 1, 1); +} + +#[test] +fn any_valid_non_monday_start_requests_a_monday_retry() { + let sandbox = Sandbox::new(); + sandbox.seed(&[("2026-09-28", 1)]); + let wednesday = fixture("period", &[("2026-09-30", 7)]); + let monday = fixture("period", &[("2026-9-28", 1)]); + sandbox.stub(&[(&wednesday, 0), (&monday, 0)]); + let rows = weekly_rows(&sandbox.run("json")); + sandbox.assert_calls(2); + assert_eq!(rows.len(), 1); + assert_joined(&rows[0], "2026-09-28", 1, 1, 1); +} + +#[test] +fn ignored_monday_flag_degrades_after_exactly_two_calls() { + let sandbox = Sandbox::new(); + sandbox.seed(&[("2026-09-28", 1)]); + let sunday = fixture("period", &[("2026-09-27", 4)]); + sandbox.stub(&[(&sunday, 0), (&sunday, 0)]); + let out = sandbox.run("json"); + sandbox.assert_calls(2); + assert_rtk_only(&out); + assert!( + String::from_utf8_lossy(&out.stderr).contains("[warn] ccusage returned non-Monday weeks") + ); +} + +#[test] +fn initially_empty_weekly_data_does_not_retry() { + let sandbox = Sandbox::new(); + sandbox.seed(&[("2026-09-28", 1)]); + sandbox.stub(&[(r#"{"weekly":[]}"#, 0)]); + let out = sandbox.run("json"); + sandbox.assert_calls(1); + assert_rtk_only(&out); +} + +#[test] +fn missing_ccusage_and_npx_preserve_rtk_only_reporting() { + let sandbox = Sandbox::new(); + sandbox.seed(&[("2026-09-28", 1)]); + let out = sandbox.run("json"); + sandbox.assert_calls(0); + assert_rtk_only(&out); + assert!(String::from_utf8_lossy(&out.stderr).contains("[warn] ccusage not found")); +} + +#[test] +fn failed_initial_subprocess_preserves_rtk_only_reporting() { + let sandbox = Sandbox::new(); + sandbox.seed(&[("2026-09-28", 1)]); + sandbox.stub(&[("synthetic subprocess failure", 17)]); + let out = sandbox.run("json"); + sandbox.assert_calls(1); + assert_rtk_only(&out); + assert!( + String::from_utf8_lossy(&out.stderr) + .contains("[warn] ccusage exited with 17: synthetic subprocess failure") + ); +} + +#[test] +fn failed_retry_discards_initial_spending_and_preserves_rtk_only_reporting() { + let sandbox = Sandbox::new(); + sandbox.seed(&[("2026-09-28", 1)]); + let sunday = fixture("period", &[("2026-09-27", 99)]); + sandbox.stub(&[(&sunday, 0), ("synthetic retry failure", 23)]); + let out = sandbox.run("json"); + sandbox.assert_calls(2); + assert_rtk_only(&out); + assert!( + String::from_utf8_lossy(&out.stderr) + .contains("[warn] ccusage exited with 23: synthetic retry failure") + ); +} + +#[test] +fn empty_retry_discards_initial_spending_without_a_third_call() { + let sandbox = Sandbox::new(); + sandbox.seed(&[("2026-09-28", 1)]); + let sunday = fixture("period", &[("2026-09-27", 99)]); + sandbox.stub(&[(&sunday, 0), (r#"{"weekly":[]}"#, 0)]); + let out = sandbox.run("json"); + sandbox.assert_calls(2); + assert_rtk_only(&out); +} + +#[test] +fn invalid_initial_date_after_a_sunday_errors_without_retry() { + let sandbox = Sandbox::new(); + sandbox.seed(&[("2026-09-28", 1)]); + let invalid = fixture("period", &[("2026-09-27", 4), ("2026-02-30", 6)]); + sandbox.stub(&[(&invalid, 0)]); + let out = sandbox.run("json"); + sandbox.assert_calls(1); + assert_fetch_error(&out); + assert!(String::from_utf8_lossy(&out.stderr).contains("2026-02-30")); +} + +#[test] +fn invalid_retry_date_errors_without_a_third_call() { + let sandbox = Sandbox::new(); + sandbox.seed(&[("2026-09-28", 1)]); + let sunday = fixture("week", &[("2026-09-27", 4)]); + let invalid = fixture("week", &[("2026-09-28", 1), ("not-a-date", 6)]); + sandbox.stub(&[(&sunday, 0), (&invalid, 0)]); + let out = sandbox.run("json"); + sandbox.assert_calls(2); + assert_fetch_error(&out); + assert!(String::from_utf8_lossy(&out.stderr).contains("not-a-date")); +} + +#[test] +fn malformed_initial_json_errors_without_retry() { + let sandbox = Sandbox::new(); + sandbox.seed(&[("2026-09-28", 1)]); + sandbox.stub(&[(r#"{"weekly":[{"period":"2026-09-27"},"#, 0)]); + let out = sandbox.run("json"); + sandbox.assert_calls(1); + assert_fetch_error(&out); +} + +#[test] +fn malformed_retry_json_errors_without_a_third_call() { + let sandbox = Sandbox::new(); + sandbox.seed(&[("2026-09-28", 1)]); + let sunday = fixture("period", &[("2026-09-27", 4)]); + sandbox.stub(&[(&sunday, 0), ("invalid JSON", 0)]); + let out = sandbox.run("json"); + sandbox.assert_calls(2); + assert_fetch_error(&out); +} + +fn duplicated_monday_fixture(key: &str) -> String { + // Legacy groupByProject output can repeat a date without retaining any + // project identity. totalTokens is deliberately independent of the four + // category counters, so aggregation must preserve all five input fields. + json!({"weekly": [ + { + (key): "2026-09-28", + "inputTokens": 101, + "outputTokens": 23, + "cacheCreationTokens": 47, + "cacheReadTokens": 211, + "totalTokens": 999, + "totalCost": 11.0 + }, + { + (key): "2026-09-28", + "inputTokens": 307, + "outputTokens": 59, + "cacheCreationTokens": 73, + "cacheReadTokens": 419, + "totalTokens": 2001, + "totalCost": 29.0 + } + ]}) + .to_string() +} + +fn assert_duplicate_mondays_are_summed(key: &str, retry: bool) { + let sandbox = Sandbox::new(); + sandbox.seed(&[("2026-09-28", 1)]); + let duplicates = duplicated_monday_fixture(key); + let sunday = fixture(key, &[("2026-09-27", 99)]); + if retry { + sandbox.stub(&[(&sunday, 0), (&duplicates, 0)]); + } else { + sandbox.stub(&[(&duplicates, 0)]); + } + let rows = weekly_rows(&sandbox.run("json")); + sandbox.assert_calls(if retry { 2 } else { 1 }); + assert_eq!(rows.len(), 1, "duplicate Mondays belong to one joined row"); + let row = &rows[0]; + assert_eq!(row.as_object().expect("period object").len(), 17); + assert_eq!(row["label"], "2026-09-28"); + for (key, expected) in [ + ("cc_input_tokens", 408), + ("cc_output_tokens", 82), + ("cc_cache_create_tokens", 120), + ("cc_cache_read_tokens", 630), + ("cc_active_tokens", 490), + ("cc_total_tokens", 3000), + ("rtk_commands", 1), + ("rtk_saved_tokens", 800), + ] { + assert_eq!(row[key], expected, "{key}: {row}"); + } + for (key, expected) in [ + ("cc_cost", 40.0), + ("rtk_savings_pct", 80.0), + ("weighted_input_cpt", 40.0 / 1031.0), + ("savings_weighted", 800.0 * 40.0 / 1031.0), + ("blended_cpt", 40.0 / 3000.0), + ("active_cpt", 40.0 / 490.0), + ("savings_blended", 800.0 * 40.0 / 3000.0), + ("savings_active", 800.0 * 40.0 / 490.0), + ] { + assert_metric(row, key, expected); + } +} + +#[test] +fn duplicate_legacy_week_rows_sum_every_metric() { + assert_duplicate_mondays_are_summed("week", false); +} + +#[test] +fn duplicate_current_period_rows_sum_every_metric() { + assert_duplicate_mondays_are_summed("period", false); +} + +#[test] +fn duplicate_monday_retry_rows_sum_without_initial_spending() { + assert_duplicate_mondays_are_summed("period", true); +} + +#[test] +fn non_monday_daily_key_joins_with_exactly_one_call() { + let sandbox = Sandbox::new(); + sandbox.seed(&[("2026-09-30", 1)]); + let daily = period_fixture("daily", "date", "2026-09-30"); + sandbox.stub(&[(&daily, 0)]); + let out = sandbox + .command() + .args(["cc-economics", "--daily", "--format", "json"]) + .output() + .expect("run daily economics CLI"); + assert_success(&out); + assert_eq!( + fs::read_to_string(sandbox.root.path().join("calls")).expect("read call log"), + "<--json><--since><20250101>\n" + ); + let report: Value = serde_json::from_slice(&out.stdout).expect("valid daily JSON"); + let rows = report["daily"].as_array().expect("daily rows"); + assert_eq!(rows.len(), 1); + assert_joined(&rows[0], "2026-09-30", 1, 1, 1); + assert!(report["weekly"].is_null()); + assert!(report["monthly"].is_null()); +} + +fn period_fixture(granularity: &str, key: &str, date: &str) -> String { + let mut value: Value = + serde_json::from_str(&fixture(key, &[(date, 1)])).expect("parse test fixture"); + let rows = value["weekly"].take(); + json!({(granularity): rows}).to_string() +} + +#[test] +fn ignored_monday_retry_preserves_all_report_sections() { + // Model ccusage 18's observed response with either an empty config or + // an explicit Sunday preference. The stub deliberately ignores the flag; + // this tests RTK's boundary behavior, not ccusage's config parser. + for config in ["{}", r#"{"startOfWeek":"sunday"}"#] { + for format in ["json", "csv", "text"] { + let sandbox = Sandbox::new(); + sandbox.seed(&[("2026-09-30", 1)]); + let config_dir = sandbox.root.path().join("cwd/.ccusage"); + fs::create_dir(&config_dir).expect("create isolated ccusage config directory"); + fs::write(config_dir.join("ccusage.json"), config).expect("write ccusage config"); + let daily = period_fixture("daily", "date", "2026-09-30"); + let sunday = fixture("week", &[("2026-09-27", 99)]); + let monthly = period_fixture("monthly", "month", "2026-09"); + sandbox.stub(&[(&daily, 0), (&sunday, 0), (&sunday, 0), (&monthly, 0)]); + let out = sandbox + .command() + .args(["cc-economics", "--all", "--format", format]) + .output() + .expect("run all economics sections"); + assert_success(&out); + assert_eq!( + fs::read_to_string(sandbox.root.path().join("calls")).expect("read call log"), + format!( + "<--json><--since><20250101>\n{INITIAL_ARGS}{RETRY_ARGS}<--json><--since><20250101>\n" + ) + ); + assert!( + String::from_utf8_lossy(&out.stderr) + .contains("[warn] ccusage returned non-Monday weeks") + ); + let stdout = String::from_utf8(out.stdout).expect("UTF-8 report"); + match format { + "json" => { + let report: Value = serde_json::from_str(&stdout).expect("complete all JSON"); + assert_eq!(report["daily"].as_array().expect("daily rows").len(), 1); + assert_eq!(report["monthly"].as_array().expect("monthly rows").len(), 1); + assert_joined(&report["daily"][0], "2026-09-30", 1, 1, 1); + // Preserve the existing monthly formula (saved divided by + // saved + input + output), independent of weekly fallback. + let mut expected_month = report["daily"][0].clone(); + expected_month["label"] = json!("2026-09"); + expected_month["rtk_savings_pct"] = json!(40.0); + assert_eq!(report["monthly"][0], expected_month); + let weekly = report["weekly"].as_array().expect("weekly rows"); + assert_eq!(weekly.len(), 1); + assert_eq!(weekly[0]["label"], "2026-09-28"); + assert_eq!(weekly[0]["rtk_saved_tokens"], 800); + assert!(weekly[0]["cc_cost"].is_null()); + } + "csv" => { + let rows: Vec<_> = stdout.lines().collect(); + assert_eq!(rows.len(), 4, "complete daily/weekly/monthly CSV: {stdout}"); + assert!(rows[1].starts_with("2026-09-30,2.7000,")); + assert!(rows[2].starts_with("2026-09-28,,")); + assert!(rows[3].starts_with("2026-09,2.7000,")); + } + _ => { + assert!(stdout.contains("Daily Economics")); + assert!(stdout.contains("Weekly Economics")); + assert!(stdout.contains("Monthly Economics")); + assert!(stdout.contains("2026-09-30")); + assert!(stdout.contains("2026-09-28")); + } + } + } + } +} diff --git a/tests/fixtures/ccusage_weekly_focused_v20_snapshot.json b/tests/fixtures/ccusage_weekly_focused_v20_snapshot.json new file mode 100644 index 0000000000..3c97a4a2ed --- /dev/null +++ b/tests/fixtures/ccusage_weekly_focused_v20_snapshot.json @@ -0,0 +1,78 @@ +{ + "totals": { + "cacheCreationTokens": 45, + "cacheReadTokens": 75, + "inputTokens": 230, + "outputTokens": 35, + "totalCost": 0.0005585, + "totalTokens": 385 + }, + "weekly": [ + { + "cacheCreationTokens": 15, + "cacheReadTokens": 25, + "inputTokens": 60, + "modelBreakdowns": [ + { + "cacheCreationTokens": 15, + "cacheReadTokens": 25, + "cost": 0.00015549999999999999, + "inputTokens": 60, + "modelName": "GLM-5.2", + "outputTokens": 10 + } + ], + "modelsUsed": [ + "GLM-5.2" + ], + "outputTokens": 10, + "totalCost": 0.00015549999999999999, + "totalTokens": 110, + "week": "2098-12-28" + }, + { + "cacheCreationTokens": 30, + "cacheReadTokens": 40, + "inputTokens": 130, + "modelBreakdowns": [ + { + "cacheCreationTokens": 30, + "cacheReadTokens": 40, + "cost": 0.0003224, + "inputTokens": 130, + "modelName": "GLM-5.3", + "outputTokens": 20 + } + ], + "modelsUsed": [ + "GLM-5.3" + ], + "outputTokens": 20, + "totalCost": 0.0003224, + "totalTokens": 220, + "week": "2099-01-11" + }, + { + "cacheCreationTokens": 0, + "cacheReadTokens": 10, + "inputTokens": 40, + "modelBreakdowns": [ + { + "cacheCreationTokens": 0, + "cacheReadTokens": 10, + "cost": 0.0000806, + "inputTokens": 40, + "modelName": "GLM-5.3", + "outputTokens": 5 + } + ], + "modelsUsed": [ + "GLM-5.3" + ], + "outputTokens": 5, + "totalCost": 0.0000806, + "totalTokens": 55, + "week": "2099-02-01" + } + ] +} diff --git a/tests/fixtures/ccusage_weekly_provenance.md b/tests/fixtures/ccusage_weekly_provenance.md new file mode 100644 index 0000000000..ef79bd2bf0 --- /dev/null +++ b/tests/fixtures/ccusage_weekly_provenance.md @@ -0,0 +1,21 @@ +# ccusage weekly fixture provenance + +These fixtures contain synthetic upstream test records, not user spend. + +## Focused weekly snapshot + +`ccusage_weekly_focused_v20_snapshot.json` is the unchanged JSON payload of an actual ccusage v20.0.26 focused Zcode renderer snapshot, with only its Insta header removed. The Sunday bucket starts use the same weekly/week shape as legacy ccusage. This is not a root-CLI capture or Claude billing fixture. + +Source: https://github.com/ccusage/ccusage/blob/d9821088b98aa536c7a385aa1a4579d6fa02269b/rust/adapters/zcode/src/snapshots/ccusage_adapter_zcode__report__tests__focused_zcode_weekly_json.snap + +## Root weekly synthetic fixture + +`ccusage_weekly_root_v20_synthetic.json` is explicitly source-derived synthetic. The original daily record is moved into a weekly array and its period 2026-01-02 is changed to Monday 2025-12-29, following the root aggregator/serializer. All metrics, metadata and totals are unchanged. No live CLI was executed to produce it. + +Original record: https://github.com/ccusage/ccusage/blob/d9821088b98aa536c7a385aa1a4579d6fa02269b/rust/crates/ccusage-adapter-all/src/snapshots/ccusage_adapter_all__tests__renders_multi_section_json_with_command_totals.snap + +Aggregator: https://github.com/ccusage/ccusage/blob/d9821088b98aa536c7a385aa1a4579d6fa02269b/rust/crates/ccusage-adapter-all/src/loader.rs#L887-L906 + +Serializer: https://github.com/ccusage/ccusage/blob/d9821088b98aa536c7a385aa1a4579d6fa02269b/rust/crates/ccusage-adapter-all/src/report.rs#L133-L137 + +The reported totalTokens is 120 while the components sum to 130. Preserve the source's independent total, rather than reconstructing it. diff --git a/tests/fixtures/ccusage_weekly_root_v20_synthetic.json b/tests/fixtures/ccusage_weekly_root_v20_synthetic.json new file mode 100644 index 0000000000..f4ae33952d --- /dev/null +++ b/tests/fixtures/ccusage_weekly_root_v20_synthetic.json @@ -0,0 +1,31 @@ +{ + "weekly": [ + { + "agent": "all", + "cacheCreationTokens": 0, + "cacheReadTokens": 10, + "inputTokens": 100, + "metadata": { + "agents": [ + "codex" + ] + }, + "modelBreakdowns": [], + "modelsUsed": [ + "gpt-5" + ], + "outputTokens": 20, + "period": "2025-12-29", + "totalCost": 0.01, + "totalTokens": 120 + } + ], + "totals": { + "cacheCreationTokens": 0, + "cacheReadTokens": 10, + "inputTokens": 100, + "outputTokens": 20, + "totalCost": 0.01, + "totalTokens": 120 + } +}