heddle-cli 0.4.0

An AI-native version control system
Documentation
// SPDX-License-Identifier: Apache-2.0
//! JSON error envelopes for commands that need current thread/session context.

use std::{fs, str};

use repo::Repository;
use serde_json::Value;
use tempfile::TempDir;

use super::{assert_json_recovery_advice_fields, heddle, heddle_argv_json, heddle_output};

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

    let repo = Repository::open(temp.path()).unwrap();
    let head = repo
        .head()
        .unwrap()
        .expect("repo should have a current state after capture");
    fs::write(
        temp.path().join(".heddle").join("HEAD"),
        format!("{}\n", head.to_string_full()),
    )
    .unwrap();
    drop(repo);

    temp
}

fn json_failure(args: &[&str], cwd: &std::path::Path) -> Value {
    let output = heddle_output(args, Some(cwd)).expect("invoke JSON failure");
    assert!(
        !output.status.success(),
        "command should fail for args {args:?}"
    );
    let stdout = str::from_utf8(&output.stdout).expect("stdout should be utf8");
    assert!(
        stdout.trim().is_empty(),
        "JSON failures should not emit success-shaped stdout: {stdout}"
    );
    let stderr = str::from_utf8(&output.stderr).expect("stderr should be utf8");
    let envelope: Value = serde_json::from_str(stderr.trim()).expect("stderr should be JSON");
    assert_json_recovery_advice_fields(&envelope, stderr);
    envelope
}

#[test]
fn ready_without_current_thread_uses_typed_advice() {
    let temp = setup_detached_repo_without_current_thread();

    let envelope = json_failure(&["--output", "json", "ready"], temp.path());
    assert_eq!(envelope["kind"], "no_current_thread");
    assert_eq!(envelope["primary_command"], "heddle ready --thread <name>");
    assert_eq!(envelope["primary_command_argv"], Value::Null);
    assert_action_template(
        &envelope["primary_command_template"],
        "heddle ready --thread <name>",
        heddle_argv_json(["ready", "--thread", "<thread>"]),
        &["thread"],
        true,
    );
    assert!(
        envelope["hint"]
            .as_str()
            .is_some_and(|hint| hint.contains("--thread")),
        "ready advice should name the explicit selector: {envelope}"
    );
}

#[test]
fn ship_without_current_thread_uses_typed_advice() {
    let temp = setup_detached_repo_without_current_thread();

    let envelope = json_failure(&["--output", "json", "land"], temp.path());
    assert_eq!(envelope["kind"], "no_current_thread");
    assert_eq!(envelope["primary_command"], "heddle land --thread <name>");
    assert_eq!(envelope["primary_command_argv"], Value::Null);
    assert_action_template(
        &envelope["primary_command_template"],
        "heddle land --thread <name>",
        heddle_argv_json(["land", "--thread", "<thread>"]),
        &["thread"],
        true,
    );
    assert!(
        envelope["hint"]
            .as_str()
            .is_some_and(|hint| hint.contains("--thread")),
        "land advice should name the explicit selector: {envelope}"
    );
}

#[test]
fn removed_phase_2_roots_are_unknown_commands() {
    let temp = setup_detached_repo_without_current_thread();
    for root in [
        "attempt",
        "branch",
        "conflict",
        "delegate",
        "fork",
        "goto",
        "inspect",
        "marker",
        "stack",
        "workspace",
    ] {
        let output = heddle_output(&[root], Some(temp.path())).expect("invoke removed root");
        assert!(
            !output.status.success(),
            "{root} should fail as an unknown command"
        );
        let stderr = str::from_utf8(&output.stderr).expect("stderr should be utf8");
        assert!(
            stderr.contains("unrecognized subcommand"),
            "{root} should be rejected by clap as unknown, got: {stderr}"
        );
    }
}

fn assert_action_template(
    template: &Value,
    action: &str,
    argv_template: Value,
    required_inputs: &[&str],
    agent_may_fill: bool,
) {
    assert_eq!(template["action"], action);
    assert_eq!(template["argv_template"], argv_template);
    assert_eq!(
        template["required_inputs"],
        serde_json::json!(required_inputs)
    );
    assert_eq!(template["agent_may_fill"], agent_may_fill);
}

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

    let envelope = json_failure(&["--output", "json", "session", "show"], temp.path());
    assert_eq!(envelope["kind"], "no_current_session");
    assert_eq!(envelope["primary_command"], "heddle session start");
    assert!(
        envelope["hint"]
            .as_str()
            .is_some_and(|hint| hint.contains("heddle session start")),
        "session advice should point at session start: {envelope}"
    );
}

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

    let envelope = json_failure(
        &[
            "--output",
            "json",
            "session",
            "segment",
            "--provider",
            "codex",
            "--model",
            "test-model",
        ],
        temp.path(),
    );
    assert_eq!(envelope["kind"], "no_current_session");
    assert_eq!(envelope["primary_command"], "heddle session start");
    assert!(
        envelope["error"]
            .as_str()
            .is_some_and(|error| error == "No active session"),
        "session segment should share the concise no-session error: {envelope}"
    );
}