shepherd-cli 6.4.5

The canonical shepherd command-line interface over the per-project registry, run artifacts, and sprint pipeline.
use std::{
    fs,
    path::{Path, PathBuf},
    process::{Command, Output},
    time::{SystemTime, UNIX_EPOCH},
};

fn binary() -> &'static str {
    env!("CARGO_BIN_EXE_shepherd")
}

fn fixture(label: &str) -> PathBuf {
    let nonce = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("clock")
        .as_nanos();
    let root = std::env::temp_dir().join(format!("shepherd-seed-{label}-{nonce:x}"));
    fs::create_dir_all(&root).expect("fixture");
    root
}

fn write(root: &Path, name: &str, content: &str) -> PathBuf {
    let path = root.join(name);
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).expect("fixture parent");
    }
    fs::write(&path, content).expect("fixture file");
    path
}

fn run(root: &Path, args: &[&str]) -> Output {
    Command::new(binary())
        .args(args)
        .current_dir(root)
        .env("HOME", root.join("home"))
        .env("SHEPHERD_HOME", root.join("home/.shepherd"))
        .output()
        .expect("native seed command")
}

#[test]
fn usage_and_validation_have_stable_streams_and_exit_codes() {
    let root = fixture("usage");
    let bare = run(&root, &["seed"]);
    assert!(bare.status.success());
    assert!(String::from_utf8_lossy(&bare.stdout).starts_with("shepherd seed verify"));
    assert!(bare.stderr.is_empty());

    let help = run(&root, &["seed", "--help"]);
    assert_eq!(help.stdout, bare.stdout);
    assert!(help.status.success());

    let unknown = run(&root, &["seed", "bogus"]);
    assert_eq!(unknown.status.code(), Some(2));
    assert!(unknown.stdout.is_empty());
    assert!(String::from_utf8_lossy(&unknown.stderr).starts_with("unknown subcommand: bogus\n"));

    let missing = run(&root, &["seed", "verify"]);
    assert_eq!(missing.status.code(), Some(2));
    assert_eq!(missing.stderr, b"ERR: seed verify needs a <path>\n");

    let flag = run(&root, &["seed", "verify", "--bogus"]);
    assert_eq!(flag.status.code(), Some(2));
    assert_eq!(flag.stderr, b"unknown flag: --bogus\n");
    fs::remove_dir_all(root).expect("cleanup");
}

#[test]
fn universal_hard_checks_preserve_order_and_quiet_only_suppresses_output() {
    let root = fixture("hard");
    let body = format!(
        "kind: patch-seed\nTODO: resolve\nLane 4 is prescriptive\n{}\n",
        (0..205)
            .map(|index| format!("line {index}"))
            .collect::<Vec<_>>()
            .join("\n")
    );
    let path = write(&root, "broken.seed.md", &body);
    let shown = path.to_string_lossy();
    let loud = run(&root, &["seed", "verify", &shown]);
    assert_eq!(loud.status.code(), Some(1));
    let stdout = String::from_utf8_lossy(&loud.stdout);
    let footprint = stdout.find("HARD  footprint").expect("footprint");
    let todo = stdout.find("HARD  TODO:/FIXME:").expect("todo");
    let lane = stdout.find("HARD  prescriptive 'Lane N'").expect("lane");
    assert!(footprint < todo && todo < lane);
    assert!(stdout.ends_with("FAIL: 3 hard failure(s), 0 warning(s)\n"));
    assert!(loud.stderr.is_empty());

    let quiet = run(&root, &["seed", "verify", &shown, "--quiet"]);
    assert_eq!(quiet.status.code(), Some(1));
    assert!(quiet.stdout.is_empty());
    assert!(quiet.stderr.is_empty());
    fs::remove_dir_all(root).expect("cleanup");
}

#[test]
fn file_scope_literals_new_markers_flow_lists_and_globs_are_deterministic() {
    let root = fixture("scope");
    let existing = write(&root, "src/exists.rs", "// fixture\n");
    let missing = root.join("src/missing.rs");
    let content = format!(
        "kind: sprint-seed\nmilestone: v1\nfile_scope:\n  exclusive: [{}, {}]\n  - {} (NEW - planned)\n  - {}\n---\n",
        existing.display(),
        missing.display(),
        root.join("src/new.rs").display(),
        root.join("src/*.rs").display(),
    );
    let seed = write(&root, "scope.seed.md", &content);
    let output = run(&root, &["seed", "verify", &seed.to_string_lossy()]);
    assert_eq!(output.status.code(), Some(1));
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains(&format!(
        "file_scope path does not resolve and is not marked (NEW): {}",
        missing.display()
    )));
    assert_eq!(
        stdout.matches("file_scope path does not resolve").count(),
        1
    );
    fs::remove_dir_all(root).expect("cleanup");
}

