use std::path::Path;
use tempfile::TempDir;
use super::{git_hermetic, heddle_output};
fn assert_exit(args: &[&str], dir: &Path, expected: i32) {
let output =
heddle_output(args, Some(dir)).unwrap_or_else(|err| panic!("spawn {args:?}: {err}"));
let actual = output.status.code();
assert_eq!(
actual,
Some(expected),
"{args:?} should exit {expected} (documented in docs/exit-codes.md), got {actual:?}\n\
stdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}
fn init_repo() -> TempDir {
let temp = TempDir::new().expect("tempdir");
assert_exit(
&[
"init",
"--principal-name",
"Heddle Test",
"--principal-email",
"heddle@test.example",
],
temp.path(),
0,
);
temp
}
fn git(args: &[&str], dir: &Path) {
git_hermetic(args, dir);
}
fn adopted_git_overlay() -> TempDir {
let temp = TempDir::new().expect("tempdir");
let dir = temp.path();
git(&["init", "-q", "-b", "main", "."], dir);
git(&["config", "user.email", "heddle@test.example"], dir);
git(&["config", "user.name", "Heddle Test"], dir);
std::fs::write(dir.join("a.txt"), "hello\n").expect("write a.txt");
git(&["add", "a.txt"], dir);
git(&["commit", "-qm", "init"], dir);
assert_exit(&["adopt"], dir, 0);
temp
}
#[test]
fn init_exits_zero_on_success() {
let temp = TempDir::new().expect("tempdir");
assert_exit(
&[
"init",
"--principal-name",
"Heddle Test",
"--principal-email",
"heddle@test.example",
],
temp.path(),
0,
);
}
#[test]
fn status_exits_zero_in_initialized_repo() {
let repo = init_repo();
assert_exit(&["status"], repo.path(), 0);
}
#[test]
fn verify_exits_zero_when_clean() {
let repo = init_repo();
std::fs::write(repo.path().join("f.txt"), "base\n").expect("write f.txt");
assert_exit(&["commit", "-m", "base"], repo.path(), 0);
assert_exit(&["verify"], repo.path(), 0);
}
#[test]
fn commit_with_nothing_staged_is_data_err() {
let repo = init_repo();
std::fs::write(repo.path().join("f.txt"), "base\n").expect("write f.txt");
assert_exit(&["commit", "-m", "base"], repo.path(), 0);
assert_exit(&["commit", "-m", "again"], repo.path(), 65);
}
#[test]
fn push_without_remote_is_config() {
let repo = init_repo();
assert_exit(&["push"], repo.path(), 78);
}
#[test]
fn pull_without_remote_is_config() {
let repo = init_repo();
assert_exit(&["pull"], repo.path(), 78);
}
#[test]
fn merge_preview_exits_zero() {
let repo = init_repo();
std::fs::write(repo.path().join("f.txt"), "base\n").expect("write base");
assert_exit(&["commit", "-m", "base"], repo.path(), 0);
assert_exit(&["thread", "create", "feature"], repo.path(), 0);
assert_exit(&["switch", "feature"], repo.path(), 0);
std::fs::write(repo.path().join("f.txt"), "base\nfeat\n").expect("write feat");
assert_exit(&["commit", "-m", "feat"], repo.path(), 0);
assert_exit(&["switch", "main"], repo.path(), 0);
assert_exit(&["merge", "feature", "--preview"], repo.path(), 0);
}
#[test]
fn bridge_git_import_exits_zero() {
let repo = adopted_git_overlay();
assert_exit(
&["bridge", "git", "import", "--ref", "main"],
repo.path(),
0,
);
}
#[test]
fn bridge_git_sync_exits_zero() {
let repo = adopted_git_overlay();
assert_exit(
&["bridge", "git", "import", "--ref", "main"],
repo.path(),
0,
);
assert_exit(&["bridge", "git", "sync"], repo.path(), 0);
}
#[test]
fn bridge_git_reconcile_without_side_is_data_err() {
let repo = adopted_git_overlay();
assert_exit(
&["bridge", "git", "import", "--ref", "main"],
repo.path(),
0,
);
assert_exit(
&["bridge", "git", "reconcile", "--ref", "main"],
repo.path(),
65,
);
}
#[test]
fn unsupported_output_json_is_data_err() {
let repo = init_repo();
assert_exit(
&["--output", "json", "shell", "completion", "bash"],
repo.path(),
65,
);
}
#[test]
fn unsupported_output_json_compact_is_data_err() {
let repo = init_repo();
let output = heddle_output(&["--output", "json-compact", "log"], Some(repo.path()))
.expect("spawn log --output json-compact");
assert_eq!(
output.status.code(),
Some(65),
"json-compact rejection should exit 65 (DataErr); stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
let envelope: serde_json::Value = serde_json::from_str(stderr.trim())
.unwrap_or_else(|err| panic!("rejection envelope not JSON: {err}\n stderr: {stderr}"));
assert_eq!(envelope["kind"], "json_compact_unsupported");
assert_eq!(
envelope["exit_code"], 65,
"envelope exit_code must agree with the process exit: {envelope}"
);
}
#[test]
fn status_on_corrupted_state_is_data_err_with_recovery_path() {
let repo = init_repo();
std::fs::write(repo.path().join("f.txt"), "base\n").expect("write f.txt");
assert_exit(&["commit", "-m", "base"], repo.path(), 0);
let states_dir = repo.path().join(".heddle/objects/states");
let mut corrupted = 0usize;
for entry in std::fs::read_dir(&states_dir).expect("read states dir") {
let path = entry.expect("dir entry").path();
std::fs::write(&path, [0x90u8]).expect("corrupt state file");
corrupted += 1;
}
assert!(corrupted > 0, "fixture should have stored state objects");
let json = heddle_output(&["--output", "json", "status"], Some(repo.path()))
.expect("spawn status on corrupted repo");
assert_eq!(
json.status.code(),
Some(65),
"corrupted state should exit 65 (DataErr); stderr: {}",
String::from_utf8_lossy(&json.stderr)
);
let stderr = String::from_utf8_lossy(&json.stderr);
let envelope: serde_json::Value = serde_json::from_str(stderr.trim()).unwrap_or_else(|err| {
panic!("corrupted-state envelope not JSON: {err}\n stderr: {stderr}")
});
assert_eq!(envelope["kind"], "state_corrupted");
assert_eq!(envelope["exit_code"], 65);
assert_eq!(
envelope["error"], "Repository state is corrupted or unreadable",
"user-facing error must name the condition, not echo decoder internals: {envelope}"
);
let recovery_commands = envelope["recovery_commands"]
.as_array()
.unwrap_or_else(|| panic!("recovery_commands should be an array: {envelope}"));
assert!(
!recovery_commands.is_empty(),
"corrupted state must hand back recovery commands: {envelope}"
);
assert!(
recovery_commands
.iter()
.any(|command| command.as_str().is_some_and(|c| c.contains("fsck"))),
"recovery should point at the integrity tooling: {envelope}"
);
let text =
heddle_output(&["status"], Some(repo.path())).expect("spawn text status on corrupted repo");
assert_eq!(text.status.code(), Some(65));
let text_stderr = String::from_utf8_lossy(&text.stderr);
assert!(
text_stderr.contains("Repository state is corrupted or unreadable")
&& text_stderr.contains("Next: heddle verify"),
"text mode should name the condition and the recovery probe: {text_stderr}"
);
}
#[test]
fn unconfigured_remote_keeps_no_default_remote_phrasing() {
let repo = init_repo();
let output = heddle_output(&["--output", "json", "pull"], Some(repo.path()))
.expect("spawn pull --output json");
let stderr = String::from_utf8_lossy(&output.stderr);
let envelope: serde_json::Value = serde_json::from_str(stderr.trim().lines().next().unwrap())
.unwrap_or_else(|err| panic!("pull envelope not JSON: {err}\n stderr: {stderr}"));
assert_eq!(
output.status.code(),
Some(78),
"pull without a remote should exit 78; envelope: {envelope}"
);
}