Files
pkh/src/debian/tests_control.rs
T
vhaudiquet 82a15c7426 debian: type the errors of the format primitives
Give every parser of the shared format layer a proper error enum
(thiserror): VersionParseError, ArchError, deps::ParseError (wrapping
the version and arch failures as #[source] so the context chain
survives), ControlParseError, ChecksumParseError, FilesParseError and
TestsControlParseError, each carrying its structured pieces (the
offending string, the stanza number, the field name) instead of a
pre-formatted String.

Display texts are byte-identical to the old format! strings, so
user-facing output and the message-asserting tests are unchanged; the
few tests asserting on error text now render the error first. Flow
boundaries that still take String (build::env, quirks, test::expand)
render with to_string() at the edge — typing those flows is a later
step. ControlInfo's FromStr impl follows parse_content's error type.
2026-10-03 02:17:22 +02:00

287 lines
11 KiB
Rust

//! DEP-8 as-installed test metadata (`debian/tests/control`).
//!
//! Parses the test control file into runnable test descriptions: one
//! [`Test`] per `Tests:` script and per `Test-Command:` value, carrying
//! the stanza fields they share (`Depends:`, `Restrictions:`,
//! `Tests-Directory:`). The `@`-family substitution of `Depends:` needs
//! the list of built binaries and is runner-side logic — this module
//! only hands over the raw value.
use std::path::Path;
use crate::debian::control::parse_paragraphs;
/// One runnable DEP-8 test.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Test {
/// Test name: the `Tests:` script path as written, the `test-name:`
/// value, or the `command1`, `command2`, ... enumeration of
/// `Test-Command:` stanzas. The enumeration advances once per
/// `Test-Command:` stanza whether or not `test-name:` overrides the
/// name.
pub name: String,
/// Script path relative to the tests directory (`Tests:` stanzas):
/// `debian/tests/` or the stanza's [`Test::tests_directory`], with
/// subdirectories as written (`upstream/foo`).
pub script: Option<String>,
/// Inline shell command run under `bash -e` (`Test-Command:` stanzas).
pub command: Option<String>,
/// Raw `Depends:` value as written; `None` when absent, meaning the
/// DEP-8 default `@` (all built binaries).
pub depends: Option<String>,
/// Declared restrictions, in declaration order, as written
/// (e.g. `allow-stderr`).
pub restrictions: Vec<String>,
/// `Tests-Directory:` replacing the `debian/tests` path segment for
/// this stanza's scripts (relative path).
pub tests_directory: Option<String>,
}
impl Test {
/// Whether the test declares `restriction` (case-insensitive).
pub fn has_restriction(&self, restriction: &str) -> bool {
self.restrictions
.iter()
.any(|r| r.eq_ignore_ascii_case(restriction))
}
}
/// Parsed `debian/tests/control`: the runnable tests, in declaration
/// order.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TestsControl {
/// One entry per `Tests:` script and per `Test-Command:` stanza.
pub tests: Vec<Test>,
}
/// Failure of [`TestsControl::parse_content`]: a malformed stanza, naming
/// it by its 1-based position.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum TestsControlParseError {
/// The stanza carries both `Tests:` and `Test-Command:`.
#[error("stanza {stanza}: Tests and Test-Command are mutually exclusive")]
MutuallyExclusive {
/// The 1-based stanza position.
stanza: usize,
},
/// The stanza carries neither `Tests:` nor `Test-Command:`.
#[error("stanza {stanza}: missing a Tests or Test-Command field")]
MissingField {
/// The 1-based stanza position.
stanza: usize,
},
/// `test-name:` appeared on a `Tests:` (script list) stanza.
#[error("stanza {stanza}: test-name only applies to a Test-Command stanza")]
TestNameOnScripts {
/// The 1-based stanza position.
stanza: usize,
},
}
impl TestsControl {
/// Parse the test control file at `path`.
pub fn parse(path: &Path) -> Result<TestsControl, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
Ok(TestsControl::parse_content(&content)?)
}
/// Parse the test control file from its content.
///
/// Every non-empty stanza must carry exactly one of `Tests:` or
/// `Test-Command:` (a script list expands to one test per script,
/// sharing the stanza fields); `test-name:` is only valid on a
/// `Test-Command:` stanza. A file without stanzas parses to an empty
/// [`TestsControl`] — whether the package declares tests at all is
/// the caller's check (`Testsuite: autopkgtest`).
pub fn parse_content(content: &str) -> Result<TestsControl, TestsControlParseError> {
let mut tests_control = TestsControl::default();
let mut command_index = 0usize;
for (index, paragraph) in parse_paragraphs(content).iter().enumerate() {
let stanza = index + 1;
let tests_field = non_empty(paragraph.get("Tests"));
let command_field = non_empty(paragraph.get("Test-Command"));
let test_name = non_empty(paragraph.get("test-name"));
let depends = non_empty(paragraph.get("Depends")).map(String::from);
let restrictions: Vec<String> = non_empty(paragraph.get("Restrictions"))
.map(|value| value.split_whitespace().map(String::from).collect())
.unwrap_or_default();
let tests_directory = non_empty(paragraph.get("Tests-Directory")).map(String::from);
match (tests_field, command_field) {
(Some(_), Some(_)) => {
return Err(TestsControlParseError::MutuallyExclusive { stanza });
}
(None, None) => {
return Err(TestsControlParseError::MissingField { stanza });
}
(Some(scripts), None) => {
if test_name.is_some() {
return Err(TestsControlParseError::TestNameOnScripts { stanza });
}
for script in scripts.split_whitespace() {
tests_control.tests.push(Test {
name: script.to_string(),
script: Some(script.to_string()),
command: None,
depends: depends.clone(),
restrictions: restrictions.clone(),
tests_directory: tests_directory.clone(),
});
}
}
(None, Some(command)) => {
command_index += 1;
let name = test_name
.map(String::from)
.unwrap_or_else(|| format!("command{command_index}"));
tests_control.tests.push(Test {
name,
script: None,
command: Some(command.to_string()),
depends: depends.clone(),
restrictions: restrictions.clone(),
tests_directory: tests_directory.clone(),
});
}
}
}
Ok(tests_control)
}
}
/// A field value with its surrounding whitespace trimmed; `None` when the
/// field is absent or whitespace-only.
fn non_empty(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
/// A realistic control file: a restrictions stanza, a multi-script
/// stanza sharing its fields, a Tests-Directory stanza and two
/// Test-Command stanzas (one enumerated, one named).
#[test]
fn parse_golden_multi_stanza() {
let control = TestsControl::parse_content(
"# a leading comment\n\
\n\
Tests: smoke\n\
Restrictions: allow-stderr needs-root\n\
Depends: @, build-essential\n\
\n\
Tests: one two\n\
Depends: @\n\
\n\
Tests: checks/cli\n\
Tests-Directory: tests\n\
\n\
Test-Command: hello --version\n\
Depends: @builddeps@\n\
\n\
Test-Command: python3 -c \"print('ok')\"\n\
test-name: py-smoke\n\
Restrictions: superficial skippable\n",
)
.unwrap();
let names: Vec<&str> = control.tests.iter().map(|t| t.name.as_str()).collect();
assert_eq!(
names,
["smoke", "one", "two", "checks/cli", "command1", "py-smoke"]
);
let smoke = &control.tests[0];
assert_eq!(smoke.script.as_deref(), Some("smoke"));
assert_eq!(smoke.depends.as_deref(), Some("@, build-essential"));
assert!(smoke.has_restriction("NEEDS-ROOT"));
assert!(!smoke.has_restriction("skippable"));
// The multi-script stanza's fields are shared by every test.
assert_eq!(control.tests[1].depends, control.tests[2].depends);
assert_eq!(control.tests[2].script.as_deref(), Some("two"));
let cli = &control.tests[3];
assert_eq!(cli.tests_directory.as_deref(), Some("tests"));
assert_eq!(cli.depends, None); // absent: the runner defaults to @
let enumerated = &control.tests[4];
assert_eq!(enumerated.command.as_deref(), Some("hello --version"));
assert_eq!(enumerated.script, None);
let named = &control.tests[5];
assert_eq!(named.name, "py-smoke");
assert!(named.has_restriction("skippable") && named.has_restriction("superficial"));
}
/// The command enumeration advances per Test-Command stanza, even
/// when test-name overrides the name.
#[test]
fn command_numbering_skips_named_stanzas() {
let control = TestsControl::parse_content(
"Test-Command: a\n\
test-name: named\n\
\n\
Test-Command: b\n",
)
.unwrap();
assert_eq!(
control
.tests
.iter()
.map(|t| t.name.as_str())
.collect::<Vec<_>>(),
["named", "command2"]
);
}
/// Continuation lines join field values; comments inside a value are
/// not possible, but comment lines between fields are ignored.
#[test]
fn continuation_lines_join_values() {
let control = TestsControl::parse_content(concat!(
"Tests: smoke\n",
"Depends: @,\n",
" glib-network-service,\n",
"# comment line\n",
" locales-all\n",
))
.unwrap();
assert_eq!(
control.tests[0].depends.as_deref(),
Some("@,\nglib-network-service,\nlocales-all")
);
}
/// An empty file (or only comments) parses to no tests.
#[test]
fn empty_file_parses_to_no_tests() {
assert!(TestsControl::parse_content("").unwrap().tests.is_empty());
assert!(
TestsControl::parse_content("# nothing\n\n")
.unwrap()
.tests
.is_empty()
);
}
/// Malformed stanzas are errors, naming the stanza.
#[test]
fn malformed_stanzas_are_errors() {
for (content, expected) in [
("Depends: @\n", "missing a Tests or Test-Command"),
("Tests: a\nTest-Command: b\n", "mutually exclusive"),
(
"Test-Command: b\ntest-name: x\n\nTests: a\ntest-name: y\n",
"only applies",
),
] {
let error = TestsControl::parse_content(content)
.unwrap_err()
.to_string();
assert!(error.contains(expected), "{error}");
}
}
}