repopilot 0.7.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;
use tempfile::tempdir;

fn repopilot() -> Command {
    Command::new(env!("CARGO_BIN_EXE_repopilot"))
}

#[test]
fn review_reports_working_tree_findings_on_changed_lines() {
    let temp = tempdir().expect("failed to create temp dir");
    init_repo(temp.path());
    write_covered_source(temp.path(), "lib", "pub fn live() {}\n");
    commit_all(temp.path(), "initial");

    fs::write(
        temp.path().join("src/lib.rs"),
        "pub fn live() {}\n// TODO: review this\n",
    )
    .expect("failed to modify source file");

    let json = run_review_json(temp.path(), &["review", ".", "--format", "json"]);

    assert_eq!(json["review"]["in_diff_findings"], 1);
    assert_eq!(json["review"]["out_of_diff_findings"], 0);
    assert_eq!(json["changed_files"][0]["path"], "src/lib.rs");
    assert!(
        json["findings"].as_array().unwrap().iter().any(|finding| {
            finding["rule_id"] == "code-marker.todo" && finding["in_diff"] == true
        })
    );
}

#[test]
fn review_treats_untracked_files_as_fully_changed() {
    let temp = tempdir().expect("failed to create temp dir");
    init_repo(temp.path());
    fs::create_dir_all(temp.path().join("src")).expect("failed to create src dir");
    fs::create_dir_all(temp.path().join("tests")).expect("failed to create tests dir");
    fs::write(temp.path().join("src/lib.rs"), "pub fn live() {}\n").expect("failed to write lib");
    fs::write(temp.path().join("tests/lib.rs"), "fn covers_lib() {}\n")
        .expect("failed to write test");
    commit_all(temp.path(), "initial");

    write_covered_source(
        temp.path(),
        "creds",
        "const API_KEY: &str = \"abc12345\";\n",
    );

    let json = run_review_json(temp.path(), &["review", ".", "--format", "json"]);

    assert!(
        json["changed_files"]
            .as_array()
            .unwrap()
            .iter()
            .any(|file| file["path"] == "src/creds.rs" && file["status"] == "untracked")
    );
    assert!(json["findings"].as_array().unwrap().iter().any(|finding| {
        finding["rule_id"] == "security.secret-candidate" && finding["in_diff"] == true
    }));
}

#[test]
fn review_accepts_file_paths() {
    let temp = tempdir().expect("failed to create temp dir");
    init_repo(temp.path());
    write_covered_source(temp.path(), "lib", "pub fn live() {}\n");
    commit_all(temp.path(), "initial");

    fs::write(
        temp.path().join("src/lib.rs"),
        "pub fn live() {}\n// TODO: review file path\n",
    )
    .expect("failed to modify source file");

    let json = run_review_json(temp.path(), &["review", "src/lib.rs", "--format", "json"]);

    assert_eq!(json["review"]["in_diff_findings"], 1);
    assert_eq!(json["changed_files"][0]["path"], "src/lib.rs");
}

#[test]
fn review_supports_base_head_refs() {
    let temp = tempdir().expect("failed to create temp dir");
    init_repo(temp.path());
    write_covered_source(temp.path(), "lib", "pub fn live() {}\n");
    commit_all(temp.path(), "initial");
    let base = git_output(temp.path(), &["rev-parse", "HEAD"]);

    fs::write(
        temp.path().join("src/lib.rs"),
        "pub fn live() {}\n// FIXME: changed on branch\n",
    )
    .expect("failed to modify source file");
    commit_all(temp.path(), "add fixme");

    let json = run_review_json(
        temp.path(),
        &["review", ".", "--base", base.trim(), "--format", "json"],
    );

    assert_eq!(json["review"]["in_diff_findings"], 1);
    assert!(json["findings"].as_array().unwrap().iter().any(|finding| {
        finding["rule_id"] == "code-marker.fixme" && finding["in_diff"] == true
    }));
}

#[test]
fn review_fail_on_new_high_ignores_out_of_diff_high_findings() {
    let temp = tempdir().expect("failed to create temp dir");
    init_repo(temp.path());
    write_covered_source(
        temp.path(),
        "config",
        "const API_KEY: &str = \"abc12345\";\n",
    );
    write_covered_source(temp.path(), "lib", "pub fn live() {}\n");
    commit_all(temp.path(), "initial");

    fs::write(
        temp.path().join("src/lib.rs"),
        "pub fn live() {}\n// TODO: changed low finding\n",
    )
    .expect("failed to modify source file");

    let output = repopilot()
        .args(["review", ".", "--fail-on", "new-high"])
        .current_dir(temp.path())
        .output()
        .expect("failed to run review");

    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("CI gate: passed (new-high)"));
    assert!(stdout.contains("Out-of-diff findings: 1"));
}

