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::path::PathBuf;

use tempfile::TempDir;

// We test the index through a standalone integration approach:
// create temp dirs mimicking ~/.claude/projects/<encoded-project>/*.jsonl,
// then use the index module's public API.

/// Helper: write JSONL lines to a file inside a temp project dir.
fn setup_project(
    base: &std::path::Path,
    project_encoded: &str,
    session_name: &str,
    lines: &[&str],
) -> PathBuf {
    let proj_dir = base.join(project_encoded);
    fs::create_dir_all(&proj_dir).expect("create project dir");
    let file_path = proj_dir.join(format!("{session_name}.jsonl"));
    let mut f = fs::File::create(&file_path).expect("create jsonl");
    for line in lines {
        writeln!(f, "{line}").expect("write line");
    }
    f.flush().expect("flush");
    file_path
}

/// Realistic session with user + assistant + system + pr-link + file-history-snapshot
fn realistic_session_lines() -> Vec<&'static str> {
    vec![
        r#"{"type":"user","sessionId":"sess-001","timestamp":"2026-04-18T10:00:00Z","message":{"content":"fix the bug"}}"#,
        r#"{"type":"assistant","sessionId":"sess-001","timestamp":"2026-04-18T10:01:00Z","message":{"model":"claude-opus-4-6","stop_reason":"tool_use","usage":{"input_tokens":500,"output_tokens":200,"cache_creation_input_tokens":100,"cache_read_input_tokens":1000,"inference_geo":"us-east-1","speed":35.2,"service_tier":"default","iterations":1},"content":[{"type":"thinking","text":"let me think"},{"type":"tool_use","name":"Bash","id":"t1","input":{"command":"ls"}},{"type":"text","text":"I found the issue"}]}}"#,
        r#"{"type":"assistant","sessionId":"sess-001","timestamp":"2026-04-18T10:02:00Z","message":{"model":"claude-opus-4-6","stop_reason":"end_turn","usage":{"input_tokens":300,"output_tokens":150,"cache_creation_input_tokens":50,"cache_read_input_tokens":800},"content":[{"type":"tool_use","name":"Edit","id":"t2","input":{}},{"type":"text","text":"Fixed it"}]}}"#,
        r#"{"type":"system","subtype":"turn_duration","durationMs":45000,"timestamp":"2026-04-18T10:01:30Z","sessionId":"sess-001"}"#,
        r#"{"type":"system","subtype":"turn_duration","durationMs":30000,"timestamp":"2026-04-18T10:02:30Z","sessionId":"sess-001"}"#,
        r#"{"type":"pr-link","prNumber":42,"prUrl":"https://github.com/org/repo/pull/42","prRepository":"org/repo","timestamp":"2026-04-18T10:03:00Z","sessionId":"sess-001"}"#,
        r#"{"type":"file-history-snapshot","snapshot":{"messageId":"m1","trackedFileBackups":{"src/main.rs":{"backupFileName":"x","version":1},"src/lib.rs":{"backupFileName":"y","version":1}},"timestamp":"2026-04-18T10:02:00Z"}}"#,
        r#"{"type":"permission-mode","mode":"bypassPermissions","timestamp":"2026-04-18T10:00:00Z","sessionId":"sess-001"}"#,
    ]
}

#[test]
fn session_stats_merge_rolls_up_child() {
    let dir = TempDir::new().unwrap();
    let parent_file = setup_project(
        dir.path(),
        "-Users-test-Projects-x",
        "parent-1",
        &[
            r#"{"type":"user","sessionId":"parent-1","timestamp":"2026-04-10T10:00:00Z","message":{"content":"go"}}"#,
            r#"{"type":"assistant","sessionId":"parent-1","timestamp":"2026-04-10T10:01:00Z","message":{"model":"claude-sonnet-4-6","usage":{"input_tokens":100,"output_tokens":10},"content":[{"type":"text","text":"ok"}]}}"#,
        ],
    );
    let child_file = setup_project(
        dir.path(),
        "-Users-test-Projects-x",
        "agent-child",
        &[
            r#"{"type":"assistant","isSidechain":true,"sessionId":"child-1","timestamp":"2026-04-10T10:03:00Z","message":{"model":"claude-opus-4-6","usage":{"input_tokens":900,"output_tokens":90},"content":[{"type":"tool_use","name":"Edit","id":"t2","input":{}},{"type":"text","text":"done"}]}}"#,
        ],
    );

    let mut parent = claudex::parser::parse_session(&parent_file).unwrap();
    let child = claudex::parser::parse_session(&child_file).unwrap();
    parent.merge(child);

    // Token usage and counts sum across parent + child.
    assert_eq!(parent.usage.input_tokens, 1000);
    assert_eq!(parent.usage.output_tokens, 100);
    // Per-model breakdown spans both models; identity stays the parent's.
    assert_eq!(parent.model_usage.len(), 2);
    assert_eq!(parent.session_id.as_deref(), Some("parent-1"));
    // Timestamp envelope covers both.
    assert_eq!(
        parent.last_timestamp.map(|t| t.to_rfc3339()),
        Some("2026-04-10T10:03:00+00:00".to_string())
    );
}

