llman 0.0.43

A tool for managing LLM application rules(prompts) ...
Documentation
mod common;

use common::{llman_command, prepare_work_and_config_dirs, run_llman};
use rusqlite::Connection;
use serde_json::Value;
use std::fs;
use tempfile::TempDir;

fn create_state_db(path: &std::path::Path) {
    let conn = Connection::open(path).expect("open sqlite");

    conn.execute(
        r#"
CREATE TABLE threads (
    id TEXT PRIMARY KEY,
    rollout_path TEXT,
    created_at INTEGER NOT NULL,
    updated_at INTEGER NOT NULL,
    cwd TEXT NOT NULL,
    title TEXT,
    tokens_used INTEGER NOT NULL DEFAULT 0
);
"#,
        (),
    )
    .expect("create threads table");
}

fn insert_thread(
    conn: &Connection,
    id: &str,
    cwd: &str,
    tokens_used: i64,
    rollout_path: Option<&str>,
) {
    conn.execute(
        "INSERT INTO threads (id, rollout_path, created_at, updated_at, cwd, title, tokens_used) VALUES (?1, ?2, 1, 2, ?3, 't', ?4);",
        (id, rollout_path, cwd, tokens_used),
    )
    .expect("insert thread");
}

#[test]
fn codex_stats_summary_json_filters_by_cwd() {
    let temp = TempDir::new().expect("temp dir");
    let (work_dir, config_dir) = prepare_work_and_config_dirs(temp.path());

    let db_path = temp.path().join("state_1.sqlite");
    create_state_db(&db_path);

    {
        let conn = Connection::open(&db_path).expect("open sqlite");
        insert_thread(&conn, "t1", &work_dir.to_string_lossy(), 9, None);
        insert_thread(&conn, "t2", "/other", 100, None);
    }

    let output = run_llman(
        &[
            "x",
            "codex",
            "stats",
            "--state-db",
            db_path.to_str().unwrap(),
            "--format",
            "json",
        ],
        &work_dir,
        &config_dir,
    );

    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let v: Value = serde_json::from_slice(&output.stdout).expect("parse json");
    assert_eq!(v["tool"], "codex");
    assert_eq!(v["view"], "summary");
    assert_eq!(v["result"]["coverage"]["total_sessions"], 1);
    assert_eq!(v["result"]["totals"]["tokens_total_known"], 9);
}

#[test]
fn codex_stats_session_json_with_breakdown() {
    let temp = TempDir::new().expect("temp dir");
    let (work_dir, config_dir) = prepare_work_and_config_dirs(temp.path());

    let rollout = temp.path().join("rollout.jsonl");
    fs::write(
        &rollout,
        r#"{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1,"cached_input_tokens":2,"output_tokens":3,"reasoning_output_tokens":4,"total_tokens":10}}}}"#,
    )
    .expect("write rollout");

    let db_path = temp.path().join("state_1.sqlite");
    create_state_db(&db_path);

    {
        let conn = Connection::open(&db_path).expect("open sqlite");
        insert_thread(
            &conn,
            "t1",
            &work_dir.to_string_lossy(),
            9,
            Some(&rollout.to_string_lossy()),
        );
    }

    let output = run_llman(
        &[
            "x",
            "codex",
            "stats",
            "--state-db",
            db_path.to_str().unwrap(),
            "--with-breakdown",
            "--view",
            "session",
            "--id",
            "t1",
            "--format",
            "json",
        ],
        &work_dir,
        &config_dir,
    );

    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let v: Value = serde_json::from_slice(&output.stdout).expect("parse json");
    assert_eq!(v["view"], "session");
    assert_eq!(v["result"]["session"]["id"], "t1");
    assert_eq!(v["result"]["session"]["token_usage"]["total"], 9);
    assert_eq!(v["result"]["session"]["token_usage"]["input"], 1);
    assert_eq!(v["result"]["session"]["token_usage"]["cache"], 2);
    assert_eq!(v["result"]["session"]["token_usage"]["output"], 3);
    assert_eq!(v["result"]["session"]["token_usage"]["reasoning"], 4);
}

