use clap::Parser;
use gwm::cli::{build_status_json, Cli, Command};
use gwm::contract;
use gwm::github::{BranchLink, CiState, IssueState, IssueStatus, LinkSource, PrState, PrStatus};
use gwm::json_api::{JsonCheck, JsonDoctorReport, JsonPath, JsonStatus, JsonWorktree};
use serde_json::Value;
use std::collections::BTreeSet;
use std::path::PathBuf;
fn schema_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("docs/schema")
}
fn read_schema(name: &str) -> Value {
let path = schema_dir().join(name);
let raw = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
serde_json::from_str(&raw).unwrap_or_else(|e| panic!("parse {}: {e}", path.display()))
}
fn object_keys(schema: &Value, pointer: &str) -> BTreeSet<String> {
schema
.pointer(pointer)
.unwrap_or_else(|| panic!("schema has no object at {pointer}"))
.as_object()
.unwrap_or_else(|| panic!("{pointer} is not an object"))
.keys()
.cloned()
.collect()
}
fn required_set(schema: &Value, pointer: &str) -> BTreeSet<String> {
schema
.pointer(pointer)
.unwrap_or_else(|| panic!("schema has no `required` at {pointer}"))
.as_array()
.unwrap_or_else(|| panic!("{pointer} is not an array"))
.iter()
.map(|v| v.as_str().expect("required entry must be a string").to_string())
.collect()
}
fn serialized_keys<T: serde::Serialize>(value: &T) -> BTreeSet<String> {
serde_json::to_value(value)
.expect("serialize")
.as_object()
.expect("DTO must serialize to an object")
.keys()
.cloned()
.collect()
}
fn assert_field_contract(
what: &str,
required: &BTreeSet<String>,
serialized: &BTreeSet<String>,
properties: &BTreeSet<String>,
) {
let missing_required: Vec<_> = required.difference(serialized).collect();
assert!(
missing_required.is_empty(),
"{what}: stable field(s) {missing_required:?} are `required` in the schema but no longer serialized — a rename/removal that breaks the contract"
);
let undocumented: Vec<_> = serialized.difference(properties).collect();
assert!(
undocumented.is_empty(),
"{what}: serialized field(s) {undocumented:?} are not in the schema `properties` — an undocumented field leaking into the frozen output"
);
}
fn sample_worktree() -> JsonWorktree {
JsonWorktree {
name: "feat-317".into(),
id: "feat-317".into(),
path: "/wt/feat-317".into(),
branch: Some("feat/#317-freeze-machine-contracts".into()),
head: Some("a".repeat(40)),
is_main: false,
is_locked: false,
is_prunable: false,
status: JsonStatus {
is_dirty: true,
has_upstream: true,
ahead: 1,
behind: 0,
unknown: false,
},
age_seconds: Some(10),
issue: Some(317),
pr: Some(318),
}
}
#[test]
fn worktree_list_schema_matches_the_dto() {
let schema = read_schema("worktree-list.schema.json");
let required = required_set(&schema, "/$defs/worktree/required");
let properties = object_keys(&schema, "/$defs/worktree/properties");
let serialized = serialized_keys(&sample_worktree());
assert_field_contract("worktree-list row", &required, &serialized, &properties);
}
#[test]
fn worktree_status_schema_matches_the_dto() {
let schema = read_schema("worktree-list.schema.json");
let required = required_set(&schema, "/$defs/status/required");
let properties = object_keys(&schema, "/$defs/status/properties");
let serialized = serialized_keys(&sample_worktree().status);
assert_field_contract("worktree status", &required, &serialized, &properties);
}
#[test]
fn workspace_repo_field_is_in_properties_but_not_required() {
let schema = read_schema("worktree-list.schema.json");
let required = required_set(&schema, "/$defs/worktree/required");
let properties = object_keys(&schema, "/$defs/worktree/properties");
assert!(
properties.contains("repo"),
"workspace-mode `repo` must be a documented property"
);
assert!(
!required.contains("repo"),
"`repo` is workspace-only — requiring it would break single-repo rows"
);
let mut row = serde_json::to_value(sample_worktree()).unwrap();
row
.as_object_mut()
.unwrap()
.insert("repo".into(), Value::String("gwm-cli".into()));
let serialized: BTreeSet<String> = row.as_object().unwrap().keys().cloned().collect();
assert_field_contract("workspace worktree row", &required, &serialized, &properties);
}
#[test]
fn doctor_schema_matches_the_dtos() {
let schema = read_schema("doctor.schema.json");
let report = JsonDoctorReport {
checks: vec![JsonCheck {
name: "config".into(),
status: "ok".into(),
detail: "found .gwm.toml".into(),
fix_hint: None,
}],
severity: "ok".into(),
exit_code: 0,
};
let required = required_set(&schema, "/required");
let properties = object_keys(&schema, "/properties");
assert_field_contract("doctor report", &required, &serialized_keys(&report), &properties);
let check_required = required_set(&schema, "/$defs/check/required");
let check_properties = object_keys(&schema, "/$defs/check/properties");
assert_field_contract(
"doctor check",
&check_required,
&serialized_keys(&report.checks[0]),
&check_properties,
);
}
#[test]
fn path_schema_matches_the_dto() {
let schema = read_schema("path.schema.json");
let required = required_set(&schema, "/required");
let properties = object_keys(&schema, "/properties");
let dto = JsonPath {
name: "feat-317".into(),
path: "/wt/feat-317".into(),
branch: Some("feat/#317-freeze-machine-contracts".into()),
};
assert_field_contract("path result", &required, &serialized_keys(&dto), &properties);
}
#[test]
fn schema_version_baseline_is_one() {
assert_eq!(contract::SCHEMA_VERSION, 1);
}
#[test]
fn every_schema_file_declares_the_contract_version() {
for file in [
"worktree-list.schema.json",
"doctor.schema.json",
"path.schema.json",
"status.schema.json",
] {
let schema = read_schema(file);
let v = schema
.get("version")
.unwrap_or_else(|| panic!("{file} must declare a `version`"))
.as_u64()
.unwrap_or_else(|| panic!("{file} `version` must be an integer"));
assert_eq!(
v as u32,
contract::SCHEMA_VERSION,
"{file} version drifted from contract::SCHEMA_VERSION"
);
}
}
#[test]
fn daemon_notification_carries_the_schema_version() {
let note = gwm::daemon::worktrees_changed_notification(&[]);
assert_eq!(note["method"], Value::String("worktrees.changed".into()));
assert_eq!(
note["params"]["schema_version"]
.as_u64()
.expect("schema_version present"),
contract::SCHEMA_VERSION as u64
);
assert!(note["params"]["worktrees"].is_array());
}
#[test]
fn daemon_method_and_notification_names_are_frozen() {
assert_eq!(contract::DAEMON_METHODS, &["list", "doctor", "path", "subscribe"]);
assert_eq!(contract::DAEMON_NOTIFICATIONS, &["worktrees.changed"]);
}
#[test]
fn daemon_jsonrpc_error_codes_are_the_standard_values() {
assert_eq!(gwm::daemon::PARSE_ERROR, -32700);
assert_eq!(gwm::daemon::INVALID_REQUEST, -32600);
assert_eq!(gwm::daemon::METHOD_NOT_FOUND, -32601);
assert_eq!(gwm::daemon::INVALID_PARAMS, -32602);
assert_eq!(gwm::daemon::INTERNAL_ERROR, -32603);
}
#[test]
fn config_top_level_sections_are_frozen() {
let serialized = serialized_keys(&gwm::config::Config::default());
let frozen: BTreeSet<String> = contract::CONFIG_SECTIONS.iter().map(|s| s.to_string()).collect();
assert_eq!(
serialized, frozen,
"the `.gwm.toml` top-level section set drifted from contract::CONFIG_SECTIONS"
);
}
#[test]
fn a_config_with_every_frozen_section_round_trips() {
let toml = "\
[worktree]
[bootstrap]
[hooks]
[doctor]
[tui]
[theme]
[git_tui]
[review]
[issue_template]
[pr_template]
[aliases]
[gitmoji]
[exec]
[clean]
";
let cfg: gwm::config::Config = toml::from_str(toml).expect("frozen sections must parse");
let _ = serde_json::to_value(&cfg).unwrap();
}
fn json_type(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Number(n) if n.is_i64() || n.is_u64() => "integer",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
fn assert_type_baseline<T: serde::Serialize>(what: &str, value: &T, baseline: &[(&str, &str)]) {
let v = serde_json::to_value(value).expect("serialize");
let obj = v.as_object().expect("DTO must serialize to an object");
for (field, expected) in baseline {
let actual = obj
.get(*field)
.unwrap_or_else(|| panic!("{what}: stable field `{field}` is gone from the serialized output (rename/removal?)"));
assert_eq!(
json_type(actual),
*expected,
"{what}: stable field `{field}` changed type — was `{expected}`, now `{}`",
json_type(actual)
);
}
}
#[test]
fn worktree_field_types_are_frozen() {
assert_type_baseline(
"worktree-list row",
&sample_worktree(),
&[
("name", "string"),
("id", "string"),
("path", "string"),
("branch", "string"),
("head", "string"),
("is_main", "bool"),
("is_locked", "bool"),
("is_prunable", "bool"),
("status", "object"),
("age_seconds", "integer"),
("issue", "integer"),
("pr", "integer"),
],
);
assert_type_baseline(
"worktree status",
&sample_worktree().status,
&[
("is_dirty", "bool"),
("has_upstream", "bool"),
("ahead", "integer"),
("behind", "integer"),
("unknown", "bool"),
],
);
}
#[test]
fn path_field_types_are_frozen() {
let dto = JsonPath {
name: "feat-317".into(),
path: "/wt/feat-317".into(),
branch: Some("feat/#317-freeze-machine-contracts".into()),
};
assert_type_baseline(
"path result",
&dto,
&[("name", "string"), ("path", "string"), ("branch", "string")],
);
}
#[test]
fn doctor_field_types_are_frozen() {
let report = JsonDoctorReport {
checks: vec![JsonCheck {
name: "config".into(),
status: "ok".into(),
detail: "found .gwm.toml".into(),
fix_hint: Some("run gwm init".into()),
}],
severity: "ok".into(),
exit_code: 0,
};
assert_type_baseline(
"doctor report",
&report,
&[("checks", "array"), ("severity", "string"), ("exit_code", "integer")],
);
assert_type_baseline(
"doctor check",
&report.checks[0],
&[
("name", "string"),
("status", "string"),
("detail", "string"),
("fix_hint", "string"),
],
);
}
fn schema_type_descriptor(prop: &Value) -> String {
if let Some(r) = prop.get("$ref").and_then(|v| v.as_str()) {
return format!("$ref:{r}");
}
match prop.get("type") {
Some(Value::String(s)) => s.clone(),
Some(Value::Array(a)) => {
let mut parts: Vec<String> = a.iter().filter_map(|x| x.as_str().map(String::from)).collect();
parts.sort();
parts.join("|")
}
_ => panic!("schema property has neither `$ref` nor `type`"),
}
}
fn assert_schema_baseline(
what: &str,
schema: &Value,
base_ptr: &str,
frozen_required: &[&str],
frozen_types: &[(&str, &str)],
) {
let required = required_set(schema, &format!("{base_ptr}/required"));
let frozen: BTreeSet<String> = frozen_required.iter().map(|s| s.to_string()).collect();
assert_eq!(
required, frozen,
"{what}: schema `required` set drifted from the frozen stable baseline — a dropped/added required field is a contract change"
);
let props = schema
.pointer(&format!("{base_ptr}/properties"))
.unwrap_or_else(|| panic!("{what}: no properties at {base_ptr}"));
for (field, expected) in frozen_types {
let prop = props
.get(*field)
.unwrap_or_else(|| panic!("{what}: stable field `{field}` missing from schema properties"));
assert_eq!(
&schema_type_descriptor(prop),
expected,
"{what}: schema-declared type of `{field}` drifted from the frozen contract"
);
}
}
#[test]
fn worktree_schema_required_and_types_are_frozen() {
let schema = read_schema("worktree-list.schema.json");
assert_schema_baseline(
"worktree-list row",
&schema,
"/$defs/worktree",
&[
"name",
"id",
"path",
"branch",
"head",
"is_main",
"is_locked",
"is_prunable",
"status",
"age_seconds",
"issue",
"pr",
],
&[
("name", "string"),
("id", "string"),
("path", "string"),
("branch", "null|string"),
("head", "null|string"),
("is_main", "boolean"),
("is_locked", "boolean"),
("is_prunable", "boolean"),
("status", "$ref:#/$defs/status"),
("age_seconds", "integer|null"),
("issue", "integer|null"),
("pr", "integer|null"),
],
);
assert_schema_baseline(
"worktree status",
&schema,
"/$defs/status",
&["is_dirty", "has_upstream", "ahead", "behind", "unknown"],
&[
("is_dirty", "boolean"),
("has_upstream", "boolean"),
("ahead", "integer"),
("behind", "integer"),
("unknown", "boolean"),
],
);
}
#[test]
fn doctor_schema_required_and_types_are_frozen() {
let schema = read_schema("doctor.schema.json");
assert_schema_baseline(
"doctor report",
&schema,
"",
&["checks", "severity", "exit_code"],
&[
("checks", "array"),
("severity", "$ref:#/$defs/status"),
("exit_code", "integer"),
],
);
assert_schema_baseline(
"doctor check",
&schema,
"/$defs/check",
&["name", "status", "detail", "fix_hint"],
&[
("name", "string"),
("status", "$ref:#/$defs/status"),
("detail", "string"),
("fix_hint", "null|string"),
],
);
}
#[test]
fn path_schema_required_and_types_are_frozen() {
let schema = read_schema("path.schema.json");
assert_schema_baseline(
"path result",
&schema,
"",
&["name", "path", "branch"],
&[("name", "string"), ("path", "string"), ("branch", "null|string")],
);
}
#[test]
fn output_schemas_tolerate_additive_fields() {
let cases: &[(&str, &[&str])] = &[
("worktree-list.schema.json", &["/$defs/worktree", "/$defs/status"]),
("doctor.schema.json", &["", "/$defs/check"]),
("path.schema.json", &[""]),
("status.schema.json", &["", "/$defs/issue_link", "/$defs/pr_link"]),
];
for (file, pointers) in cases {
let schema = read_schema(file);
for ptr in *pointers {
let obj = schema
.pointer(ptr)
.unwrap_or_else(|| panic!("{file}: no object at '{ptr}'"));
assert_ne!(
obj.get("additionalProperties"),
Some(&Value::Bool(false)),
"{file} at '{ptr}': additionalProperties:false breaks the additive-compatibility policy"
);
}
}
}
fn populated_status() -> Value {
let mut link = BranchLink::empty();
link.issue = Some(317);
link.pr = Some(322);
link.issue_source = LinkSource::Explicit;
link.pr_source = LinkSource::Detected;
let issue = Some(IssueStatus {
number: 317,
title: "freeze the contracts".into(),
state: IssueState::Open,
url: "https://example/317".into(),
labels: vec!["enhancement".into()],
updated_at: "2026-06-17T00:00:00Z".into(),
});
let pr = Some(PrStatus {
number: 322,
title: "freeze & version".into(),
state: PrState::Open,
url: "https://example/322".into(),
updated_at: "2026-06-17T00:00:00Z".into(),
checks_passed: 3,
checks_total: 4,
ci: CiState::Running,
});
build_status_json(
"feat/#317-freeze-machine-contracts",
Some("kbrdn1/gwm-cli"),
&link,
&issue,
&pr,
)
}
#[test]
fn status_json_matches_the_schema() {
let schema = read_schema("status.schema.json");
let top_required = required_set(&schema, "/required");
let top_props = object_keys(&schema, "/properties");
let v = populated_status();
let top: BTreeSet<String> = v.as_object().unwrap().keys().cloned().collect();
assert_field_contract("status result", &top_required, &top, &top_props);
let issue_required = required_set(&schema, "/$defs/issue_link/required");
let issue_props = object_keys(&schema, "/$defs/issue_link/properties");
let issue: BTreeSet<String> = v["issue"].as_object().unwrap().keys().cloned().collect();
assert_field_contract("status issue link", &issue_required, &issue, &issue_props);
let pr_required = required_set(&schema, "/$defs/pr_link/required");
let pr_props = object_keys(&schema, "/$defs/pr_link/properties");
let pr: BTreeSet<String> = v["pr"].as_object().unwrap().keys().cloned().collect();
assert_field_contract("status pr link", &pr_required, &pr, &pr_props);
}
#[test]
fn status_json_null_link_still_carries_required_keys() {
let v = build_status_json("dev", None, &BranchLink::empty(), &None, &None);
let obj = v.as_object().unwrap();
assert_eq!(obj["branch"], Value::String("dev".into()));
assert_eq!(obj["issue"], Value::Null);
assert_eq!(obj["pr"], Value::Null);
assert!(!obj.contains_key("repo"), "repo must be absent without a remote slug");
}
#[test]
fn status_json_field_types_are_frozen() {
let v = populated_status();
assert_type_baseline(
"status result",
&v,
&[
("branch", "string"),
("repo", "string"),
("issue", "object"),
("pr", "object"),
],
);
assert_type_baseline(
"status issue link",
&v["issue"],
&[
("number", "integer"),
("source", "string"),
("state", "string"),
("title", "string"),
("labels", "array"),
("url", "string"),
],
);
assert_type_baseline(
"status pr link",
&v["pr"],
&[
("number", "integer"),
("source", "string"),
("state", "string"),
("title", "string"),
("checks_passed", "integer"),
("checks_total", "integer"),
("url", "string"),
],
);
}
#[test]
fn status_schema_required_and_types_are_frozen() {
let schema = read_schema("status.schema.json");
assert_eq!(
required_set(&schema, "/required"),
["branch", "issue", "pr"]
.iter()
.map(|s| s.to_string())
.collect::<BTreeSet<_>>(),
"status top-level required set drifted"
);
assert_eq!(schema_type_descriptor(&schema["properties"]["branch"]), "string");
assert_eq!(schema_type_descriptor(&schema["properties"]["repo"]), "string");
for field in ["issue", "pr"] {
let one_of = schema["properties"][field]["oneOf"]
.as_array()
.unwrap_or_else(|| panic!("status `{field}` must be a oneOf[null, link]"));
let descriptors: BTreeSet<String> = one_of.iter().map(schema_type_descriptor).collect();
let expected_ref = format!("$ref:#/$defs/{}_link", field);
assert!(descriptors.contains("null"), "status `{field}` must permit null");
assert!(
descriptors.contains(&expected_ref),
"status `{field}` must reference its link definition"
);
}
assert_schema_baseline(
"status issue link",
&schema,
"/$defs/issue_link",
&["number", "source"],
&[
("number", "integer"),
("source", "$ref:#/$defs/link_source"),
("state", "string"),
("title", "string"),
("labels", "array"),
("url", "string"),
],
);
assert_schema_baseline(
"status pr link",
&schema,
"/$defs/pr_link",
&["number", "source"],
&[
("number", "integer"),
("source", "$ref:#/$defs/link_source"),
("state", "string"),
("title", "string"),
("checks_passed", "integer"),
("checks_total", "integer"),
("url", "string"),
],
);
}
#[test]
fn exec_flag_surface_is_frozen() {
let cli = Cli::try_parse_from([
"gwm",
"--workspace",
"/tmp/ws",
"exec",
"--profile",
"ci",
"--jobs",
"4",
"--",
"cargo",
"test",
])
.expect("frozen exec flags must still parse");
assert_eq!(
cli.workspace.as_deref(),
Some(std::path::Path::new("/tmp/ws")),
"`--workspace <DIR>` is a frozen global flag that takes a value"
);
match cli.command {
Some(Command::Exec {
profile,
jobs,
command,
slugs,
}) => {
assert!(slugs.is_empty(), "no slugs before `--`");
assert_eq!(
profile.as_deref(),
Some("ci"),
"`--profile <NAME>` frozen, takes a value"
);
assert_eq!(jobs, Some(4), "`--jobs <N>` frozen, takes an integer value");
assert_eq!(
command,
vec!["cargo".to_string(), "test".to_string()],
"`-- <cmd>` forwarded verbatim"
);
}
other => panic!("expected Command::Exec, got {other:?}"),
}
}
#[test]
fn clean_flag_surface_is_frozen() {
let cli = Cli::try_parse_from([
"gwm",
"--workspace",
"/tmp/ws",
"clean",
"feat-1",
"--profile",
"rust",
"--yes",
])
.expect("frozen clean flags must still parse");
assert_eq!(
cli.workspace.as_deref(),
Some(std::path::Path::new("/tmp/ws")),
"`--workspace <DIR>` is a frozen global flag that takes a value"
);
match cli.command {
Some(Command::Clean { slugs, profile, yes }) => {
assert_eq!(slugs, vec!["feat-1".to_string()], "positional slug still accepted");
assert_eq!(
profile.as_deref(),
Some("rust"),
"`--profile <NAME>` frozen, takes a value"
);
assert!(yes, "`--yes` frozen as a boolean flag (no value)");
}
other => panic!("expected Command::Clean, got {other:?}"),
}
}
#[test]
fn clean_yes_flag_takes_no_value() {
let cli = Cli::try_parse_from(["gwm", "clean", "--yes", "trailing-token"]).expect("parses with --yes as a boolean");
match cli.command {
Some(Command::Clean { slugs, yes, .. }) => {
assert!(yes, "--yes set");
assert_eq!(
slugs,
vec!["trailing-token".to_string()],
"the trailing token is a slug, not a value consumed by --yes"
);
}
other => panic!("expected Command::Clean, got {other:?}"),
}
}