csusage-adapter-zcode 0.0.0

Coding agent CLI usage reports (ccusage fork with Claude Science support)
use std::collections::BTreeMap;

use serde_json::{Value, json};

use crate::{
    BucketKind, LoadedEntry, Result, SessionAccumulator,
    cli::{AgentReportKind, WeekDay},
    summarize_by_key, summarize_summaries_by_bucket, totals_json,
};

pub(crate) fn report_from_rows(rows: &[crate::UsageSummary], kind: AgentReportKind) -> Value {
    let rows_json = rows
        .iter()
        .map(|row| csusage_core::agent_summary_json(row, kind, kind == AgentReportKind::Session))
        .collect::<Vec<_>>();
    json!({
        rows_key(kind): rows_json,
        "totals": totals_json(rows),
    })
}

pub fn summarize_entries(
    entries: &[LoadedEntry],
    kind: AgentReportKind,
) -> Result<Vec<crate::UsageSummary>> {
    match kind {
        AgentReportKind::Daily => summarize_by_key(
            entries,
            |entry| entry.date.clone(),
            |date| (date.to_string(), None),
        ),
        AgentReportKind::Monthly => {
            let daily = summarize_entries(entries, AgentReportKind::Daily)?;
            Ok(summarize_summaries_by_bucket(
                &daily,
                BucketKind::Monthly,
                WeekDay::Sunday,
            ))
        }
        AgentReportKind::Session => {
            let mut groups = BTreeMap::<String, SessionAccumulator>::new();
            for entry in entries {
                groups
                    .entry(entry.session_id.to_string())
                    .or_default()
                    .add_entry(entry);
            }
            groups
                .into_values()
                .map(SessionAccumulator::into_summary)
                .collect()
        }
        AgentReportKind::Weekly => {
            let daily = summarize_entries(entries, AgentReportKind::Daily)?;
            Ok(summarize_summaries_by_bucket(
                &daily,
                BucketKind::Weekly,
                WeekDay::Sunday,
            ))
        }
    }
}

