use std::{fs, path::Path, path::PathBuf, process::Output, str};
use serde_json::Value;
use tempfile::TempDir;
use super::{heddle, heddle_output};
fn sibling_checkout_path(repo: &std::path::Path, suffix: &str) -> PathBuf {
let repo_name = repo
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("repo");
repo.with_file_name(format!("{repo_name}-{suffix}"))
}
fn stdout(output: &Output) -> &str {
str::from_utf8(&output.stdout).unwrap_or("")
}
fn stderr(output: &Output) -> &str {
str::from_utf8(&output.stderr).unwrap_or("")
}
fn combined(output: &Output) -> String {
format!("{}\n{}", stdout(output), stderr(output))
}
fn setup_feature_search() -> (TempDir, PathBuf) {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).unwrap();
fs::write(temp.path().join("seed.txt"), "seed\n").unwrap();
heddle(&["capture", "-m", "seed"], Some(temp.path())).unwrap();
let checkout = sibling_checkout_path(temp.path(), "search");
let checkout_arg = checkout.to_str().expect("checkout path utf8");
heddle(
&["start", "feature/search", "--path", checkout_arg],
Some(temp.path()),
)
.unwrap();
fs::write(checkout.join("search.txt"), "find it\n").unwrap();
heddle(&["capture", "-m", "feature work"], Some(&checkout)).unwrap();
(temp, checkout)
}
fn assert_no_repair_git(output: &Output, context: &str) {
let text = combined(output);
assert!(
!text.contains("repair git") && !text.contains("fsck repair"),
"{context} must not recommend repair git on a native repo:\n{text}"
);
}
#[test]
fn field_study_bare_land_and_dry_run_use_current_thread() {
let (main, checkout) = setup_feature_search();
let dry = heddle_output(&["land", "--dry-run"], Some(&checkout))
.expect("land --dry-run should spawn");
assert_eq!(
dry.status.code(),
Some(0),
"bare land --dry-run must succeed:\n{}",
combined(&dry)
);
let dry_text = combined(&dry);
assert!(
dry_text.contains("feature/search"),
"dry-run must preview the current thread:\n{dry_text}"
);
assert!(
!dry_text.contains("land thread 'main'") && !dry_text.contains("thread 'main' not found"),
"dry-run must not preview main as the land subject:\n{dry_text}"
);
assert!(
!dry_text.contains("writes Git refs") && !dry_text.contains("network I/O"),
"native dry-run must not claim Git-ref writes or network I/O:\n{dry_text}"
);
assert_no_repair_git(&dry, "land --dry-run");
let land = heddle_output(&["land"], Some(&checkout)).expect("heddle land should spawn");
assert_ne!(
land.status.code(),
Some(74),
"bare land must not exit 74 treating main as an imported Git ref:\n{}",
combined(&land)
);
assert_eq!(
land.status.code(),
Some(0),
"bare land from the isolated checkout must land the current thread:\n{}",
combined(&land)
);
let land_text = combined(&land);
assert!(
land_text.contains("feature/search"),
"bare land must name the current thread:\n{land_text}"
);
assert!(
!land_text.contains("imported Git ref"),
"bare land must not treat main as an imported Git ref:\n{land_text}"
);
assert_no_repair_git(&land, "bare land");
assert_eq!(
fs::read_to_string(main.path().join("search.txt")).unwrap(),
"find it\n",
"bare land must integrate the current thread into the target"
);
}
#[test]
fn field_study_ready_then_double_land_thread_is_already_landed() {
let (main, checkout) = setup_feature_search();
let ready = heddle_output(&["ready"], Some(&checkout)).expect("ready should spawn");
assert_eq!(
ready.status.code(),
Some(0),
"ready must succeed:\n{}",
combined(&ready)
);
let ready_text = combined(&ready);
assert!(
ready_text.contains("Next:") && ready_text.contains("heddle land"),
"ready must recommend land:\n{ready_text}"
);
assert!(
!ready_text.contains("land --thread") && !ready_text.contains("--repo"),
"ready next from the current checkout must omit --thread/--repo:\n{ready_text}"
);
assert_no_repair_git(&ready, "ready");
let first = run_land_thread(main.path(), &checkout);
assert_eq!(
first.status.code(),
Some(0),
"land --thread feature/search must succeed after ready:\n{}",
combined(&first)
);
assert!(
combined(&first).contains("feature/search"),
"first land --thread must name the thread:\n{}",
combined(&first)
);
assert_no_repair_git(&first, "first land --thread");
assert_eq!(
fs::read_to_string(main.path().join("search.txt")).unwrap(),
"find it\n"
);
let second = run_land_thread(main.path(), &checkout);
assert_eq!(
second.status.code(),
Some(0),
"second land --thread must stay a clean exit 0:\n{}",
combined(&second)
);
let second_text = combined(&second);
assert!(
second_text.contains("already landed"),
"second land --thread must be already-landed, not a second merge:\n{second_text}"
);
assert!(
!second_text.contains("automatic integration merge"),
"second land --thread must not claim another automatic integration merge:\n{second_text}"
);
assert_no_repair_git(&second, "second land --thread");
}
fn run_land_thread(main: &Path, checkout: &Path) -> Output {
heddle_output(
&[
"--repo",
main.to_str().expect("main path utf8"),
"land",
"--thread",
"feature/search",
],
Some(checkout),
)
.expect("land --thread feature/search should spawn")
}
#[test]
fn land_unmanaged_main_on_native_recommends_thread_list() {
let (_main, checkout) = setup_feature_search();
let output = heddle_output(
&["--output", "json", "land", "--thread", "main"],
Some(&checkout),
)
.expect("land --thread main should spawn");
assert_eq!(
output.status.code(),
Some(74),
"unmanaged main still fails closed (field-study exit 74), but recovery must change:\n{}",
combined(&output)
);
let stderr = stderr(&output);
let envelope: Value = serde_json::from_str(
stderr
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or(stderr.trim()),
)
.unwrap_or_else(|err| panic!("JSON envelope: {err}\n{stderr}"));
assert_eq!(envelope["kind"], "imported_git_ref_not_managed_thread");
assert_eq!(envelope["primary_command"], "heddle thread list");
assert_no_repair_git(&output, "land --thread main");
}