Skip to main content

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}