use std::collections::BTreeMap;
use std::path::PathBuf;
use sysknife_brain::planning_tools::propose_plan::KNOWN_ACTIONS;
use sysknife_core::action_family::{
DEBIAN_ONLY_ACTIONS, FEDORA_ONLY_ACTIONS, NON_CANONICAL_ON_FEDORA, UBUNTU_ONLY_ACTIONS,
};
use sysknife_daemon::actions::{
catalogue, ActionMechanism, ActionSpec, DISPATCHER_INTERNAL_ACTIONS,
};
use sysknife_types::KNOWN_ACTION_NAMES;
fn sections() -> Vec<(&'static str, Vec<ActionSpec>)> {
catalogue()
}
fn table_text(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('|', "\\|")
.replace('*', "\\*")
.replace('[', "\\[")
.replace(']', "\\]")
.replace('<', "<")
.replace('>', ">")
.replace('\n', " ")
}
fn code_cell(s: &str) -> String {
format!(
"`{}`",
s.replace('`', "'")
.replace('\\', "\\\\")
.replace('|', "\\|")
)
}
fn command(m: &ActionMechanism) -> String {
match m {
ActionMechanism::Command { program, args } => {
let mut parts = vec![(*program).to_string()];
parts.extend(args.iter().map(|a| {
if a.is_empty() || a.contains(|c: char| c.is_whitespace() || c == '\'') {
format!("\"{}\"", a.replace('"', "\\\""))
} else {
a.clone()
}
}));
code_cell(&parts.join(" "))
}
ActionMechanism::FileScan { path } => format!("scan {}", code_cell(path)),
ActionMechanism::FileWrite { path, .. } => format!("write {}", code_cell(path)),
ActionMechanism::FilePatch { path, .. } => format!("patch {}", code_cell(path)),
ActionMechanism::FileDelete { path } => format!("delete {}", code_cell(path)),
}
}
fn distro(name: &str) -> &'static str {
if FEDORA_ONLY_ACTIONS.contains(&name) {
"Fedora"
} else if DEBIAN_ONLY_ACTIONS.contains(&name)
|| UBUNTU_ONLY_ACTIONS.contains(&name)
|| NON_CANONICAL_ON_FEDORA.contains(&name)
{
"Ubuntu"
} else {
"All"
}
}
fn build_reference() -> String {
let descriptions: BTreeMap<&str, &str> = KNOWN_ACTIONS.iter().copied().collect();
let mut out = String::new();
out.push_str("# Action reference\n\n");
out.push_str(
"**This file is generated. Do not edit by hand.**\n\
Regenerate with `UPDATE_ACTION_REFERENCE=1 cargo test -p sysknife-daemon \
--test action_reference_doc`; a plain `cargo test` fails if it drifts from \
the catalogue.\n\n\
Every row is derived from the live code: the command from each action's \
`ActionSpec` mechanism, the risk from its `risk_level`, the distro from \
`sysknife-core::action_family`, and the description from the brain's \
`KNOWN_ACTIONS` list. **Distro** identifies the default supported catalogue: \
`All`, `Ubuntu`, or `Fedora`. It includes planner preferences, not just \
hard execution fences; see [action compatibility](action-compatibility.md). **Rb** = requires \
reboot; **Ro** = automatic rollback available.\n\n",
);
let mut total = 0usize;
for (title, specs) in sections() {
out.push_str(&format!("## {title}\n\n"));
out.push_str("| Action | Command | Risk | Distro | Rb | Ro | Description |\n");
out.push_str("|---|---|---|---|---|---|---|\n");
for spec in &specs {
total += 1;
let desc = descriptions.get(spec.action_name).copied().unwrap_or("");
out.push_str(&format!(
"| `{}` | {} | {:?} | {} | {} | {} | {} |\n",
spec.action_name,
command(&spec.mechanism),
spec.risk_level,
distro(spec.action_name),
if spec.reboot_required { "✓" } else { "–" },
if spec.rollback_available {
"✓"
} else {
"–"
},
table_text(desc),
));
}
out.push('\n');
}
let catalogue_total = KNOWN_ACTION_NAMES.len();
let expected_tabled = catalogue_total
.checked_sub(DISPATCHER_INTERNAL_ACTIONS.len())
.expect("dispatcher-internal action count exceeds the catalogue");
assert_eq!(
total, expected_tabled,
"ActionSpec table count must equal KNOWN_ACTION_NAMES minus dispatcher-internal actions"
);
let dispatcher_names = DISPATCHER_INTERNAL_ACTIONS
.iter()
.map(|name| format!("`{name}`"))
.collect::<Vec<_>>()
.join(", ");
out.push_str(&format!(
"---\n\n_{total} actions have an `ActionSpec` and are tabled above. The \
full catalogue (`KNOWN_ACTION_NAMES`) also includes {dispatcher_names}, \
which the dispatcher handles before the executor, for **{catalogue_total}** total._\n"
));
out
}
fn doc_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../docs/action-reference.md")
}
fn first_diff_line(committed: &str, generated: &str) -> Option<usize> {
if committed == generated {
return None;
}
let committed_lines = committed.split('\n').collect::<Vec<_>>();
let generated_lines = generated.split('\n').collect::<Vec<_>>();
committed_lines
.iter()
.zip(generated_lines.iter())
.position(|(cli, gli)| cli != gli)
.map(|i| i + 1)
.or_else(|| {
(committed_lines.len() != generated_lines.len())
.then(|| committed_lines.len().min(generated_lines.len()) + 1)
})
}
fn diff_message(committed: &str, generated: &str) -> Option<String> {
let line = first_diff_line(committed, generated)?;
let committed_line = committed.lines().nth(line - 1).unwrap_or("<end of file>");
let generated_line = generated.lines().nth(line - 1).unwrap_or("<end of file>");
Some(format!(
"docs/action-reference.md is out of date with the action catalogue.\n\
First difference at line {line}:\n\
\x20 committed: {}\n\
\x20 generated: {}\n\
Regenerate: UPDATE_ACTION_REFERENCE=1 cargo test -p sysknife-daemon \
--test action_reference_doc",
committed_line.trim_end_matches('\r'),
generated_line.trim_end_matches('\r'),
))
}
#[test]
fn footer_counts_match_catalogue_and_dispatcher_internal_actions() {
let tabled = catalogue()
.iter()
.map(|(_, specs)| specs.len())
.sum::<usize>();
assert_eq!(
tabled,
KNOWN_ACTION_NAMES.len() - DISPATCHER_INTERNAL_ACTIONS.len()
);
let generated = build_reference();
assert!(generated.contains(&format!(
"_{tabled} actions have an `ActionSpec` and are tabled above."
)));
assert!(generated.contains(&format!("for **{}** total.", KNOWN_ACTION_NAMES.len())));
}
#[test]
fn footer_names_every_dispatcher_internal_action() {
let generated = build_reference();
for name in DISPATCHER_INTERNAL_ACTIONS {
assert!(
generated.contains(&format!("`{name}`")),
"generated footer must name dispatcher-internal action {name}"
);
}
}
#[test]
fn action_reference_doc_is_current() {
let generated = build_reference();
let path = doc_path();
if std::env::var("UPDATE_ACTION_REFERENCE").is_ok() {
std::fs::write(&path, &generated).expect("write action-reference.md");
return;
}
let committed = std::fs::read_to_string(&path).unwrap_or_default();
if let Some(message) = diff_message(&committed, &generated) {
panic!("{message}");
}
}
#[test]
fn first_diff_line_names_a_differing_line() {
let committed = "line one\nline two\nline three\n";
let generated = "line one\nline two\nline THREE\n";
assert_eq!(first_diff_line(committed, generated), Some(3));
}
#[test]
fn first_diff_line_returns_none_for_identical_documents() {
assert_eq!(first_diff_line("hello", "hello"), None);
assert_eq!(first_diff_line("", ""), None);
assert_eq!(first_diff_line("a\nb\n", "a\nb\n"), None);
}
#[test]
fn first_diff_line_reports_a_truncated_document() {
assert_eq!(first_diff_line("a\nb\nc", "a\nb\nc\nd"), Some(4));
assert_eq!(first_diff_line("a\nb\nc\nd", "a\nb\nc"), Some(4));
assert_eq!(first_diff_line("a\nb", "a\nb\n"), Some(3));
assert_eq!(
first_diff_line("heads\nsay\n", "heads\nonly\nsay\nheads\n"),
Some(2)
);
}
#[test]
fn diff_message_names_the_line_and_both_sides() {
let committed = "line one\nline two\nline three\n";
let generated = "line one\nline two\nline THREE\n";
let message = diff_message(committed, generated).unwrap();
assert!(message.contains("First difference at line 3:"), "{message}");
assert!(
message.contains("committed: line three"),
"message must show the committed side: {message}"
);
assert!(
message.contains("generated: line THREE"),
"message must show the generated side: {message}"
);
assert!(
message.contains("Regenerate: UPDATE_ACTION_REFERENCE=1"),
"message must keep the regeneration command: {message}"
);
assert!(
!message.contains("line one"),
"message must not echo the equal prefix: {message}"
);
}
#[test]
fn diff_message_names_the_missing_tail() {
let committed = "line one\nline two";
let generated = "line one\nline two\nline three";
let message = diff_message(committed, generated).unwrap();
assert!(message.contains("First difference at line 3:"), "{message}");
assert!(
message.contains("committed: <end of file>"),
"message must say the committed side ends: {message}"
);
assert!(
message.contains("generated: line three"),
"message must show the generated side: {message}"
);
}
#[test]
fn diff_message_returns_none_when_documents_match() {
assert_eq!(diff_message("same\ncontent\n", "same\ncontent\n"), None);
}