repopilot 0.10.0

Local-first CLI for repository audit, architecture risk detection, baseline tracking, and CI-friendly code review.
Documentation
use serde_json::Value;
use std::fs;
use std::path::Path;
use std::process::{Command, Output};

fn repopilot_bin() -> &'static str {
    env!("CARGO_BIN_EXE_repopilot")
}

fn run(args: &[&str], cwd: &Path) -> Output {
    Command::new(repopilot_bin())
        .current_dir(cwd)
        .args(args)
        .output()
        .expect("failed to run repopilot")
}

fn run_ok(args: &[&str], cwd: &Path) -> Output {
    let output = run(args, cwd);
    assert!(
        output.status.success(),
        "command failed\nargs: {:?}\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}",
        args,
        output.status.code(),
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    output
}

fn stdout(output: &Output) -> String {
    String::from_utf8_lossy(&output.stdout).into_owned()
}

fn create_project() -> tempfile::TempDir {
    let temp = tempfile::tempdir().expect("temp dir");
    fs::create_dir_all(temp.path().join("src")).expect("src dir");
    fs::create_dir_all(temp.path().join("tests")).expect("tests dir");
    fs::write(
        temp.path().join("src/lib.rs"),
        "// TODO: tracked by CLI stabilization test\npub fn token() -> &'static str { \"sk_live_fake_repopilot_test_token_123456\" }\n",
    )
    .expect("source file");
    fs::write(
        temp.path().join("tests/lib_test.rs"),
        "#[test]\nfn token_exists() { assert!(demo::token().contains(\"sk_live\")); }\n",
    )
    .expect("test file");
    temp
}

#[test]
fn top_level_help_shows_stable_command_surface() {
    let temp = tempfile::tempdir().expect("temp dir");
    let output = run_ok(&["--help"], temp.path());
    let help = stdout(&output);

    for command in [
        "baseline", "compare", "scan", "review", "ai", "inspect", "init", "doctor",
    ] {
        assert!(help.contains(command), "help should show {command}\n{help}");
    }

    for removed in ["vibe", "harden", "prompt", "explain", "knowledge"] {
        assert!(
            !help.contains(&format!("  {removed}  ")),
            "top-level help should not list removed command {removed} as a subcommand\n{help}"
        );
    }
}

#[test]
fn scan_and_review_help_have_flag_descriptions() {
    let temp = tempfile::tempdir().expect("temp dir");
    let scan_help = stdout(&run_ok(&["scan", "--help"], temp.path()));
    let review_help = stdout(&run_ok(&["review", "--help"], temp.path()));

    assert!(scan_help.contains("Path to project, folder, or file to scan"));
    assert!(scan_help.contains("Write report to a file instead of stdout"));
    assert!(scan_help.contains("Scan each detected workspace package separately"));

    assert!(review_help.contains("Path to project, folder, or file to review"));
    assert!(review_help.contains("Base Git ref for branch/CI review"));
    assert!(review_help.contains("Exit with code 1 when in-diff findings"));
}

#[test]
fn grouped_ai_commands_work() {
    let project = create_project();

    let context = run_ok(
        &[
            "ai", "context", ".", "--focus", "security", "--budget", "2k",
        ],
        project.path(),
    );
    assert!(stdout(&context).contains("RepoPilot Vibe Check"));

    let plan = run_ok(&["ai", "plan", ".", "--budget", "2k"], project.path());
    assert!(stdout(&plan).contains("RepoPilot Harden Plan"));

    let prompt = run_ok(&["ai", "prompt", ".", "--budget", "2k"], project.path());
    assert!(stdout(&prompt).contains("RepoPilot Remediation Prompt"));
}

#[test]
fn inspect_commands_work() {
    let project = create_project();

    let explain = run_ok(
        &[
            "inspect",
            "explain",
            "src/lib.rs",
            "--format",
            "json",
            "--rule",
            "language.rust.panic-risk",
            "--signal",
            "rust.unwrap",
        ],
        project.path(),
    );

    let explain_json: Value =
        serde_json::from_slice(&explain.stdout).expect("inspect explain json");
    assert!(explain_json["context"].is_object());

    let knowledge = run_ok(
        &[
            "inspect",
            "knowledge",
            "--section",
            "rules",
            "--format",
            "json",
        ],
        project.path(),
    );

    let knowledge_json: Value =
        serde_json::from_slice::<Value>(&knowledge.stdout).expect("knowledge json");
    assert!(knowledge_json["summary"].is_object());
}

#[test]
fn hidden_legacy_commands_remain_executable() {
    let project = create_project();

    let vibe = run_ok(
        &["vibe", ".", "--focus", "security", "--budget", "2k"],
        project.path(),
    );
    assert!(stdout(&vibe).contains("RepoPilot Vibe Check"));

    let harden = run_ok(&["harden", ".", "--budget", "2k"], project.path());
    assert!(stdout(&harden).contains("RepoPilot Harden Plan"));

    let prompt = run_ok(&["prompt", ".", "--budget", "2k"], project.path());
    assert!(stdout(&prompt).contains("RepoPilot Remediation Prompt"));

    let explain = run_ok(
        &[
            "explain",
            "src/lib.rs",
            "--format",
            "json",
            "--rule",
            "language.rust.panic-risk",
            "--signal",
            "rust.unwrap",
        ],
        project.path(),
    );
    let explain_json: Value = serde_json::from_slice(&explain.stdout).expect("legacy explain json");
    assert!(explain_json["context"].is_object());

    let knowledge = run_ok(
        &["knowledge", "--section", "rules", "--format", "json"],
        project.path(),
    );
    let knowledge_json: Value =
        serde_json::from_slice::<Value>(&knowledge.stdout).expect("legacy knowledge json");
    assert!(knowledge_json["summary"].is_object());
}

#[test]
fn exit_codes_distinguish_findings_usage_and_runtime_errors() {
    let project = create_project();

    let threshold = run(
        &["scan", ".", "--fail-on", "low", "--format", "json"],
        project.path(),
    );
    assert_eq!(threshold.status.code(), Some(1));

    let usage = run(&["review", ".", "--head", "HEAD"], project.path());
    assert_eq!(usage.status.code(), Some(2));

    let runtime = run(&["scan", "missing-path"], project.path());
    assert_eq!(runtime.status.code(), Some(3));
}

#[test]
fn self_audit_stays_clean_at_high_severity() {
    let repo = Path::new(env!("CARGO_MANIFEST_DIR"));
    let output = run_ok(
        &["scan", ".", "--min-severity", "high", "--format", "json"],
        repo,
    );
    let json: Value = serde_json::from_slice(&output.stdout).expect("json output from self-audit");
    let finding_count = json["findings"]
        .as_array()
        .map(|a| a.len())
        .unwrap_or(usize::MAX);
    assert_eq!(
        finding_count,
        0,
        "self-audit high severity should stay clean\n{}",
        serde_json::to_string_pretty(&json).unwrap_or_default()
    );
}