heddle-cli 0.3.1

An AI-native version control system
// SPDX-License-Identifier: Apache-2.0
use super::*;

#[test]
fn status_text_counts_dirty_worktree_paths() {
    let temp = TempDir::new().unwrap();
    heddle(&["init"], Some(temp.path())).unwrap();
    std::fs::write(temp.path().join("dirty.txt"), "pending").unwrap();

    let text = heddle(&["--output", "text", "status"], Some(temp.path())).unwrap();
    assert!(
        text.contains("Changed paths: 1"),
        "dirty worktree should not render as zero changed paths: {text}"
    );
    assert!(
        text.contains("Changes not yet saved") && text.contains("dirty.txt"),
        "status should still list the dirty file: {text}"
    );
    assert!(
        text.contains("Verdict: work in progress")
            && text.contains("Lifecycle: active")
            && text.contains("Work in progress")
            && !text.contains("Verdict: blocked")
            && !text.contains("Lifecycle: blocked")
            && !text.contains("Blocked by"),
        "ordinary dirty work should read like work in progress, not failure: {text}"
    );
    assert!(
        !text.contains("Tracked changes: 0"),
        "old contradictory label should not appear: {text}"
    );
}

#[test]
fn merged_thread_list_reads_integrated_not_actionable() {
    let temp = TempDir::new().unwrap();
    init_git_repo(temp.path());
    std::fs::write(temp.path().join("base.txt"), "base").unwrap();
    git_commit_all(temp.path(), "seed");
    heddle(&["adopt"], Some(temp.path())).unwrap();

    let started: Value = serde_json::from_str(
        &heddle(
            &[
                "--output",
                "json",
                "start",
                "feature/polish",
                "--workspace",
                "auto",
            ],
            Some(temp.path()),
        )
        .unwrap(),
    )
    .unwrap();
    let thread_path = std::path::PathBuf::from(started["execution_path"].as_str().unwrap());
    std::fs::write(thread_path.join("polish.txt"), "premium").unwrap();

    let landed: Value = serde_json::from_str(
        &heddle(
            &["--output", "json", "land", "--thread", "feature/polish"],
            Some(temp.path()),
        )
        .unwrap(),
    )
    .unwrap();
    assert_eq!(landed["status"], "landed");

    heddle(&["thread", "refresh", "feature/polish"], Some(temp.path())).unwrap();
    let listed: Value = serde_json::from_str(
        &heddle(&["thread", "list", "--output", "json"], Some(temp.path())).unwrap(),
    )
    .unwrap();
    let thread = listed["threads"]
        .as_array()
        .unwrap()
        .iter()
        .find(|thread| thread["name"] == "feature/polish")
        .expect("merged thread should be listed");
    assert_eq!(thread["thread_state"], "merged");
    assert_eq!(thread["coordination_status"], "clean");

    let text = heddle(&["--output", "text", "thread", "list"], Some(temp.path())).unwrap();
    let row = text
        .lines()
        .find(|line| line.contains("feature/polish"))
        .unwrap_or("");
    assert!(
        row.contains("clean") && !row.contains("ahead") && !row.contains("stale"),
        "merged thread row should not look actionable: {text}"
    );
    assert!(
        text.contains("lifecycle: merged"),
        "merged state should be visible: {text}"
    );
}

#[test]
fn thread_create_reports_ref_only_not_isolated_checkout() {
    let temp = TempDir::new().unwrap();
    heddle(&["init"], Some(temp.path())).unwrap();

    let created: Value = serde_json::from_str(
        &heddle(
            &["--output", "json", "thread", "create", "feature/ref-only"],
            Some(temp.path()),
        )
        .unwrap(),
    )
    .unwrap();
    let thread = &created["thread"];
    assert_eq!(thread["is_isolated"], false, "{created}");
    assert!(thread["path"].is_null(), "{created}");
    assert!(thread["execution_path"].is_null(), "{created}");
    assert_eq!(thread["visibility"], "ref_only", "{created}");

    let show = heddle(
        &["--output", "text", "thread", "show", "feature/ref-only"],
        Some(temp.path()),
    )
    .unwrap();
    assert!(
        show.contains("Checkout: no dedicated checkout") && !show.contains("Path:"),
        "ref-only thread should not be described as an isolated materialized checkout: {show}"
    );
}