#[test]
fn test_parser_realistic_session() {
    let dir = TempDir::new().unwrap();
    let lines = realistic_session_lines();
    let file = setup_project(dir.path(), "-Users-test-Projects-myapp", "sess-001", &lines);

    let stats = claudex::parser::parse_session(&file).unwrap();

    assert_eq!(stats.session_id.as_deref(), Some("sess-001"));
    assert_eq!(stats.message_count, 3); // 1 user + 2 assistant
    assert_eq!(stats.model.as_deref(), Some("claude-opus-4-6"));
    assert_eq!(stats.usage.input_tokens, 800);
    assert_eq!(stats.usage.output_tokens, 350);
    assert_eq!(stats.usage.cache_creation_tokens, 150);
    assert_eq!(stats.usage.cache_read_tokens, 1800);
    assert_eq!(stats.tool_names, vec!["Bash", "Edit"]);
    assert_eq!(stats.model_usage.len(), 1);
    assert_eq!(
        stats
            .model_usage
            .get("claude-opus-4-6")
            .map(|m| m.assistant_message_count),
        Some(2)
    );
    assert_eq!(stats.thinking_block_count, 1);
    assert_eq!(stats.turn_durations.len(), 2);
    assert_eq!(stats.turn_durations[0].0, 45000);
    assert_eq!(stats.pr_links.len(), 1);
    assert_eq!(stats.pr_links[0].0, 42);
    assert_eq!(stats.pr_links[0].2, "org/repo");
    assert_eq!(stats.file_paths_modified.len(), 2);
    assert!(
        stats
            .file_paths_modified
            .contains(&"src/main.rs".to_string())
    );
    assert_eq!(stats.permission_modes.len(), 1);
    assert_eq!(stats.inference_geo.as_deref(), Some("us-east-1"));
    assert_eq!(stats.speed, Some(35.2));
    assert_eq!(stats.iterations, 1);
    assert_eq!(*stats.stop_reason_counts.get("tool_use").unwrap(), 1);
    assert_eq!(*stats.stop_reason_counts.get("end_turn").unwrap(), 1);
}

#[test]
fn test_parser_tracks_mixed_model_usage() {
    let dir = TempDir::new().unwrap();
    let file = setup_project(
        dir.path(),
        "-Users-test-Projects-myapp",
        "sess-mixed",
        &[
            r#"{"type":"user","sessionId":"sess-mixed","timestamp":"2026-04-18T10:00:00Z","message":{"content":"fix it"}}"#,
            r#"{"type":"assistant","sessionId":"sess-mixed","timestamp":"2026-04-18T10:01:00Z","message":{"model":"claude-opus-4-6","usage":{"input_tokens":100,"output_tokens":20,"cache_creation_input_tokens":10,"cache_read_input_tokens":50},"content":[{"type":"text","text":"first pass"}]}}"#,
            r#"{"type":"assistant","sessionId":"sess-mixed","timestamp":"2026-04-18T10:02:00Z","message":{"model":"claude-sonnet-4-6","usage":{"input_tokens":200,"output_tokens":40,"cache_creation_input_tokens":0,"cache_read_input_tokens":25},"content":[{"type":"text","text":"second pass"}]}}"#,
        ],
    );

    let stats = claudex::parser::parse_session(&file).unwrap();

    assert_eq!(stats.model_usage.len(), 2);
    assert_eq!(
        stats
            .model_usage
            .get("claude-opus-4-6")
            .map(|m| m.usage.input_tokens),
        Some(100)
    );
    assert_eq!(
        stats
            .model_usage
            .get("claude-sonnet-4-6")
            .map(|m| m.usage.input_tokens),
        Some(200)
    );
    let expected = claudex::types::TokenUsage {
        input_tokens: 100,
        output_tokens: 20,
        cache_creation_tokens: 10,
        cache_read_tokens: 50,
    }
    .cost_for_model(Some("claude-opus-4-6"))
        + claudex::types::TokenUsage {
            input_tokens: 200,
            output_tokens: 40,
            cache_creation_tokens: 0,
            cache_read_tokens: 25,
        }
        .cost_for_model(Some("claude-sonnet-4-6"));
    assert!((stats.cost_usd() - expected).abs() < 0.0001);
}

