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}