tests/support/ci_commands.rs
//! **What `.woodpecker.yml` actually RUNS**, as opposed to what it mentions.
//!
//! Six guards in this suite exist to keep an off-by-default feature from going
//! dark: `inotify`, `atom-cli`, `solo,robot-remote`, `testmatrix`, the
//! feature-combo gate and the linkage audit are each compiled by exactly ONE
//! line of CI, and each has a companion `#[test]` that is supposed to go red
//! when that line disappears. Every one of them was written as a substring
//! search over the WHOLE file — and a CI file is mostly prose.
//!
//! MEASURED 2026-08-23, the red probe that produced this module: putting a `#`
//! in front of all four `- cargo test -p nornir --features …` lines — the shape
//! a human reaches for when a step is slow or flaky — left
//! `ci_compiles_the_testmatrix_arm`, `ci_compiles_the_atom_marker_feature`,
//! `something_in_ci_compiles_the_feature` and `ci_compiles_the_solo_proof`
//! **all four GREEN**. The needle they search for is still in the file; it is
//! just commented out. A guard a `#` defeats guards nothing. Worse for
//! `testmatrix`: its first assertion (`ci.contains("testmatrix")`) could not go
//! red even on outright DELETION of the command, because the word `testmatrix`
//! appears five times in the comment block above it.
//!
//! So the needle has to be looked for in the COMMANDS, which is what this
//! module extracts. One parser, six callers (LAW 5): a commented-out step is not
//! a command by construction, and no guard has to remember to say so.
use std::path::PathBuf;
/// Repo root (this file lives at `<root>/tests/support/`).
fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
/// The shell commands a Woodpecker YAML will actually execute, in order, one
/// `String` per list item under a `commands:` key.
///
/// * A `#`-commented line is NOT a command — that is the whole point.
/// * A continuation line (deeper indent, no `- `) is folded onto the command it
/// continues, so the multi-line `apt-get install` step reads as one string and
/// a flag wrapped onto the next line is still findable.
/// * Lists that are not `commands:` (`when:`, `variables:`) are skipped, so a
/// needle appearing in a branch filter cannot satisfy a build guard.
///
/// Split from [`ci_commands`] so the fixture test at the bottom exercises the
/// same code path the guards use, instead of a second copy of it.
pub fn parse_commands(text: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut list_indent: Option<usize> = None;
for raw in text.lines() {
let trimmed = raw.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
// A comment or a blank line neither opens nor closes a block: a
// comment BETWEEN two commands must not end the list, and a
// commented-out command must not join it.
continue;
}
let indent = raw.len() - trimmed.len();
if let Some(key_indent) = list_indent {
if indent > key_indent {
if let Some(item) = trimmed.strip_prefix("- ") {
out.push(item.trim().to_string());
} else if let Some(last) = out.last_mut() {
// Folded continuation of the previous command.
last.push(' ');
last.push_str(trimmed.trim_end());
}
continue;
}
list_indent = None;
}
if trimmed.starts_with("commands:") {
list_indent = Some(indent);
}
}
out
}
/// [`parse_commands`] over this repo's `.woodpecker.yml`.
pub fn ci_commands() -> Vec<String> {
let path = repo_root().join(".woodpecker.yml");
let text =
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
let cmds = parse_commands(&text);
assert!(
!cmds.is_empty(),
"parsed ZERO commands out of {} — every CI guard in this suite would then \
be asserting against an empty list, which is the vacuous pass they exist \
to prevent. Either the file lost its `commands:` blocks or this parser \
stopped understanding them.",
path.display()
);
cmds
}
/// True when some CI command contains `needle`.
pub fn ci_runs(needle: &str) -> bool {
ci_commands().iter().any(|c| c.contains(needle))
}
/// The commands, one per line, so an assertion message can show what WAS found
/// and not only what was missing.
pub fn ci_commands_listing() -> String {
ci_commands()
.iter()
.map(|c| format!(" {c}"))
.collect::<Vec<_>>()
.join("\n")
}
/// ★ THE PARSER'S OWN RED. Everything above is only worth having if a `#`
/// actually removes a command, so that is asserted on a fixture — the live file
/// is what the guards assert, and a parser tested only against the live file
/// would pass while ignoring comments in neither.
///
/// Not `#[cfg(test)]`: this file is `#[path]`-included into integration test
/// targets, where `cfg(test)` is false and the module would be dead code.
#[test]
fn a_commented_command_is_not_a_command() {
let yml = concat!(
"steps:\n",
" build:\n",
" commands:\n",
" - cargo test --features live\n",
" # - cargo test --features dead\n",
" - apt-get install\n",
" libfoo-dev\n",
"when:\n",
" - event: [push]\n",
);
let cmds = parse_commands(yml);
assert_eq!(
cmds,
vec![
"cargo test --features live".to_string(),
"apt-get install libfoo-dev".to_string()
],
"the commented command must be absent, the folded line must be joined onto \
its command, and `when:`'s list must not be read as commands"
);
assert!(
!cmds.iter().any(|c| c.contains("dead")),
"a `#`-commented step counted as a command — the exact blindness this module \
exists to remove"
);
}