git-slop 0.9.1

Deterministic repository health and maintenance-pressure analysis for humans and AI agents.
Documentation
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

use assert_cmd::cargo::cargo_bin_cmd;
use predicates::prelude::*;
use serde_json::Value;
use tempfile::TempDir;

fn manifest_dir() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}

fn fixture(name: &str) -> PathBuf {
    manifest_dir().join("tests/fixtures/reports").join(name)
}

fn git(repository: &Path, args: &[&str]) {
    let output = Command::new("git")
        .current_dir(repository)
        .args(args)
        .output()
        .expect("run git");
    assert!(
        output.status.success(),
        "git {} failed: {}",
        args.join(" "),
        String::from_utf8_lossy(&output.stderr)
    );
}

fn read_json(path: &Path) -> Value {
    serde_json::from_slice(&fs::read(path).expect("read JSON file")).expect("parse JSON file")
}

fn assert_prompt_pack_safety(pack: &Path) -> Value {
    assert!(pack.join("prompt.md").is_file(), "missing prompt.md");
    assert!(pack.join("README.md").is_file(), "missing README.md");

    let context = read_json(&pack.join("context.json"));
    assert_eq!(context["prompt_pack_version"], 1);
    assert_eq!(context["report_excerpt"]["schema_version"], 4);

    let boundary = context["boundary"].as_str().expect("prompt-pack boundary");
    assert!(boundary.contains("advisory only"));
    assert!(boundary.contains("must not rescore detector truth"));
    assert!(boundary.contains("mutate code, GitHub, or report data"));

    let prompt = fs::read_to_string(pack.join("prompt.md")).expect("read prompt.md");
    assert!(prompt.contains("Use only the facts in context.json"));
    assert!(prompt.contains("do not rescore detector truth"));
    assert!(prompt.contains("instead of inventing context"));

    let readme = fs::read_to_string(pack.join("README.md")).expect("read README.md");
    assert!(readme.contains("must not mutate code, GitHub, or detector truth"));
    assert!(readme.contains("must not rescore detector truth"));

    context
}

