koda-cli 0.5.0

A high-performance AI coding agent for macOS and Linux
//! CLI binary integration tests.
//!
//! Tests that the compiled binary handles command-line arguments correctly.
//! These run the actual `koda` binary as a subprocess.

use std::process::Command;

/// Get the path to the built binary.
fn koda_bin() -> String {
    // cargo test builds the binary in target/debug/
    let mut path = std::env::current_exe().unwrap();
    path.pop(); // remove test binary name
    path.pop(); // remove deps/
    path.push("koda");
    path.to_string_lossy().to_string()
}

#[test]
fn test_cli_version() {
    let output = Command::new(koda_bin())
        .arg("--version")
        .output()
        .expect("Failed to run koda --version");

    assert!(output.status.success(), "koda --version should succeed");
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        stdout.contains("koda"),
        "Version output should contain 'koda': {stdout}"
    );
    let version = env!("CARGO_PKG_VERSION");
    assert!(
        stdout.contains(version),
        "Version output should contain '{version}': {stdout}"
    );
    // #1259-followup: --version is the user-facing replacement for
    // the (removed) `koda doctor` subcommand. It MUST surface kernel
    // sandbox state on a one-liner so bug filers can paste it without
    // a second command. Pin both halves so a future refactor that
    // strips it becomes loud.
    assert!(
        stdout.contains("sandbox:"),
        "--version must include sandbox state for bug-report aid; got: {stdout}"
    );
    // The sandbox status is one of {available, UNAVAILABLE} and the
    // backend identifier is in brackets (e.g. `[seatbelt]`, `[bwrap]`,
    // `[none]`). Don't pin a specific backend — CI runs on
    // multiple platforms.
    assert!(
        stdout.contains("available") || stdout.contains("UNAVAILABLE"),
        "--version sandbox state must say available or UNAVAILABLE; got: {stdout}"
    );
    assert!(
        stdout.contains('[') && stdout.contains(']'),
        "--version must include backend identifier in brackets; got: {stdout}"
    );
}

#[test]
fn test_cli_help() {
    let output = Command::new(koda_bin())
        .arg("--help")
        .output()
        .expect("Failed to run koda --help");

    assert!(output.status.success(), "koda --help should succeed");
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("--agent"), "Help should mention --agent");
    assert!(stdout.contains("--resume"), "Help should mention --resume");
    assert!(
        stdout.contains("--provider"),
        "Help should mention --provider"
    );
    assert!(stdout.contains("--model"), "Help should mention --model");
}

#[test]
fn test_cli_invalid_flag() {
    let output = Command::new(koda_bin())
        .arg("--nonexistent-flag")
        .output()
        .expect("Failed to run koda with invalid flag");

    assert!(
        !output.status.success(),
        "Invalid flag should exit with error"
    );
}

#[test]
fn test_cli_help_mentions_headless() {
    let output = Command::new(koda_bin())
        .arg("--help")
        .output()
        .expect("Failed to run koda --help");

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        stdout.contains("--prompt") || stdout.contains("-p"),
        "Help should mention -p/--prompt for headless mode: {stdout}"
    );
    assert!(
        stdout.contains("--output-format"),
        "Help should mention --output-format: {stdout}"
    );
}

#[test]
fn test_cli_headless_piped_stdin_empty() {
    // Piping empty stdin should not hang — should detect empty and still work
    let output = Command::new(koda_bin())
        .arg("--help") // Just verify the binary handles stdin detection
        .stdin(std::process::Stdio::null())
        .output()
        .expect("Failed to run koda with null stdin");
    assert!(output.status.success());
}

/// Snapshot test — `koda --help` must match the committed snapshot.
///
/// Fails when any CLI flag is added, removed, renamed, or its description
/// edited. To update: run `cargo run -p koda-cli -- --help > \
/// koda-cli/tests/snapshots/help.snap`, then edit `docs/src/cli-reference.md`.
#[test]
fn test_cli_help_snapshot() {
    let output = Command::new(koda_bin())
        .arg("--help")
        .output()
        .expect("Failed to run koda --help");

    assert!(output.status.success());
    let actual = String::from_utf8_lossy(&output.stdout).replace("\r\n", "\n");
    let snapshot = include_str!("snapshots/help.snap").replace("\r\n", "\n");
    assert_eq!(
        actual.trim(),
        snapshot.trim(),
        concat!(
            "\n\nkoda --help output changed — update snapshot + docs:\n",
            "  cargo run -p koda-cli -- --help ",
            "> koda-cli/tests/snapshots/help.snap\n",
            "  # then edit docs/src/cli-reference.md\n"
        )
    );
}

/// Snapshot test — `koda server --help` must match the committed snapshot.
///
/// Fails when any server flag is added, removed, renamed, or its description
/// edited. To update: run `cargo run -p koda-cli -- server --help > \
/// koda-cli/tests/snapshots/server_help.snap`, then edit `docs/src/acp.md`.
#[test]
fn test_cli_server_help_snapshot() {
    let output = Command::new(koda_bin())
        .args(["server", "--help"])
        .output()
        .expect("Failed to run koda server --help");

    assert!(output.status.success());
    let actual = String::from_utf8_lossy(&output.stdout).replace("\r\n", "\n");
    let snapshot = include_str!("snapshots/server_help.snap").replace("\r\n", "\n");
    assert_eq!(
        actual.trim(),
        snapshot.trim(),
        concat!(
            "\n\nkoda server --help output changed — update snapshot + docs:\n",
            "  cargo run -p koda-cli -- server --help ",
            "> koda-cli/tests/snapshots/server_help.snap\n",
            "  # then edit docs/src/acp.md\n"
        )
    );
}

#[test]
fn test_cli_output_format_validates() {
    let output = Command::new(koda_bin())
        .args(["--output-format", "invalid_format", "-p", "test"])
        .output()
        .expect("Failed to run koda");
    // clap should reject invalid output formats
    assert!(
        !output.status.success(),
        "Invalid output-format should fail"
    );
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("invalid") || stderr.contains("possible values"),
        "Should mention invalid value: {stderr}"
    );
}

/// A bad context-window pin is an error at parse time, from the flag or the
/// env var, never a silent fall-back to the server's answer or the table.
#[test]
fn test_cli_max_context_tokens_rejects_zero_and_non_numbers() {
    for bad in ["0", "abc", "64k"] {
        let flag = Command::new(koda_bin())
            .args(["--max-context-tokens", bad, "-p", "test"])
            .output()
            .expect("Failed to run koda");
        assert!(!flag.status.success(), "--max-context-tokens {bad}");
        let stderr = String::from_utf8_lossy(&flag.stderr);
        assert!(
            stderr.contains("--max-context-tokens") && stderr.contains("context window"),
            "flag {bad}: {stderr}"
        );

        let env = Command::new(koda_bin())
            .env("KODA_MAX_CONTEXT_TOKENS", bad)
            .args(["-p", "test"])
            .output()
            .expect("Failed to run koda");
        assert!(!env.status.success(), "KODA_MAX_CONTEXT_TOKENS={bad}");
        let stderr = String::from_utf8_lossy(&env.stderr);
        assert!(
            stderr.contains("--max-context-tokens") && stderr.contains("context window"),
            "env {bad}: {stderr}"
        );
    }
}