#[test]
fn deliverable_and_canonical_warning_rules_match_the_frozen_policy() {
    let root = fixture("canonical");
    let seed = write(
        &root,
        "canonical.seed.md",
        "kind: sprint-seed\n### Medium delivery [MEDIUM]\n**Priority:** MEDIUM\nPhase 0 mesh\n| 1 | first |\n| 2 | second |\n",
    );
    let output = run(&root, &["seed", "verify", &seed.to_string_lossy()]);
    assert_eq!(output.status.code(), Some(1));
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("1 deliverable block(s) carry a priority but no **GH:** anchor"));
    assert!(stdout.contains("Phase 0 mesh has 2 row(s) (< 8 recommended)"));
    assert!(stdout.contains("no deliverable ranked CRITICAL or HIGH"));
    assert!(stdout.contains("frontmatter missing 'milestone:'"));
    assert!(stdout.ends_with("FAIL: 1 hard failure(s), 3 warning(s)\n"));
    fs::remove_dir_all(root).expect("cleanup");
}

#[test]
fn clean_seed_and_trailing_blank_lines_are_stable() {
    let root = fixture("clean");
    let seed = write(
        &root,
        "clean.seed.md",
        "kind: patch-seed\nordinary prose\n\n\n",
    );
    let output = run(&root, &["seed", "verify", &seed.to_string_lossy()]);
    assert!(output.status.success());
    assert_eq!(output.stdout, b"OK: 0 hard failures, 0 warning(s)\n");
    assert!(output.stderr.is_empty());
    fs::remove_dir_all(root).expect("cleanup");
}

#[test]
fn crlf_input_has_stable_verdict_bytes() {
    let root = fixture("crlf");
    let seed = write(
        &root,
        "crlf.seed.md",
        "kind: sprint-seed\r\nordinary prose\r\n",
    );
    let output = run(&root, &["seed", "verify", &seed.to_string_lossy()]);
    assert!(output.status.success());
    assert_eq!(output.stdout, b"OK: 0 hard failures, 0 warning(s)\n");
    assert!(output.stderr.is_empty());
    fs::remove_dir_all(root).expect("cleanup");
}

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

    let root = fixture("shell-globs");
    let parent = root.parent().expect("fixture parent");
    let sibling_name = format!(
        "{}-shared",
        root.file_name().expect("fixture name").to_string_lossy()
    );
    let sibling = parent.join(&sibling_name);
    fs::create_dir_all(&sibling).expect("sibling fixture");
    write(&sibling, "b.rs", "// bracket range fixture\n");
    fs::create_dir_all(root.join("links")).expect("links fixture");
    symlink(root.join("missing-target"), root.join("links/dangling.rs"))
        .expect("dangling symlink fixture");

    let seed = write(
        &root,
        "glob.seed.md",
        &format!(
            "kind: sprint-seed\nmilestone: v1\nfile_scope:\n  - ../{sibling_name}/[a-z].rs\n  - links/dangling.*\n---\n"
        ),
    );
    let output = run(&root, &["seed", "verify", &seed.to_string_lossy()]);
    assert_eq!(
        output.status.code(),
        Some(0),
        "stdout={} stderr={}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    assert_eq!(output.stdout, b"OK: 0 hard failures, 0 warning(s)\n");
    assert!(output.stderr.is_empty());
    fs::remove_dir_all(root).expect("cleanup root");
    fs::remove_dir_all(sibling).expect("cleanup sibling");
}

#[test]
fn seed_input_is_bounded_before_allocating_the_whole_file() {
    let root = fixture("size-bound");
    let seed = root.join("oversized.seed.md");
    fs::write(&seed, vec![b'x'; 1_048_577]).expect("oversized fixture");

    let output = run(&root, &["seed", "verify", &seed.to_string_lossy()]);
    assert_eq!(output.status.code(), Some(1));
    assert!(output.stdout.is_empty());
    assert_eq!(
        String::from_utf8_lossy(&output.stderr),
        format!(
            "ERROR: seed input exceeds 1048576 bytes: {}\n",
            seed.display()
        )
    );
    fs::remove_dir_all(root).expect("cleanup");
}