#[test]
fn help_lists_the_current_command_surface_only() {
    let output = cargo_bin_cmd!("git-slop")
        .current_dir(manifest_dir())
        .arg("--help")
        .output()
        .expect("run help");
    assert!(
        output.status.success(),
        "help failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8(output.stdout).expect("help is UTF-8");
    for command in [
        "init", "find", "show", "explain", "plan", "check", "compare", "sarif", "health", "version",
    ] {
        assert!(
            stdout.contains(&format!("\n  {command}")),
            "help omitted {command}:\n{stdout}"
        );
    }
    assert!(!stdout.contains("refactor-preview"));
}

#[test]
fn removed_refactor_preview_subcommand_is_rejected_by_clap() {
    cargo_bin_cmd!("git-slop")
        .current_dir(manifest_dir())
        .arg("refactor-preview")
        .assert()
        .code(2)
        .stderr(predicate::str::contains("unrecognized subcommand"))
        .stderr(predicate::str::contains("refactor-preview"));
}

#[test]
fn report_consumers_preserve_missing_report_exit_two() {
    let temporary = TempDir::new().expect("temporary directory");
    let missing = temporary.path().join("missing-report.json");
    let missing_display = missing.to_string_lossy().into_owned();

    for args in [
        vec!["show", "README.md", "--report", &missing_display],
        vec!["check", "--report", &missing_display],
        vec![
            "explain",
            "--path",
            "README.md",
            "--report",
            &missing_display,
        ],
        vec!["plan", "--path", "README.md", "--report", &missing_display],
    ] {
        cargo_bin_cmd!("git-slop")
            .current_dir(manifest_dir())
            .args(&args)
            .assert()
            .code(2)
            .stdout(predicate::str::contains(format!(
                "Report not found: {missing_display}"
            )));
    }
}

#[test]
fn check_rejects_the_removed_priority_band_flag() {
    cargo_bin_cmd!("git-slop")
        .current_dir(manifest_dir())
        .args(["check", "--fail-on-priority-band", "critical"])
        .assert()
        .code(2)
        .stderr(predicate::str::contains("unexpected argument"))
        .stderr(predicate::str::contains("--fail-on-priority-band"));
}

#[test]
fn explain_prompt_pack_carries_the_native_payload_and_safety_boundary() {
    let temporary = TempDir::new().expect("temporary directory");
    let pack = temporary.path().join("explain-pack");
    let report = fixture("local_repo_folder_report.json");

    cargo_bin_cmd!("git-slop")
        .current_dir(manifest_dir())
        .args([
            "explain",
            "--report",
            report.to_str().expect("fixture path"),
            "--path",
            "src/git_slop",
            "--prompt-pack",
            pack.to_str().expect("prompt-pack path"),
            "--format",
            "json",
        ])
        .assert()
        .success();

    let context = assert_prompt_pack_safety(&pack);
    assert_eq!(context["command"], "explain");
    assert_eq!(context["payload"]["schema_version"], 2);
    assert_eq!(context["payload"]["target"]["path"], "src/git_slop");
}

#[test]
fn plan_prompt_pack_keeps_backlog_handoff_preview_only() {
    let temporary = TempDir::new().expect("temporary directory");
    let pack = temporary.path().join("plan-pack");
    let report = fixture("relationship_focused_report.json");

    cargo_bin_cmd!("git-slop")
        .current_dir(manifest_dir())
        .args([
            "plan",
            "--report",
            report.to_str().expect("fixture path"),
            "--relationship",
            "near_duplicate_neighborhood-35e7fad1c4e0",
            "--prompt-pack",
            pack.to_str().expect("prompt-pack path"),
            "--format",
            "json",
        ])
        .assert()
        .success();

    let context = assert_prompt_pack_safety(&pack);
    assert_eq!(context["command"], "plan");
    assert_eq!(context["payload"]["schema_version"], 2);
    assert_eq!(
        context["payload"]["backlog_handoff"]["mutation_policy"],
        "preview_only"
    );
}

#[test]
fn prompt_pack_rejects_an_existing_file_target() {
    let temporary = TempDir::new().expect("temporary directory");
    let pack = temporary.path().join("not-a-directory");
    fs::write(&pack, "occupied\n").expect("write occupied target");
    let report = fixture("local_repo_folder_report.json");

    cargo_bin_cmd!("git-slop")
        .current_dir(manifest_dir())
        .args([
            "explain",
            "--report",
            report.to_str().expect("fixture path"),
            "--top",
            "1",
            "--prompt-pack",
            pack.to_str().expect("prompt-pack path"),
        ])
        .assert()
        .code(2)
        .stdout(predicate::str::contains(format!(
            "Prompt pack path is not a directory: {}",
            pack.display()
        )));
}

#[test]
fn init_writes_schema_two_config_ignore_rules_and_state_directories() {
    let repository = TempDir::new().expect("temporary repository");
    git(repository.path(), &["init", "-b", "main"]);

    cargo_bin_cmd!("git-slop")
        .current_dir(repository.path())
        .arg("init")
        .assert()
        .success()
        .stdout(predicate::str::contains(
            "Initialized .slop/config.yaml (written).",
        ))
        .stdout(predicate::str::contains(
            "Ensured .slop/latest/, .slop/runs/, and .slop/cache/ exist.",
        ));

    let slop = repository.path().join(".slop");
    let config_path = slop.join("config.yaml");
    let gitignore_path = slop.join(".gitignore");
    assert!(config_path.is_file(), "missing config.yaml");
    assert!(gitignore_path.is_file(), "missing .gitignore");
    for directory in ["latest", "runs", "cache"] {
        assert!(slop.join(directory).is_dir(), "missing {directory}/");
    }

    let config: Value =
        serde_yaml::from_str(&fs::read_to_string(config_path).expect("read generated config.yaml"))
            .expect("parse generated config.yaml");
    assert_eq!(config["schema_version"], 2);
    assert!(config["tokenization"].is_object());

    assert_eq!(
        fs::read_to_string(gitignore_path).expect("read generated .gitignore"),
        "/latest/\n/runs/\n/cache/\n"
    );
}