git-prism 0.10.0

Agent-optimized git data MCP server — structured change manifests and full file snapshots for LLM agents
#![cfg(unix)]

//! Adversarial QA (issue #325, gate 3): auto-PATH setup UX.
//!
//! These drive the built binary end-to-end via `git-prism shim install`,
//! injecting stdin and an isolated HOME. They probe the idempotency /
//! consent logic in `src/shim_cmd.rs`.
//!
//! Updated for issue #355: install now writes a marker-delimited block to
//! `~/.zshenv` + `~/.zshrc` (not a single export line to `~/.zprofile`).

use std::process::{Command, Stdio};

use tempfile::TempDir;

/// Clean PATH that does NOT lead with the isolated shim dir, so detection always
/// reports "not first in PATH" and the consent prompt fires.
const CLEAN_PATH: &str = "/usr/bin:/bin:/usr/sbin:/sbin";

const BLOCK_START_MARKER: &str = "# >>> git-prism shim >>>";

fn count_marker_blocks(rc: &str) -> usize {
    rc.matches(BLOCK_START_MARKER).count()
}

/// The old bug: when the rc file merely MENTIONS the fragment `.local/share/git-prism/bin`
/// in a comment, the fragment-based idempotency check false-positives and skips writing
/// the real block. With marker-based idempotency this is fixed: the start marker must be
/// present (not any fragment) for the block to be considered already written.
#[test]
fn consent_writes_block_even_when_fragment_appears_in_unrelated_line() {
    let bin = env!("CARGO_BIN_EXE_git-prism");
    let home = TempDir::new().unwrap();
    // Pre-populate .zshenv with a comment that contains the path fragment.
    // With the old fragment-based check this would have caused a false positive.
    let zshenv = home.path().join(".zshenv");
    std::fs::write(
        &zshenv,
        "# I once read about .local/share/git-prism/bin in the docs\n",
    )
    .unwrap();

    let out = Command::new(bin)
        .env("HOME", home.path())
        .env("PATH", CLEAN_PATH)
        .env("SHELL", "/bin/zsh")
        .args(["shim", "install"])
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .and_then(|mut child| {
            use std::io::Write;
            child.stdin.take().unwrap().write_all(b"y\n").unwrap();
            child.wait_with_output()
        })
        .unwrap();

    let stdout = String::from_utf8_lossy(&out.stdout);
    let zshenv_after = std::fs::read_to_string(&zshenv).unwrap();

    // The tool must report success...
    assert!(
        stdout.contains("Added to"),
        "consent path must print the success message; got: {stdout}"
    );
    // ...and exactly one marker block must be present in .zshenv.
    assert_eq!(
        count_marker_blocks(&zshenv_after),
        1,
        "consent must write exactly one marker block to .zshenv even when the \
         fragment appears as an unrelated substring; .zshenv was:\n{zshenv_after}"
    );
}

/// A shim dir already FIRST on PATH (even with a trailing slash) must be recognized
/// as already-in-position and suppress the consent prompt entirely.
///
/// The check is now "is the shim dir first?" not "is it present anywhere?", so a
/// trailing slash on the first entry must still count as already-first.
#[test]
fn trailing_slash_path_entry_is_recognized_as_already_first() {
    let bin = env!("CARGO_BIN_EXE_git-prism");
    let home = TempDir::new().unwrap();
    std::fs::write(home.path().join(".zshenv"), "# zshenv\n").unwrap();
    std::fs::write(home.path().join(".zshrc"), "# rc\n").unwrap();
    let shim_dir = home.path().join(".local/share/git-prism/bin");

    // Put the shim dir FIRST with a trailing slash.
    let path_with_trailing_slash = format!("{}/:{}", shim_dir.display(), CLEAN_PATH);

    let out = Command::new(bin)
        .env("HOME", home.path())
        .env("PATH", path_with_trailing_slash)
        .env("SHELL", "/bin/zsh")
        .args(["shim", "install"])
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .spawn()
        .and_then(|mut child| {
            use std::io::Write;
            child.stdin.take().unwrap().write_all(b"n\n").unwrap();
            child.wait_with_output()
        })
        .unwrap();

    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(
        !stdout.contains("not first"),
        "shim dir first with a trailing slash should count as already-first-in-PATH; \
         got prompt: {stdout}"
    );
    // Recognized-as-first must mean rc files are left untouched.
    let zshenv_after = std::fs::read_to_string(home.path().join(".zshenv")).unwrap();
    assert_eq!(
        zshenv_after, "# zshenv\n",
        "already-first detection must not append a block to .zshenv; got:\n{zshenv_after}"
    );
    let zshrc_after = std::fs::read_to_string(home.path().join(".zshrc")).unwrap();
    assert_eq!(
        zshrc_after, "# rc\n",
        "already-first detection must not append a block to .zshrc; got:\n{zshrc_after}"
    );
}