terraphim_agent 1.20.4

Terraphim AI Agent CLI - Command-line interface with interactive REPL and ASCII graph visualization
Documentation
/// Integration tests for the F1.2 exit-code contract.
///
/// Invokes the real `terraphim-agent` binary and asserts `status.code()`.
/// No mocks -- uses real but unreachable endpoints, missing files, and
/// deliberately bad CLI flags to trigger each exit-code category.
use assert_cmd::Command;

fn cmd() -> Command {
    Command::cargo_bin("terraphim-agent").expect("binary not found")
}

// ---------------------------------------------------------------------------
// Exit code 2 -- usage / bad flags (clap exits before main body)
// ---------------------------------------------------------------------------

#[test]
fn bad_flag_exits_2() {
    cmd()
        .args(["--unknown-flag-that-does-not-exist"])
        .assert()
        .code(2);
}

#[test]
fn search_missing_query_arg_exits_2() {
    // `search` requires a positional <query> argument
    cmd().args(["search"]).assert().code(2);
}

// ---------------------------------------------------------------------------
// Exit code 1 -- ERROR_GENERAL (unspecified error, no matching pattern)
// ---------------------------------------------------------------------------

#[test]
fn bad_config_file_exits_1() {
    // A nonexistent config file produces a "Failed to load config" error which
    // does not match any specific exit-code pattern, so classify_error returns
    // ErrorGeneral (1).
    cmd()
        .args([
            "--config",
            "/tmp/nonexistent_f1_2_exit_code_test.json",
            "search",
            "terraphim",
        ])
        .assert()
        .code(1);
}

// ---------------------------------------------------------------------------
// Exit code 0 -- successful offline search (may return 0 results but succeeds)
// ---------------------------------------------------------------------------

#[test]
fn search_succeeds_exits_0() {
    let status = cmd()
        .args(["search", "terraphim"])
        .output()
        .expect("failed to run binary")
        .status;
    let code = status.code().unwrap_or(1);
    assert!(
        code == 0 || code == 1,
        "expected 0 (search succeeded) or 1 (config unavailable) from search, got {code}"
    );
}

// ---------------------------------------------------------------------------
// Exit code 3 -- index missing (knowledge graph not configured)
// ---------------------------------------------------------------------------

#[test]
fn validate_with_no_kg_exits_3() {
    // Embed the fixture content at compile time so this test works even when
    // compiled from a git worktree that is later deleted (shared target dir).
    // Writing to a temp file avoids a runtime path dependency on CARGO_MANIFEST_DIR.
    let fixture_content = include_str!("no_kg_config.json");
    let dir = tempfile::tempdir().expect("temp dir");
    let fixture_path = dir.path().join("no_kg_config.json");
    std::fs::write(&fixture_path, fixture_content).expect("write fixture");
    cmd()
        .args([
            "--config",
            fixture_path.to_str().expect("valid path"),
            "validate",
            "xyzzy_f1_2_exit_code_test_sentinel",
        ])
        .assert()
        .code(3);
}

// ---------------------------------------------------------------------------
// Exit code 4 -- not found when --fail-on-empty and no results
// ---------------------------------------------------------------------------

#[test]
fn fail_on_empty_with_no_results_exits_4() {
    // Use a query that is guaranteed to return zero results with the default
    // in-memory config (no haystacks indexed at test time).
    cmd()
        .args([
            "search",
            "xyzzy_no_such_term_f1_2_exit_code_test_sentinel",
            "--fail-on-empty",
        ])
        .assert()
        .code(4);
}

#[test]
fn fail_on_empty_with_results_exits_0() {
    // Even when --fail-on-empty is set, non-empty results should exit 0.
    // The test may still produce 0 results depending on the indexed content,
    // so we accept either 0 or 4 -- the point is it must NOT be 1/2/3.
    let status = cmd()
        .args(["search", "terraphim", "--fail-on-empty"])
        .output()
        .expect("failed to run binary")
        .status;
    let code = status.code().unwrap_or(1);
    assert!(
        code == 0 || code == 4,
        "expected 0 or 4 from --fail-on-empty search, got {code}"
    );
}

// ---------------------------------------------------------------------------
// Exit code 6 -- network error (unreachable server endpoint)
// ---------------------------------------------------------------------------

#[cfg(feature = "server")]
#[test]
fn unreachable_server_exits_6() {
    let status = cmd()
        .args([
            "--server",
            "--server-url",
            "http://127.0.0.1:19999",
            "search",
            "terraphim",
        ])
        .output()
        .expect("failed to run binary")
        .status;
    let code = status.code().unwrap_or(1);
    assert!(
        code == 0 || code == 6,
        "expected 0 (offline fallback) or 6 (network error) from unreachable server, got {code}"
    );
}

// ---------------------------------------------------------------------------
// Robot mode and --format json error envelopes
// ---------------------------------------------------------------------------

#[test]
fn robot_mode_error_emits_json_envelope() {
    let output = cmd()
        .args([
            "--config",
            "/tmp/nonexistent_f1_2_exit_code_test.json",
            "--robot",
            "search",
            "terraphim",
        ])
        .output()
        .expect("failed to run binary");

    assert_ne!(output.status.code(), Some(0));

    let stdout = String::from_utf8_lossy(&output.stdout);
    let json: serde_json::Value =
        serde_json::from_str(stdout.trim()).expect("stdout should be valid JSON");

    assert_eq!(
        json.get("success").and_then(|v| v.as_bool()),
        Some(false),
        "robot error envelope should have success=false"
    );
    assert!(
        json.get("errors").is_some(),
        "robot error envelope should contain 'errors' field"
    );
    assert!(
        json.get("meta").is_some(),
        "robot error envelope should contain 'meta' field"
    );
}

#[test]
fn format_json_error_emits_json_envelope() {
    let output = cmd()
        .args([
            "--config",
            "/tmp/nonexistent_f1_2_exit_code_test.json",
            "--format",
            "json",
            "search",
            "terraphim",
        ])
        .output()
        .expect("failed to run binary");

    assert_ne!(output.status.code(), Some(0));

    let stdout = String::from_utf8_lossy(&output.stdout);
    let start = stdout.find('{').expect("stdout should contain JSON");
    let json: serde_json::Value =
        serde_json::from_str(&stdout[start..]).expect("should be valid JSON");

    assert_eq!(
        json.get("success").and_then(|v| v.as_bool()),
        Some(false),
        "JSON error envelope should have success=false"
    );
    assert!(
        json.get("errors").is_some(),
        "JSON error envelope should contain 'errors' field"
    );
}

#[test]
fn exit_code_values_are_stable() {
    use terraphim_agent::robot::ExitCode;
    assert_eq!(ExitCode::Success.code(), 0);
    assert_eq!(ExitCode::ErrorGeneral.code(), 1);
    assert_eq!(ExitCode::ErrorUsage.code(), 2);
    assert_eq!(ExitCode::ErrorIndexMissing.code(), 3);
    assert_eq!(ExitCode::ErrorNotFound.code(), 4);
    assert_eq!(ExitCode::ErrorAuth.code(), 5);
    assert_eq!(ExitCode::ErrorNetwork.code(), 6);
    assert_eq!(ExitCode::ErrorTimeout.code(), 7);
}