fn rows_key(kind: AgentReportKind) -> &'static str {
    match kind {
        AgentReportKind::Daily => "daily",
        AgentReportKind::Weekly => "weekly",
        AgentReportKind::Monthly => "monthly",
        AgentReportKind::Session => "sessions",
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{
        TimestampMs, TokenUsageRaw, UsageEntry, UsageMessage, format_rfc3339_millis,
        parse_ts_timestamp,
    };
    use std::sync::Arc;

    fn entry(session_id: &str, date: &str, millis: i64) -> LoadedEntry {
        let timestamp = TimestampMs::from_millis(millis);
        LoadedEntry {
            data: UsageEntry {
                session_id: Some(session_id.to_string()),
                timestamp: format!("{date}T12:00:00.000Z"),
                version: Some("0.16.3".to_string()),
                message: UsageMessage {
                    usage: TokenUsageRaw {
                        input_tokens: 60,
                        output_tokens: 10,
                        cache_creation_input_tokens: 15,
                        cache_read_input_tokens: 25,
                        speed: None,
                        cache_creation: None,
                    },
                    model: Some("GLM-5.3".to_string()),
                    id: Some(format!("usage-{millis}")),
                },
                cost_usd: None,
                request_id: None,
                is_api_error_message: None,
                is_sidechain: None,
            },
            timestamp,
            date: date.to_string(),
            project: Arc::from("zcode"),
            session_id: Arc::from(session_id),
            project_path: Arc::from("/project"),
            cost: 0.01,
            extra_total_tokens: 0,
            credits: None,
            message_count: None,
            model: Some("GLM-5.3".to_string()),
            usage_limit_reset_time: None,
            missing_pricing_model: None,
        }
    }

    #[test]
    fn reports_daily_and_session_totals() {
        let entries = [entry("session-1", "2026-08-16", 1_786_909_042_666)];
        let daily = summarize_entries(&entries, AgentReportKind::Daily).unwrap();
        let report = report_from_rows(&daily, AgentReportKind::Daily);
        assert_eq!(report["daily"][0]["totalTokens"], 110);
        assert_eq!(report["totals"]["totalTokens"], 110);

        let sessions = summarize_entries(&entries, AgentReportKind::Session).unwrap();
        assert_eq!(sessions[0].session_id.as_deref(), Some("session-1"));
        assert_eq!(sessions[0].project_path.as_deref(), Some("/project"));
    }

    #[test]
    fn snapshots_focused_zcode_json_reports_for_daily_monthly_weekly_and_session() {
        let entries = snapshot_entries();
        let daily = summarize_entries(&entries, AgentReportKind::Daily).unwrap();
        let monthly = summarize_entries(&entries, AgentReportKind::Monthly).unwrap();
        let weekly = summarize_entries(&entries, AgentReportKind::Weekly).unwrap();
        let session = summarize_entries(&entries, AgentReportKind::Session).unwrap();

        insta::assert_json_snapshot!(
            "focused_zcode_daily_json",
            report_from_rows(&daily, AgentReportKind::Daily)
        );
        insta::assert_json_snapshot!(
            "focused_zcode_monthly_json",
            report_from_rows(&monthly, AgentReportKind::Monthly)
        );
        insta::assert_json_snapshot!(
            "focused_zcode_weekly_json",
            report_from_rows(&weekly, AgentReportKind::Weekly)
        );
        insta::assert_json_snapshot!(
            "focused_zcode_session_json",
            report_from_rows(&session, AgentReportKind::Session)
        );
    }

    #[test]
    fn saturates_focused_daily_and_weekly_reports_for_extreme_counters() {
        let entries = [
            extreme_entry("2099-01-02", 4_070_908_800_000, u64::MAX),
            extreme_entry("2099-01-03", 4_071_081_600_000, 1),
        ];
        let daily = summarize_entries(&entries, AgentReportKind::Daily).unwrap();
        let daily_report = report_from_rows(&daily, AgentReportKind::Daily);
        let weekly = summarize_entries(&entries, AgentReportKind::Weekly).unwrap();
        let weekly_report = report_from_rows(&weekly, AgentReportKind::Weekly);

        for report in [daily_report, weekly_report] {
            for key in [
                "inputTokens",
                "outputTokens",
                "cacheCreationTokens",
                "cacheReadTokens",
                "totalTokens",
            ] {
                assert_eq!(report["totals"][key], u64::MAX, "{key}");
            }
        }
        assert_eq!(weekly[0].model_breakdowns[0].input_tokens, u64::MAX);
        assert_eq!(weekly[0].model_breakdowns[0].output_tokens, u64::MAX);
    }

    fn extreme_entry(date: &str, millis: i64, tokens: u64) -> LoadedEntry {
        let mut entry = entry("session-extreme", date, millis);
        entry.data.message.usage = TokenUsageRaw {
            input_tokens: tokens,
            output_tokens: tokens,
            cache_creation_input_tokens: tokens,
            cache_read_input_tokens: tokens,
            ..TokenUsageRaw::default()
        };
        entry.extra_total_tokens = tokens;
        entry
    }

    fn snapshot_entries() -> Vec<LoadedEntry> {
        [
            (
                "usage-52",
                "session-a",
                "2099-01-02T00:00:00.000Z",
                "GLM-5.2",
                60,
                10,
                15,
                25,
                0.00015549999999999999,
                "/workspace/project-a",
            ),
            (
                "usage-53",
                "session-a",
                "2099-01-15T12:00:00.000Z",
                "GLM-5.3",
                130,
                20,
                30,
                40,
                0.0003224,
                "/workspace/project-a",
            ),
            (
                "usage-53-b",
                "session-b",
                "2099-02-01T00:00:00.000Z",
                "GLM-5.3",
                40,
                5,
                0,
                10,
                0.0000806,
                "/workspace/project-b",
            ),
        ]
        .into_iter()
        .map(
            |(
                id,
                session_id,
                timestamp,
                model,
                input_tokens,
                output_tokens,
                cache_creation_tokens,
                cache_read_tokens,
                cost,
                project_path,
            )| {
                let date = timestamp.get(..10).unwrap().to_string();
                let timestamp = parse_ts_timestamp(timestamp).unwrap();
                LoadedEntry {
                    data: UsageEntry {
                        session_id: Some(session_id.to_string()),
                        timestamp: format_rfc3339_millis(timestamp),
                        version: Some("0.16.3".to_string()),
                        message: UsageMessage {
                            usage: TokenUsageRaw {
                                input_tokens,
                                output_tokens,
                                cache_creation_input_tokens: cache_creation_tokens,
                                cache_read_input_tokens: cache_read_tokens,
                                speed: None,
                                cache_creation: None,
                            },
                            model: Some(model.to_string()),
                            id: Some(id.to_string()),
                        },
                        cost_usd: None,
                        request_id: None,
                        is_api_error_message: None,
                        is_sidechain: None,
                    },
                    timestamp,
                    date,
                    project: Arc::from("zcode"),
                    session_id: Arc::from(session_id),
                    project_path: Arc::from(project_path),
                    cost,
                    extra_total_tokens: 0,
                    credits: None,
                    message_count: None,
                    model: Some(model.to_string()),
                    usage_limit_reset_time: None,
                    missing_pricing_model: None,
                }
            },
        )
        .collect()
    }
}