#[test]
fn test_store_canonical_project_path() {
    // Normal path
    assert_eq!(
        claudex::store::canonical_project_path("/Users/test/Projects/myapp"),
        "/Users/test/Projects/myapp"
    );

    // Worktree path should strip to parent
    assert_eq!(
        claudex::store::canonical_project_path(
            "/Users/test/Projects/myapp/.claude/worktrees/branch-hash"
        ),
        "/Users/test/Projects/myapp"
    );

    // Nested worktree
    assert_eq!(
        claudex::store::canonical_project_path(
            "/Users/test/Projects/deep/nested/.claude/worktrees/abc"
        ),
        "/Users/test/Projects/deep/nested"
    );
}

#[test]
fn test_store_decode_and_canonical() {
    let encoded = "-Users-test-Projects-myapp--claude-worktrees-branch-hash";
    let decoded = claudex::store::decode_project_name(encoded);
    assert_eq!(
        decoded,
        "/Users/test/Projects/myapp/.claude/worktrees/branch/hash"
    );
    let canonical = claudex::store::canonical_project_path(&decoded);
    assert_eq!(canonical, "/Users/test/Projects/myapp");
}

#[test]
fn test_cost_for_different_models() {
    use claudex::types::TokenUsage;

    let usage = TokenUsage {
        input_tokens: 1_000_000,
        output_tokens: 1_000_000,
        cache_creation_tokens: 0,
        cache_read_tokens: 0,
    };

    let opus_cost = usage.cost_for_model(Some("claude-opus-4-6"));
    let sonnet_cost = usage.cost_for_model(Some("claude-sonnet-4-6"));
    let haiku_cost = usage.cost_for_model(Some("claude-haiku-4-5-20251001"));

    // Opus 4.6: $5 input + $25 output = $30
    assert!((opus_cost - 30.0).abs() < 0.01);
    // Sonnet: $3 input + $15 output = $18
    assert!((sonnet_cost - 18.0).abs() < 0.01);
    // Haiku 4.5: $1 input + $5 output = $6
    assert!((haiku_cost - 6.0).abs() < 0.01);

    // Verify ordering
    assert!(opus_cost > sonnet_cost);
    assert!(sonnet_cost > haiku_cost);
}

#[test]
fn test_cache_read_cost_dominates() {
    use claudex::types::TokenUsage;

    // Realistic scenario: lots of cache reads
    let usage = TokenUsage {
        input_tokens: 1_000,
        output_tokens: 100_000,
        cache_creation_tokens: 10_000,
        cache_read_tokens: 1_000_000_000, // 1B cache reads
    };

    let cost = usage.cost_for_model(Some("claude-opus-4-6"));
    let cache_read_portion = 1_000_000_000.0 * 1.50 / 1_000_000.0;

    // Cache read should be the dominant cost
    assert!(cache_read_portion / cost > 0.95);
}

#[test]
fn test_format_duration_edge_cases() {
    use claudex::stats::format_duration;

    assert_eq!(format_duration(0), "-");
    assert_eq!(format_duration(999), "0s"); // sub-second rounds to 0
    assert_eq!(format_duration(1000), "1s");
    assert_eq!(format_duration(59_999), "59s");
    assert_eq!(format_duration(60_000), "1m0s");
    assert_eq!(format_duration(3_599_999), "59m59s");
    assert_eq!(format_duration(3_600_000), "1h0m");
    assert_eq!(format_duration(86_400_000), "24h0m");
}

#[test]
fn test_model_pricing_name_detection() {
    use claudex::types::ModelPricing;

    assert_eq!(ModelPricing::name(Some("claude-opus-4-6")), "Opus");
    assert_eq!(ModelPricing::name(Some("claude-opus-4-7")), "Opus");
    assert_eq!(ModelPricing::name(Some("claude-sonnet-4-6")), "Sonnet");
    assert_eq!(
        ModelPricing::name(Some("claude-haiku-4-5-20251001")),
        "Haiku"
    );
    assert_eq!(ModelPricing::name(Some("qwen3.6-35b-a3b-ud-mlx")), "Qwen");
    assert_eq!(ModelPricing::name(Some("ollama/qwen3.6:latest")), "Qwen");
    assert_eq!(ModelPricing::name(Some("ollama/gemma4:31b")), "Gemma");
    assert_eq!(ModelPricing::name(Some("<synthetic>")), "Other");
    assert_eq!(ModelPricing::name(None), "Sonnet"); // legacy fallback
    assert_eq!(ModelPricing::name(Some("")), "Sonnet"); // empty legacy fallback
}

#[test]
fn test_short_name_truncation() {
    use claudex::store::short_name;

    // Short paths pass through unchanged
    let short = "/Users/test/foo";
    assert_eq!(short_name(short), short);

    // Long paths get truncated with ellipsis at a path boundary
    let long = "/Users/jamesbrink/Projects/utensils/claudex/.claude/worktrees/something-very-long";
    let result = short_name(long);
    assert!(result.starts_with('…'));
    assert!(result.len() <= 55);
    // Should cut at a '/' boundary
    assert!(result[3..].starts_with('/') || result.len() == long.len());
}