use std::collections::BTreeSet;
use cli::cli::commands::{
CLONE_CONNECTION_OUTPUT_KIND, CLONE_OUTPUT_KIND, build_command_catalog,
documented_samples_with_bound_verbs, operator_emission_output_kinds, operator_envelope_verbs,
schema_for_verb,
};
use serde_json::Value;
use tempfile::TempDir;
use super::{heddle, heddle_output};
const SWEPT: &[&str] = &[
"status",
"verify",
"init",
"capture",
"checkpoint",
"commit",
"clone",
"diff",
"undo",
"thread list",
"thread show",
"doctor docs",
"doctor schemas",
"schemas",
"bridge git status",
"bridge git import",
"bridge git sync",
"bridge git reconcile",
"actor spawn",
"actor list",
"actor show",
"actor explain",
"actor done",
"revert",
"redact apply",
"redact list",
"redact purge apply",
"redact purge list",
"redact show",
"redact trust add",
"redact trust list",
"redact trust remove",
"visibility set",
"visibility promote",
"visibility show",
"visibility list",
"stash list",
"stash show",
"clean",
"discuss open",
"discuss append",
"discuss resolve",
"discuss list",
"discuss show",
"context set",
"context get",
"context list",
"context history",
"context edit",
"context supersede",
"context rm",
"context check",
"context suggest",
"context audit",
"review show",
"review sign",
"review next",
"review health",
"resolve",
"cherry-pick",
"rebase",
"show",
"abort",
"adopt",
"agent capture",
"agent ready",
"agent serve",
"agent status",
"agent stop",
"bridge git pull",
"bridge git push",
"continue",
"daemon stop",
"doctor",
"expand",
"fetch",
"land",
"log",
"maintenance gc",
"maintenance index",
"merge",
"oplog recover",
"pull",
"push",
"query",
"ready",
"remote add",
"remote list",
"remote remove",
"remote set-default",
"remote show",
"start",
"switch",
"sync",
"timeline fork",
"timeline reset",
"timeline recover",
"thread cleanup",
"thread create",
"thread drop",
"thread marker create",
"thread marker delete",
"thread marker list",
"thread marker show",
"thread promote",
"thread refresh",
"thread rename",
"thread resolve",
"thread revoke-approval",
"thread switch",
];
const KIND_FIELD_EXCEPTIONS: &[&str] = &["help"];
const UNSWEPT_TODO: &[&str] = &[
"agent heartbeat",
"agent list",
"agent release",
"agent reserve",
"bridge git export",
"bridge git init",
"bridge git reason",
"collapse",
"daemon serve",
"daemon status",
"fsck",
"git-overlay",
"hook events",
"hook install",
"hook list",
"hook uninstall",
"integration doctor",
"integration install",
"integration list",
"integration relay",
"integration uninstall",
"integration upgrade",
"maintenance inspect",
"maintenance monitor",
"maintenance run",
"retro",
"semantic hot",
"session end",
"session list",
"session segment",
"session show",
"session start",
"stash apply",
"stash clear",
"stash drop",
"stash pop",
"stash push",
"thread absorb",
"thread approvals",
"thread approve",
"thread captures",
"thread check-merge",
"thread current",
"thread move",
"transaction abort",
"transaction begin",
"transaction commit",
"transaction status",
"try",
"watch",
];
fn expected_output_kind(display: &str) -> String {
if let Some(stable) = output_kind_override(display) {
return stable.to_string();
}
display.replace(['-', ' '], "_")
}
fn output_kind_override(display: &str) -> Option<&'static str> {
match display {
"agent capture" => Some("capture"),
"agent ready" => Some("ready"),
"start" => Some("thread_start"),
"switch" => Some("thread_switch"),
"doctor" => Some("diagnose"),
"maintenance gc" => Some("gc"),
"maintenance index" => Some("index"),
"redact purge apply" => Some("purge_apply"),
"redact purge list" => Some("purge_list"),
"rebase" => Some("rebase_progress"),
"timeline fork" | "timeline reset" | "timeline recover" => Some("timeline_action"),
_ => None,
}
}
fn read_json_schemas_doc() -> String {
let doc_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(2)
.expect("workspace root")
.join("docs/json-schemas.md");
std::fs::read_to_string(&doc_path)
.unwrap_or_else(|err| panic!("read {}: {err}", doc_path.display()))
}
fn catalog_output_kind_discriminators() -> Vec<(String, String, bool)> {
build_command_catalog()
.json_discriminators
.into_iter()
.filter(|discriminator| discriminator.field == "output_kind")
.map(|discriminator| {
(
discriminator.display,
discriminator.value,
discriminator.schema_verb.is_some(),
)
})
.collect()
}
#[test]
fn every_json_emitting_verb_is_classified() {
let catalog = build_command_catalog();
let known: BTreeSet<&str> = SWEPT
.iter()
.copied()
.chain(UNSWEPT_TODO.iter().copied())
.chain(KIND_FIELD_EXCEPTIONS.iter().copied())
.collect();
let mut unclassified = Vec::new();
for entry in &catalog.commands {
if !entry.supports_json {
continue;
}
if entry.json_kind == "none" {
continue;
}
if !known.contains(entry.display.as_str()) {
unclassified.push(entry.display.clone());
}
}
assert!(
unclassified.is_empty(),
"New JSON-emitting verbs lack an `output_kind` classification. \
Either add `output_kind` to the verb's Serialize struct AND add the \
entry to `SWEPT` (with a `json_discriminator(... \"output_kind\", \
...)` declaration in `command_catalog.rs`), or — as a documented \
gap — add the entry to `UNSWEPT_TODO`. New verbs MUST take the \
first path; the second is the rolldown surface for pre-existing \
unswept verbs.\n\nUnclassified:\n - {}",
unclassified.join("\n - ")
);
}
#[test]
fn swept_verbs_declare_output_kind_in_catalog() {
let catalog = build_command_catalog();
let mut missing = Vec::new();
let mut wrong_value = Vec::new();
for &display in SWEPT {
let Some(entry) = catalog.commands.iter().find(|c| c.display == display) else {
missing.push(format!("{display}: not present in command catalog"));
continue;
};
let expected = expected_output_kind(display);
let discriminator = entry
.json_discriminators
.iter()
.find(|d| d.field == "output_kind");
match discriminator {
None => missing.push(format!(
"{display}: catalog entry has no `output_kind` discriminator (expected value `{expected}`)"
)),
Some(d) if d.value != expected => wrong_value.push(format!(
"{display}: declared output_kind=`{}` but expected `{expected}`",
d.value
)),
Some(_) => {}
}
}
if !missing.is_empty() || !wrong_value.is_empty() {
let mut msg = String::new();
if !missing.is_empty() {
msg.push_str("Verbs in SWEPT missing the `output_kind` catalog declaration:\n - ");
msg.push_str(&missing.join("\n - "));
msg.push('\n');
}
if !wrong_value.is_empty() {
msg.push_str("Verbs in SWEPT with the wrong `output_kind` value:\n - ");
msg.push_str(&wrong_value.join("\n - "));
msg.push('\n');
}
panic!(
"Catalog/SWEPT contract violations. The catalog discriminator is the \
wire-format promise agents read; it must match the verb's display \
path (snake-cased).\n\n{msg}"
);
}
}
#[test]
fn operator_envelope_verbs_have_declared_emissions() {
let catalog_verbs: BTreeSet<String> = operator_envelope_verbs().into_iter().collect();
let emissions: BTreeSet<String> = operator_emission_output_kinds()
.into_iter()
.map(|(display, _)| display)
.collect();
let missing: Vec<&str> = catalog_verbs
.difference(&emissions)
.map(String::as_str)
.collect();
let stale: Vec<&str> = emissions
.difference(&catalog_verbs)
.map(String::as_str)
.collect();
assert!(
missing.is_empty() && stale.is_empty(),
"Operator envelope verbs must be registered in the catalog and in the \
closed emission table. A missing emission would otherwise allow the \
output_kind source to drift back toward the live operation action.\n\
Missing emission declaration(s): {missing:?}\n\
Stale emission declaration(s): {stale:?}"
);
}
#[test]
fn operator_emissions_match_catalog_discriminators() {
let catalog = build_command_catalog();
let mut failures = Vec::new();
for (display, output_kind) in operator_emission_output_kinds() {
let Some(entry) = catalog
.commands
.iter()
.find(|entry| entry.display == display)
else {
failures.push(format!("{display}: not present in command catalog"));
continue;
};
let advertised: BTreeSet<&str> = entry
.json_discriminators
.iter()
.filter(|discriminator| discriminator.field == "output_kind")
.map(|discriminator| discriminator.value.as_str())
.collect();
if !advertised.contains(output_kind.as_str()) {
failures.push(format!(
"{display}: emission declares output_kind=`{output_kind}` but catalog advertises {advertised:?}"
));
}
}
assert!(
failures.is_empty(),
"Operator emission declarations drifted from the catalog:\n - {}",
failures.join("\n - ")
);
}
#[test]
fn kind_field_exceptions_use_kind_intentionally() {
let catalog = build_command_catalog();
for &display in KIND_FIELD_EXCEPTIONS {
let entry = catalog
.commands
.iter()
.find(|c| c.display == display)
.unwrap_or_else(|| {
panic!("`{display}` listed in KIND_FIELD_EXCEPTIONS is not in the catalog")
});
let has_kind = entry.json_discriminators.iter().any(|d| d.field == "kind");
assert!(
has_kind,
"`{display}` is documented as a `kind`-rather-than-output_kind exception but the catalog declares no `kind` discriminator. Update the catalog or drop the exception."
);
}
}
#[test]
fn clone_catalog_entry_advertises_both_clone_and_clone_connection() {
let catalog = build_command_catalog();
let clone = catalog
.commands
.iter()
.find(|c| c.display == "clone")
.expect("clone should be cataloged");
let output_kind_values: Vec<&str> = clone
.json_discriminators
.iter()
.filter(|d| d.field == "output_kind")
.map(|d| d.value.as_str())
.collect();
assert!(
output_kind_values.contains(&CLONE_OUTPUT_KIND),
"clone catalog entry must advertise `output_kind = {CLONE_OUTPUT_KIND}` \
(the final clone payload); actually advertises {output_kind_values:?}"
);
assert!(
output_kind_values.contains(&CLONE_CONNECTION_OUTPUT_KIND),
"clone catalog entry must advertise `output_kind = {CLONE_CONNECTION_OUTPUT_KIND}` \
alongside `{CLONE_OUTPUT_KIND}` so agents can route the hosted \
preliminary connection envelope; actually advertises {output_kind_values:?}"
);
let envelope = clone
.json_discriminators
.iter()
.find(|d| d.value == CLONE_CONNECTION_OUTPUT_KIND)
.expect("clone_connection discriminator must be present");
assert!(
envelope.schema_verb.is_none(),
"clone_connection envelope has no schema verb (it is not a Serialize struct); \
got schema_verb={:?}",
envelope.schema_verb
);
assert!(
envelope
.no_schema_reason
.as_deref()
.is_some_and(|reason| !reason.is_empty()),
"clone_connection envelope must document why it has no schema verb"
);
}
#[test]
#[ignore = "requires a live hosted gRPC fixture; runtime equality is enforced \
statically via CLONE_CONNECTION_OUTPUT_KIND (see \
clone_catalog_entry_advertises_both_clone_and_clone_connection). \
When a hosted-clone fixture lands, drop the #[ignore] and parse \
both stdout records here."]
fn hosted_clone_emits_both_discriminator_values() {
let catalog = build_command_catalog();
let clone = catalog
.commands
.iter()
.find(|c| c.display == "clone")
.expect("clone should be cataloged");
let advertised: Vec<&str> = clone
.json_discriminators
.iter()
.filter(|d| d.field == "output_kind")
.map(|d| d.value.as_str())
.collect();
assert!(advertised.contains(&CLONE_OUTPUT_KIND));
assert!(advertised.contains(&CLONE_CONNECTION_OUTPUT_KIND));
}
#[test]
fn unswept_verbs_have_no_output_kind_declaration() {
let catalog = build_command_catalog();
let mut stale = Vec::new();
for &display in UNSWEPT_TODO {
let Some(entry) = catalog.commands.iter().find(|c| c.display == display) else {
continue;
};
let has_output_kind = entry
.json_discriminators
.iter()
.any(|d| d.field == "output_kind");
if has_output_kind {
stale.push(display.to_string());
}
}
assert!(
stale.is_empty(),
"Verbs listed in UNSWEPT_TODO already declare `output_kind` in the \
catalog. Move them to SWEPT (and add a runtime invocation if \
feasible):\n - {}",
stale.join("\n - ")
);
}
#[test]
fn doc_samples_carry_catalog_output_kind_for_every_discriminated_verb() {
let doc = read_json_schemas_doc();
let mut advertised: std::collections::BTreeMap<String, BTreeSet<String>> =
std::collections::BTreeMap::new();
for (display, value, _) in catalog_output_kind_discriminators() {
advertised.entry(display).or_default().insert(value);
}
let verbs: Vec<&str> = advertised.keys().map(String::as_str).collect();
let mut failures = Vec::new();
let mut checked = 0usize;
for (sample, bound) in documented_samples_with_bound_verbs(&doc, &verbs) {
let allowed: BTreeSet<&str> = bound
.iter()
.filter_map(|verb| advertised.get(verb))
.flat_map(|values| values.iter().map(String::as_str))
.collect();
let Some(object) = sample.as_object() else {
failures.push(format!(
"sample bound to {bound:?} is not a JSON object, so it cannot carry the \
required `output_kind` discriminator (catalog advertises {allowed:?})"
));
continue;
};
checked += 1;
match object.get("output_kind").and_then(Value::as_str) {
None => failures.push(format!(
"sample bound to {bound:?} omits the `output_kind` discriminator \
(catalog advertises {allowed:?})"
)),
Some(found) if !allowed.contains(found) => failures.push(format!(
"sample bound to {bound:?} declares output_kind=`{found}`, which is not a \
catalog-advertised value for those verbs ({allowed:?})"
)),
Some(_) => {}
}
}
assert!(
failures.is_empty(),
"Documented samples drift from the catalog `output_kind` contract. The catalog is the \
source of truth; every sample bound to a discriminator verb must carry a catalog \
value:\n - {}",
failures.join("\n - ")
);
assert!(
checked >= 30,
"expected the catalog-driven doc sweep to inspect many discriminator samples; only \
{checked} were bound — the heading/inline binding likely regressed"
);
}
fn init_fixture() -> TempDir {
let temp = TempDir::new().expect("tempdir");
heddle(
&[
"init",
"--principal-name",
"Heddle Test",
"--principal-email",
"heddle@test.example",
],
Some(temp.path()),
)
.expect("heddle init");
temp
}
fn init_rebase_fast_forward_fixture() -> TempDir {
let temp = init_fixture();
heddle(&["thread", "create", "feature"], Some(temp.path())).expect("thread create feature");
heddle(&["thread", "switch", "feature"], Some(temp.path())).expect("switch feature");
std::fs::write(temp.path().join("feat.txt"), "feature work\n").expect("write feature file");
heddle(&["capture", "-m", "feature"], Some(temp.path())).expect("feature capture");
heddle(&["thread", "switch", "main"], Some(temp.path())).expect("switch main");
temp
}
fn runtime_invocation_args(
display: &str,
) -> Option<(&'static [&'static str], bool /* expect_ok */)> {
match display {
"redact purge list" => Some((&["redact", "purge", "list"], true)),
"redact list" => Some((&["redact", "list"], true)),
"redact trust list" => Some((&["redact", "trust", "list"], true)),
"stash list" => Some((&["stash", "list"], true)),
"discuss list" => Some((&["discuss", "list"], true)),
"context list" => Some((&["context", "list"], true)),
"review next" => Some((&["review", "next"], true)),
"review health" => Some((&["review", "health"], true)),
"abort" => Some((&["abort"], true)),
"continue" => Some((&["continue"], true)),
"doctor" => Some((&["doctor"], true)),
"log" => Some((&["log"], true)),
"maintenance gc" => Some((&["maintenance", "gc"], true)),
"maintenance index" => Some((&["maintenance", "index"], true)),
"query" => Some((&["query"], true)),
"remote list" => Some((&["remote", "list"], true)),
_ => None,
}
}
#[test]
fn runtime_init_emits_output_kind() {
let temp = TempDir::new().expect("tempdir");
let output = heddle_output(
&[
"--output",
"json",
"init",
"--principal-name",
"Heddle Test",
"--principal-email",
"heddle@test.example",
],
Some(temp.path()),
)
.expect("heddle init --output json");
assert!(
output.status.success(),
"init exited non-zero: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let stdout = String::from_utf8_lossy(&output.stdout);
let first_line = stdout.lines().next().unwrap_or("").trim();
let parsed: Value = serde_json::from_str(first_line).expect("init stdout is parseable JSON");
assert_eq!(
parsed.get("output_kind").and_then(|v| v.as_str()),
Some("init"),
"`heddle init --output json` must emit `output_kind: \"init\"`; payload: {first_line}"
);
}
fn runtime_top_level_keys(argv: &[&str], dir: &std::path::Path) -> BTreeSet<String> {
let output =
heddle_output(argv, Some(dir)).unwrap_or_else(|err| panic!("spawn {argv:?}: {err}"));
assert!(
output.status.success(),
"{argv:?} exited non-zero: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let stdout = String::from_utf8_lossy(&output.stdout);
let first_line = stdout.lines().next().unwrap_or("").trim();
let parsed: Value = serde_json::from_str(first_line).unwrap_or_else(|line_err| {
serde_json::from_str(stdout.trim()).unwrap_or_else(|full_err| {
panic!(
"{argv:?} stdout not JSON: first line error: {line_err}; full stdout error: {full_err}\n stdout: {stdout}"
)
})
});
parsed
.as_object()
.unwrap_or_else(|| panic!("{argv:?} top-level JSON is not an object: {first_line}"))
.keys()
.cloned()
.collect()
}
fn doc_sample_top_level_keys(doc: &str, output_kind_value: &str) -> Option<BTreeSet<String>> {
let mut in_block = false;
let mut buf = String::new();
for line in doc.lines() {
let trimmed = line.trim();
if !in_block {
if trimmed == "```json" {
in_block = true;
buf.clear();
}
continue;
}
if trimmed == "```" {
in_block = false;
if let Ok(Value::Object(map)) = serde_json::from_str::<Value>(&buf)
&& map.get("output_kind").and_then(|v| v.as_str()) == Some(output_kind_value)
{
return Some(map.keys().cloned().collect());
}
buf.clear();
continue;
}
buf.push_str(line);
buf.push('\n');
}
None
}
fn sv(args: &[&str]) -> Vec<String> {
args.iter().map(|s| s.to_string()).collect()
}
fn head_change_id(dir: &std::path::Path) -> String {
let stdout = heddle(&["--output", "json", "log"], Some(dir)).expect("heddle log");
let first = stdout.lines().next().unwrap_or("");
let parsed: Value = serde_json::from_str(first).expect("log stdout is JSON");
parsed["states"][0]["change_id"]
.as_str()
.expect("log states[0].change_id")
.to_string()
}
fn runtime_doc_case(output_kind: &str) -> Option<(TempDir, Vec<String>)> {
let case = match output_kind {
"clean" => {
let t = init_fixture();
std::fs::write(t.path().join("untracked.txt"), "junk").unwrap();
(t, sv(&["clean", "--dry-run"]))
}
"thread_switch" => {
let t = init_fixture();
std::fs::write(t.path().join("a.txt"), "base").unwrap();
heddle(&["commit", "-m", "base"], Some(t.path())).expect("commit");
(t, sv(&["switch", "main"]))
}
"revert" => {
let t = init_fixture();
std::fs::write(t.path().join("a.txt"), "base").unwrap();
heddle(&["commit", "-m", "base"], Some(t.path())).expect("commit base");
std::fs::write(t.path().join("a.txt"), "base\nmore").unwrap();
heddle(&["commit", "-m", "second"], Some(t.path())).expect("commit second");
(t, sv(&["revert", "HEAD"]))
}
"stash_list" => (init_fixture(), sv(&["stash", "list"])),
"stash_show" => {
let t = init_fixture();
std::fs::write(t.path().join("a.txt"), "base").unwrap();
heddle(&["commit", "-m", "base"], Some(t.path())).expect("commit");
std::fs::write(t.path().join("a.txt"), "base\nwip").unwrap();
heddle(&["stash", "push", "-m", "wip"], Some(t.path())).expect("stash push");
(t, sv(&["stash", "show"]))
}
"cherry_pick" => {
let t = init_fixture();
std::fs::write(t.path().join("f.txt"), "base").unwrap();
heddle(&["commit", "-m", "base"], Some(t.path())).expect("commit base");
heddle(&["thread", "create", "feature"], Some(t.path())).expect("thread feature");
heddle(&["switch", "feature"], Some(t.path())).expect("switch feature");
std::fs::write(t.path().join("g.txt"), "feat").unwrap();
heddle(&["commit", "-m", "feature work"], Some(t.path())).expect("commit feature");
let src = head_change_id(t.path());
heddle(&["switch", "main"], Some(t.path())).expect("switch main");
(t, vec!["cherry-pick".to_string(), src])
}
"redact_apply" => {
let t = init_fixture();
std::fs::write(t.path().join("secrets.env"), "TOKEN=abc").unwrap();
heddle(&["commit", "-m", "base"], Some(t.path())).expect("commit");
(
t,
sv(&[
"redact",
"apply",
"HEAD",
"--path",
"secrets.env",
"--reason",
"credential",
]),
)
}
"purge_apply" => {
let t = init_fixture();
std::fs::write(t.path().join("secrets.env"), "TOKEN=abc").unwrap();
heddle(&["commit", "-m", "base"], Some(t.path())).expect("commit");
heddle(
&[
"redact",
"apply",
"HEAD",
"--path",
"secrets.env",
"--reason",
"credential",
],
Some(t.path()),
)
.expect("redact apply");
(
t,
sv(&[
"redact",
"purge",
"apply",
"HEAD",
"--path",
"secrets.env",
"--force",
]),
)
}
"query_attribution" => {
let t = init_fixture();
std::fs::create_dir_all(t.path().join("src")).unwrap();
std::fs::write(t.path().join("src/lib.rs"), "pub fn run() {}\n").unwrap();
heddle(&["commit", "-m", "base"], Some(t.path())).expect("commit");
(
t,
sv(&["query", "--attribution", "src/lib.rs", "--context"]),
)
}
"redact_trust_add" => (
init_fixture(),
sv(&[
"redact",
"trust",
"add",
"--public-key",
"abc123def456",
"--algorithm",
"ed25519",
"--label",
"security",
]),
),
"discuss_open" => {
let t = init_fixture();
std::fs::write(t.path().join("a.txt"), "fn verify(){}").unwrap();
heddle(&["commit", "-m", "base"], Some(t.path())).expect("commit");
(
t,
sv(&["discuss", "open", "a.txt", "verify", "check edge case"]),
)
}
"discuss_list" => (init_fixture(), sv(&["discuss", "list"])),
"context_set" => {
let t = init_fixture();
std::fs::write(t.path().join("a.txt"), "code").unwrap();
heddle(&["commit", "-m", "base"], Some(t.path())).expect("commit");
(
t,
sv(&[
"context",
"set",
"--path",
"a.txt",
"--scope",
"file",
"-m",
"owner note",
]),
)
}
"context_list" => (init_fixture(), sv(&["context", "list"])),
"review_show" => {
let t = init_fixture();
std::fs::write(t.path().join("a.txt"), "fn verify(){}").unwrap();
heddle(&["commit", "-m", "base"], Some(t.path())).expect("commit");
let cid = head_change_id(t.path());
(t, vec!["review".to_string(), "show".to_string(), cid])
}
"review_next" => {
let t = init_fixture();
std::fs::write(t.path().join("a.txt"), "base").unwrap();
heddle(&["commit", "-m", "base"], Some(t.path())).expect("commit");
heddle(
&["start", "review-next", "--workspace", "solid"],
Some(t.path()),
)
.expect("start review-next");
(t, sv(&["review", "next"]))
}
"review_health" => (init_fixture(), sv(&["review", "health"])),
"visibility_set" => {
let t = init_fixture();
std::fs::write(t.path().join("a.txt"), "base").unwrap();
heddle(&["commit", "-m", "base"], Some(t.path())).expect("commit");
(t, sv(&["visibility", "set", "HEAD", "--tier", "internal"]))
}
"visibility_promote" => {
let t = init_fixture();
std::fs::write(t.path().join("a.txt"), "base").unwrap();
heddle(&["commit", "-m", "base"], Some(t.path())).expect("commit");
heddle(
&[
"visibility",
"set",
"HEAD",
"--tier",
"restricted",
"--label",
"secret",
],
Some(t.path()),
)
.expect("visibility set");
(
t,
sv(&["visibility", "promote", "HEAD", "--tier", "internal"]),
)
}
"visibility_show" => {
let t = init_fixture();
std::fs::write(t.path().join("a.txt"), "base").unwrap();
heddle(&["commit", "-m", "base"], Some(t.path())).expect("commit");
heddle(
&["visibility", "set", "HEAD", "--tier", "internal"],
Some(t.path()),
)
.expect("visibility set");
(t, sv(&["visibility", "show", "HEAD"]))
}
"visibility_list" => {
let t = init_fixture();
std::fs::write(t.path().join("a.txt"), "base").unwrap();
heddle(&["commit", "-m", "base"], Some(t.path())).expect("commit");
heddle(
&["visibility", "set", "HEAD", "--tier", "internal"],
Some(t.path()),
)
.expect("visibility set");
(t, sv(&["visibility", "list"]))
}
"rebase_progress" => (
init_rebase_fast_forward_fixture(),
sv(&["rebase", "feature"]),
),
"gc" => (init_fixture(), sv(&["maintenance", "gc"])),
"daemon_stop" => (init_fixture(), sv(&["daemon", "stop"])),
"oplog_recover" => {
let t = init_fixture();
for i in 1..=3 {
std::fs::write(t.path().join("f.txt"), format!("v{i}")).unwrap();
heddle(&["commit", "-m", &format!("c{i}")], Some(t.path()))
.expect("commit fixture capture");
}
let oplog = t.path().join(".heddle/oplog/oplog.bin");
let bytes = std::fs::read(&oplog).expect("read fixture oplog");
let cut = bytes.len() * 6 / 10;
std::fs::write(&oplog, &bytes[..cut]).expect("truncate fixture oplog");
(t, sv(&["oplog", "recover"]))
}
"timeline_log" => (init_fixture(), sv(&["log", "--timeline"])),
_ => return None,
};
Some(case)
}
fn schema_property_names(verb: &str) -> BTreeSet<String> {
let Some(schema) = schema_for_verb(verb) else {
return BTreeSet::new();
};
schema
.get("properties")
.and_then(Value::as_object)
.map(|props| props.keys().cloned().collect())
.unwrap_or_default()
}
#[test]
fn doc_samples_match_runtime_for_every_catalog_discriminator() {
let doc = read_json_schemas_doc();
let mut failures = Vec::new();
let mut covered_by_runtime = 0usize;
let mut covered_by_schema = 0usize;
let mut advertising: std::collections::BTreeMap<String, Vec<String>> =
std::collections::BTreeMap::new();
for (display, value, has_schema_verb) in catalog_output_kind_discriminators() {
if has_schema_verb {
advertising.entry(value).or_default().push(display);
}
}
for (value, displays) in &advertising {
let Some(doc_keys) = doc_sample_top_level_keys(&doc, value) else {
continue;
};
if !doc_keys.contains("output_kind") {
failures.push(format!(
"{value} ({displays:?}): documented sample is missing the `output_kind` key"
));
continue;
}
if let Some((fixture, argv)) = runtime_doc_case(value) {
let argv_refs: Vec<&str> = std::iter::once("--output")
.chain(std::iter::once("json"))
.chain(argv.iter().map(String::as_str))
.collect();
let runtime_keys = runtime_top_level_keys(&argv_refs, fixture.path());
if !runtime_keys.contains("output_kind") {
failures.push(format!(
"{value} ({displays:?}): runtime payload is missing `output_kind` (keys: {runtime_keys:?})"
));
}
if doc_keys != runtime_keys {
let doc_only: Vec<&String> = doc_keys.difference(&runtime_keys).collect();
let runtime_only: Vec<&String> = runtime_keys.difference(&doc_keys).collect();
failures.push(format!(
"{value} ({displays:?}): documented sample does not match the live \
`--output json` payload.\n in doc only: {doc_only:?}\n \
in runtime only: {runtime_only:?}\n doc keys: {doc_keys:?}\n \
runtime keys: {runtime_keys:?}"
));
}
covered_by_runtime += 1;
continue;
}
let pinned_by_some_display = displays.iter().any(|display| {
let schema_props = schema_property_names(display);
doc_keys
.iter()
.filter(|k| k.as_str() != "output_kind")
.all(|k| schema_props.contains(k))
});
if pinned_by_some_display {
covered_by_schema += 1;
} else {
failures.push(format!(
"{value} ({displays:?}): no runtime case AND no advertising display's \
registered schema pins every documented key (schema is generic or models \
a different shape). Add a `runtime_doc_case` arm so the sample is checked \
against the live payload."
));
}
}
assert!(
failures.is_empty(),
"Documented samples drifted from runtime / are unguarded:\n - {}",
failures.join("\n - ")
);
assert!(
covered_by_runtime >= 18,
"expected the sweep to runtime-check most documented persona verbs; only {covered_by_runtime} ran"
);
assert!(
covered_by_schema >= 5,
"expected several schema-guarded earlier-swept verbs (clone, status, verify, ...); got {covered_by_schema}"
);
}
#[test]
fn runtime_emits_output_kind_for_invokable_swept_verbs() {
let fixture = init_fixture();
let mut failures = Vec::new();
for &display in SWEPT {
let Some((argv, expect_ok)) = runtime_invocation_args(display) else {
continue;
};
let expected = expected_output_kind(display);
let mut full_argv: Vec<&str> = vec!["--output", "json"];
full_argv.extend(argv.iter().copied());
let output = match heddle_output(&full_argv, Some(fixture.path())) {
Ok(out) => out,
Err(err) => {
failures.push(format!("{display}: spawn failed: {err}"));
continue;
}
};
if expect_ok && !output.status.success() {
failures.push(format!(
"{display}: exited non-zero (status {:?})\nstdout: {}\nstderr: {}",
output.status.code(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
));
continue;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let first_line = stdout.lines().next().unwrap_or("").trim();
let parsed: Value = match serde_json::from_str(first_line) {
Ok(v) => v,
Err(err) => {
failures.push(format!(
"{display}: stdout is not parseable JSON: {err}\n first_line: {first_line}"
));
continue;
}
};
let actual = parsed.get("output_kind").and_then(|v| v.as_str());
match actual {
Some(value) if value == expected => {}
Some(other) => failures.push(format!(
"{display}: runtime JSON has output_kind=`{other}` but catalog declares `{expected}`"
)),
None => failures.push(format!(
"{display}: runtime JSON missing `output_kind` field (expected `{expected}`); payload: {first_line}"
)),
}
}
assert!(
failures.is_empty(),
"Runtime JSON output is missing or mismatches `output_kind`:\n - {}",
failures.join("\n - ")
);
}
fn advertised_output_kinds(display: &str) -> BTreeSet<String> {
catalog_output_kind_discriminators()
.into_iter()
.filter(|(d, _, _)| d == display)
.map(|(_, value, _)| value)
.collect()
}
fn emitted_output_kind(argv: &[&str], dir: &std::path::Path) -> String {
let output =
heddle_output(argv, Some(dir)).unwrap_or_else(|err| panic!("spawn {argv:?}: {err}"));
assert!(
output.status.success(),
"{argv:?} exited non-zero: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let stdout = String::from_utf8_lossy(&output.stdout);
let first_line = stdout.lines().next().unwrap_or("").trim();
let parsed: Value = serde_json::from_str(first_line)
.unwrap_or_else(|err| panic!("{argv:?} stdout not JSON: {err}\n line: {first_line}"));
parsed
.get("output_kind")
.and_then(Value::as_str)
.unwrap_or_else(|| panic!("{argv:?} payload missing `output_kind`: {first_line}"))
.to_string()
}
#[test]
fn folded_verb_flag_variants_emit_only_advertised_output_kinds() {
let undo_advertised = advertised_output_kinds("undo");
assert!(
undo_advertised.is_superset(&BTreeSet::from([
"undo".to_string(),
"undo_list".to_string(),
"redo".to_string(),
])),
"catalog must advertise all undo-mode output_kinds; advertised: {undo_advertised:?}"
);
let temp = init_fixture();
std::fs::write(temp.path().join("a.txt"), "one").unwrap();
heddle(&["commit", "-m", "first"], Some(temp.path())).expect("commit first");
std::fs::write(temp.path().join("a.txt"), "two").unwrap();
heddle(&["commit", "-m", "second"], Some(temp.path())).expect("commit second");
let cases: &[(&[&str], &str, &str)] = &[
(&["--output", "json", "undo", "--list"], "undo_list", "undo"),
(&["--output", "json", "undo"], "undo", "undo"),
(&["--output", "json", "undo", "--redo"], "redo", "undo"),
];
let mut failures = Vec::new();
for (argv, expected, display) in cases {
let advertised = advertised_output_kinds(display);
let kind = emitted_output_kind(argv, temp.path());
if kind != *expected {
failures.push(format!(
"{argv:?}: emitted output_kind=`{kind}`, expected `{expected}`"
));
}
if !advertised.contains(&kind) {
failures.push(format!(
"{argv:?}: emitted output_kind=`{kind}` is NOT in the catalog-advertised \
set for `{display}` ({advertised:?}) — off-contract"
));
}
}
assert!(
failures.is_empty(),
"undo/redo variants emit output_kinds outside the advertised set:\n - {}",
failures.join("\n - ")
);
}