codexusage 0.2.0

Fast CLI reports for OpenAI Codex session usage and cost
Documentation
use codexusage::app::{
    NumberFormat, ReportKind, ReportOptions, ReportOutput, ScannerParallelism, build_report,
};
use codexusage::pricing::{CacheDecision, decide_cache_action};
use serde_json::json;
use std::fs;
use std::num::NonZeroUsize;
use std::time::{Duration, SystemTime};
use tempfile::TempDir;

fn write_session_file(temp: &TempDir, relative_path: &str, contents: &str) {
    let path = temp.path().join(relative_path);
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).expect("create session directory");
    }
    fs::write(path, contents).expect("write session file");
}

fn base_options(session_dir: &std::path::Path) -> ReportOptions {
    ReportOptions {
        since: None,
        until: None,
        last_days: None,
        timezone: "UTC".to_string(),
        locale: "en-US".to_string(),
        number_format: NumberFormat::Short,
        json: true,
        offline: true,
        refresh_pricing: false,
        session_dirs: vec![session_dir.to_path_buf()],
        parallelism: ScannerParallelism::Auto,
    }
}

#[test]
fn daily_report_uses_last_usage_and_total_delta_fallback() {
    let temp = TempDir::new().expect("tempdir");
    write_session_file(
        &temp,
        "sessions/project-a/session-1.jsonl",
        concat!(
            "{\"timestamp\":\"2025-09-11T18:25:30.000Z\",\"type\":\"turn_context\",\"payload\":{\"model\":\"gpt-5\"}}\n",
            "{\"timestamp\":\"2025-09-11T18:25:40.670Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"token_count\",\"info\":{\"last_token_usage\":{\"input_tokens\":1200,\"cached_input_tokens\":200,\"output_tokens\":500,\"reasoning_output_tokens\":0,\"total_tokens\":1700},\"total_token_usage\":{\"input_tokens\":1200,\"cached_input_tokens\":200,\"output_tokens\":500,\"reasoning_output_tokens\":0,\"total_tokens\":1700}}}}\n",
            "{\"timestamp\":\"2025-09-12T00:00:00.000Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"token_count\",\"info\":{\"total_token_usage\":{\"input_tokens\":2000,\"cached_input_tokens\":300,\"output_tokens\":800,\"reasoning_output_tokens\":0,\"total_tokens\":2800}}}}\n"
        ),
    );

    let report = build_report(
        ReportKind::Daily,
        &base_options(&temp.path().join("sessions")),
    )
    .expect("build report");

    match report {
        ReportOutput::Daily { rows, totals, .. } => {
            assert_eq!(rows.len(), 2, "expected one row per day");
            assert_eq!(rows[0].date, "2025-09-11");
            assert_eq!(rows[0].input_tokens, 1_200);
            assert_eq!(rows[0].cached_input_tokens, 200);
            assert_eq!(rows[1].date, "2025-09-12");
            assert_eq!(rows[1].input_tokens, 800);
            assert_eq!(rows[1].cached_input_tokens, 100);
            assert_eq!(totals.input_tokens, 2_000);
            assert_eq!(totals.output_tokens, 800);
        }
        other => panic!("unexpected report: {other:?}"),
    }
}

#[test]
fn session_report_marks_fallback_models_when_metadata_is_missing() {
    let temp = TempDir::new().expect("tempdir");
    write_session_file(
        &temp,
        "sessions/legacy/session-legacy.jsonl",
        "{\"timestamp\":\"2025-09-15T13:00:00.000Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"token_count\",\"info\":{\"total_token_usage\":{\"input_tokens\":5000,\"cached_input_tokens\":0,\"output_tokens\":1000,\"reasoning_output_tokens\":0,\"total_tokens\":6000}}}}\n",
    );

    let report = build_report(
        ReportKind::Session,
        &base_options(&temp.path().join("sessions")),
    )
    .expect("build report");

    match report {
        ReportOutput::Session { rows, .. } => {
            assert_eq!(rows.len(), 1);
            let row = &rows[0];
            let model = row.models.get("gpt-5").expect("fallback model");
            assert!(model.is_fallback, "fallback model should be marked");
            assert_eq!(row.directory, "legacy");
            assert_eq!(row.session_file, "session-legacy");
        }
        other => panic!("unexpected report: {other:?}"),
    }
}

