xtask_lib/tasks/test.rs
1//! `cargo xtask test` — run tests across the full feature matrix.
2//!
3//! The feature matrix is derived dynamically from the workspace `Cargo.toml`
4//! files, so adding a new feature flag automatically includes it in all test
5//! runs without any manual update.
6//!
7//! Each matrix entry runs two passes:
8//! 1. `cargo nextest run` — parallel test execution with per-test output.
9//! 2. `cargo test --doc` — doctests, which nextest does not execute.
10
11use anyhow::{Result, bail};
12use clap::Args;
13
14use super::util::{cmd, matrix, workspace};
15
16/// Arguments for `cargo xtask test`.
17#[derive(Debug, Args)]
18pub struct TestArgs {
19 /// Run only the named feature combination (e.g. `"test + otel"`).
20 ///
21 /// When omitted, all matrix entries are run in sequence.
22 #[arg(long)]
23 pub features: Option<String>,
24}
25
26/// Runs `cargo nextest run` and `cargo test --doc` across the feature matrix
27/// (or a single entry).
28///
29/// The `TEST_ROOT_DIR` environment variable is set to the workspace root so
30/// that integration tests that read fixture files can locate them regardless
31/// of the working directory.
32///
33/// # Errors
34///
35/// Returns the first test failure encountered.
36pub fn run(args: TestArgs) -> Result<()> {
37 let root = workspace::root()?;
38 let root_str = root.to_string_lossy().into_owned();
39 let env = [("TEST_ROOT_DIR", root_str.as_str())];
40
41 let entries = matrix::scan(&root, &["local"])?;
42 let entries = filter(&entries, args.features.as_deref())?;
43
44 for entry in entries {
45 println!("\n==> nextest ({})", entry.label);
46
47 let mut nextest_args = vec!["nextest", "run", "--all-targets"];
48 nextest_args.extend_from_slice(&entry.cargo_args());
49 cmd::run("cargo", &nextest_args, &root, &env)?;
50
51 println!("\n==> doctest ({})", entry.label);
52
53 let mut doctest_args = vec!["test", "--doc"];
54 doctest_args.extend_from_slice(&entry.cargo_args());
55 cmd::run("cargo", &doctest_args, &root, &env)?;
56 }
57
58 Ok(())
59}
60
61/// Returns the matrix entries to run, optionally filtered by label.
62///
63/// When `label` is `None` all entries are returned. When a label is
64/// provided it is normalised via [`matrix::normalize_features_arg`] before
65/// matching, so both `"otel,test"` and `"otel + test"` resolve to the same
66/// entry. An unknown label after normalisation is an error.
67///
68/// # Errors
69///
70/// Returns an error when a label is provided but no matrix entry matches,
71/// listing all available labels.
72fn filter<'a>(
73 entries: &'a [matrix::MatrixEntry],
74 label: Option<&str>,
75) -> Result<Vec<&'a matrix::MatrixEntry>> {
76 let Some(raw) = label else {
77 return Ok(entries.iter().collect());
78 };
79
80 let normalised = matrix::normalize_features_arg(raw);
81 let matched: Vec<&matrix::MatrixEntry> =
82 entries.iter().filter(|e| e.label == normalised).collect();
83
84 if matched.is_empty() {
85 let available: Vec<&str> = entries
86 .iter()
87 .map(|e| e.label.as_str())
88 .collect::<std::collections::BTreeSet<_>>()
89 .into_iter()
90 .collect();
91 bail!(
92 "unknown feature combination {:?} (normalised to {:?})\n\nAvailable labels:\n {}",
93 raw,
94 normalised,
95 available.join("\n ")
96 );
97 }
98
99 Ok(matched)
100}