claudex 0.12.0

Reusable library for indexing and querying Claude Code, Codex, Copilot, Pi, and OpenClaw coding sessions
Documentation
use std::fs;
use std::io::Write;
use std::sync::{Mutex, OnceLock};

use claudex::api::{Claudex, ClaudexConfig, Filter, Provider};
use tempfile::TempDir;

fn env_lock() -> &'static Mutex<()> {
    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
    LOCK.get_or_init(|| Mutex::new(()))
}

fn write_session(home: &TempDir) {
    let dir = home
        .path()
        .join(".claude/projects/-Users-test-Projects-api");
    fs::create_dir_all(&dir).unwrap();
    let mut f = fs::File::create(dir.join("api-session.jsonl")).unwrap();
    writeln!(
        f,
        r#"{{"type":"user","sessionId":"api-session","timestamp":"2026-04-10T10:00:00Z","message":{{"content":"find the api bug"}}}}"#
    )
    .unwrap();
    writeln!(
        f,
        r#"{{"type":"assistant","sessionId":"api-session","timestamp":"2026-04-10T10:01:00Z","message":{{"model":"claude-sonnet-4-6","usage":{{"input_tokens":100,"output_tokens":20,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}},"content":[{{"type":"tool_use","name":"Read","id":"t1","input":{{}}}},{{"type":"text","text":"api result"}}]}}}}"#
    )
    .unwrap();
    writeln!(
        f,
        r#"{{"type":"system","subtype":"turn_duration","durationMs":12000,"timestamp":"2026-04-10T10:01:30Z","sessionId":"api-session"}}"#
    )
    .unwrap();
    writeln!(
        f,
        r#"{{"type":"file-history-snapshot","snapshot":{{"trackedFileBackups":{{"src/api.rs":{{"backupFileName":"x"}}}}}},"timestamp":"2026-04-10T10:02:00Z","sessionId":"api-session"}}"#
    )
    .unwrap();
    writeln!(
        f,
        r#"{{"type":"pr-link","prNumber":66,"prUrl":"https://github.com/utensils/claudex/pull/66","prRepository":"utensils/claudex","timestamp":"2026-04-10T10:03:00Z","sessionId":"api-session"}}"#
    )
    .unwrap();
}

fn write_codex_session(home: &TempDir) {
    let dir = home.path().join(".codex/sessions/2026/04/10");
    fs::create_dir_all(&dir).unwrap();
    let mut f = fs::File::create(dir.join("rollout-2026-04-10T11-00-00-codex-api.jsonl")).unwrap();
    writeln!(
        f,
        r#"{{"timestamp":"2026-04-10T11:00:00Z","type":"session_meta","payload":{{"id":"codex-api","cwd":"/Users/test/Projects/codex-api","originator":"codex_cli_rs","cli_version":"0.99.0","source":"cli"}}}}"#
    )
    .unwrap();
    writeln!(
        f,
        r#"{{"timestamp":"2026-04-10T11:00:30Z","type":"turn_context","payload":{{"model":"gpt-5-codex"}}}}"#
    )
    .unwrap();
    writeln!(
        f,
        r#"{{"timestamp":"2026-04-10T11:01:00Z","type":"response_item","payload":{{"type":"user_message","message":"codex prompt"}}}}"#
    )
    .unwrap();
    writeln!(
        f,
        r#"{{"timestamp":"2026-04-10T11:01:30Z","type":"response_item","payload":{{"type":"agent_message","message":"codex result"}}}}"#
    )
    .unwrap();
    writeln!(
        f,
        r#"{{"timestamp":"2026-04-10T11:02:00Z","type":"event_msg","payload":{{"type":"token_count","info":{{"total_token_usage":{{"input_tokens":1000,"cached_input_tokens":200,"output_tokens":500}}}}}}}}"#
    )
    .unwrap();
}

