//! 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, } /// 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> { 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(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::>(), ["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}"); } } }