#[test]
fn status_does_not_advertise_ready_thread_for_another_target() {
    let temp = TempDir::new().unwrap();
    heddle(&["init"], Some(temp.path())).unwrap();
    std::fs::write(temp.path().join("base.txt"), "base").unwrap();
    heddle(&["capture", "-m", "base"], Some(temp.path())).unwrap();

    let started: Value = serde_json::from_str(
        &heddle(
            &[
                "--output",
                "json",
                "start",
                "feature/ready-main",
                "--workspace",
                "materialized",
            ],
            Some(temp.path()),
        )
        .unwrap(),
    )
    .unwrap();
    let thread_path = std::path::PathBuf::from(started["execution_path"].as_str().unwrap());
    std::fs::write(thread_path.join("feature.txt"), "ready").unwrap();
    heddle(
        &["--output", "json", "ready", "-m", "ready for main"],
        Some(&thread_path),
    )
    .unwrap();

    heddle(&["thread", "create", "support/other"], Some(temp.path())).unwrap();
    heddle(&["thread", "switch", "support/other"], Some(temp.path())).unwrap();
    let status: Value =
        serde_json::from_str(&heddle(&["--output", "json", "status"], Some(temp.path())).unwrap())
            .unwrap();

    assert_ne!(
        status["recommended_action"], "heddle land --thread feature/ready-main --no-push",
        "status on a non-target thread must not suggest merging target-main work into the active thread: {status}"
    );
    assert_eq!(
        status["verification"]["workflow_status"], "clean",
        "{status}"
    );
    assert!(
        status["verification"]["workflow_summary"]
            .as_str()
            .is_some_and(|summary| summary.contains("target another thread")),
        "workflow summary should explain the scoped ready thread: {status}"
    );
}

#[test]
fn human_thread_and_status_output_use_polished_labels() {
    let temp = TempDir::new().unwrap();
    heddle(&["init"], Some(temp.path())).unwrap();
    heddle(
        &["start", "feature/visible", "--workspace", "materialized"],
        Some(temp.path()),
    )
    .unwrap();

    let status = heddle(&["--output", "text", "status"], Some(temp.path())).unwrap();
    let show = heddle(
        &["--output", "text", "thread", "show", "feature/visible"],
        Some(temp.path()),
    )
    .unwrap();
    let list = heddle(&["--output", "text", "thread", "list"], Some(temp.path())).unwrap();
    let doctor = heddle(&["--output", "text", "doctor"], Some(temp.path())).unwrap();
    let captures = heddle(
        &["--output", "text", "thread", "captures", "feature/visible"],
        Some(temp.path()),
    )
    .unwrap();
    let combined = format!("{status}\n{show}\n{list}\n{doctor}\n{captures}");
    for leaked in [
        " materialized",
        "[materialized",
        "thread mode:",
        "Mode: materialized",
        "Last 5 captures",
        "Captures on",
        "No captures recorded",
        "thread_state",
        "Freshness: unknown",
        "freshness: unknown",
        "tip only",
        "history imported",
    ] {
        assert!(
            !combined.contains(leaked),
            "human output should not leak {leaked:?}: {combined}"
        );
    }

    let json: Value = serde_json::from_str(
        &heddle(
            &["--output", "json", "thread", "show", "feature/visible"],
            Some(temp.path()),
        )
        .unwrap(),
    )
    .unwrap();
    // `--workspace visible` (alias of heavy) without `--path` resolves
    // to ThreadMode::Materialized: a real on-disk checkout managed by
    // Heddle. Materialized is reserved for explicit `--path` callers.
    assert_eq!(json["thread_mode"], "materialized");
    assert_eq!(json["freshness"], "current");
}

#[test]
fn status_output_modes_are_explicit_under_non_tty_capture() {
    let temp = TempDir::new().unwrap();
    heddle(&["init"], Some(temp.path())).unwrap();

    // Default is text — no surprise JSON on pipes/subprocesses/`| less`.
    let default = heddle(&["status"], Some(temp.path())).unwrap();
    assert!(
        default.contains("Heddle status") && serde_json::from_str::<Value>(&default).is_err(),
        "default status should be text, not JSON: {default}"
    );

    let json = heddle(&["--output", "json", "status"], Some(temp.path())).unwrap();
    serde_json::from_str::<Value>(&json)
        .unwrap_or_else(|err| panic!("--output json status should be JSON: {err}: {json}"));

    let text = heddle(&["--output", "text", "status"], Some(temp.path())).unwrap();
    assert!(
        text.contains("Heddle status") && serde_json::from_str::<Value>(&text).is_err(),
        "--output text should match the default: {text}"
    );
}