#[test]
fn review_fail_on_new_high_uses_baseline_status_for_in_diff_findings() {
    let temp = tempdir().expect("failed to create temp dir");
    init_repo(temp.path());
    write_covered_source(
        temp.path(),
        "config",
        "const API_KEY: &str = \"abc12345\";\n",
    );
    commit_all(temp.path(), "initial");
    create_baseline(temp.path());
    commit_all(temp.path(), "baseline");

    fs::write(
        temp.path().join("src/config.rs"),
        "const API_KEY: &str = \"def67890\";\n",
    )
    .expect("failed to modify existing secret");

    let passing = repopilot()
        .args([
            "review",
            ".",
            "--baseline",
            ".repopilot/baseline.json",
            "--fail-on",
            "new-high",
        ])
        .current_dir(temp.path())
        .output()
        .expect("failed to run review");

    assert!(passing.status.success());
    assert!(String::from_utf8_lossy(&passing.stdout).contains("CI gate: passed (new-high)"));

    fs::write(
        temp.path().join("src/config.rs"),
        "const API_KEY: &str = \"def67890\";\nconst ACCESS_TOKEN: &str = \"abc12345\";\n",
    )
    .expect("failed to add new secret");

    let failing = repopilot()
        .args([
            "review",
            ".",
            "--baseline",
            ".repopilot/baseline.json",
            "--fail-on",
            "new-high",
        ])
        .current_dir(temp.path())
        .output()
        .expect("failed to run review");

    assert!(!failing.status.success());
    assert!(String::from_utf8_lossy(&failing.stdout).contains("CI gate: failed (new-high)"));
    assert!(String::from_utf8_lossy(&failing.stderr).contains("RepoPilot CI Gate failed"));
}

#[test]
fn review_rejects_head_without_base() {
    let temp = tempdir().expect("failed to create temp dir");
    init_repo(temp.path());
    write_covered_source(temp.path(), "lib", "pub fn live() {}\n");
    commit_all(temp.path(), "initial");

    let output = repopilot()
        .args(["review", ".", "--head", "HEAD"])
        .current_dir(temp.path())
        .output()
        .expect("failed to run review");

    assert!(!output.status.success());
    assert!(String::from_utf8_lossy(&output.stderr).contains("--head` requires --base"));
}

fn run_review_json(root: &Path, args: &[&str]) -> Value {
    let output = repopilot()
        .args(args)
        .current_dir(root)
        .output()
        .expect("failed to run review");

    assert!(output.status.success());
    serde_json::from_slice(&output.stdout).expect("expected JSON output")
}

fn create_baseline(root: &Path) {
    let output = repopilot()
        .args(["baseline", "create", "."])
        .current_dir(root)
        .output()
        .expect("failed to run baseline create");
    assert!(output.status.success());
}

fn write_covered_source(root: &Path, module: &str, content: &str) {
    fs::create_dir_all(root.join("src")).expect("failed to create src dir");
    fs::create_dir_all(root.join("tests")).expect("failed to create tests dir");
    fs::write(root.join(format!("src/{module}.rs")), content).expect("failed to write source");
    fs::write(
        root.join(format!("tests/{module}.rs")),
        format!("fn covers_{module}() {{}}\n"),
    )
    .expect("failed to write test");
}

fn init_repo(root: &Path) {
    git(root, &["init"]);
    git(root, &["config", "user.email", "repopilot@example.invalid"]);
    git(root, &["config", "user.name", "RepoPilot Test"]);
}

fn commit_all(root: &Path, message: &str) {
    git(root, &["add", "."]);
    git(root, &["commit", "-m", message]);
}

fn git_output(root: &Path, args: &[&str]) -> String {
    let output = Command::new("git")
        .args(args)
        .current_dir(root)
        .output()
        .expect("failed to run git");

    assert!(output.status.success());
    String::from_utf8_lossy(&output.stdout).to_string()
}

fn git(root: &Path, args: &[&str]) {
    let output = Command::new("git")
        .args(args)
        .current_dir(root)
        .output()
        .expect("failed to run git");

    assert!(
        output.status.success(),
        "git {:?} failed: {}",
        args,
        String::from_utf8_lossy(&output.stderr)
    );
}