csusage-adapter-opencode 0.0.0

Coding agent CLI usage reports (ccusage fork with Claude Science support)
use serde_json::{Value, json};

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

pub fn report_json(
    entries: &[LoadedEntry],
    kind: AgentReportKind,
    order: &SortOrder,
) -> Result<Value> {
    let mut rows = summarize_entries(entries, kind)?;
    sort_summaries(&mut rows, order, |row| summary_period(row));
    Ok(report_from_rows(&rows, kind))
}

fn report_from_rows(rows: &[crate::UsageSummary], kind: AgentReportKind) -> Value {
    let rows_json = rows
        .iter()
        .map(|row| agent_summary_json(row, kind, false))
        .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::Weekly => {
            let daily = summarize_by_key(
                entries,
                |entry| entry.date.clone(),
                |date| (date.to_string(), None),
            )?;
            Ok(summarize_summaries_by_bucket(
                &daily,
                BucketKind::Weekly,
                WeekDay::Monday,
            ))
        }
        AgentReportKind::Monthly => {
            let daily = summarize_by_key(
                entries,
                |entry| entry.date.clone(),
                |date| (date.to_string(), None),
            )?;
            Ok(summarize_summaries_by_bucket(
                &daily,
                BucketKind::Monthly,
                WeekDay::Sunday,
            ))
        }
        AgentReportKind::Session => {
            let mut grouped: Vec<SessionAccumulator> = Vec::new();
            let mut group_indexes = std::collections::HashMap::new();
            for entry in entries {
                let key = &entry.session_id;
                let index = *group_indexes.entry(key.clone()).or_insert_with(|| {
                    let index = grouped.len();
                    grouped.push(SessionAccumulator::default());
                    index
                });
                grouped[index].add_entry(entry);
            }
            let mut rows = Vec::with_capacity(grouped.len());
            for group in grouped {
                rows.push(group.into_summary()?);
            }
            Ok(rows)
        }
    }
}

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 std::sync::Arc;

    use super::*;
    use crate::{
        LoadedEntry, ModelBreakdown, TimestampMs, TokenUsageRaw, UsageEntry, UsageMessage,
        UsageSummary, cli::AgentReportKind, format_rfc3339_millis,
    };

    #[test]
    fn snapshots_agent_summary_json_period_keys_and_session_metadata() {
        let daily = snapshot_row();
        let mut weekly = snapshot_row();
        weekly.date = None;
        weekly.month = None;
        let mut monthly = snapshot_row();
        monthly.date = None;
        monthly.week = None;
        let mut session = snapshot_row();
        session.date = None;
        session.week = None;
        session.month = None;

        insta::assert_json_snapshot!(serde_json::json!({
            "daily": agent_summary_json(&daily, AgentReportKind::Daily, false),
            "weekly": agent_summary_json(&weekly, AgentReportKind::Weekly, false),
            "monthly": agent_summary_json(&monthly, AgentReportKind::Monthly, false),
            "session": agent_summary_json(&session, AgentReportKind::Session, true),
            "dailyReport": report_from_rows(std::slice::from_ref(&daily), AgentReportKind::Daily),
            "sessionReport": report_from_rows(&[session], AgentReportKind::Session),
        }));
    }

    #[test]
    fn summarize_session_entries_preserves_session_id_and_activity_bounds() {
        let entries = vec![
            loaded_entry("session-a", 1_767_316_800_000, 100),
            loaded_entry("session-a", 1_767_402_000_000, 20),
        ];

        let rows = summarize_entries(&entries, AgentReportKind::Session).unwrap();

        assert_eq!(rows.len(), 1);
        assert_eq!(rows[0].session_id.as_deref(), Some("session-a"));
        assert_eq!(
            rows[0].first_activity.as_deref(),
            Some("2026-01-02T01:20:00.000Z")
        );
        assert_eq!(
            rows[0].last_activity.as_deref(),
            Some("2026-01-03T01:00:00.000Z")
        );
    }

    fn snapshot_row() -> UsageSummary {
        UsageSummary {
            date: Some("2026-01-02".to_string()),
            month: Some("2026-01".to_string()),
            week: Some("2025-12-29".to_string()),
            session_id: Some("session-a".to_string()),
            project_path: Some("/workspace/api".to_string()),
            last_activity: Some("2026-01-02T12:34:56.000Z".to_string()),
            first_activity: Some("2026-01-01T10:30:00.000Z".to_string()),
            input_tokens: 100,
            output_tokens: 50,
            cache_creation_tokens: 10,
            cache_read_tokens: 5,
            extra_total_tokens: 7,
            total_cost: 0.25,
            credits: Some(1.5),
            message_count: Some(3),
            models_used: vec![
                "gpt-5.2-codex".to_string(),
                "claude-sonnet-4-20250514".to_string(),
            ],
            model_breakdowns: vec![ModelBreakdown {
                model_name: "gpt-5.2-codex".to_string(),
                input_tokens: 100,
                output_tokens: 50,
                cache_creation_tokens: 10,
                cache_read_tokens: 5,
                extra_total_tokens: 7,
                cost: 0.25,
                missing_pricing: false,
            }],
            project: None,
            versions: Some(vec!["1.0.0".to_string()]),
        }
    }

    fn loaded_entry(session_id: &str, timestamp_millis: i64, input_tokens: u64) -> LoadedEntry {
        let timestamp = TimestampMs::from_millis(timestamp_millis);
        LoadedEntry {
            data: UsageEntry {
                session_id: Some(session_id.to_string()),
                timestamp: format_rfc3339_millis(timestamp),
                version: None,
                message: UsageMessage {
                    usage: TokenUsageRaw {
                        input_tokens,
                        output_tokens: 0,
                        cache_creation_input_tokens: 0,
                        cache_read_input_tokens: 0,
                        cache_creation: None,
                        speed: None,
                    },
                    model: Some("gpt-5.2-codex".to_string()),
                    id: Some(format!("msg-{timestamp_millis}")),
                },
                cost_usd: None,
                request_id: None,
                is_api_error_message: None,
                is_sidechain: None,
            },
            timestamp,
            date: "2026-01-02".to_string(),
            project: Arc::from("opencode"),
            session_id: Arc::from(session_id),
            project_path: Arc::from("/workspace/api"),
            cost: 0.0,
            extra_total_tokens: 0,
            credits: None,
            message_count: Some(1),
            model: Some("gpt-5.2-codex".to_string()),
            usage_limit_reset_time: None,
            missing_pricing_model: None,
        }
    }
}