#[test]
fn codex_stats_table_color_auto_has_no_ansi_when_captured() {
    let temp = TempDir::new().expect("temp dir");
    let (work_dir, config_dir) = prepare_work_and_config_dirs(temp.path());

    let db_path = temp.path().join("state_1.sqlite");
    create_state_db(&db_path);

    {
        let conn = Connection::open(&db_path).expect("open sqlite");
        insert_thread(&conn, "t1", &work_dir.to_string_lossy(), 9, None);
    }

    let output = run_llman(
        &[
            "x",
            "codex",
            "stats",
            "--state-db",
            db_path.to_str().unwrap(),
            "--format",
            "table",
            "--color",
            "auto",
        ],
        &work_dir,
        &config_dir,
    );

    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(
        !output.stdout.windows(2).any(|w| w == b"\x1b["),
        "expected no ANSI escape sequences in captured output, got:\n{}",
        String::from_utf8_lossy(&output.stdout)
    );
}

#[test]
fn codex_stats_table_color_always_includes_ansi_when_captured() {
    let temp = TempDir::new().expect("temp dir");
    let (work_dir, config_dir) = prepare_work_and_config_dirs(temp.path());

    let db_path = temp.path().join("state_1.sqlite");
    create_state_db(&db_path);

    {
        let conn = Connection::open(&db_path).expect("open sqlite");
        insert_thread(&conn, "t1", &work_dir.to_string_lossy(), 9, None);
    }

    let output = run_llman(
        &[
            "x",
            "codex",
            "stats",
            "--state-db",
            db_path.to_str().unwrap(),
            "--format",
            "table",
            "--color",
            "always",
        ],
        &work_dir,
        &config_dir,
    );

    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(
        output.stdout.windows(2).any(|w| w == b"\x1b["),
        "expected ANSI escape sequences with --color always, got:\n{}",
        String::from_utf8_lossy(&output.stdout)
    );
}

#[test]
fn codex_stats_json_is_never_colored() {
    let temp = TempDir::new().expect("temp dir");
    let (work_dir, config_dir) = prepare_work_and_config_dirs(temp.path());

    let db_path = temp.path().join("state_1.sqlite");
    create_state_db(&db_path);

    {
        let conn = Connection::open(&db_path).expect("open sqlite");
        insert_thread(&conn, "t1", &work_dir.to_string_lossy(), 9, None);
    }

    let output = run_llman(
        &[
            "x",
            "codex",
            "stats",
            "--state-db",
            db_path.to_str().unwrap(),
            "--format",
            "json",
            "--color",
            "always",
        ],
        &work_dir,
        &config_dir,
    );

    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(
        !output.stdout.windows(2).any(|w| w == b"\x1b["),
        "expected no ANSI escape sequences in json output, got:\n{}",
        String::from_utf8_lossy(&output.stdout)
    );
    let v: Value = serde_json::from_slice(&output.stdout).expect("parse json");
    assert_eq!(v["tool"], "codex");
}

#[test]
#[cfg(unix)]
fn codex_stats_table_no_color_env_disables_ansi_in_tty_auto_mode() {
    use expectrl::Session;
    use expectrl::process::unix::WaitStatus;
    use std::io::Read;

    let temp = TempDir::new().expect("temp dir");
    let (work_dir, config_dir) = prepare_work_and_config_dirs(temp.path());

    let db_path = temp.path().join("state_1.sqlite");
    create_state_db(&db_path);

    {
        let conn = Connection::open(&db_path).expect("open sqlite");
        insert_thread(&conn, "t1", &work_dir.to_string_lossy(), 9, None);
    }

    let mut cmd = llman_command(&config_dir);
    cmd.env("NO_COLOR", "1")
        .args([
            "x",
            "codex",
            "stats",
            "--state-db",
            db_path.to_str().unwrap(),
            "--format",
            "table",
            "--color",
            "auto",
        ])
        .current_dir(&work_dir);

    let mut session = Session::spawn(cmd).expect("spawn llman in pty");
    let mut buf = Vec::new();
    session.read_to_end(&mut buf).expect("read stdout");
    assert_eq!(
        session.get_process().wait().expect("wait"),
        WaitStatus::Exited(session.get_process().pid(), 0)
    );
    assert!(
        !buf.windows(2).any(|w| w == b"\x1b["),
        "expected no ANSI escape sequences with NO_COLOR=1 in tty auto mode, got:\n{}",
        String::from_utf8_lossy(&buf)
    );
}