use serde_json::Value;
use tempfile::TempDir;
use super::{heddle, heddle_output};
fn json(args: &[&str], cwd: &std::path::Path) -> Value {
let output = heddle_output(args, Some(cwd))
.unwrap_or_else(|err| panic!("`heddle {}` should run: {err}", args.join(" ")));
assert!(
output.status.success(),
"`heddle {}` should succeed\nstdout: {}\nstderr: {}",
args.join(" "),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
serde_json::from_str(&stdout).unwrap_or_else(|err| {
panic!(
"`heddle {}` should emit JSON: {err}\nstdout: {stdout}\nstderr: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr)
)
})
}
fn setup_native_repo() -> TempDir {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).unwrap();
std::fs::write(temp.path().join("base.txt"), "base\n").unwrap();
heddle(&["capture", "-m", "base"], Some(temp.path())).unwrap();
temp
}
fn setup_managed_thread(name: &str) -> (TempDir, TempDir, String) {
let main = setup_native_repo();
let checkout = TempDir::new().unwrap();
let checkout_arg = checkout.path().join("work");
let started = json(
&[
"--output",
"json",
"start",
name,
"--path",
checkout_arg.to_str().unwrap(),
],
main.path(),
);
let execution_path = started["execution_path"]
.as_str()
.expect("start should report execution_path")
.to_string();
(main, checkout, execution_path)
}
#[test]
fn ready_dry_run_emits_plan_without_mutation() {
let (main, checkout_owner, execution_path) = setup_managed_thread("feature/ready-dry");
let checkout = std::path::Path::new(&execution_path);
std::fs::write(checkout.join("feature.txt"), "feature\n").unwrap();
heddle(&["capture", "-m", "feature"], Some(checkout)).unwrap();
let plan = json(
&[
"--output",
"json",
"ready",
"--dry-run",
"--thread",
"feature/ready-dry",
],
main.path(),
);
assert_eq!(plan["output_kind"], "dry_run_plan");
assert_eq!(plan["command"], "ready");
assert_eq!(plan["performed_mutation"], false);
assert!(
plan["integrations"]
.as_array()
.is_some_and(|v| !v.is_empty()),
"ready dry-run should preview integration: {plan}"
);
let shown = json(
&["--output", "json", "thread", "show", "feature/ready-dry"],
main.path(),
);
assert_ne!(
shown["state"].as_str().unwrap_or(""),
"ready",
"dry-run must not mark the thread ready: {shown}"
);
drop(checkout_owner);
}
#[test]
fn ready_dry_run_reports_missing_thread_as_blocker() {
let main = setup_native_repo();
let plan = json(
&[
"--output",
"json",
"ready",
"--dry-run",
"--thread",
"does-not-exist",
],
main.path(),
);
assert_eq!(plan["output_kind"], "dry_run_plan");
assert_eq!(plan["command"], "ready");
let blockers = plan["blockers"]
.as_array()
.expect("blockers array")
.iter()
.map(|v| v.as_str().unwrap_or(""))
.collect::<Vec<_>>();
assert!(
blockers
.iter()
.any(|b| b.contains("thread") || b.contains("no current")),
"missing thread should surface as a blocker: {plan}"
);
}
#[test]
fn land_dry_run_emits_plan_without_landing() {
let (main, checkout_owner, execution_path) = setup_managed_thread("feature/land-dry");
let checkout = std::path::Path::new(&execution_path);
std::fs::write(checkout.join("feature.txt"), "feature\n").unwrap();
heddle(&["capture", "-m", "feature"], Some(checkout)).unwrap();
heddle(
&["ready", "--thread", "feature/land-dry"],
Some(main.path()),
)
.unwrap();
let plan = json(
&[
"--output",
"json",
"land",
"--dry-run",
"--thread",
"feature/land-dry",
],
main.path(),
);
assert_eq!(plan["output_kind"], "dry_run_plan");
assert_eq!(plan["command"], "land");
assert_eq!(plan["performed_mutation"], false);
assert!(
plan["integrations"]
.as_array()
.is_some_and(|v| !v.is_empty()),
"land dry-run should preview integrations: {plan}"
);
let shown = json(
&["--output", "json", "thread", "show", "feature/land-dry"],
main.path(),
);
assert_ne!(
shown["state"].as_str().unwrap_or(""),
"landed",
"dry-run must not land the thread: {shown}"
);
drop(checkout_owner);
}
#[test]
fn run_cmd_requires_command_and_executes_when_present() {
let main = setup_native_repo();
let missing = heddle_output(&["--output", "json", "run"], Some(main.path()))
.expect("run with no command should invoke");
assert!(
!missing.status.success(),
"run without a command must refuse"
);
let ok = heddle(&["run", "--", "true"], Some(main.path()));
assert!(ok.is_ok(), "run -- true should succeed: {ok:?}");
}
#[test]
fn push_dry_run_emits_plan_without_network() {
let main = setup_native_repo();
let output = heddle_output(
&["--output", "json", "push", "--dry-run"],
Some(main.path()),
)
.expect("push --dry-run should run");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if output.status.success() {
let plan: Value = serde_json::from_str(&stdout).expect("json plan");
assert_eq!(plan["output_kind"], "dry_run_plan");
assert_eq!(plan["command"], "push");
assert_eq!(plan["performed_mutation"], false);
} else {
assert!(
!stdout.is_empty() || !stderr.is_empty(),
"push --dry-run failure should still emit a message"
);
}
}