use std::str;
use serde_json::Value;
use tempfile::TempDir;
use super::{heddle, heddle_output};
const COMPACT_ALLOWED_KEYS: &[&str] = &[
"output_kind",
"status",
"coordination_status",
"blockers",
"next_action",
"next_action_template",
"changed_paths",
"changed_path_count",
"conflicts",
"conflict_count",
"state_id",
];
fn assert_only_compact_keys(value: &Value, context: &str) {
let obj = value
.as_object()
.unwrap_or_else(|| panic!("{context}: compact payload must be a JSON object: {value}"));
for key in obj.keys() {
assert!(
COMPACT_ALLOWED_KEYS.contains(&key.as_str()),
"{context}: compact payload leaked non-decision-surface key `{key}`: {value}"
);
}
}
fn compact_json(args: &[&str], temp: &TempDir) -> Value {
let mut argv: Vec<&str> = vec!["--output", "json-compact"];
argv.extend(args.iter().copied());
let out =
heddle_output(&argv, Some(temp.path())).unwrap_or_else(|err| panic!("spawn failed: {err}"));
assert!(
out.status.success(),
"heddle {argv:?} should succeed; stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let stdout = str::from_utf8(&out.stdout).expect("stdout utf8");
let line = stdout.lines().next().unwrap_or_else(|| {
panic!(
"heddle {argv:?} produced no stdout; stderr={}",
String::from_utf8_lossy(&out.stderr)
)
});
serde_json::from_str(line)
.unwrap_or_else(|err| panic!("heddle {argv:?} stdout not JSON: {err}\n line: {line}"))
}
fn compact_json_output(args: &[&str], temp: &TempDir) -> Value {
let out =
heddle_output(args, Some(temp.path())).unwrap_or_else(|err| panic!("spawn failed: {err}"));
assert!(
out.status.success(),
"heddle {args:?} should succeed; stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let stdout = str::from_utf8(&out.stdout).expect("stdout utf8");
let line = stdout
.lines()
.next()
.unwrap_or_else(|| panic!("heddle {args:?} produced no stdout"));
serde_json::from_str(line)
.unwrap_or_else(|err| panic!("heddle {args:?} stdout not JSON: {err}\n line: {line}"))
}
fn assert_compact_op_id_error_envelope(args: &[&str], temp: &TempDir, context: &str) -> Value {
let out =
heddle_output(args, Some(temp.path())).unwrap_or_else(|err| panic!("spawn failed: {err}"));
assert!(
!out.status.success(),
"{context}: heddle {args:?} should fail; stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
out.stdout.is_empty(),
"{context}: failed compact op-id command should not emit stdout: {}",
String::from_utf8_lossy(&out.stdout)
);
let stderr = str::from_utf8(&out.stderr).expect("stderr utf8");
let envelope: Value = serde_json::from_str(stderr.trim())
.unwrap_or_else(|err| panic!("{context}: stderr not JSON: {err}\n stderr: {stderr}"));
for key in ["kind", "error", "exit_code", "hint"] {
assert!(
envelope.get(key).is_some(),
"{context}: compact op-id failure stripped `{key}` from error envelope: {envelope}"
);
}
envelope
}
fn full_json(args: &[&str], temp: &TempDir) -> Value {
let mut argv: Vec<&str> = vec!["--output", "json"];
argv.extend(args.iter().copied());
let stdout = heddle(&argv, Some(temp.path()))
.unwrap_or_else(|err| panic!("heddle {argv:?} failed: {err}"));
let line = stdout.lines().next().expect("full json stdout");
serde_json::from_str(line).expect("full json parses")
}
#[test]
fn capture_op_id_compact_failure_preserves_error_envelope() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).expect("init");
let op_id = "550e8400-e29b-41d4-a716-446655440471";
let args = ["--output", "json-compact", "--op-id", op_id, "capture"];
let first = assert_compact_op_id_error_envelope(&args, &temp, "capture op-id failure");
let replayed =
assert_compact_op_id_error_envelope(&args, &temp, "capture op-id failure replay");
assert_eq!(
replayed, first,
"compact op-id replay should preserve the cached error envelope verbatim"
);
}
#[test]
fn status_compact_emits_only_decision_surface() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).expect("init");
let compact = compact_json(&["status"], &temp);
assert_eq!(
compact["output_kind"].as_str(),
Some("status"),
"compact status must carry output_kind: {compact}"
);
assert!(
compact.get("coordination_status").is_some(),
"compact status must carry coordination_status: {compact}"
);
assert!(
compact.get("changed_path_count").is_some(),
"compact status must carry changed_path_count: {compact}"
);
assert!(
compact.get("changed_paths").is_some(),
"compact status must carry changed_paths: {compact}"
);
assert_only_compact_keys(&compact, "status");
let full = full_json(&["status"], &temp);
assert!(
full.get("git_overlay_health").is_none() && full.get("verification").is_some(),
"full status must expose verification without the legacy git_overlay_health alias: {full}"
);
assert!(
compact.get("git_overlay_health").is_none() && compact.get("verification").is_none(),
"compact status must drop git_overlay_health/verification: {compact}"
);
}
#[test]
fn status_compact_keeps_uncaptured_changed_path_count_consistent() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).expect("init");
std::fs::write(temp.path().join("work.txt"), "pending\n").unwrap();
let compact = compact_json(&["status"], &temp);
let changed_paths = compact["changed_paths"]
.as_array()
.unwrap_or_else(|| panic!("compact status must carry changed_paths: {compact}"));
assert_eq!(
compact["changed_path_count"].as_u64(),
Some(changed_paths.len() as u64),
"compact status count must match changed_paths in a dirty uncaptured repo: {compact}"
);
assert_eq!(
changed_paths.as_slice(),
[Value::String("work.txt".to_string())],
"dirty uncaptured repo should report the pending worktree path: {compact}"
);
assert_only_compact_keys(&compact, "dirty uncaptured status");
}
#[test]
fn capture_op_id_compact_replay_emits_only_decision_surface() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).expect("init");
std::fs::write(temp.path().join("work.txt"), "pending\n").unwrap();
let op_id = "550e8400-e29b-41d4-a716-446655440470";
let args = [
"--output",
"json-compact",
"--op-id",
op_id,
"capture",
"-m",
"compact op-id capture",
];
let first = compact_json_output(&args, &temp);
assert_only_compact_keys(&first, "capture op-id executed");
assert!(
first.get("operation_record").is_none()
&& first.get("op_id").is_none()
&& first.get("idempotency_status").is_none()
&& first.get("replayed").is_none(),
"compact executed op-id output must not leak idempotency fields: {first}"
);
let replayed = compact_json_output(&args, &temp);
assert_only_compact_keys(&replayed, "capture op-id replayed");
assert_eq!(
replayed, first,
"compact op-id replay should return the cached compact payload without wrapper decoration"
);
assert!(
replayed.get("operation_record").is_none()
&& replayed.get("op_id").is_none()
&& replayed.get("idempotency_status").is_none()
&& replayed.get("replayed").is_none(),
"compact replayed op-id output must not leak idempotency fields: {replayed}"
);
}
#[test]
fn continue_compact_drops_operator_metadata() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).expect("init");
let compact = compact_json(&["continue"], &temp);
assert_eq!(compact["output_kind"].as_str(), Some("continue"));
assert_eq!(compact["status"].as_str(), Some("noop"));
assert_only_compact_keys(&compact, "continue");
let full = full_json(&["continue"], &temp);
assert!(full.get("message").is_some() && full.get("action").is_some());
assert!(
compact.get("message").is_none()
&& compact.get("action").is_none()
&& compact.get("recommended_action").is_none(),
"compact continue must drop message/action/recommended_action: {compact}"
);
}
#[test]
fn json_compact_is_a_valid_output_value() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).expect("init");
let out =
heddle_output(&["--output", "json-compact", "status"], Some(temp.path())).expect("spawn");
assert!(
out.status.success(),
"--output json-compact must parse and run: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn json_compact_rejects_commands_without_projection() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).expect("init");
let out =
heddle_output(&["--output", "json-compact", "help"], Some(temp.path())).expect("spawn");
assert!(
!out.status.success(),
"compact-less command must reject json-compact"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("json-compact is not supported"),
"rejection should explain unsupported compact mode: {stderr}"
);
}
fn compact_error_envelope(args: &[&str], cwd: Option<&std::path::Path>) -> Value {
let out = heddle_output(args, cwd).unwrap_or_else(|err| panic!("spawn failed: {err}"));
assert!(
!out.status.success(),
"heddle {args:?} should fail; stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let stderr = str::from_utf8(&out.stderr).expect("stderr utf8");
serde_json::from_str(stderr.trim())
.unwrap_or_else(|err| panic!("stderr not JSON: {err}\n stderr: {stderr}"))
}
const COMPACT_ERROR_KEYS: &[&str] = &["kind", "error", "exit_code", "hint", "primary_command"];
fn assert_only_compact_error_keys(value: &Value, context: &str) {
let obj = value
.as_object()
.unwrap_or_else(|| panic!("{context}: compact error must be a JSON object: {value}"));
for key in obj.keys() {
assert!(
COMPACT_ERROR_KEYS.contains(&key.as_str()),
"{context}: compact error leaked non-decision-surface key `{key}`: {value}"
);
}
for key in COMPACT_ERROR_KEYS {
assert!(
obj.contains_key(*key),
"{context}: compact error missing `{key}`: {value}"
);
}
}
#[test]
fn field_study_everyday_verbs_accept_json_compact() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).expect("init");
std::fs::write(temp.path().join("main.rs"), "fn main() {}\n").unwrap();
heddle(&["capture", "-m", "seed"], Some(temp.path())).expect("seed");
for args in [
&["log"][..],
&["diff"][..],
&["verify"][..],
&[
"context",
"set",
"--path",
"main.rs",
"--scope",
"file",
"--kind",
"rationale",
"-m",
"entry point",
][..],
&["discuss", "open", "main.rs", "main", "first turn"][..],
] {
let compact = compact_json(args, &temp);
assert!(
compact.get("output_kind").and_then(Value::as_str).is_some(),
"heddle {args:?} --output json-compact must emit output_kind: {compact}"
);
assert_only_compact_keys(&compact, &format!("field-study {}", args.join(" ")));
assert!(
compact.get("verification").is_none()
&& compact.get("machine_contract_coverage").is_none(),
"compact {args:?} must not embed machine-contract self-noise: {compact}"
);
}
let checkout = TempDir::new().unwrap();
let checkout_arg = checkout.path().join("work");
let started = compact_json(
&[
"start",
"feature/search",
"--path",
checkout_arg.to_str().unwrap(),
],
&temp,
);
assert_eq!(started["output_kind"].as_str(), Some("thread_start"));
assert_only_compact_keys(&started, "field-study start");
}
#[test]
fn capture_compact_includes_state_id() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).expect("init");
std::fs::write(temp.path().join("work.txt"), "pending\n").unwrap();
let compact = compact_json(&["capture", "-m", "compact state_id"], &temp);
assert_eq!(compact["output_kind"].as_str(), Some("capture"));
let state_id = compact["state_id"]
.as_str()
.unwrap_or_else(|| panic!("capture compact must include state_id: {compact}"));
assert!(
!state_id.is_empty(),
"capture compact state_id must be non-empty: {compact}"
);
assert_only_compact_keys(&compact, "capture state_id");
let full = full_json(&["log", "--limit", "1"], &temp);
assert_eq!(
full["states"][0]["state_id"].as_str(),
Some(state_id),
"compact capture state_id must match the tip state"
);
}
#[test]
fn status_compact_after_ready_has_land_next_action() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).expect("init");
std::fs::write(temp.path().join("base.txt"), "base\n").unwrap();
heddle(&["capture", "-m", "base"], Some(temp.path())).expect("base");
let checkout = TempDir::new().unwrap();
let checkout_arg = checkout.path().join("work");
let started = full_json(
&[
"start",
"feature/search",
"--path",
checkout_arg.to_str().unwrap(),
],
&temp,
);
let execution_path = started["execution_path"]
.as_str()
.expect("start should report execution_path");
let checkout_path = std::path::Path::new(execution_path);
std::fs::write(checkout_path.join("feature.txt"), "feature\n").unwrap();
heddle(&["capture", "-m", "feature"], Some(checkout_path)).expect("feature");
heddle(&["ready"], Some(checkout_path)).expect("ready");
let status_out = heddle_output(&["--output", "json-compact", "status"], Some(checkout_path))
.expect("status compact after ready");
assert!(
status_out.status.success(),
"compact status after ready should succeed; stderr={}",
String::from_utf8_lossy(&status_out.stderr)
);
let compact: Value = serde_json::from_str(
str::from_utf8(&status_out.stdout)
.expect("stdout utf8")
.lines()
.next()
.expect("status compact stdout"),
)
.expect("status compact JSON");
let next_action = compact["next_action"]
.as_str()
.unwrap_or_else(|| panic!("compact status after ready must have next_action: {compact}"));
assert!(
next_action.contains("land --thread feature/search"),
"compact status after ready must recommend land: {compact}"
);
assert_only_compact_keys(&compact, "status after ready");
assert!(
compact.get("verification").is_none(),
"compact status must drop the coverage report: {compact}"
);
}
#[test]
fn status_compact_before_init_stays_compact() {
let temp = TempDir::new().unwrap();
let envelope =
compact_error_envelope(&["--output", "json-compact", "status"], Some(temp.path()));
assert_eq!(envelope["kind"], "repository_not_found");
assert_only_compact_error_keys(&envelope, "status compact before init");
let primary = envelope["primary_command"]
.as_str()
.unwrap_or_else(|| panic!("compact pre-init error must keep primary_command: {envelope}"));
assert!(
primary.contains("heddle init"),
"compact pre-init next step must be init: {envelope}"
);
assert!(
envelope.get("advice_contract_valid").is_none()
&& envelope.get("recovery_commands").is_none()
&& envelope.get("unsafe_condition").is_none(),
"compact pre-init must not dump the full recovery envelope: {envelope}"
);
}