hygiene/ci.rs
1//! The checks that `just ci` runs for a diff.
2//!
3//! # Design
4//!
5//! `just ci` runs only the checks that the diff of the branch can break
6//! (01M3WNMKB6PAP6J0QXX4A684HH). `hygiene ci [BASE]` finds each file
7//! that differs from the merge base of `HEAD` and BASE (the default is
8//! `origin/main`): committed, not committed, and not tracked. Then
9//! [`choose`] gives the recipe of the justfile to run:
10//!
11//! | Recipe | Checks | When |
12//! |---|---|---|
13//! | `ci-text` | `book`, `reqs`, `wrap`, `versions` | Each changed file is text. |
14//! | `ci-full` | Each check. | Each other case. |
15//!
16//! A file is text ([`is_text`]) when it is in `docs/` or `design/`, or
17//! when it is a `.md` file outside `crates/`. A `.md` file in `crates/`
18//! is not text: tests read them.
19//!
20//! When git cannot compare with BASE, or when no file differs, the
21//! recipe is `ci-full`. `hygiene ci` prints the recipe to stdout and
22//! one line for the person to stderr: the set, and why. The Gate on
23//! GitHub runs the checks of `just ci-full` as jobs at the same time
24//! (01M3WNN7VQJKN5MJH7JN50VF4D, 01M513W2K3HNRWPQTQNBQPT8MZ). See
25//! [`crate::part`].
26//!
27//! ## The check of an author: `just check`
28//!
29//! The Gate on GitHub runs each check of `just ci-full`: the one full
30//! run of each pushed commit (01M49HAZ5BZYGAR9PGC089RM3F). Before a
31//! push, an author runs `just check` (01M49HAZA5K08XW2JQ11TG87JP): the fast
32//! checks (`fmt-check`, `lint`, `doc`, `book`, `reqs`, `wrap`, `versions`)
33//! and the tests of the crates that the diff touches. `hygiene crates
34//! [BASE]` prints the arguments of `cargo test` for these crates
35//! ([`tests`]), and one line for the person to stderr. A text file needs
36//! a test here: the tests read the book, `CLAUDE.md` and the requirements.
37//!
38//! ```mermaid
39//! flowchart LR
40//! D["the changed files"] --> T{"each file"}
41//! T -- "in crates/NAME/" --> C["NAME, and each crate<br/>that depends on it"]
42//! T -- "each other file,<br/>also text" --> W["--workspace"]
43//! ```
44//!
45//! ## One run at a time in a worktree
46//!
47//! Two runs in one worktree share its `target`. Each waits for the
48//! cargo lock of the other, and the load and the time double. So
49//! `just ci` and `just ci-full` first source `crates/hygiene/ci-lock.sh`
50//! and call `ci_lock` (01M43DKYVAX0TJ2F5YYGYFSZ4G). The check is shell,
51//! not this crate: `cargo run` of a second run waits for the build lock
52//! of the first run, so it cannot stop at once.
53//!
54//! ```mermaid
55//! flowchart TD
56//! S["just ci"] --> E{"RIFF_CI_LOCK set?<br/>(the holder started this run)"}
57//! E -- yes --> R["run the checks"]
58//! E -- no --> C{"create target/.riff-ci.lock<br/>(fails when it exists)"}
59//! C -- created --> H["hold it: pid, start time;<br/>remove it at the exit"] --> R
60//! C -- exists --> L{"does its pid run,<br/>with the same start time?"}
61//! L -- yes --> X["one line, exit 1"]
62//! L -- no --> D["remove the old lock"] --> C
63//! ```
64//!
65//! The pid and the start time of `ps -o lstart=` name one process, so a
66//! new process with the same pid does not hold the lock. The lock is in
67//! the `target` of the worktree, so runs in two worktrees go on.
68//!
69//! ```
70//! use hygiene::ci::{choose, Checks};
71//!
72//! let text = ["design/reviews/x.md".to_owned()];
73//! let choice = choose("origin/main", Some(&text));
74//! assert_eq!(choice.checks, Checks::Text);
75//! assert_eq!(choice.checks.recipe(), "ci-text");
76//!
77//! let code = ["docs/src/waves.md".to_owned(), "justfile".to_owned()];
78//! let choice = choose("origin/main", Some(&code));
79//! assert_eq!(choice.checks, Checks::Full);
80//! assert_eq!(
81//! choice.to_string(),
82//! "just ci runs each check: justfile is not a text file"
83//! );
84//! ```
85
86use std::fmt;
87
88/// A set of checks: a recipe of the justfile.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum Checks {
91 /// The checks for text: `book`, `reqs`, `wrap` and `versions`.
92 Text,
93 /// Each check.
94 Full,
95}
96
97impl Checks {
98 /// The recipe of the justfile that runs this set.
99 pub fn recipe(self) -> &'static str {
100 match self {
101 Self::Text => "ci-text",
102 Self::Full => "ci-full",
103 }
104 }
105}
106
107/// The set that `just ci` runs, and why.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct Choice {
110 /// The set.
111 pub checks: Checks,
112 /// The reason, for the line that `just ci` prints.
113 pub why: String,
114}
115
116impl fmt::Display for Choice {
117 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118 let set = match self.checks {
119 Checks::Text => "only the text checks (book, reqs, wrap, versions)",
120 Checks::Full => "each check",
121 };
122 write!(f, "just ci runs {set}: {}", self.why)
123 }
124}
125
126/// True when no code check can fail from a change of the file `path`.
127/// `path` is relative to the top of the repository.
128///
129/// ```
130/// use hygiene::ci::is_text;
131///
132/// assert!(is_text("docs/src/how-it-works.md"));
133/// assert!(is_text("docs/book.toml"));
134/// assert!(is_text("design/reviews/x.md"));
135/// assert!(is_text("CLAUDE.md"));
136/// assert!(!is_text("crates/riff/src/main.rs"));
137/// assert!(!is_text("crates/riff/README.md"));
138/// assert!(!is_text("justfile"));
139/// ```
140pub fn is_text(path: &str) -> bool {
141 if path.starts_with("crates/") {
142 return false;
143 }
144 path.starts_with("docs/") || path.starts_with("design/") || path.ends_with(".md")
145}
146
147/// The set for the files `changed` that differ from the merge base with
148/// `base`. `None` says that git cannot compare with `base`.
149pub fn choose(base: &str, changed: Option<&[String]>) -> Choice {
150 let full = |why: String| Choice {
151 checks: Checks::Full,
152 why,
153 };
154 let Some(changed) = changed else {
155 return full(format!("git cannot compare this tree with {base}"));
156 };
157 if changed.is_empty() {
158 return full(format!("no file differs from {base}"));
159 }
160 if let Some(code) = changed.iter().find(|path| !is_text(path)) {
161 return full(format!("{code} is not a text file"));
162 }
163 let why = match changed {
164 [one] => format!("{one} is the only file that differs from {base}, and it is text"),
165 _ => format!(
166 "each of the {} files that differ from {base} is text",
167 changed.len()
168 ),
169 };
170 Choice {
171 checks: Checks::Text,
172 why,
173 }
174}
175
176/// A crate of the workspace, as `cargo metadata --no-deps` gives it.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct Member {
179 /// The name of the package, for example `riff-core`.
180 pub name: String,
181 /// The directory of the crate from the top of the repository, for
182 /// example `crates/riff-core`.
183 pub dir: String,
184 /// The names of each dependency: normal, dev and build.
185 pub deps: Vec<String>,
186}
187
188/// The tests that `just check` runs.
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub enum Tests {
191 /// No test: the diff changes no crate.
192 None,
193 /// The tests of these crates, in name order.
194 Crates(Vec<String>),
195 /// The tests of each crate.
196 Workspace,
197}
198
199impl Tests {
200 /// The arguments of `cargo test`: empty, `-p NAME` for each crate, or
201 /// `--workspace`.
202 pub fn args(&self) -> String {
203 match self {
204 Self::None => String::new(),
205 Self::Crates(names) => names
206 .iter()
207 .map(|name| format!("-p {name}"))
208 .collect::<Vec<_>>()
209 .join(" "),
210 Self::Workspace => "--workspace".to_owned(),
211 }
212 }
213}
214
215/// The tests that `just check` runs, and why.
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct TestChoice {
218 /// The tests.
219 pub tests: Tests,
220 /// The reason, for the line that `just check` prints.
221 pub why: String,
222}
223
224impl fmt::Display for TestChoice {
225 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226 let set = match &self.tests {
227 Tests::None => "no test".to_owned(),
228 Tests::Crates(names) => format!("the tests of {}", names.join(", ")),
229 Tests::Workspace => "the tests of each crate".to_owned(),
230 };
231 write!(f, "just check runs {set}: {}", self.why)
232 }
233}
234
235/// The tests for the files `changed` that differ from the merge base
236/// with `base` (01M49HAZA5K08XW2JQ11TG87JP): the tests of each crate
237/// with a changed file, and of each crate that depends on one of them,
238/// also through another crate. Each file outside the crates, for
239/// example `Cargo.lock`, the `justfile` or a page of the book, needs the
240/// tests of each crate: a test can read it. `None` says that git cannot
241/// compare with `base`: then each crate.
242///
243/// ```
244/// use hygiene::ci::{tests, Member, Tests};
245///
246/// let member = |name: &str, deps: &[&str]| Member {
247/// name: name.into(),
248/// dir: format!("crates/{name}"),
249/// deps: deps.iter().map(|&d| d.into()).collect(),
250/// };
251/// let members = [
252/// member("riff-core", &[]),
253/// member("riff-server", &["riff-core"]),
254/// member("riff", &["riff-core", "riff-server"]),
255/// member("reqs", &[]),
256/// ];
257/// let changed = ["crates/riff-server/src/lib.rs".to_owned()];
258/// let choice = tests("origin/main", Some(&changed), &members);
259/// assert_eq!(choice.tests.args(), "-p riff -p riff-server");
260///
261/// let core = ["crates/riff-core/src/record.rs".to_owned()];
262/// let choice = tests("origin/main", Some(&core), &members);
263/// assert_eq!(choice.tests.args(), "-p riff -p riff-core -p riff-server");
264///
265/// let book = ["docs/src/development.md".to_owned()];
266/// let choice = tests("origin/main", Some(&book), &members);
267/// assert_eq!(choice.tests, Tests::Workspace);
268/// assert_eq!(
269/// choice.to_string(),
270/// "just check runs the tests of each crate: docs/src/development.md is in no crate, \
271/// and a test can read it"
272/// );
273///
274/// assert_eq!(tests("origin/main", Some(&[]), &members).tests, Tests::None);
275/// ```
276pub fn tests(base: &str, changed: Option<&[String]>, members: &[Member]) -> TestChoice {
277 let Some(changed) = changed else {
278 return TestChoice {
279 tests: Tests::Workspace,
280 why: format!("git cannot compare this tree with {base}"),
281 };
282 };
283 let mut names: Vec<String> = Vec::new();
284 for path in changed {
285 let member = members
286 .iter()
287 .find(|m| path.starts_with(&format!("{}/", m.dir)));
288 match member {
289 Some(member) => names.push(member.name.clone()),
290 None => {
291 return TestChoice {
292 tests: Tests::Workspace,
293 why: format!("{path} is in no crate, and a test can read it"),
294 };
295 }
296 }
297 }
298 if names.is_empty() {
299 return TestChoice {
300 tests: Tests::None,
301 why: format!("no crate differs from {base}"),
302 };
303 }
304 names.sort();
305 names.dedup();
306 let changed_crates = match &names[..] {
307 [one] => format!("{one} differs"),
308 many => format!("{} differ", many.join(", ")),
309 };
310 loop {
311 let more: Vec<String> = members
312 .iter()
313 .filter(|m| !names.contains(&m.name))
314 .filter(|m| m.deps.iter().any(|d| names.contains(d)))
315 .map(|m| m.name.clone())
316 .collect();
317 if more.is_empty() {
318 break;
319 }
320 names.extend(more);
321 }
322 names.sort();
323 TestChoice {
324 tests: Tests::Crates(names),
325 why: format!(
326 "{changed_crates} from {base}, and each crate that depends on a changed crate runs too"
327 ),
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 fn paths(names: &[&str]) -> Vec<String> {
336 names.iter().map(|&name| name.to_owned()).collect()
337 }
338
339 #[test]
340 fn a_diff_of_only_text_files_gets_the_text_checks() {
341 let one = choose("origin/main", Some(&paths(&["design/reviews/x.md"])));
342 assert_eq!(one.checks, Checks::Text);
343 assert_eq!(
344 one.to_string(),
345 "just ci runs only the text checks (book, reqs, wrap, versions): design/reviews/x.md \
346 is the only file that differs from origin/main, and it is text"
347 );
348 let two = choose(
349 "origin/main",
350 Some(&paths(&["docs/src/how-it-works.md", "README.md"])),
351 );
352 assert_eq!(two.checks, Checks::Text);
353 assert!(two.why.starts_with("each of the 2 files"), "{}", two.why);
354 }
355
356 #[test]
357 fn one_file_that_is_not_text_gets_each_check() {
358 let changed = paths(&["docs/src/development.md", "crates/riff/src/main.rs"]);
359 let choice = choose("origin/main", Some(&changed));
360 assert_eq!(choice.checks, Checks::Full);
361 assert_eq!(choice.why, "crates/riff/src/main.rs is not a text file");
362 }
363
364 #[test]
365 fn a_markdown_file_of_a_crate_is_not_text() {
366 for path in [
367 "crates/riff/README.md",
368 "Cargo.lock",
369 ".github/workflows/ci.yml",
370 "documents.rs",
371 ] {
372 assert!(!is_text(path), "{path}");
373 }
374 }
375
376 fn members() -> Vec<Member> {
377 let member = |name: &str, deps: &[&str]| Member {
378 name: name.into(),
379 dir: format!("crates/{name}"),
380 deps: deps.iter().map(|&d| d.into()).collect(),
381 };
382 vec![
383 member("hygiene", &[]),
384 member("isolated", &[]),
385 member("riff-core", &[]),
386 member("riff-server", &["riff-core", "isolated"]),
387 member("riff", &["riff-core", "hygiene", "riff-server", "isolated"]),
388 ]
389 }
390
391 #[test]
392 fn a_crate_with_no_dependent_runs_only_its_own_tests() {
393 let changed = paths(&["crates/riff/src/pr.rs", "crates/riff/tests/pr.rs"]);
394 let choice = tests("origin/main", Some(&changed), &members());
395 assert_eq!(choice.tests, Tests::Crates(vec!["riff".into()]));
396 assert_eq!(
397 choice.to_string(),
398 "just check runs the tests of riff: riff differs from origin/main, and each crate \
399 that depends on a changed crate runs too"
400 );
401 }
402
403 #[test]
404 fn the_dependents_of_a_crate_run_also_through_another_crate() {
405 let changed = paths(&["crates/isolated/src/lib.rs"]);
406 let choice = tests("origin/main", Some(&changed), &members());
407 assert_eq!(choice.tests.args(), "-p isolated -p riff -p riff-server");
408 }
409
410 #[test]
411 fn a_crate_dir_is_not_the_start_of_another_crate_dir() {
412 let changed = paths(&["crates/riff-core/src/lib.rs"]);
413 let choice = tests("origin/main", Some(&changed), &members());
414 assert_eq!(choice.tests.args(), "-p riff -p riff-core -p riff-server");
415 let hygiene = paths(&["crates/hygiene/ci-lock.sh"]);
416 let choice = tests("origin/main", Some(&hygiene), &members());
417 assert_eq!(choice.tests.args(), "-p hygiene -p riff");
418 }
419
420 #[test]
421 fn a_file_outside_the_crates_runs_each_test_also_text() {
422 for path in [
423 "justfile",
424 ".github/workflows/ci.yml",
425 "Cargo.toml",
426 "CLAUDE.md",
427 "README.md",
428 "docs/src/how-it-works.md",
429 "docs/src/requirements.md",
430 "design/reviews/x.md",
431 ] {
432 let choice = tests("origin/main", Some(&paths(&[path])), &members());
433 assert_eq!(choice.tests, Tests::Workspace, "{path}");
434 assert_eq!(choice.tests.args(), "--workspace");
435 }
436 let none = tests("origin/main", Some(&[]), &members());
437 assert_eq!(none.tests, Tests::None);
438 assert_eq!(none.tests.args(), "");
439 assert_eq!(
440 none.to_string(),
441 "just check runs no test: no crate differs from origin/main"
442 );
443 let unknown = tests("origin/main", None, &members());
444 assert_eq!(unknown.tests, Tests::Workspace);
445 }
446
447 #[test]
448 fn no_diff_and_no_base_get_each_check() {
449 let none = choose("origin/main", Some(&[]));
450 assert_eq!(none.checks, Checks::Full);
451 assert_eq!(none.why, "no file differs from origin/main");
452 let unknown = choose("origin/main", None);
453 assert_eq!(unknown.checks, Checks::Full);
454 assert_eq!(unknown.why, "git cannot compare this tree with origin/main");
455 }
456}