Skip to main content

hygiene/
part.rs

1//! The parts of the tests of the Gate.
2//!
3//! # Design
4//!
5//! The Gate on GitHub runs its checks as jobs at the same time
6//! (01M513W2K3HNRWPQTQNBQPT8MZ). The tests take the most time, so they
7//! run in N parts, each part in a job of its own. `just test-part I/N`
8//! runs `hygiene test-part I/N` (01M513W2NSE303E6XAM18BW7T6):
9//!
10//! 1. `cargo test --workspace -- --list --format terse` builds each test
11//!    binary and lists the name of each test, also of each doc test.
12//! 2. [`names`] reads the names, and [`Part::plan`] keeps each name of a
13//!    test that is not a doc test whose [`fnv1a`] hash modulo N is I − 1.
14//! 3. `cargo test --workspace --tests --no-fail-fast -- --exact NAME...`
15//!    runs these tests.
16//! 4. Part 1 also runs each doc test: `cargo test --workspace --doc
17//!    --no-fail-fast`.
18//!
19//! With `--no-fail-fast`, a failed test binary does not stop the others,
20//! so the log of a part shows each failed test.
21//!
22//! A doc test has a name with spaces ([`is_doc`]), for example
23//! `crates/x/src/lib.rs - ulid (line 57)`. rustdoc splits each filter at
24//! its spaces, so no filter names one doc test. The doc tests take
25//! seconds, so they run as one group.
26//!
27//! A name is in one part only, so the N parts together run each test. A
28//! name that two test binaries share runs in each of them, as in a full
29//! run. The hash of a name does not change with the other names, so a
30//! new test moves no other test to another part.
31//!
32//! ```mermaid
33//! flowchart LR
34//!     L["cargo test -- --list:<br/>each name"] --> D{"a doc test?"}
35//!     D -- yes --> P1["part 1: cargo test --doc"]
36//!     D -- no --> H{"fnv1a(name) mod N"}
37//!     H -- "I − 1" --> R["cargo test --tests -- --exact<br/>the names of part I"]
38//!     H -- "another value" --> S["the job of another part"]
39//! ```
40//!
41//! ```
42//! use hygiene::part::{names, Part};
43//!
44//! let list = "cli::help: test\n\
45//!             cli::version: test\n\
46//!             src/lib.rs - ulid (line 57): test\n\
47//!             bench::fast: benchmark\n";
48//! let all = names(list);
49//! assert_eq!(all, ["cli::help", "cli::version", "src/lib.rs - ulid (line 57)"]);
50//!
51//! let plans: Vec<_> = ["1/2", "2/2"]
52//!     .iter()
53//!     .map(|p| p.parse::<Part>().unwrap().plan(&all))
54//!     .collect();
55//! let mut each: Vec<&str> = plans.iter().flat_map(|p| p.tests.clone()).collect();
56//! each.sort();
57//! assert_eq!(each, ["cli::help", "cli::version"]);
58//! assert_eq!((plans[0].doc, plans[1].doc), (1, 0));
59//! assert!("3/2".parse::<Part>().is_err());
60//! ```
61
62use std::fmt;
63use std::str::FromStr;
64
65/// Part `index` of `count` parts of the tests, as `I/N` gives it.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct Part {
68    /// I: from 1 to `count`.
69    pub index: u64,
70    /// N: 1 or more.
71    pub count: u64,
72}
73
74impl FromStr for Part {
75    type Err = String;
76
77    /// Reads `I/N`, with 1 ≤ I ≤ N.
78    ///
79    /// ```
80    /// use hygiene::part::Part;
81    ///
82    /// assert_eq!("2/4".parse(), Ok(Part { index: 2, count: 4 }));
83    /// for bad in ["0/4", "5/4", "1/0", "2", "a/4", "1/4/2", ""] {
84    ///     assert_eq!(
85    ///         bad.parse::<Part>(),
86    ///         Err(format!("\"{bad}\" is not a part. Give I/N with 1 ≤ I ≤ N, for example 2/4."))
87    ///     );
88    /// }
89    /// ```
90    fn from_str(text: &str) -> Result<Self, Self::Err> {
91        let bad = || format!("\"{text}\" is not a part. Give I/N with 1 ≤ I ≤ N, for example 2/4.");
92        let (index, count) = text.split_once('/').ok_or_else(bad)?;
93        let number = |n: &str| -> Result<u64, String> {
94            if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) {
95                return Err(bad());
96            }
97            n.parse().map_err(|_| bad())
98        };
99        let (index, count) = (number(index)?, number(count)?);
100        if index == 0 || index > count {
101            return Err(bad());
102        }
103        Ok(Self { index, count })
104    }
105}
106
107impl fmt::Display for Part {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        write!(f, "{}/{}", self.index, self.count)
110    }
111}
112
113/// What one part runs.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct Plan<'a> {
116    /// The names of the tests of the part that are not doc tests, in
117    /// the order of the list.
118    pub tests: Vec<&'a str>,
119    /// The count of the doc tests that the part runs: each doc test in
120    /// part 1, else none.
121    pub doc: usize,
122}
123
124impl Part {
125    /// True when the test `name`, which is not a doc test, is in this
126    /// part.
127    ///
128    /// ```
129    /// use hygiene::part::Part;
130    ///
131    /// let one = Part { index: 1, count: 1 };
132    /// assert!(one.has("any::test"));
133    /// ```
134    pub fn has(&self, name: &str) -> bool {
135        fnv1a(name) % self.count == self.index - 1
136    }
137
138    /// What this part runs of the tests `names`.
139    pub fn plan<'a>(&self, names: &'a [String]) -> Plan<'a> {
140        let (doc, tests): (Vec<&str>, Vec<&str>) =
141            names.iter().map(String::as_str).partition(|n| is_doc(n));
142        Plan {
143            tests: tests.into_iter().filter(|name| self.has(name)).collect(),
144            doc: if self.index == 1 { doc.len() } else { 0 },
145        }
146    }
147}
148
149/// True when `name` is the name of a doc test: it has a space, as in
150/// `crates/x/src/lib.rs - ulid (line 57)`. The name of each other test
151/// is a Rust path.
152///
153/// ```
154/// use hygiene::part::is_doc;
155///
156/// assert!(is_doc("crates/x/src/lib.rs - ulid (line 57)"));
157/// assert!(!is_doc("cli::help"));
158/// ```
159pub fn is_doc(name: &str) -> bool {
160    name.contains(' ')
161}
162
163/// The 64-bit FNV-1a hash of `text`. It is the same on each machine and
164/// with each Rust, so each job of the Gate gives a name the same part.
165///
166/// ```
167/// use hygiene::part::fnv1a;
168///
169/// assert_eq!(fnv1a(""), 0xcbf2_9ce4_8422_2325);
170/// assert_eq!(fnv1a("a"), 0xaf63_dc4c_8601_ec8c);
171/// ```
172pub fn fnv1a(text: &str) -> u64 {
173    text.bytes().fold(0xcbf2_9ce4_8422_2325, |hash, byte| {
174        (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
175    })
176}
177
178/// The test names of the output of `cargo test -- --list --format
179/// terse`: each line `NAME: test`, sorted, each name one time. A
180/// benchmark is not a test.
181pub fn names(list: &str) -> Vec<String> {
182    let mut names: Vec<String> = list
183        .lines()
184        .filter_map(|line| line.strip_suffix(": test"))
185        .filter(|name| !name.is_empty())
186        .map(str::to_owned)
187        .collect();
188    names.sort();
189    names.dedup();
190    names
191}
192
193/// The line that `just test-part` prints before it runs the tests of
194/// `plan`, of the tests `names`.
195///
196/// ```
197/// use hygiene::part::{summary, Plan, Part};
198///
199/// let names = ["a".to_owned(), "b".to_owned(), "x.rs - f (line 1)".to_owned()];
200/// let part = Part { index: 1, count: 2 };
201/// let plan = Plan { tests: vec!["a"], doc: 1 };
202/// assert_eq!(
203///     summary(part, &plan, &names),
204///     "just test-part 1/2 runs 1 of 2 tests, and the 1 doc tests"
205/// );
206/// let part = Part { index: 2, count: 2 };
207/// let plan = Plan { tests: vec!["b"], doc: 0 };
208/// assert_eq!(summary(part, &plan, &names), "just test-part 2/2 runs 1 of 2 tests");
209/// ```
210pub fn summary(part: Part, plan: &Plan<'_>, names: &[String]) -> String {
211    let all = names.iter().filter(|n| !is_doc(n)).count();
212    let line = format!(
213        "just test-part {part} runs {} of {all} tests",
214        plan.tests.len()
215    );
216    if plan.doc == 0 {
217        line
218    } else {
219        format!("{line}, and the {} doc tests", plan.doc)
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    fn all(count: u64) -> Vec<Part> {
228        (1..=count).map(|index| Part { index, count }).collect()
229    }
230
231    #[test]
232    fn each_name_is_in_exactly_one_part() {
233        let names: Vec<String> = (0..500).map(|i| format!("module::test_{i}")).collect();
234        for count in 1..=6 {
235            let parts = all(count);
236            for name in &names {
237                let holders = parts.iter().filter(|p| p.has(name)).count();
238                assert_eq!(holders, 1, "{name} in {count} parts");
239            }
240        }
241    }
242
243    #[test]
244    fn the_parts_are_near_the_same_size() {
245        let names: Vec<String> = (0..2000).map(|i| format!("module::test_{i}")).collect();
246        for part in all(4) {
247            let size = part.plan(&names).tests.len();
248            assert!((350..=650).contains(&size), "{part}: {size} of 2000");
249        }
250    }
251
252    #[test]
253    fn a_new_name_moves_no_other_name() {
254        let names: Vec<String> = (0..50).map(|i| format!("t{i}")).collect();
255        let mut more = names.clone();
256        more.push("a_new_test".to_owned());
257        for part in all(3) {
258            let after: Vec<&str> = part
259                .plan(&more)
260                .tests
261                .into_iter()
262                .filter(|n| *n != "a_new_test")
263                .collect();
264            assert_eq!(part.plan(&names).tests, after, "{part}");
265        }
266    }
267
268    #[test]
269    fn only_part_one_runs_the_doc_tests() {
270        let names: Vec<String> = ["a::b", "x.rs - f (line 1)", "x.rs - g (line 9)"]
271            .map(str::to_owned)
272            .to_vec();
273        for part in all(3) {
274            let plan = part.plan(&names);
275            assert!(plan.tests.iter().all(|n| !is_doc(n)), "{plan:?}");
276            assert_eq!(plan.doc, if part.index == 1 { 2 } else { 0 });
277        }
278    }
279
280    #[test]
281    fn names_reads_only_the_lines_of_tests() {
282        let list = "\n2 tests, 0 benchmarks\nb::two: test\na::one: test\nb::two: test\n\
283                    : test\nx: benchmark\nRunning a line of cargo\n";
284        assert_eq!(names(list), ["a::one", "b::two"]);
285    }
286}