use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::Value;
fn bin() -> Command {
let mut c = Command::cargo_bin("orchestratectl").expect("binary builds");
c.env_remove("ORCHESTRATECTL_LOG");
c.env_remove("NO_COLOR");
c.env_remove("CLICOLOR");
c
}
fn help_stdout(args: &[&str]) -> String {
let out = bin()
.args(args)
.assert()
.success()
.stderr(predicate::str::is_empty())
.get_output()
.stdout
.clone();
let s = String::from_utf8(out).expect("stdout is utf8");
let v: Value = serde_json::from_str(s.trim()).expect("help stdout is a JSON envelope");
assert_eq!(v["schema_version"], 1, "envelope schema: {v}");
assert_eq!(v["data"]["schema_version_help"], 3, "help schema: {v}");
assert!(v.get("error").is_none(), "help envelope carries error: {v}");
s
}
fn snapshot(name: &str, value: &str) {
let mut settings = insta::Settings::clone_current();
settings.add_filter(r#""version": "[^"]*""#, r#""version": "[VERSION]""#);
settings.bind(|| insta::assert_snapshot!(name, value));
}
#[test]
fn top_level_help_json_locks_the_whole_surface() {
let stdout = help_stdout(&["--help", "--output", "json", "--depth", "tree"]);
snapshot("top_level_help_json", &stdout);
}
#[test]
fn top_level_help_json_default_depth_summarizes_subcommands() {
let stdout = help_stdout(&["--help", "--output", "json"]);
let v: Value = serde_json::from_str(stdout.trim()).expect("json");
let subs = v["data"]["subcommands"]
.as_array()
.expect("subcommands array");
assert!(!subs.is_empty(), "root should list its subcommands");
for s in subs {
assert!(
s.get("flags").is_none(),
"depth=1 child must be a summary (no `flags`): {s}"
);
assert!(
s.get("has_subcommands").is_some(),
"summary carries `has_subcommands`: {s}"
);
}
}
#[test]
fn depth_two_expands_immediate_children_fully() {
let stdout = help_stdout(&["--help", "--output", "json", "--depth", "2"]);
let v: Value = serde_json::from_str(stdout.trim()).expect("json");
let run = v["data"]["subcommands"]
.as_array()
.expect("subcommands array")
.iter()
.find(|s| s["command"] == "orchestratectl run")
.expect("run noun");
assert!(run.get("flags").is_some(), "depth=2 child is Full: {run}");
let verbs = run["subcommands"].as_array().expect("run has subcommands");
for v in verbs {
assert!(
v.get("flags").is_none(),
"depth=2 grandchild is Summary: {v}"
);
}
}
#[test]
fn invalid_depth_value_errors_with_invalid_arguments() {
let assert = bin()
.args(["--help", "--output", "json", "--depth", "garbage"])
.assert()
.failure();
let out = assert.get_output();
assert!(out.stdout.is_empty(), "no help on stdout when input is bad");
let stderr = String::from_utf8(out.stderr.clone()).expect("utf8");
let v: Value = serde_json::from_str(stderr.trim()).expect("error envelope");
assert_eq!(v["error"]["code"], "invalid_arguments", "envelope: {v}");
assert_eq!(v["error"]["invalid_value"], "garbage", "envelope: {v}");
}
#[test]
fn leaf_verb_help_json() {
let stdout = help_stdout(&["run", "create", "--help", "--output", "json"]);
snapshot("run_create_help_json", &stdout);
}
#[test]
fn noun_only_help_json_lists_verbs() {
let stdout = help_stdout(&["run", "--help", "--output", "json"]);
snapshot("run_help_json", &stdout);
}
#[test]
fn flags_are_sorted_by_name() {
let stdout = help_stdout(&["run", "create", "--help", "--output", "json"]);
let v: Value = serde_json::from_str(stdout.trim()).expect("json");
let names: Vec<String> = v["data"]["flags"]
.as_array()
.expect("flags array")
.iter()
.map(|f| f["name"].as_str().expect("name is string").to_string())
.collect();
let mut sorted = names.clone();
sorted.sort();
assert_eq!(names, sorted, "flags must be sorted by name");
assert!(!names.is_empty(), "leaf verb should expose flags");
}
#[test]
fn output_flag_reports_custom_accepted_values_and_arity() {
let stdout = help_stdout(&["run", "create", "--help", "--output", "json"]);
let v: Value = serde_json::from_str(stdout.trim()).expect("json");
let output = v["data"]["flags"]
.as_array()
.unwrap()
.iter()
.find(|f| f["name"] == "output")
.expect("output flag present");
assert_eq!(
output["accepted_values"],
serde_json::json!(["json", "jsonl", "text"]),
"custom-parser accepted values: {output}"
);
assert_eq!(output["accepts_file_paths"], true);
assert_eq!(output["is_global"], true);
assert_eq!(output["arity"]["min"], 1);
assert_eq!(output["arity"]["max"], 1);
}
#[test]
fn conflicting_flags_expose_the_edge() {
let stdout = help_stdout(&["run", "create", "--help", "--output", "json"]);
let v: Value = serde_json::from_str(stdout.trim()).expect("json");
let task = v["data"]["flags"]
.as_array()
.unwrap()
.iter()
.find(|f| f["name"] == "task")
.expect("task flag present");
assert_eq!(
task["conflicts_with"],
serde_json::json!(["prompt_file"]),
"conflicts_with edge: {task}"
);
}
#[test]
fn requiring_flags_expose_the_edge() {
let stdout = help_stdout(&["run", "create", "--help", "--output", "json"]);
let v: Value = serde_json::from_str(stdout.trim()).expect("json");
let flag = |name: &str| {
v["data"]["flags"]
.as_array()
.unwrap()
.iter()
.find(|f| f["name"] == name)
.unwrap_or_else(|| panic!("{name} flag present"))
.clone()
};
assert_eq!(
flag("parent_run_id")["requires"],
serde_json::json!(["parent_node_id"]),
"requires edge: {}",
flag("parent_run_id")
);
assert_eq!(
flag("parent_node_id")["requires"],
serde_json::json!(["parent_run_id"]),
"requires edge (reverse): {}",
flag("parent_node_id")
);
assert_eq!(
flag("title")["requires"],
serde_json::json!([]),
"non-requiring flag defaults to []: {}",
flag("title")
);
}
#[test]
fn jsonl_is_a_single_line() {
let stdout = help_stdout(&["--help", "--output", "jsonl"]);
assert_eq!(
stdout.lines().count(),
1,
"jsonl help must be a single line: {stdout:?}"
);
}
#[test]
fn bare_help_is_unchanged_text() {
let out = bin()
.args(["run", "create", "--help"])
.output()
.expect("spawn");
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).expect("utf8");
assert!(
stdout.starts_with("Create a new run"),
"bare --help should render clap text: {stdout:?}"
);
assert!(
serde_json::from_str::<Value>(stdout.trim()).is_err(),
"bare --help must not be JSON"
);
}
#[test]
fn output_text_with_help_stays_text() {
let out = bin()
.args(["run", "create", "--help", "--output", "text"])
.output()
.expect("spawn");
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).expect("utf8");
assert!(
serde_json::from_str::<Value>(stdout.trim()).is_err(),
"--output text --help must not be JSON: {stdout:?}"
);
}
#[test]
fn output_equals_form_triggers_json_help() {
let stdout = help_stdout(&["run", "create", "--help", "--output=json"]);
let v: Value = serde_json::from_str(stdout.trim()).expect("json");
assert_eq!(v["data"]["command"], "orchestratectl run create");
}
#[test]
fn double_dash_suppresses_json_help_detection() {
let out = bin()
.args(["run", "--", "--help", "--output", "json"])
.output()
.expect("spawn");
assert!(
out.stdout.is_empty(),
"`--` must suppress JSON help on stdout: {}",
String::from_utf8_lossy(&out.stdout)
);
}
#[test]
fn subcommands_are_sorted_by_name() {
let stdout = help_stdout(&["run", "--help", "--output", "json"]);
let v: Value = serde_json::from_str(stdout.trim()).expect("json");
let cmds: Vec<String> = v["data"]["subcommands"]
.as_array()
.expect("subcommands array")
.iter()
.map(|s| s["command"].as_str().expect("command string").to_string())
.collect();
let mut sorted = cmds.clone();
sorted.sort();
assert_eq!(cmds, sorted, "subcommands must be sorted");
assert!(cmds.len() >= 4, "run should list its verbs: {cmds:?}");
}
#[test]
fn unknown_subcommand_with_json_help_errors() {
let assert = bin()
.args(["bogus-subcommand", "--help", "--output", "json"])
.assert()
.failure();
let out = assert.get_output();
assert!(
out.stdout.is_empty(),
"no JSON help on stdout for an unknown subcommand: {}",
String::from_utf8_lossy(&out.stdout)
);
let stderr = String::from_utf8(out.stderr.clone()).expect("utf8");
let v: Value = serde_json::from_str(stderr.trim()).expect("stderr is an error envelope");
assert!(v.get("error").is_some(), "error envelope on stderr: {v}");
}
#[test]
fn unknown_subcommand_after_flags_errors() {
let assert = bin()
.args(["--help", "--output", "json", "bogus-subcommand"])
.assert()
.failure();
let out = assert.get_output();
assert!(
out.stdout.is_empty(),
"no JSON help on stdout: {}",
String::from_utf8_lossy(&out.stdout)
);
let stderr = String::from_utf8(out.stderr.clone()).expect("utf8");
let v: Value = serde_json::from_str(stderr.trim()).expect("stderr is an error envelope");
assert_eq!(
v["error"]["code"], "unknown_subcommand",
"resolver-level unknown-subcommand error: {v}"
);
}
#[test]
fn nested_unknown_subcommand_after_a_valid_noun_errors() {
let assert = bin()
.args(["--output", "json", "--help", "run", "bogus"])
.assert()
.failure();
let out = assert.get_output();
assert!(
out.stdout.is_empty(),
"no JSON help on stdout: {}",
String::from_utf8_lossy(&out.stdout)
);
let stderr = String::from_utf8(out.stderr.clone()).expect("utf8");
let v: Value = serde_json::from_str(stderr.trim()).expect("stderr is an error envelope");
assert_eq!(v["error"]["code"], "unknown_subcommand", "envelope: {v}");
}
#[test]
fn output_file_help_writes_json_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("help.json");
bin()
.args(["--help", "--output", path.to_str().unwrap()])
.assert()
.success();
let body = std::fs::read_to_string(&path).expect("help file written");
let v: Value = serde_json::from_str(&body).expect("file is valid JSON");
assert_eq!(v["data"]["schema_version_help"], 3);
}