smackdebt 0.3.0

Find costly code debt and see whether your changes make it better
use std::fs;
use std::path::Path;
use std::process::Command as Process;

use assert_cmd::Command;
use serde_json::{Value, json};
use tempfile::TempDir;

fn git(path: &Path, args: &[&str]) {
    let output = Process::new("git")
        .current_dir(path)
        .args(args)
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );
}

fn repository() -> TempDir {
    let repo = TempDir::new().unwrap();
    git(repo.path(), &["init", "-q"]);
    git(repo.path(), &["config", "user.email", "test@example.com"]);
    git(repo.path(), &["config", "user.name", "Test"]);
    fs::write(
        repo.path().join("Cargo.toml"),
        "[package]\nname = \"sample\"\nversion = \"0.1.0\"\n",
    )
    .unwrap();
    fs::create_dir(repo.path().join("src")).unwrap();
    fs::write(
        repo.path().join("src/lib.rs"),
        "pub fn calculate(x: i32) -> i32 { x }\n",
    )
    .unwrap();
    git(repo.path(), &["add", "."]);
    git(repo.path(), &["commit", "-qm", "start"]);
    repo
}

fn worsened_source() -> String {
    let mut source = String::from("pub fn calculate(x: i32) -> i32 {\n");
    for threshold in 0..10 {
        source.push_str(&format!("if x > {threshold} {{\n"));
    }
    source.push_str("return x;\n");
    for _ in 0..10 {
        source.push_str("}\n");
    }
    source.push_str("x\n}\n");
    source
}

fn hook(repo: &TempDir, agent: &str, payload: Value) -> Value {
    let mut command = Command::new(assert_cmd::cargo::cargo_bin!("smackdebt"));
    let output = command
        .current_dir(repo.path())
        .env("XDG_CACHE_HOME", repo.path().join(".git/cache").join(agent))
        .args(["__hook", "--agent", agent])
        .write_stdin(payload.to_string())
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );
    serde_json::from_slice(&output.stdout).unwrap()
}

#[test]
fn hook_requests_one_focused_review_for_worsened_code() {
    let repo = repository();
    let cwd = repo.path().to_str().unwrap();
    assert_eq!(hook(&repo, "codex", json!({"cwd": cwd})), json!({}));
    fs::write(repo.path().join("src/lib.rs"), worsened_source()).unwrap();
    let first = hook(&repo, "codex", json!({"cwd": cwd}));
    assert_eq!(first["decision"], "block");
    assert!(first["reason"].as_str().unwrap().contains("worse 1"));
    assert_eq!(hook(&repo, "codex", json!({"cwd": cwd})), json!({}));
    assert_eq!(
        hook(
            &repo,
            "codex",
            json!({"cwd": cwd, "stop_hook_active": true})
        ),
        json!({})
    );
}

#[test]
fn cursor_uses_the_workspace_root_and_ignores_followup_turns() {
    let repo = repository();
    fs::write(repo.path().join("src/lib.rs"), worsened_source()).unwrap();
    let cwd = repo.path().to_str().unwrap();
    let result = hook(
        &repo,
        "cursor",
        json!({"workspace_roots": [cwd], "status": "completed", "loop_count": 0}),
    );
    assert!(
        result["followup_message"]
            .as_str()
            .is_some_and(|message| message.contains("Smackdebt"))
    );
    assert_eq!(
        hook(
            &repo,
            "cursor",
            json!({"workspace_roots": [cwd], "status": "completed", "loop_count": 1})
        ),
        json!({})
    );
}

#[test]
fn each_agent_receives_its_supported_review_response() {
    let repo = repository();
    fs::write(repo.path().join("src/lib.rs"), worsened_source()).unwrap();
    let cwd = repo.path().to_str().unwrap();
    for agent in ["codex", "claude-code", "cursor", "copilot", "gemini"] {
        let payload = if agent == "cursor" {
            json!({"workspace_roots": [cwd], "status": "completed"})
        } else {
            json!({"cwd": cwd})
        };
        let output = hook(&repo, agent, payload);
        let reason = match agent {
            "claude-code" => output["hookSpecificOutput"]["additionalContext"].as_str(),
            "cursor" => output["followup_message"].as_str(),
            _ => output["reason"].as_str(),
        };
        assert!(
            reason.is_some_and(|text| text.contains("worse 1")),
            "{agent}: {output}"
        );
        if agent == "gemini" {
            assert_eq!(output["decision"], "deny");
        }
    }
}