fn init_git_repo(path: &std::path::Path) {
    std::process::Command::new("git")
        .args(["init"])
        .current_dir(path)
        .output()
        .expect("git init should run");
    std::process::Command::new("git")
        .args(["config", "user.email", "test@example.com"])
        .current_dir(path)
        .output()
        .expect("git config email should run");
    std::process::Command::new("git")
        .args(["config", "user.name", "Test User"])
        .current_dir(path)
        .output()
        .expect("git config name should run");
}

#[test]
fn status_long_default_shows_combined_verdict_not_component_axes() {
    // (a) Both axes clean → default long mode shows ONE combined
    // verdict and hides the per-axis Health/Coordination lines.
    let temp = TempDir::new().unwrap();
    heddle(&["init"], Some(temp.path())).unwrap();

    let text = heddle(&["--output", "text", "status"], Some(temp.path())).unwrap();
    assert!(
        text.contains("Verdict: clean"),
        "default long mode should show a combined verdict: {text}"
    );
    assert!(
        !text.contains("Health:") && !text.contains("Coordination:"),
        "default long mode must hide the per-axis component lines: {text}"
    );
}

#[test]
fn status_long_default_verdict_signals_non_clean_when_health_dirty() {
    // (a) Non-clean axis → the combined verdict must still surface the
    // problem so a default-view reader learns the checkout isn't clean,
    // without the per-axis lines being printed.
    let temp = TempDir::new().unwrap();
    heddle(&["init"], Some(temp.path())).unwrap();
    std::fs::write(temp.path().join("dirty.txt"), "pending").unwrap();

    let text = heddle(&["--output", "text", "status"], Some(temp.path())).unwrap();
    assert!(
        text.contains("Verdict: work in progress"),
        "a dirty worktree must read as a non-clean combined verdict: {text}"
    );
    assert!(
        !text.contains("Verdict: clean"),
        "combined verdict must not claim clean when an axis is dirty: {text}"
    );
    assert!(
        !text.contains("Health:") && !text.contains("Coordination:"),
        "default long mode must hide the per-axis component lines: {text}"
    );
    // The dirty health blocker is encoded as `coordination_status =
    // Blocked`; the combined verdict reason must not double-count it as a
    // coordination failure (heddle#276 r2 / cid 3327903846).
    assert!(
        !text.contains("coordination") && !text.contains("both need attention"),
        "ordinary dirty WIP must not surface a coordination warning: {text}"
    );
    assert!(
        text.contains("checkout health needs attention"),
        "the reason for dirty WIP must be the health/WIP reason alone: {text}"
    );
}

#[test]
fn status_long_verbose_shows_combined_verdict_and_both_axes() {
    // (b) `-v` long mode → combined verdict PLUS both component lines.
    let temp = TempDir::new().unwrap();
    heddle(&["init"], Some(temp.path())).unwrap();
    std::fs::write(temp.path().join("dirty.txt"), "pending").unwrap();

    let text = heddle(&["--output", "text", "-v", "status"], Some(temp.path())).unwrap();
    assert!(
        text.contains("Verdict: work in progress")
            && text.contains("Health: work in progress")
            && text.contains("Coordination: work in progress"),
        "verbose long mode should show the verdict and both component axes: {text}"
    );
}

#[test]
fn status_json_always_emits_both_component_fields() {
    // (c) JSON always carries BOTH component fields, schema unchanged,
    // for clean and non-clean cases alike.
    let temp = TempDir::new().unwrap();
    heddle(&["init"], Some(temp.path())).unwrap();

    let clean = heddle(&["status", "--output", "json"], Some(temp.path())).unwrap();
    let clean: Value = serde_json::from_str(&clean).unwrap();
    assert_eq!(clean["thread_health"], "clean");
    assert_eq!(clean["coordination_status"], "clean");

    std::fs::write(temp.path().join("dirty.txt"), "pending").unwrap();
    let dirty = heddle(&["status", "--output", "json"], Some(temp.path())).unwrap();
    let dirty: Value = serde_json::from_str(&dirty).unwrap();
    assert!(
        dirty["thread_health"].is_string() && dirty["coordination_status"].is_string(),
        "non-clean JSON must still carry both component fields: {dirty}"
    );
    assert_ne!(
        dirty["thread_health"], "clean",
        "dirty worktree should report a non-clean health in JSON: {dirty}"
    );
}

fn git_commit_all(path: &std::path::Path, message: &str) {
    std::process::Command::new("git")
        .args(["add", "."])
        .current_dir(path)
        .output()
        .expect("git add should run");
    let output = std::process::Command::new("git")
        .args(["commit", "-m", message])
        .current_dir(path)
        .output()
        .expect("git commit should run");
    assert!(
        output.status.success(),
        "git commit failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
}