shepherd-cli 6.8.3

Small installed-plugin diagnostics for the Shepherd workflow
use std::path::Path;
use std::process::{Command, Output};

fn shepherd(root: &Path, args: &[&str]) -> Output {
    Command::new(env!("CARGO_BIN_EXE_shepherd"))
        .args(args)
        .arg("--root")
        .arg(root)
        .output()
        .unwrap()
}

#[test]
fn init_and_lane_create_only_the_focused_workspace() {
    let project = tempfile::tempdir().unwrap();
    let initialized = shepherd(project.path(), &["init"]);
    assert!(
        initialized.status.success(),
        "{}",
        String::from_utf8_lossy(&initialized.stderr)
    );
    assert!(project.path().join(".shepherd/.gitignore").is_file());
    assert!(project.path().join(".shepherd/runs").is_dir());

    let lane = shepherd(
        project.path(),
        &["lane", "--run", "release-1", "--name", "cli"],
    );
    assert!(
        lane.status.success(),
        "{}",
        String::from_utf8_lossy(&lane.stderr)
    );
    let lane_root = project.path().join(".shepherd/runs/release-1/lanes/cli");
    assert!(lane_root.join("plan.md").is_file());
    assert!(lane_root.join("ledger.md").is_file());
    assert!(lane_root.join("evidence").is_dir());
    assert!(
        std::fs::read_to_string(lane_root.join("ledger.md"))
            .unwrap()
            .contains("Status: pending")
    );

    assert!(shepherd(project.path(), &["init"]).status.success());
    assert!(
        shepherd(
            project.path(),
            &["lane", "--run", "release-1", "--name", "cli"]
        )
        .status
        .success()
    );
}

#[test]
fn fresh_workspace_keeps_durable_reports_visible_and_scratch_ignored() {
    let project = tempfile::tempdir().unwrap();
    let git = |args: &[&str]| {
        Command::new("git")
            .args(["-c", "core.excludesFile=", "-C"])
            .arg(project.path())
            .args(args)
            .output()
            .unwrap()
    };
    assert!(git(&["init", "--quiet"]).status.success());
    assert!(shepherd(project.path(), &["init"]).status.success());

    for path in [
        ".shepherd/.gitignore",
        ".shepherd/runs/r1/seed.md",
        ".shepherd/runs/r1/plan.md",
        ".shepherd/runs/r1/reports/result.md",
        ".shepherd/runs/r1/audits/review.md",
        ".shepherd/runs/r1/lanes/cli/ledger.md",
        ".shepherd/runs/r1/lanes/cli/evidence/repro.sh",
    ] {
        let result = git(&["check-ignore", "--no-index", "--quiet", path]);
        assert_eq!(
            result.status.code(),
            Some(1),
            "durable evidence is ignored: {path}: {}",
            String::from_utf8_lossy(&result.stderr)
        );
    }
    for path in [
        ".shepherd/private.env",
        ".shepherd/runs/r1/scratch.log",
        ".shepherd/runs/r1/reports/private.env",
        ".shepherd/runs/r1/reports/raw.json",
        ".shepherd/runs/r1/audits/token.txt",
    ] {
        let result = git(&["check-ignore", "--no-index", "--quiet", path]);
        assert_eq!(
            result.status.code(),
            Some(0),
            "scratch escaped the ignore boundary: {path}: {}",
            String::from_utf8_lossy(&result.stderr)
        );
    }
}

#[test]
fn lane_requires_init_and_safe_identifiers() {
    let project = tempfile::tempdir().unwrap();
    let missing = shepherd(project.path(), &["lane", "--run", "r1", "--name", "docs"]);
    assert!(!missing.status.success());
    assert!(String::from_utf8_lossy(&missing.stderr).contains("is not initialized"));

    assert!(shepherd(project.path(), &["init"]).status.success());
    for args in [
        ["lane", "--run", "../escape", "--name", "docs"],
        ["lane", "--run", "r1", "--name", "a/b"],
    ] {
        let invalid = shepherd(project.path(), &args);
        assert!(!invalid.status.success());
        assert!(String::from_utf8_lossy(&invalid.stderr).contains("must be 1-80 ASCII"));
    }
    assert!(!project.path().join("escape").exists());
}

#[test]
fn generated_files_are_never_overwritten() {
    let project = tempfile::tempdir().unwrap();
    assert!(shepherd(project.path(), &["init"]).status.success());
    let ignore = project.path().join(".shepherd/.gitignore");
    std::fs::write(&ignore, "custom\n").unwrap();
    let conflict = shepherd(project.path(), &["init"]);
    assert!(!conflict.status.success());
    assert_eq!(std::fs::read_to_string(ignore).unwrap(), "custom\n");
}

#[test]
#[cfg(unix)]
fn workspace_paths_cannot_follow_symlinks() {
    use std::os::unix::fs::symlink;

    let project = tempfile::tempdir().unwrap();
    let outside = tempfile::tempdir().unwrap();
    symlink(outside.path(), project.path().join(".shepherd")).unwrap();
    let result = shepherd(project.path(), &["init"]);
    assert!(!result.status.success());
    assert!(String::from_utf8_lossy(&result.stderr).contains("linked"));
    assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
}

#[test]
fn edited_lane_records_and_evidence_survive_reinitialization() {
    let project = tempfile::tempdir().unwrap();
    assert!(shepherd(project.path(), &["init"]).status.success());
    let args = ["lane", "--run", "r1", "--name", "508-custody.12"];
    assert!(shepherd(project.path(), &args).status.success());
    let lane = project
        .path()
        .join(".shepherd/runs/r1/lanes/508-custody.12");
    let ledger = std::fs::read_to_string(lane.join("ledger.md")).unwrap();
    for field in [
        "Git common directory:",
        "Full branch ref:",
        "Base ref and SHA:",
        "Integration target:",
        "Tested candidate SHA:",
        "Accepted plan revision:",
    ] {
        assert!(ledger.contains(field), "missing custody field {field}");
    }
    std::fs::write(lane.join("plan.md"), "accepted edited plan\n").unwrap();
    std::fs::write(lane.join("ledger.md"), "live owner and candidate\n").unwrap();
    std::fs::write(lane.join("evidence/preserved.txt"), "precious evidence\n").unwrap();
    assert!(!shepherd(project.path(), &args).status.success());
    assert_eq!(
        std::fs::read_to_string(lane.join("plan.md")).unwrap(),
        "accepted edited plan\n"
    );
    assert_eq!(
        std::fs::read_to_string(lane.join("ledger.md")).unwrap(),
        "live owner and candidate\n"
    );
    assert_eq!(
        std::fs::read_to_string(lane.join("evidence/preserved.txt")).unwrap(),
        "precious evidence\n"
    );
}