#[test]
fn api_default_config_indexes_and_queries_temp_home() {
    let _guard = env_lock().lock().unwrap();
    let home = TempDir::new().unwrap();
    let state = TempDir::new().unwrap();
    write_session(&home);

    let old_home = std::env::var_os("HOME");
    let old_state = std::env::var_os("CLAUDEX_DIR");
    unsafe {
        std::env::set_var("HOME", home.path());
        std::env::set_var("CLAUDEX_DIR", state.path());
    }

    let result = (|| {
        let mut cx = Claudex::new()?;
        cx.ensure_fresh()?;

        let summary = cx.summary(Filter::default())?;
        assert_eq!(summary.total_sessions, 1);

        let sessions = cx.sessions(None, None, Filter::default(), 10)?;
        assert_eq!(sessions.len(), 1);
        assert_eq!(sessions[0].provider, "claude");

        let hits = cx.search("api", Filter::default(), 10)?;
        assert!(!hits.is_empty());

        let filtered = cx.sessions(
            None,
            None,
            Filter {
                providers: vec![Provider::Claude],
                model: Some("sonnet".to_string()),
                since: Some("2026-04-01".to_string()),
                until: Some("2026-04-30".to_string()),
                on_disk_only: true,
            },
            10,
        )?;
        assert_eq!(filtered.len(), 1);

        Ok::<(), Box<dyn std::error::Error>>(())
    })();

    unsafe {
        match old_home {
            Some(value) => std::env::set_var("HOME", value),
            None => std::env::remove_var("HOME"),
        }
        match old_state {
            Some(value) => std::env::set_var("CLAUDEX_DIR", value),
            None => std::env::remove_var("CLAUDEX_DIR"),
        }
    }

    result.unwrap();
}

#[test]
fn api_configured_providers_scope_default_filters() {
    let _guard = env_lock().lock().unwrap();
    let home = TempDir::new().unwrap();
    let state = TempDir::new().unwrap();
    write_session(&home);
    write_codex_session(&home);

    let old_home = std::env::var_os("HOME");
    let old_state = std::env::var_os("CLAUDEX_DIR");
    unsafe {
        std::env::set_var("HOME", home.path());
        std::env::set_var("CLAUDEX_DIR", state.path());
    }

    let result = (|| {
        let mut all = Claudex::new()?;
        all.sync_now()?;
        assert_eq!(all.summary(Filter::default())?.total_sessions, 2);

        let mut codex_only = Claudex::with_config(ClaudexConfig {
            state_dir: Some(state.path().to_path_buf()),
            providers: vec![Provider::Codex],
        })?;

        let summary = codex_only.summary(Filter::default())?;
        assert_eq!(summary.total_sessions, 1);

        let sessions = codex_only.sessions(None, None, Filter::default(), 10)?;
        assert_eq!(sessions.len(), 1);
        assert_eq!(sessions[0].provider, "codex");

        let hits = codex_only.search("api", Filter::default(), 10)?;
        assert!(
            hits.is_empty(),
            "default Codex scope must not search existing Claude rows"
        );

        let explicit_codex = codex_only.sessions(
            None,
            None,
            Filter {
                providers: vec![Provider::Codex],
                ..Filter::default()
            },
            10,
        )?;
        assert_eq!(explicit_codex.len(), 1);

        let explicit_claude = codex_only.sessions(
            None,
            None,
            Filter {
                providers: vec![Provider::Claude],
                ..Filter::default()
            },
            10,
        )?;
        assert!(
            explicit_claude.is_empty(),
            "call filters should narrow within the configured provider scope"
        );

        let matches = codex_only.session_matches("codex-api", None)?;
        assert_eq!(matches.len(), 1);
        assert_eq!(matches[0].provider, "codex");

        let claude_matches = codex_only.session_matches("api-session", None)?;
        assert!(
            claude_matches.is_empty(),
            "session matching should not leak existing Claude rows"
        );

        let claude_detail = all
            .session_matches("api-session", None)?
            .into_iter()
            .find(|row| row.provider == "claude")
            .expect("claude row");
        assert!(
            codex_only
                .session_detail(&claude_detail.file_path)?
                .is_none(),
            "session detail should also honor configured provider scope"
        );

        Ok::<(), Box<dyn std::error::Error>>(())
    })();

    unsafe {
        match old_home {
            Some(value) => std::env::set_var("HOME", value),
            None => std::env::remove_var("HOME"),
        }
        match old_state {
            Some(value) => std::env::set_var("CLAUDEX_DIR", value),
            None => std::env::remove_var("CLAUDEX_DIR"),
        }
    }

    result.unwrap();
}

