heddle-cli 0.11.0

An AI-native version control system
Documentation
use std::{
    fs,
    path::Path,
    process::{Command, Output},
};

use tempfile::TempDir;

fn isolated_command(repo: &Path, home: &Path, heddle_home: &Path, args: &[&str]) -> Command {
    let mut command = Command::new(env!("CARGO_BIN_EXE_heddle"));
    command
        .args(args)
        .current_dir(repo)
        .env("HOME", home)
        .env("HEDDLE_HOME", heddle_home)
        .env_remove("HEDDLE_CONFIG")
        .env_remove("HEDDLE_PRINCIPAL_NAME")
        .env_remove("HEDDLE_PRINCIPAL_EMAIL")
        .env_remove("HEDDLE_AGENT_PROVIDER")
        .env_remove("HEDDLE_AGENT_MODEL")
        .env_remove("NO_COLOR");
    command
}

fn output_text(output: &Output) -> String {
    String::from_utf8_lossy(&[output.stdout.as_slice(), output.stderr.as_slice()].concat())
        .into_owned()
}

fn assert_success(output: &Output, command: &str) {
    assert!(
        output.status.success(),
        "{command} failed with {:?}:\n{}",
        output.status.code(),
        output_text(output)
    );
}

#[test]
fn heddle_home_is_honored_alongside_heddle_config() {
    let temp = TempDir::new().expect("tempdir");
    let shared_home = temp.path().join("shared-home");
    let repo = temp.path().join("repo");
    let heddle_home = temp.path().join("myhome");
    let config = temp.path().join("my.toml");
    fs::create_dir_all(&shared_home).expect("shared home");
    fs::create_dir_all(&repo).expect("repo");
    super::git_hermetic(&["init"], &repo);

    let output = isolated_command(
        &repo,
        &shared_home,
        &heddle_home,
        &[
            "init",
            "--principal-name",
            "T",
            "--principal-email",
            "t@x.io",
        ],
    )
    .env("HEDDLE_CONFIG", &config)
    .output()
    .expect("run init");
    assert_success(&output, "init with HEDDLE_HOME and HEDDLE_CONFIG");
    assert!(config.is_file(), "HEDDLE_CONFIG should be written");
    assert!(
        heddle_home.is_dir(),
        "HEDDLE_HOME should be created instead of silently ignored"
    );
}

#[test]
fn unusable_heddle_home_fails_loudly_without_fallback() {
    let temp = TempDir::new().expect("tempdir");
    let shared_home = temp.path().join("shared-home");
    let repo = temp.path().join("repo");
    let heddle_home = temp.path().join("not-a-directory");
    let config = temp.path().join("my.toml");
    fs::create_dir_all(&shared_home).expect("shared home");
    fs::create_dir_all(&repo).expect("repo");
    fs::write(&heddle_home, "blocking file\n").expect("blocking file");
    super::git_hermetic(&["init"], &repo);

    let output = isolated_command(
        &repo,
        &shared_home,
        &heddle_home,
        &[
            "init",
            "--principal-name",
            "T",
            "--principal-email",
            "t@x.io",
        ],
    )
    .env("HEDDLE_CONFIG", &config)
    .output()
    .expect("run init");
    assert!(!output.status.success(), "unusable HEDDLE_HOME must fail");
    assert!(
        output_text(&output).contains(&heddle_home.display().to_string()),
        "error must identify the HEDDLE_HOME path:\n{}",
        output_text(&output)
    );
    assert!(!config.exists(), "must not fall back to HEDDLE_CONFIG only");
    assert!(
        !shared_home.join(".heddle").exists(),
        "must not write global state to the shared HOME"
    );
}

