use clap::CommandFactory;
use ignition_cli::cli::Cli;
use ignition_core::error::CoreError;
const README: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../README.md"));
fn deploy_command() -> Option<clap::Command> {
Cli::command()
.get_subcommands()
.find(|c| c.get_name() == "deploy")
.cloned()
}
fn leaves(cmd: &clap::Command, prefix: &str, out: &mut Vec<String>) {
for sub in cmd.get_subcommands() {
if sub.get_name() == "help" {
continue;
}
let path = if prefix.is_empty() {
sub.get_name().to_string()
} else {
format!("{prefix} {}", sub.get_name())
};
if sub.get_subcommands().all(|c| c.get_name() == "help") {
out.push(path.clone());
}
leaves(sub, &path, out);
}
}
fn deploy_leaves() -> Vec<String> {
let deploy = deploy_command().expect("the `deploy` subcommand exists");
let mut out = Vec::new();
leaves(&deploy, "", &mut out);
out
}
fn section(heading: &str) -> &'static str {
let marker = format!("\n## {heading}");
let start = README
.find(&marker)
.unwrap_or_else(|| panic!("README has no `## {heading}` section"))
+ 1;
let rest = &README[start..];
let end = rest[3..].find("\n## ").map_or(rest.len(), |i| i + 3);
&rest[..end]
}
fn mentions_leaf(line: &str, leaf: &str) -> bool {
let needle = format!("ign deploy {leaf}");
let mut from = 0;
while let Some(i) = line[from..].find(&needle) {
let end = from + i + needle.len();
if matches!(line[end..].chars().next(), Some(' ') | Some('`')) {
return true;
}
from = end;
}
false
}
#[test]
fn deploy_family_has_leaves() {
let deploy = deploy_command().expect("the `deploy` subcommand is missing from the CLI");
assert!(
deploy.is_subcommand_required_set(),
"`deploy` must require a subcommand (a bare `ign deploy` is not an action)"
);
assert!(
!deploy_leaves().is_empty(),
"`deploy` has no leaves; the coverage tests below would pass vacuously"
);
}
#[test]
fn every_deploy_leaf_has_a_readme_commands_row() {
assert!(mentions_leaf("| `ign deploy plan --env X` | x |", "plan"));
assert!(mentions_leaf("| `ign deploy plan` | x |", "plan"));
assert!(!mentions_leaf("| `ign deploy plan-all` | x |", "plan"));
assert!(!mentions_leaf("| `ign deploy planner` | x |", "plan"));
assert!(mentions_leaf(
"| `ign deploy plan-all` | `ign deploy plan` |",
"plan"
));
let found = deploy_leaves();
assert!(!found.is_empty(), "no deploy leaves to check");
let commands = section("Commands");
let missing: Vec<String> = found
.iter()
.filter(|leaf| {
!commands
.lines()
.any(|line| line.starts_with('|') && mentions_leaf(line, leaf))
})
.map(|leaf| format!("deploy {leaf}"))
.collect();
assert!(
missing.is_empty(),
"deploy leaves with no README `## Commands` table row \
(a row must contain `ign deploy <leaf>` followed by a space or a backtick): {missing:#?}"
);
}
#[test]
fn every_deploy_slug_is_in_the_readme_exit_table() {
assert!(
!CoreError::DEPLOY_SLUGS.is_empty(),
"DEPLOY_SLUGS is empty; the check would pass vacuously"
);
let table = section("Exit codes");
let missing: Vec<&&str> = CoreError::DEPLOY_SLUGS
.iter()
.filter(|slug| !table.contains(&format!("`{slug}`")))
.collect();
assert!(
missing.is_empty(),
"deploy slugs missing (backticked) from README `## Exit codes`: {missing:#?}"
);
}
#[cfg(feature = "tui")]
#[test]
fn every_deploy_leaf_has_a_projects_route_row() {
use ignition_tui::routes::{Mapping, routes};
use ignition_tui::state::Screen;
let found = deploy_leaves();
assert!(!found.is_empty(), "no deploy leaves to check");
let mut problems = Vec::new();
for leaf in &found {
let path = format!("deploy {leaf}");
let rows: Vec<_> = routes().iter().filter(|r| r.path == path).collect();
match rows.as_slice() {
[row] if matches!(row.mapping, Mapping::Screen(Screen::Projects)) => {}
[_] => problems.push(format!("{path}: row is not mapped to Screen::Projects")),
[] => problems.push(format!("{path}: no routes() row")),
many => problems.push(format!(
"{path}: {} routes() rows, expected one",
many.len()
)),
}
}
for row in routes() {
if let Some(leaf) = row.path.strip_prefix("deploy ")
&& !found.iter().any(|l| l == leaf)
{
problems.push(format!("{}: route row with no clap leaf", row.path));
}
}
assert!(problems.is_empty(), "{problems:#?}");
}
#[test]
fn rolled_back_verdict_exception_is_dated_and_documented() {
let body = dated_exception_body("### The `deploy apply` rolled-back verdict exception (");
for needle in [
"data.ok",
"data.slug",
"data.cause",
"deploy_rolled_back",
"exit 6",
"`ok: true`",
"Decision A",
] {
assert!(
body.contains(needle),
"exception text must mention {needle}: {body}"
);
}
assert!(
!section("Exit codes").contains("deploy_rolled_back"),
"`deploy_rolled_back` is a verdict slug, never an exit-table row"
);
assert!(
!CoreError::DEPLOY_SLUGS.contains(&"deploy_rolled_back"),
"the verdict slug is not a CoreError slug"
);
assert!(
README.lines().any(|line| line == "### `deploy apply`"),
"the README has a `### `deploy apply`` section"
);
}
#[test]
fn backup_unsynced_verdict_exception_is_dated_and_documented() {
let body = dated_exception_body("### The `deploy apply` backup-unsynced verdict exception (");
for needle in [
"data.ok",
"data.slug",
"data.backup.reason",
"deploy_backup_unsynced",
"not_syncing",
"content_differs",
"unreachable",
"sync_exhausted",
"exit 6",
"`ok: true`",
"Decision A",
"not rolled back",
"--node backup",
] {
assert!(
body.contains(needle),
"exception text must mention {needle}: {body}"
);
}
assert!(
!section("Exit codes").contains("deploy_backup_unsynced"),
"`deploy_backup_unsynced` is a verdict slug, never an exit-table row"
);
assert!(
!CoreError::DEPLOY_SLUGS.contains(&"deploy_backup_unsynced"),
"the verdict slug is not a CoreError slug"
);
}
#[test]
fn projects_unverified_verdict_exception_is_dated_and_documented() {
let body = dated_exception_body("### The `deploy projects` unverified verdict exception (");
for needle in [
"data.ok",
"data.slug",
"deploy_project_unverified",
"imported_unverified",
"exit 6",
"`ok: true`",
"Decision A",
"restore_command",
"--collision-policy overwrite",
"SIXTH",
"No release record is written",
"no second import",
] {
assert!(
body.contains(needle),
"exception text must mention {needle}: {body}"
);
}
assert!(
!section("Exit codes").contains("deploy_project_unverified"),
"`deploy_project_unverified` is a verdict slug, never an exit-table row"
);
assert!(
!CoreError::DEPLOY_SLUGS.contains(&"deploy_project_unverified"),
"the verdict slug is not a CoreError slug"
);
assert_eq!(
CoreError::DEPLOY_SLUGS.len(),
19,
"DEPLOY_SLUGS is 19 entries (the 15 the verdict found plus `deploy_unreadable`, \
quick-261029-ms3, `deploy_pair_tags_refused`, debug deploy-tag-redundancy-sync, \
`tag_provider_claimed`, 26-05, and `tag_delete_not_configured`, 26-20): the verdict \
adds none"
);
}
fn dated_exception_body(prefix: &str) -> &'static str {
let contract = section("Output contract (for agents)");
let start = contract
.find(prefix)
.unwrap_or_else(|| panic!("no `{prefix}<YYYY-MM-DD>)` heading under the output contract"));
let after = &contract[start + prefix.len()..];
let date = after
.lines()
.next()
.and_then(|line| line.strip_suffix(')'))
.expect("the heading closes with `)`");
let bytes = date.as_bytes();
assert!(
bytes.len() == 10
&& bytes[4] == b'-'
&& bytes[7] == b'-'
&& bytes
.iter()
.enumerate()
.all(|(i, b)| i == 4 || i == 7 || b.is_ascii_digit()),
"the heading date must be YYYY-MM-DD, got {date:?}"
);
let body_start = &after[date.len()..];
let body_end = body_start[3..]
.find("\n### ")
.map_or(body_start.len(), |i| i + 3);
&body_start[..body_end]
}
#[test]
fn verify_verdict_exception_is_dated_and_documented() {
let contract = section("Output contract (for agents)");
let prefix = "### The `deploy verify` verdict exception (";
let start = contract
.find(prefix)
.unwrap_or_else(|| panic!("no `{prefix}<YYYY-MM-DD>)` heading under the output contract"));
let after = &contract[start + prefix.len()..];
let date = after
.lines()
.next()
.and_then(|line| line.strip_suffix(')'))
.expect("the heading closes with `)`");
let bytes = date.as_bytes();
assert!(
bytes.len() == 10
&& bytes[4] == b'-'
&& bytes[7] == b'-'
&& bytes
.iter()
.enumerate()
.all(|(i, b)| i == 4 || i == 7 || b.is_ascii_digit()),
"the heading date must be YYYY-MM-DD, got {date:?}"
);
let body_start = &after[date.len()..];
let body_end = body_start[3..]
.find("\n### ")
.map_or(body_start.len(), |i| i + 3);
let body = &body_start[..body_end];
for needle in ["data.ok", "deploy_verify_failed", "exit 6", "`ok: true`"] {
assert!(
body.contains(needle),
"exception text must mention {needle}: {body}"
);
}
assert!(
!section("Exit codes").contains("deploy_verify_failed"),
"`deploy_verify_failed` is a verdict slug, never an exit-table row"
);
assert!(
!CoreError::DEPLOY_SLUGS.contains(&"deploy_verify_failed"),
"the verdict slug is not a CoreError slug"
);
}
fn subsection(heading_line: &str) -> String {
let mut lines = README.lines();
let mut out = Vec::new();
for line in lines.by_ref() {
if line == heading_line {
break;
}
}
for line in lines {
if line.starts_with("### ") || line.starts_with("## ") {
break;
}
out.push(line);
}
assert!(!out.is_empty(), "README has no `{heading_line}` section");
out.join(" ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
#[test]
fn rollback_section_agrees_with_apply_section() {
let apply = subsection("### `deploy apply`");
let rollback = subsection("### `deploy rollback`");
assert_eq!(
README.matches("\n### `deploy rollback`").count(),
1,
"exactly one rollback section"
);
let command = "ign deploy rollback --run <id> --env <env>";
assert!(apply.contains(command), "the apply section names {command}");
assert!(
rollback.contains(command),
"the rollback section names {command}"
);
assert!(apply.contains("can no longer be rolled back"));
assert!(rollback.contains("Runs are never pruned"));
assert!(apply.contains("Never delete a `journal.json` by hand unless the"));
assert!(rollback.contains("rollback_failed"));
assert!(rollback.contains("delete the journal at the path"));
assert!(rollback.contains("FA-23-05"));
assert!(rollback.contains("Linux ssh was run against a live host"));
assert!(rollback.contains("0644 files and 0755 directories"));
assert!(rollback.contains(
"`ign deploy plan`, `apply`, `verify` and `rollback --run` against the real `production` collection"
));
assert!(rollback.contains("read and execute (list) on `data` and `data/config`"));
assert!(rollback.contains("different file systems"));
assert!(
!rollback.contains("unproven against a live host"),
"the old unproven claim is gone"
);
assert!(
!rollback.contains(
"pass the shared host conformance table under a local `sh` (dash, with `shasum`) only"
),
"the old, pre-live sentence is gone"
);
assert!(rollback.contains("176 s"));
assert!(rollback.contains("After an ssh drop"));
assert!(apply.contains("**After an ssh drop.**"));
for needle in [
"cleared",
"interrupted_run",
"`--discard-live-changes`",
"A9",
"A10",
"no Ctrl-C handler",
] {
assert!(rollback.contains(needle), "rollback section lacks {needle}");
}
assert!(
!rollback.contains("deploy_rolled_back"),
"rollback has no verdict slug"
);
let plan = subsection("### `deploy plan`");
assert!(
plan.contains("deploy_journal_present") && plan.contains("`deploy rollback`"),
"`deploy plan` says it refuses over a journal"
);
}