#[test]
fn api_report_methods_return_typed_results() {
    let _guard = env_lock().lock().unwrap();
    let home = TempDir::new().unwrap();
    let state = TempDir::new().unwrap();
    write_session(&home);

    let old_home = std::env::var_os("HOME");
    let old_state = std::env::var_os("CLAUDEX_DIR");
    unsafe {
        std::env::set_var("HOME", home.path());
        std::env::set_var("CLAUDEX_DIR", state.path());
    }

    let result = (|| {
        let mut cx = Claudex::with_config(ClaudexConfig {
            state_dir: Some(state.path().to_path_buf()),
            providers: vec![Provider::Claude],
        })?;

        assert_eq!(cx.force_rebuild()?, 1);
        assert_eq!(
            cx.index()
                .query_sessions(None, None, &Default::default(), 10)?
                .len(),
            1
        );
        cx.index_mut().ensure_fresh(&[])?;

        let filter = Filter::default();
        assert_eq!(cx.summary(filter.clone())?.total_sessions, 1);
        assert_eq!(cx.sessions(Some("api"), None, filter.clone(), 10)?.len(), 1);
        assert_eq!(cx.search("api", filter.clone(), 10)?.len(), 2);

        let costs_by_project = cx.costs_by_project(None, filter.clone(), 10)?;
        assert_eq!(costs_by_project.len(), 1);
        let costs_per_session = cx.costs_per_session(None, filter.clone(), 10)?;
        assert_eq!(costs_per_session.len(), 1);
        let cost_summary = cx.cost_summary(None, filter.clone())?;
        assert_eq!(cost_summary.session_count, 1);

        let tools = cx.tools(None, filter.clone(), 10)?;
        assert_eq!(tools.len(), 1);
        assert_eq!(tools[0].tool_name, "Read");
        let tools_per_session = cx.tools_per_session(None, filter.clone(), 10)?;
        assert_eq!(tools_per_session.len(), 1);
        assert_eq!(tools_per_session[0].tools.get("Read"), Some(&1));

        let models = cx.models(None, filter.clone())?;
        assert_eq!(models.len(), 1);
        assert!(models[0].model.contains("sonnet"));

        assert_eq!(cx.timeline(filter.clone(), false, 10)?.len(), 1);
        assert_eq!(cx.timeline(filter.clone(), true, 10)?.len(), 1);

        let activity = cx.activity(filter.clone(), 10)?;
        assert_eq!(activity.recent_sessions.len(), 1);
        assert_eq!(activity.recent_prs.len(), 1);
        assert_eq!(activity.hot_files.len(), 1);
        assert_eq!(activity.slow_projects.len(), 1);

        let status = cx.provider_status(filter.clone(), false)?;
        assert_eq!(status.len(), 1);
        assert_eq!(status[0].provider, "claude");

        let retention = cx.retention_stats(filter.clone())?;
        assert_eq!(retention.total_sessions, 1);

        let matches = cx.session_matches("api-session", None)?;
        assert_eq!(matches.len(), 1);
        let detail = cx
            .session_detail(&matches[0].file_path)?
            .expect("session detail");
        assert_eq!(detail.provider, "claude");

        let prs = cx.prs(None, filter.clone(), 10)?;
        assert_eq!(prs.len(), 1);
        assert_eq!(prs[0].pr_number, 66);

        let files = cx.files(None, Some("api.rs"), filter.clone(), 10)?;
        assert_eq!(files.len(), 1);
        assert_eq!(files[0].file_path, "src/api.rs");

        let turns = cx.turns(None, filter, 10)?;
        assert_eq!(turns.len(), 1);
        assert_eq!(turns[0].turn_count, 1);

        Ok::<(), Box<dyn std::error::Error>>(())
    })();

    unsafe {
        match old_home {
            Some(value) => std::env::set_var("HOME", value),
            None => std::env::remove_var("HOME"),
        }
        match old_state {
            Some(value) => std::env::set_var("CLAUDEX_DIR", value),
            None => std::env::remove_var("CLAUDEX_DIR"),
        }
    }

    result.unwrap();
}

#[test]
fn library_crate_omits_cli_only_dependencies() {
    let manifest =
        fs::read_to_string(format!("{}/Cargo.toml", env!("CARGO_MANIFEST_DIR"))).unwrap();
    for forbidden in [
        "clap",
        "clap_complete",
        "comfy-table",
        "indicatif",
        "owo-colors",
        "terminal_size",
        "tar",
        "flate2",
        "sha2",
    ] {
        assert!(
            !manifest.contains(&format!("{forbidden}.workspace")),
            "library crate should not depend on {forbidden}"
        );
    }
}