codex-cost 0.1.0

Local web dashboard for inspecting Codex token usage from session logs.
Documentation
use std::{
    env,
    path::{Path, PathBuf},
};

use walkdir::WalkDir;

use crate::{
    models::{UsageLoad, UsageWarning},
    parser::parse_session_file,
};

pub trait UsageProvider: Send + Sync {
    fn load_events(&self) -> UsageLoad;
}

#[derive(Debug, Clone)]
pub struct LocalCodexProvider {
    codex_home: PathBuf,
}

impl LocalCodexProvider {
    pub fn new(codex_home: Option<PathBuf>) -> Self {
        Self {
            codex_home: codex_home.unwrap_or_else(default_codex_home),
        }
    }

    pub fn codex_home(&self) -> &Path {
        &self.codex_home
    }
}

impl UsageProvider for LocalCodexProvider {
    fn load_events(&self) -> UsageLoad {
        if !self.codex_home.exists() {
            return UsageLoad {
                warnings: vec![UsageWarning {
                    file: self.codex_home.display().to_string(),
                    line: None,
                    message: "Codex home not found".to_string(),
                }],
                ..UsageLoad::default()
            };
        }

        let mut load = UsageLoad::default();
        for path in discover_session_files(&self.codex_home) {
            load.session_count += 1;
            let parsed = parse_session_file(&path);
            load.events.extend(parsed.events);
            load.warnings.extend(parsed.warnings);
        }

        load
    }
}

fn discover_session_files(codex_home: &Path) -> Vec<PathBuf> {
    let mut files = Vec::new();
    collect_jsonl_files(&codex_home.join("sessions"), &mut files);
    collect_jsonl_files(&codex_home.join("archived_sessions"), &mut files);
    files.sort();
    files
}

fn collect_jsonl_files(root: &Path, files: &mut Vec<PathBuf>) {
    if !root.exists() {
        return;
    }

    for entry in WalkDir::new(root).into_iter().filter_map(Result::ok) {
        if entry.file_type().is_file()
            && entry
                .path()
                .extension()
                .is_some_and(|extension| extension.eq_ignore_ascii_case("jsonl"))
        {
            files.push(entry.path().to_path_buf());
        }
    }
}

fn default_codex_home() -> PathBuf {
    if let Some(home) = env::var_os("USERPROFILE").or_else(|| env::var_os("HOME")) {
        return PathBuf::from(home).join(".codex");
    }

    PathBuf::from(".codex")
}

#[cfg(test)]
mod tests {
    use std::{fs, path::Path};

    use tempfile::tempdir;

    use super::{LocalCodexProvider, UsageProvider};

    fn write_session(path: &Path, session_id: &str, cwd: &str, tokens: u64) {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).expect("create fixture directory");
        }

        let session_meta = serde_json::json!({
            "timestamp": "2026-07-06T08:00:00.000Z",
            "type": "session_meta",
            "payload": {
                "id": session_id,
                "cwd": cwd,
                "thread_name": format!("Thread {session_id}"),
            },
        });
        let token_count = serde_json::json!({
            "timestamp": "2026-07-06T08:01:00.000Z",
            "type": "event_msg",
            "payload": {
                "type": "token_count",
                "info": {
                    "last_token_usage": {
                        "total_tokens": tokens,
                    },
                },
            },
        });
        fs::write(path, format!("{session_meta}\n{token_count}\n")).expect("write fixture session");
    }

    #[test]
    fn discovers_active_and_archived_session_files() {
        let root = tempdir().expect("create codex home");
        write_session(
            &root.path().join("sessions/2026/07/06/rollout-active.jsonl"),
            "active",
            "C:\\work\\active",
            11,
        );
        write_session(
            &root.path().join("archived_sessions/rollout-archived.jsonl"),
            "archived",
            "C:\\work\\archived",
            17,
        );

        let provider = LocalCodexProvider::new(Some(root.path().to_path_buf()));
        let loaded = provider.load_events();

        assert_eq!(loaded.events.len(), 2);
        assert_eq!(loaded.session_count, 2);
        assert!(loaded.warnings.is_empty());
        assert!(
            loaded
                .events
                .iter()
                .any(|event| event.project == "C:\\work\\active")
        );
        assert!(
            loaded
                .events
                .iter()
                .any(|event| event.project == "C:\\work\\archived")
        );
        assert_eq!(
            loaded
                .events
                .iter()
                .map(|event| event.usage.total_tokens)
                .sum::<u64>(),
            28
        );
    }

    #[test]
    fn missing_codex_home_returns_empty_load_with_warning() {
        let root = tempdir().expect("create temp root");
        let missing = root.path().join("missing-codex-home");

        let provider = LocalCodexProvider::new(Some(missing));
        let loaded = provider.load_events();

        assert!(loaded.events.is_empty());
        assert_eq!(loaded.session_count, 0);
        assert_eq!(loaded.warnings.len(), 1);
        assert!(loaded.warnings[0].message.contains("Codex home not found"));
    }
}