#[test]
fn duplicate_session_ids_across_roots_prefer_the_longer_file() {
    let first = TempDir::new().expect("first");
    let second = TempDir::new().expect("second");
    write_session_file(
        &first,
        "sessions/project/session.jsonl",
        concat!(
            "{\"timestamp\":\"2025-09-11T18:00:00.000Z\",\"type\":\"turn_context\",\"payload\":{\"model\":\"gpt-5\"}}\n",
            "{\"timestamp\":\"2025-09-11T18:01:00.000Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"token_count\",\"info\":{\"last_token_usage\":{\"input_tokens\":100,\"cached_input_tokens\":0,\"output_tokens\":10,\"reasoning_output_tokens\":0,\"total_tokens\":110}}}}\n"
        ),
    );
    write_session_file(
        &second,
        "sessions/project/session.jsonl",
        concat!(
            "{\"timestamp\":\"2025-09-11T18:00:00.000Z\",\"type\":\"turn_context\",\"payload\":{\"model\":\"gpt-5\"}}\n",
            "{\"timestamp\":\"2025-09-11T18:01:00.000Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"token_count\",\"info\":{\"last_token_usage\":{\"input_tokens\":100,\"cached_input_tokens\":0,\"output_tokens\":10,\"reasoning_output_tokens\":0,\"total_tokens\":110}}}}\n",
            "{\"timestamp\":\"2025-09-11T18:02:00.000Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"token_count\",\"info\":{\"last_token_usage\":{\"input_tokens\":50,\"cached_input_tokens\":0,\"output_tokens\":5,\"reasoning_output_tokens\":0,\"total_tokens\":55}}}}\n"
        ),
    );

    let report = build_report(
        ReportKind::Session,
        &ReportOptions {
            since: None,
            until: None,
            last_days: None,
            timezone: "UTC".to_string(),
            locale: "en-US".to_string(),
            number_format: NumberFormat::Short,
            json: true,
            offline: true,
            refresh_pricing: false,
            session_dirs: vec![
                first.path().join("sessions"),
                second.path().join("sessions"),
            ],
            parallelism: ScannerParallelism::Auto,
        },
    )
    .expect("build report");

    match report {
        ReportOutput::Session { rows, totals, .. } => {
            assert_eq!(rows.len(), 1);
            assert_eq!(rows[0].session_id, "project/session");
            assert_eq!(rows[0].input_tokens, 150);
            assert_eq!(rows[0].output_tokens, 15);
            assert_eq!(totals.total_tokens, 165);
        }
        other => panic!("unexpected report: {other:?}"),
    }
}

#[test]
fn session_last_activity_uses_selected_timezone() {
    let temp = TempDir::new().expect("tempdir");
    write_session_file(
        &temp,
        "sessions/project/session.jsonl",
        concat!(
            "{\"timestamp\":\"2025-01-15T23:00:00.000Z\",\"type\":\"turn_context\",\"payload\":{\"model\":\"gpt-5\"}}\n",
            "{\"timestamp\":\"2025-01-15T23:00:30.000Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"token_count\",\"info\":{\"last_token_usage\":{\"input_tokens\":100,\"cached_input_tokens\":0,\"output_tokens\":10,\"reasoning_output_tokens\":0,\"total_tokens\":110}}}}\n"
        ),
    );

    let report = build_report(
        ReportKind::Session,
        &ReportOptions {
            since: None,
            until: None,
            last_days: None,
            timezone: "Europe/Warsaw".to_string(),
            locale: "en-US".to_string(),
            number_format: NumberFormat::Short,
            json: true,
            offline: true,
            refresh_pricing: false,
            session_dirs: vec![temp.path().join("sessions")],
            parallelism: ScannerParallelism::Auto,
        },
    )
    .expect("build report");

    match report {
        ReportOutput::Session { rows, .. } => {
            assert_eq!(rows.len(), 1);
            assert_eq!(rows[0].last_activity, "2025-01-16T00:00:30.000+01:00");
        }
        other => panic!("unexpected report: {other:?}"),
    }
}

#[test]
fn missing_directories_are_reported_without_failing_the_run() {
    let temp = TempDir::new().expect("tempdir");
    let mut options = base_options(&temp.path().join("missing"));
    options.session_dirs.push(temp.path().join("also-missing"));

    let report = build_report(ReportKind::Monthly, &options).expect("build report");

    match report {
        ReportOutput::Monthly {
            rows,
            totals,
            missing_directories,
        } => {
            assert!(
                rows.is_empty(),
                "missing directories should not invent rows"
            );
            assert_eq!(totals.total_tokens, 0);
            assert_eq!(missing_directories.len(), 2);
        }
        other => panic!("unexpected report: {other:?}"),
    }
}

