diff --git a/src/debian/mod.rs b/src/debian/mod.rs index 5ba7422..e18815e 100644 --- a/src/debian/mod.rs +++ b/src/debian/mod.rs @@ -10,6 +10,7 @@ //! - [`files`]: `debian/files` artifact registry (`Dpkg::Dist::Files`) //! - [`version`]: Debian version splitting/validation/comparison //! - [`changelog`]: `debian/changelog` entry parsing +//! - [`tests_control`]: DEP-8 test metadata (`debian/tests/control`) pub mod arch; pub mod changelog; @@ -17,6 +18,7 @@ pub mod checksums; pub mod control; pub mod deps; pub mod files; +pub mod tests_control; pub mod version; pub use changelog::{ @@ -28,4 +30,5 @@ pub use control::{ ControlInfo, Paragraph, parse_paragraphs, strip_clearsigned_armour, write_paragraph, }; pub use files::{FilesEntry, FilesList}; +pub use tests_control::{Test, TestsControl}; pub use version::DebianVersion; diff --git a/src/debian/tests_control.rs b/src/debian/tests_control.rs new file mode 100644 index 0000000..66f6a77 --- /dev/null +++ b/src/debian/tests_control.rs @@ -0,0 +1,266 @@ +//! 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, + /// Inline shell command run under `bash -e` (`Test-Command:` stanzas). + pub command: Option, + /// Raw `Depends:` value as written; `None` when absent, meaning the + /// DEP-8 default `@` (all built binaries). + pub depends: Option, + /// Declared restrictions, in declaration order, as written + /// (e.g. `allow-stderr`). + pub restrictions: Vec, + /// `Tests-Directory:` replacing the `debian/tests` path segment for + /// this stanza's scripts (relative path). + pub tests_directory: Option, +} + +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, +} + +impl TestsControl { + /// Parse the test control file at `path`. + pub fn parse(path: &Path) -> Result> { + 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 { + 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 = 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(format!( + "stanza {stanza}: Tests and Test-Command are mutually exclusive" + )); + } + (None, None) => { + return Err(format!( + "stanza {stanza}: missing a Tests or Test-Command field" + )); + } + (Some(scripts), None) => { + if test_name.is_some() { + return Err(format!( + "stanza {stanza}: test-name only applies to a Test-Command 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::>(), + ["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(); + assert!(error.contains(expected), "{error}"); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 44cf391..e89b99c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,7 +15,7 @@ pub(crate) mod data; /// Build a Debian package into a binary (.deb) pub mod deb; /// Reusable Debian format primitives (control/deb822, checksums, versions, -/// changelog entries, artifact registries) +/// changelog entries, test metadata, artifact registries) pub mod debian; /// Obtain general information about distribution, series, etc pub mod distro_info;