#[test]
fn heddle_home_isolates_concurrent_user_configs() {
    let temp = TempDir::new().expect("tempdir");
    let shared_home = temp.path().join("shared-home");
    let repo_a = temp.path().join("repo-a");
    let repo_b = temp.path().join("repo-b");
    let home_a = temp.path().join("heddle-a");
    let home_b = temp.path().join("heddle-b");
    fs::create_dir_all(&shared_home).expect("shared home");
    fs::create_dir_all(&repo_a).expect("repo a");
    fs::create_dir_all(&repo_b).expect("repo b");
    super::git_hermetic(&["init"], &repo_a);
    super::git_hermetic(&["init"], &repo_b);

    let child_a = isolated_command(
        &repo_a,
        &shared_home,
        &home_a,
        &[
            "init",
            "--principal-name",
            "Agent A",
            "--principal-email",
            "a@example.test",
        ],
    )
    .spawn()
    .expect("spawn init a");
    let child_b = isolated_command(
        &repo_b,
        &shared_home,
        &home_b,
        &[
            "init",
            "--principal-name",
            "Agent B",
            "--principal-email",
            "b@example.test",
        ],
    )
    .spawn()
    .expect("spawn init b");

    let output_a = child_a.wait_with_output().expect("wait init a");
    let output_b = child_b.wait_with_output().expect("wait init b");
    assert_success(&output_a, "init a");
    assert_success(&output_b, "init b");

    let config_a = fs::read_to_string(home_a.join("config.toml"))
        .expect("HEDDLE_HOME A should contain its user config");
    let config_b = fs::read_to_string(home_b.join("config.toml"))
        .expect("HEDDLE_HOME B should contain its user config");
    assert!(config_a.contains("Agent A") && !config_a.contains("Agent B"));
    assert!(config_b.contains("Agent B") && !config_b.contains("Agent A"));
    assert!(
        !shared_home.join(".config/heddle/config.toml").exists(),
        "HEDDLE_HOME must prevent fallback to the shared HOME"
    );

    let native_a = temp.path().join("native-a");
    let native_b = temp.path().join("native-b");
    repo::Repository::init_default(&native_a).expect("native repo a");
    repo::Repository::init_default(&native_b).expect("native repo b");
    super::git_hermetic(&["init"], &native_a);
    super::git_hermetic(&["init"], &native_b);
    fs::write(native_a.join("a.txt"), "a\n").expect("worktree a");
    fs::write(native_b.join("b.txt"), "b\n").expect("worktree b");

    let capture_a = isolated_command(
        &native_a,
        &shared_home,
        &home_a,
        &["capture", "-m", "isolated capture a"],
    )
    .spawn()
    .expect("spawn capture a");
    let capture_b = isolated_command(
        &native_b,
        &shared_home,
        &home_b,
        &["capture", "-m", "isolated capture b"],
    )
    .spawn()
    .expect("spawn capture b");
    let capture_output_a = capture_a.wait_with_output().expect("wait capture a");
    let capture_output_b = capture_b.wait_with_output().expect("wait capture b");
    assert_success(&capture_output_a, "capture a");
    assert_success(&capture_output_b, "capture b");
    assert!(home_a.join("session").is_dir());
    assert!(home_b.join("session").is_dir());
    assert!(
        !shared_home.join(".heddle/session").exists(),
        "session state must not fall back to the shared HOME"
    );
}

#[test]
fn init_status_and_capture_agree_on_user_config_principal() {
    let temp = TempDir::new().expect("tempdir");
    let shared_home = temp.path().join("shared-home");
    let heddle_home = temp.path().join("isolated-heddle-home");
    let repo = temp.path().join("repo");
    fs::create_dir_all(&repo).expect("repo");
    super::git_hermetic(&["init"], &repo);
    fs::create_dir_all(shared_home.join(".config/heddle")).expect("shared config directory");
    fs::write(
        shared_home.join(".config/heddle/config.toml"),
        "[principal]\nname = \"Stale Stranger\"\nemail = \"stale@example.invalid\"\n",
    )
    .expect("stale shared config");

    let init = isolated_command(
        &repo,
        &shared_home,
        &heddle_home,
        &[
            "init",
            "--principal-name",
            "Luke Thorne",
            "--principal-email",
            "luke@example.test",
        ],
    )
    .output()
    .expect("run init");
    assert_success(&init, "init");
    assert!(
        output_text(&init).contains(
            "Principal: Luke Thorne <luke@example.test> from user_config (shared global config)"
        ),
        "init must report the resolved principal and its global source:\n{}",
        output_text(&init)
    );

    let status = isolated_command(&repo, &shared_home, &heddle_home, &["status"])
        .output()
        .expect("run status");
    assert_success(&status, "status");
    assert!(
        output_text(&status).contains(
            "Identity: Luke Thorne <luke@example.test> from user_config (shared global config)"
        ),
        "status must report the same principal and source:\n{}",
        output_text(&status)
    );

    fs::write(repo.join("identity.txt"), "identity evidence\n").expect("worktree change");
    let capture = isolated_command(
        &repo,
        &shared_home,
        &heddle_home,
        &["capture", "-m", "identity resolution evidence"],
    )
    .output()
    .expect("run capture");
    assert_success(&capture, "capture");
    assert!(
        output_text(&capture).contains(
            "Saved by: Luke Thorne <luke@example.test> from user_config (shared global config)"
        ),
        "capture must report the same principal and source:\n{}",
        output_text(&capture)
    );

    fs::write(repo.join("identity-json.txt"), "machine evidence\n").expect("second change");
    let json_capture = isolated_command(
        &repo,
        &shared_home,
        &heddle_home,
        &["capture", "-m", "machine evidence", "--output", "json"],
    )
    .output()
    .expect("JSON capture");
    assert_success(&json_capture, "JSON capture");
    let json: serde_json::Value =
        serde_json::from_slice(&json_capture.stdout).expect("capture JSON");
    assert_eq!(json["principal_source"], "user_config");

    let isolated_config =
        fs::read_to_string(heddle_home.join("config.toml")).expect("isolated user config");
    assert!(isolated_config.contains("Luke Thorne"));
    let stale_config = fs::read_to_string(shared_home.join(".config/heddle/config.toml"))
        .expect("shared config remains readable");
    assert!(
        stale_config.contains("Stale Stranger"),
        "the isolated run must not overwrite shared global config"
    );
}