#[test]
fn explicit_single_thread_mode_matches_multi_worker_results() {
    let temp = TempDir::new().expect("tempdir");
    for index in 0..4 {
        let session_contents = [
            json!({
                "timestamp": "2025-09-11T18:00:00.000Z",
                "type": "turn_context",
                "payload": {"model": "gpt-5"},
            })
            .to_string(),
            json!({
                "timestamp": format!("2025-09-11T18:0{index}:00.000Z"),
                "type": "event_msg",
                "payload": {
                    "type": "token_count",
                    "info": {
                        "last_token_usage": {
                            "input_tokens": 100 + index,
                            "cached_input_tokens": 10,
                            "output_tokens": 20 + index,
                            "reasoning_output_tokens": 0,
                            "total_tokens": 120 + (index * 2),
                        }
                    }
                }
            })
            .to_string(),
            String::new(),
        ]
        .join("\n");
        write_session_file(
            &temp,
            &format!("sessions/project/session-{index}.jsonl"),
            &session_contents,
        );
    }

    let session_dir = temp.path().join("sessions");
    let mut single_threaded = base_options(&session_dir);
    single_threaded.parallelism =
        ScannerParallelism::Fixed(NonZeroUsize::new(1).expect("non-zero"));
    let mut multi_worker = base_options(&session_dir);
    multi_worker.parallelism = ScannerParallelism::Fixed(NonZeroUsize::new(2).expect("non-zero"));

    let single_report =
        build_report(ReportKind::Session, &single_threaded).expect("single-threaded report");
    let multi_report =
        build_report(ReportKind::Session, &multi_worker).expect("multi-worker report");

    assert_eq!(single_report, multi_report);
}

#[test]
fn fresh_pricing_cache_skips_refresh_but_force_refresh_overrides_it() {
    let temp = TempDir::new().expect("tempdir");
    let cache_path = temp.path().join("pricing-cache.json");
    let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_750_000_000);
    let refreshed_at_epoch_seconds = now
        .duration_since(SystemTime::UNIX_EPOCH)
        .expect("duration")
        .as_secs();
    let refreshed_at_epoch_seconds =
        i64::try_from(refreshed_at_epoch_seconds).expect("timestamp fits in i64");
    fs::write(
        &cache_path,
        format!("{{\"refreshed_at_epoch_seconds\":{refreshed_at_epoch_seconds},\"models\":{{}}}}"),
    )
    .expect("write cache");
    let ttl = Duration::from_hours(24);

    let decision = decide_cache_action(&cache_path, now, ttl, false, false).expect("decision");
    assert_eq!(decision, CacheDecision::UseCache);

    let forced = decide_cache_action(&cache_path, now, ttl, false, true).expect("decision");
    assert_eq!(forced, CacheDecision::Refresh);
}

#[test]
fn offline_mode_never_refreshes_pricing() {
    let temp = TempDir::new().expect("tempdir");
    let cache_path = temp.path().join("pricing-cache.json");
    let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_750_000_000);
    let ttl = Duration::from_hours(24);

    let decision = decide_cache_action(&cache_path, now, ttl, true, false).expect("decision");
    assert_eq!(decision, CacheDecision::UseCache);
}

#[test]
fn invalid_timezone_is_rejected() {
    let temp = TempDir::new().expect("tempdir");
    let error = build_report(
        ReportKind::Daily,
        &ReportOptions {
            since: None,
            until: None,
            last_days: None,
            timezone: "Europe/Warswa".to_string(),
            locale: "en-US".to_string(),
            number_format: NumberFormat::Short,
            json: true,
            offline: true,
            refresh_pricing: false,
            session_dirs: vec![temp.path().join("sessions")],
            parallelism: ScannerParallelism::Auto,
        },
    )
    .expect_err("invalid timezone should fail");

    assert!(error.to_string().contains("invalid timezone"));
}

#[test]
fn stale_payload_timestamp_triggers_refresh_even_with_fresh_file_mtime() {
    let temp = TempDir::new().expect("tempdir");
    let cache_path = temp.path().join("pricing-cache.json");
    let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_750_000_000);
    let refreshed_at_epoch_seconds = now
        .duration_since(SystemTime::UNIX_EPOCH)
        .expect("duration")
        .as_secs();
    let refreshed_at_epoch_seconds = i64::try_from(refreshed_at_epoch_seconds)
        .expect("timestamp fits in i64")
        - i64::try_from(Duration::from_hours(48).as_secs()).expect("duration fits in i64");
    fs::write(
        &cache_path,
        format!("{{\"refreshed_at_epoch_seconds\":{refreshed_at_epoch_seconds},\"models\":{{}}}}"),
    )
    .expect("write cache");

    let decision = decide_cache_action(&cache_path, now, Duration::from_hours(24), false, false)
        .expect("decision");
    assert_eq!(decision, CacheDecision::Refresh);
}