Skip to main content

hygiene/
lib.rs

1//! Issue hygiene of this repository: the form of a pull request, and of
2//! a commit on `main`. The module [`book`] checks the book, [`wrap`]
3//! checks the wrap of its pages, [`blank`] finds two blank lines in a
4//! row, [`versions`] finds the name of a version of riff, [`ci`] picks
5//! the checks that `just ci` runs, and [`terminal`] finds the crates of
6//! the terminal in the dependencies of a crate.
7//!
8//! # Design
9//!
10//! A change reaches `main` through a pull request, as one squash commit.
11//! GitHub makes the commit from the pull request: its title, then the
12//! number of the pull request, then its body. So the form of the body is
13//! the form of each commit on `main`:
14//!
15//! ```text
16//! Closes #77
17//!
18//! Pause and resume the riff. A new riff starts paused.
19//!
20//! Issue: #77
21//! Milestone: Wave 3
22//! ```
23//!
24//! - The link line, `Closes #N` or `Refs #N`, links the pull request and
25//!   the issue. The last pull request of an issue has `Closes #N`, so its
26//!   merge closes the issue. Each other one has `Refs #N`.
27//! - The trailers `Issue:` and `Milestone:` end the body. They name the
28//!   issue and the wave in the same form in each commit. The trailer
29//!   `Milestone:` names a wave by its number: `Wave 3`, with no name
30//!   ([`milestone_value`]). The check compares only the number, so a
31//!   rename of a wave breaks no pull request ([`names_milestone`],
32//!   01M518A04JWYJE5FA1VR309SAA).
33//! - The trailers stand in the last block of git trailers of the
34//!   message ([`trailers`]). Other trailers may stand in the block,
35//!   before or after them: `Signed-off-by:`, `Generated-by:`. At the
36//!   squash, GitHub adds a `Co-authored-by:` line for each co-author of
37//!   the commits, in a paragraph of its own. So `hygiene pr` checks the
38//!   message that the squash will make ([`PullRequest::squash_message`]),
39//!   not only the body.
40//! - The merge wraps each line of the body over 72 characters
41//!   ([`merge_wrap`]). The tail of a trailer line goes to a line with no
42//!   `Key:`, and that line ends the block of trailers. So `hygiene pr`
43//!   fails a trailer line over the width (`trailer-wrap`) before the
44//!   merge.
45//!
46//! `hygiene pr N` reads pull request N and its issue with `gh`, and runs
47//! [`check_pr`]. `hygiene commit [REV]` reads the message of a commit
48//! with `git`, and runs [`check_commit`]. Each error names its rule:
49//!
50//! | Rule | Finds |
51//! |---|---|
52//! | `link` | A body with no link line, or with more than one. |
53//! | `keyword` | Another closing keyword of GitHub before an issue number, for example `Fixes #12`. It closes an issue with no check. |
54//! | `title` | A title of a pull request that ends with `(#N)`. GitHub adds the number of the pull request. |
55//! | `issue-trailer` | No `Issue: #N` trailer, more than one, or one that names another issue than the link line. |
56//! | `milestone-trailer` | No `Milestone:` trailer, more than one, or one that names another wave than the pull request. |
57//! | `trailer-wrap` | A line of the last block of trailers over 72 characters. The merge wraps it, and the wrapped part is no trailer line ([`merge_wrap`]). |
58//! | `milestone` | A pull request or an issue with no milestone, or two different milestones. |
59//! | `issue-open` | An issue that is closed. |
60//! | `commit-title` | A commit title that does not end with `(#PR)`, or that names the issue in place of the pull request. |
61//!
62//! ```
63//! use hygiene::{Issue, PullRequest};
64//!
65//! let pr = PullRequest::new(
66//!     "Pause and resume the riff",
67//!     "Closes #77\n\nPause the riff.\n\nIssue: #77\nMilestone: Wave 3\n",
68//!     Some("Wave 3"),
69//! );
70//! let issue = Issue::new(77, "OPEN", Some("Wave 3"));
71//! assert!(hygiene::check_pr(&pr, Some(&issue)).is_empty());
72//!
73//! let commit = "Pause and resume the riff (#90)\n\nCloses #77\n\nIssue: #77\nMilestone: Wave 3\n";
74//! assert!(hygiene::check_commit(commit).is_empty());
75//! ```
76
77use std::fmt;
78
79use serde::Deserialize;
80
81pub mod blank;
82pub mod book;
83pub mod ci;
84pub mod part;
85pub mod terminal;
86pub mod versions;
87pub mod wrap;
88
89/// A milestone, as `gh --json milestone` gives it.
90#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
91pub struct Milestone {
92    /// The name, for example `Wave 3`.
93    pub title: String,
94}
95
96/// A pull request, as `gh pr view --json title,body,milestone,author,commits`
97/// gives it.
98#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
99pub struct PullRequest {
100    /// The title. The squash commit gets it, and the number.
101    pub title: String,
102    /// The body. The squash commit gets it as its message.
103    pub body: String,
104    /// The milestone, or `None`.
105    pub milestone: Option<Milestone>,
106    /// The author of the pull request. The squash commit is its work, so
107    /// it is not a co-author.
108    #[serde(default)]
109    pub author: Option<Person>,
110    /// The commits of the pull request.
111    #[serde(default)]
112    pub commits: Vec<PullCommit>,
113}
114
115/// A person, as `gh` gives an author. `gh` leaves out the fields that
116/// it does not know.
117#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
118pub struct Person {
119    /// The login on GitHub.
120    #[serde(default)]
121    pub login: String,
122    /// The name.
123    #[serde(default)]
124    pub name: String,
125    /// The email.
126    #[serde(default)]
127    pub email: String,
128}
129
130/// A commit of a pull request: the people that GitHub counts as its
131/// authors. They are the author, and each person of a `Co-authored-by:`
132/// line.
133#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
134pub struct PullCommit {
135    /// The authors of the commit.
136    #[serde(default)]
137    pub authors: Vec<Person>,
138}
139
140impl PullRequest {
141    /// A pull request with a title, a body and a milestone, and no
142    /// co-author.
143    pub fn new(title: &str, body: &str, milestone: Option<&str>) -> Self {
144        Self {
145            title: title.to_owned(),
146            body: body.to_owned(),
147            milestone: milestone.map(milestone_of),
148            author: None,
149            commits: Vec::new(),
150        }
151    }
152
153    /// The same pull request with `author` as its author.
154    pub fn by(mut self, author: Person) -> Self {
155        self.author = Some(author);
156        self
157    }
158
159    /// The same pull request with one more commit by `author`.
160    pub fn with_commit_by(mut self, author: Person) -> Self {
161        self.commits.push(PullCommit {
162            authors: vec![author],
163        });
164        self
165    }
166
167    /// The lines `Co-authored-by: Name <email>` that GitHub adds to the
168    /// squash commit: one for each author of a commit, except the author
169    /// of the pull request, and each one once.
170    ///
171    /// ```
172    /// use hygiene::{Person, PullRequest};
173    /// let person = |login: &str, name: &str, email: &str| Person {
174    ///     login: login.into(),
175    ///     name: name.into(),
176    ///     email: email.into(),
177    /// };
178    /// let pr = PullRequest::new("T", "Text.", None)
179    ///     .by(person("ana", "Ana", "ana@example.com"))
180    ///     .with_commit_by(person("ana", "Ana", "ana@example.com"))
181    ///     .with_commit_by(person("", "GLM-5.3-Flash", "noreply@z.ai"))
182    ///     .with_commit_by(person("", "GLM-5.3-Flash", "noreply@z.ai"));
183    /// assert_eq!(pr.co_authors(), ["Co-authored-by: GLM-5.3-Flash <noreply@z.ai>"]);
184    /// ```
185    pub fn co_authors(&self) -> Vec<String> {
186        let mut lines: Vec<String> = Vec::new();
187        for person in self.commits.iter().flat_map(|c| &c.authors) {
188            let own = self.author.as_ref().is_some_and(|a| {
189                (!a.login.is_empty() && a.login == person.login)
190                    || (!a.email.is_empty() && a.email == person.email)
191            });
192            let name = if person.name.is_empty() {
193                &person.login
194            } else {
195                &person.name
196            };
197            if own || name.is_empty() {
198                continue;
199            }
200            let line = format!("Co-authored-by: {name} <{}>", person.email);
201            if !lines.contains(&line) {
202                lines.push(line);
203            }
204        }
205        lines
206    }
207
208    /// The message that the squash will make from the body: the body, with
209    /// each line over [`MERGE_WIDTH`] wrapped as the merge wraps it
210    /// ([`merge_wrap`]), then the lines of [`PullRequest::co_authors`] in a
211    /// paragraph of their own. GitHub adds them after a blank line, also
212    /// when the body ends with trailers.
213    ///
214    /// ```
215    /// use hygiene::{Person, PullRequest};
216    /// let glm = Person { name: "GLM".into(), email: "noreply@z.ai".into(), ..Person::default() };
217    /// let pr = PullRequest::new("T", "Text.\n\nIssue: #7\n", None).with_commit_by(glm);
218    /// assert_eq!(
219    ///     pr.squash_message(),
220    ///     "Text.\n\nIssue: #7\n\nCo-authored-by: GLM <noreply@z.ai>\n"
221    /// );
222    /// ```
223    pub fn squash_message(&self) -> String {
224        self.with_co_authors(merge_wrap(&self.body.replace("\r\n", "\n")))
225    }
226
227    /// The message of [`PullRequest::squash_message`] as the author wrote
228    /// it, before the merge wraps its long lines.
229    fn written_message(&self) -> String {
230        self.with_co_authors(self.body.replace("\r\n", "\n"))
231    }
232
233    /// `body`, then the lines of [`PullRequest::co_authors`].
234    fn with_co_authors(&self, mut message: String) -> String {
235        let co_authors = self.co_authors();
236        if !co_authors.is_empty() {
237            message.truncate(message.trim_end().len());
238            message.push_str("\n\n");
239            message.push_str(&co_authors.join("\n"));
240            message.push('\n');
241        }
242        message
243    }
244}
245
246/// An issue, as `gh issue view --json number,state,milestone` gives it.
247#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
248pub struct Issue {
249    /// The number.
250    pub number: u64,
251    /// `OPEN` or `CLOSED`.
252    pub state: String,
253    /// The milestone, or `None`.
254    pub milestone: Option<Milestone>,
255}
256
257impl Issue {
258    /// An issue with a number, a state and a milestone.
259    pub fn new(number: u64, state: &str, milestone: Option<&str>) -> Self {
260        Self {
261            number,
262            state: state.to_owned(),
263            milestone: milestone.map(milestone_of),
264        }
265    }
266}
267
268fn milestone_of(title: &str) -> Milestone {
269    Milestone {
270        title: title.to_owned(),
271    }
272}
273
274/// The number N of the wave title `title`: `Wave N`, or `Wave N: NAME`
275/// with a name that is not blank. `None` for each other title.
276///
277/// ```
278/// use hygiene::wave_number;
279///
280/// assert_eq!(wave_number("Wave 6"), Some(6));
281/// assert_eq!(wave_number("Wave 27: 3.0 Wave A"), Some(27));
282/// assert_eq!(wave_number("Backlog"), None);
283/// assert_eq!(wave_number("Wave 2x"), None);
284/// assert_eq!(wave_number("Wave 6:"), None);
285/// assert_eq!(wave_number("Wave -1"), None);
286/// ```
287pub fn wave_number(title: &str) -> Option<u64> {
288    let rest = title.strip_prefix("Wave ")?;
289    let (number, name) = match rest.split_once(':') {
290        Some((number, name)) => (number, Some(name)),
291        None => (rest, None),
292    };
293    let digits = !number.is_empty() && number.bytes().all(|b| b.is_ascii_digit());
294    let named = name.is_none_or(|n| n.starts_with(' ') && !n.trim().is_empty());
295    (digits && named).then(|| number.parse().ok()).flatten()
296}
297
298/// The value of the trailer `Milestone:` for the milestone `title`: the
299/// wave by its number, `Wave N` with no name, or the whole title of a
300/// milestone that is not a wave (01M518A07QF1YACBYT91M0DD1R).
301///
302/// ```
303/// assert_eq!(hygiene::milestone_value("Wave 28: cloud, fixes that slow the work"), "Wave 28");
304/// assert_eq!(hygiene::milestone_value("Wave 3"), "Wave 3");
305/// assert_eq!(hygiene::milestone_value("Backlog"), "Backlog");
306/// ```
307pub fn milestone_value(title: &str) -> String {
308    wave_number(title).map_or_else(|| title.to_owned(), |n| format!("Wave {n}"))
309}
310
311/// True when the trailer `Milestone: value` names the milestone `title`.
312/// Two waves match by their number, so the name of a wave can change.
313/// Each other milestone matches by its whole title
314/// (01M518A04JWYJE5FA1VR309SAA).
315///
316/// ```
317/// use hygiene::names_milestone;
318///
319/// let renamed = "Wave 28: cloud, fixes that slow the work";
320/// assert!(names_milestone("Wave 28", renamed));
321/// assert!(names_milestone("Wave 28: after 1.4.0", renamed), "the title before the rename");
322/// assert!(!names_milestone("Wave 27: after 1.4.0", renamed), "another wave");
323/// assert!(names_milestone("Backlog", "Backlog"));
324/// assert!(!names_milestone("Wave 28", "Backlog"));
325/// ```
326pub fn names_milestone(value: &str, title: &str) -> bool {
327    match (wave_number(value), wave_number(title)) {
328        (Some(named), Some(own)) => named == own,
329        _ => value == title,
330    }
331}
332
333/// A broken rule of issue hygiene.
334///
335/// ```
336/// let error = hygiene::Error::new("link", "the body has no link line");
337/// assert_eq!(error.to_string(), "link: the body has no link line");
338/// ```
339#[derive(Debug, Clone, PartialEq, Eq)]
340pub struct Error {
341    /// The rule, for example `link`. See the table of rules.
342    pub rule: &'static str,
343    /// What is wrong, and what to do.
344    pub text: String,
345}
346
347impl Error {
348    /// An error of `rule`.
349    pub fn new(rule: &'static str, text: impl Into<String>) -> Self {
350        Self {
351            rule,
352            text: text.into(),
353        }
354    }
355}
356
357impl fmt::Display for Error {
358    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
359        write!(f, "{}: {}", self.rule, self.text)
360    }
361}
362
363/// The link line of a pull request.
364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365pub enum Link {
366    /// `Closes #N`: the last pull request of issue N. Its merge closes
367    /// the issue.
368    Closes(u64),
369    /// `Refs #N`: each other pull request of issue N.
370    Refs(u64),
371}
372
373impl Link {
374    /// The number of the issue.
375    pub fn issue(self) -> u64 {
376        match self {
377            Self::Closes(n) | Self::Refs(n) => n,
378        }
379    }
380
381    /// The link of one line: `Closes #N` or `Refs #N`, and nothing else.
382    ///
383    /// ```
384    /// use hygiene::Link;
385    /// assert_eq!(Link::parse("Closes #12"), Some(Link::Closes(12)));
386    /// assert_eq!(Link::parse("  Refs #7 "), Some(Link::Refs(7)));
387    /// assert_eq!(Link::parse("closes #12"), None, "a keyword, not a link line");
388    /// assert_eq!(Link::parse("Closes #12 and #13"), None);
389    /// ```
390    pub fn parse(line: &str) -> Option<Self> {
391        let mut words = line.split_whitespace();
392        let (word, number, None) = (words.next()?, words.next()?, words.next()) else {
393            return None;
394        };
395        let n = issue_ref(number)?;
396        match word {
397            "Closes" => Some(Self::Closes(n)),
398            "Refs" => Some(Self::Refs(n)),
399            _ => None,
400        }
401    }
402}
403
404/// The number of `#N`.
405///
406/// ```
407/// assert_eq!(hygiene::issue_ref("#12"), Some(12));
408/// assert_eq!(hygiene::issue_ref("12"), None);
409/// assert_eq!(hygiene::issue_ref("#"), None);
410/// ```
411pub fn issue_ref(word: &str) -> Option<u64> {
412    let digits = word.strip_prefix('#')?;
413    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
414        return None;
415    }
416    digits.parse().ok()
417}
418
419/// The closing keywords of GitHub.
420const KEYWORDS: &[&str] = &[
421    "close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved",
422];
423
424/// Each closing keyword of GitHub before an issue number, outside the
425/// link lines. GitHub closes such an issue at the merge.
426///
427/// ```
428/// let body = "Closes #12\n\nThis fixes #13, and closes: #14. It closes a gap.";
429/// assert_eq!(hygiene::stray_keywords(body), ["fixes #13", "closes: #14"]);
430/// ```
431pub fn stray_keywords(body: &str) -> Vec<String> {
432    let mut found = Vec::new();
433    for line in body.lines().filter(|l| Link::parse(l).is_none()) {
434        let words: Vec<&str> = line.split_whitespace().collect();
435        for pair in words.windows(2) {
436            let keyword = pair[0].trim_end_matches(':').to_lowercase();
437            if KEYWORDS.contains(&keyword.as_str()) && names_issue(pair[1]) {
438                let number = pair[1].trim_end_matches(|c: char| !c.is_ascii_digit());
439                found.push(format!("{} {number}", pair[0]));
440            }
441        }
442    }
443    found
444}
445
446/// True for `#N` or `OWNER/REPO#N`, with punctuation after it.
447fn names_issue(word: &str) -> bool {
448    let word = word.trim_end_matches(|c: char| !c.is_ascii_digit());
449    word.rfind('#')
450        .is_some_and(|i| issue_ref(&word[i..]).is_some())
451}
452
453/// The trailers of a message: the lines `Key: value` of its last block of
454/// trailers. The block is the last paragraphs of the message that hold
455/// only such lines. GitHub adds its `Co-authored-by:` lines after a blank
456/// line, so the block may have more than one paragraph. A paragraph with a
457/// line of another form ends the block, and a last paragraph of that form
458/// leaves no trailer at all.
459///
460/// ```
461/// let body = "Closes #7\n\nText.\n\nIssue: #7\nMilestone: Wave 3\n";
462/// assert_eq!(hygiene::trailers(body), [
463///     ("Issue".to_owned(), "#7".to_owned()),
464///     ("Milestone".to_owned(), "Wave 3".to_owned()),
465/// ]);
466/// assert!(hygiene::trailers("Text.\n\nIssue: #7\nmore text").is_empty());
467///
468/// // The squash of a pull request with a co-author:
469/// let squash = "Text.\n\nIssue: #7\nMilestone: Wave 3\n\nCo-authored-by: A <a@b.c>\n";
470/// let keys: Vec<_> = hygiene::trailers(squash).into_iter().map(|(k, _)| k).collect();
471/// assert_eq!(keys, ["Issue", "Milestone", "Co-authored-by"]);
472///
473/// // A paragraph of text after the trailers ends the block.
474/// assert!(hygiene::trailers("Issue: #7\n\nText.\n\nCo-authored-by: A <a@b.c>\n")
475///     .iter()
476///     .all(|(k, _)| k != "Issue"));
477/// ```
478pub fn trailers(text: &str) -> Vec<(String, String)> {
479    trailer_block(text)
480        .into_iter()
481        .filter_map(trailer_line)
482        .collect()
483}
484
485/// The lines of the last block of trailers of a message, each one a line
486/// `Key: value` ([`trailers`]).
487fn trailer_block(text: &str) -> Vec<&str> {
488    let mut paragraphs: Vec<Vec<&str>> = Vec::new();
489    let mut open = false;
490    for line in text.lines().map(str::trim_end) {
491        if line.is_empty() {
492            open = false;
493        } else if open {
494            paragraphs.last_mut().unwrap().push(line);
495        } else {
496            paragraphs.push(vec![line]);
497            open = true;
498        }
499    }
500    let mut found = Vec::new();
501    for paragraph in paragraphs.iter().rev() {
502        if !paragraph.iter().all(|line| trailer_line(line).is_some()) {
503            break;
504        }
505        found.splice(0..0, paragraph.iter().copied());
506    }
507    found
508}
509
510/// The width of a line in the message of a squash commit: the merge
511/// wraps a longer line ([`merge_wrap`]).
512pub const MERGE_WIDTH: usize = 72;
513
514/// The text as the squash merge of GitHub leaves it: a line of
515/// [`MERGE_WIDTH`] characters or fewer stays as it is. A longer line loses
516/// its indent and its double spaces, and is filled again with whole words
517/// up to [`MERGE_WIDTH`] characters. The wrapped lines have no indent, and
518/// the next line does not join them. A word longer than the width stays on
519/// a line of its own.
520///
521/// So a trailer line over the width is no trailer line in the commit: the
522/// second line of it has no `Key:`, and it ends the block of trailers
523/// (`trailer-wrap`, 01M6FYR8XH4WPAH2AFW6B0NARJ).
524///
525/// ```
526/// let line = "Generated-by: GLM-5.3-Flash (brief, implementation, task review, final review)";
527/// assert_eq!(
528///     hygiene::merge_wrap(line),
529///     "Generated-by: GLM-5.3-Flash (brief, implementation, task review, final\nreview)"
530/// );
531///
532/// // A line of 72 characters stays. The next line stays too.
533/// let kept = format!("{}\n  indent\n", "word ".repeat(14) + "wo");
534/// assert_eq!(hygiene::merge_wrap(&kept), kept);
535///
536/// // A long line with an indent loses it.
537/// let item = "  ".to_owned() + &"word ".repeat(15);
538/// assert!(!hygiene::merge_wrap(&item).starts_with(' '));
539/// ```
540pub fn merge_wrap(text: &str) -> String {
541    let mut wrapped = String::with_capacity(text.len());
542    for (i, line) in text.split('\n').enumerate() {
543        if i > 0 {
544            wrapped.push('\n');
545        }
546        if line.chars().count() <= MERGE_WIDTH {
547            wrapped.push_str(line);
548            continue;
549        }
550        let mut width = 0;
551        for word in line.split_whitespace() {
552            let length = word.chars().count();
553            if width == 0 {
554                // The first word of a line.
555            } else if width + 1 + length <= MERGE_WIDTH {
556                wrapped.push(' ');
557                width += 1;
558            } else {
559                wrapped.push('\n');
560                width = 0;
561            }
562            wrapped.push_str(word);
563            width += length;
564        }
565    }
566    wrapped
567}
568
569/// Each line of the last block of trailers of `text` that the merge wraps.
570fn wrapped_trailer_lines(text: &str) -> Vec<&str> {
571    trailer_block(text)
572        .into_iter()
573        .filter(|line| line.chars().count() > MERGE_WIDTH)
574        .collect()
575}
576
577/// The key and the value of a line `Key: value`, or `None`.
578fn trailer_line(line: &str) -> Option<(String, String)> {
579    let (key, value) = line.split_once(": ")?;
580    let key_ok = !key.is_empty() && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-');
581    if !key_ok || value.trim().is_empty() {
582        return None;
583    }
584    Some((key.to_owned(), value.trim().to_owned()))
585}
586
587/// The issue of a pull request: the issue of its first link line, or of
588/// its `Issue:` trailer. `hygiene pr` reads this issue.
589///
590/// ```
591/// use hygiene::PullRequest;
592/// let pr = PullRequest::new("T", "Refs #9\n\nIssue: #9\nMilestone: Wave 3", None);
593/// assert_eq!(hygiene::issue_number(&pr), Some(9));
594/// assert_eq!(hygiene::issue_number(&PullRequest::new("T", "Text.", None)), None);
595/// ```
596pub fn issue_number(pr: &PullRequest) -> Option<u64> {
597    let body = pr.body.replace("\r\n", "\n");
598    body.lines()
599        .find_map(Link::parse)
600        .map(Link::issue)
601        .or_else(|| {
602            trailers(&body)
603                .iter()
604                .find(|(k, _)| k == "Issue")
605                .and_then(|(_, v)| issue_ref(v))
606        })
607}
608
609/// The number `N` at the end of a title `TEXT (#N)`.
610fn title_number(title: &str) -> Option<u64> {
611    let (_, tail) = title.trim_end().strip_suffix(')')?.rsplit_once("(#")?;
612    issue_ref(&format!("#{tail}"))
613}
614
615/// The values of the trailer `key`.
616fn values<'a>(trailers: &'a [(String, String)], key: &str) -> Vec<&'a str> {
617    trailers
618        .iter()
619        .filter(|(k, _)| k == key)
620        .map(|(_, v)| v.as_str())
621        .collect()
622}
623
624/// The issue of the one `Issue: #N` trailer. Adds an `issue-trailer`
625/// error when the trailer is missing, when it is there more than one
626/// time, or when its value is not `#N`.
627fn issue_trailer(trailers: &[(String, String)], errors: &mut Vec<Error>) -> Option<u64> {
628    match values(trailers, "Issue")[..] {
629        [] => {
630            errors.push(Error::new(
631                "issue-trailer",
632                "the message does not end with the trailer `Issue: #N`",
633            ));
634            None
635        }
636        [value] => {
637            let n = issue_ref(value);
638            if n.is_none() {
639                errors.push(Error::new(
640                    "issue-trailer",
641                    format!("`Issue: {value}` does not name an issue as `#N`"),
642                ));
643            }
644            n
645        }
646        ref many => {
647            errors.push(Error::new(
648                "issue-trailer",
649                format!("the message has {} `Issue:` trailers; keep one", many.len()),
650            ));
651            None
652        }
653    }
654}
655
656/// The value of the one `Milestone:` trailer. Adds a
657/// `milestone-trailer` error when the trailer is missing, or when it is
658/// there more than one time.
659fn milestone_trailer<'a>(
660    trailers: &'a [(String, String)],
661    errors: &mut Vec<Error>,
662) -> Option<&'a str> {
663    match values(trailers, "Milestone")[..] {
664        [] => {
665            errors.push(Error::new(
666                "milestone-trailer",
667                "the message does not end with the trailer `Milestone: M`",
668            ));
669            None
670        }
671        [value] => Some(value),
672        ref many => {
673            errors.push(Error::new(
674                "milestone-trailer",
675                format!(
676                    "the message has {} `Milestone:` trailers; keep one",
677                    many.len()
678                ),
679            ));
680            None
681        }
682    }
683}
684
685/// Checks a pull request and its issue. `issue` is `None` when the pull
686/// request names no issue. Returns each broken rule, in the order of the
687/// table of rules.
688///
689/// ```
690/// use hygiene::{Issue, PullRequest};
691///
692/// let pr = PullRequest::new("Fix it (#12)", "Fixes #12\n\nIssue: #12\n", Some("Wave 3"));
693/// let issue = Issue::new(12, "CLOSED", Some("Wave 4"));
694/// let rules: Vec<_> = hygiene::check_pr(&pr, Some(&issue)).iter().map(|e| e.rule).collect();
695/// assert_eq!(rules, ["link", "keyword", "title", "milestone-trailer", "milestone", "issue-open"]);
696/// ```
697pub fn check_pr(pr: &PullRequest, issue: Option<&Issue>) -> Vec<Error> {
698    check_pr_for(Rules::All, pr, issue)
699}
700
701/// The rules that a check of a pull request applies.
702///
703/// riff reads the link line and the trailer `Issue:`. The other rules
704/// are the habits of riff itself. So a repository gets them only when
705/// its riff config asks for them.
706///
707/// ```
708/// use hygiene::Rules;
709///
710/// assert!(Rules::All.applies("milestone"));
711/// assert!(Rules::Read.applies("link") && Rules::Read.applies("issue-trailer"));
712/// assert!(!Rules::Read.applies("title"));
713/// ```
714#[derive(Debug, Clone, Copy, PartialEq, Eq)]
715pub enum Rules {
716    /// Each rule of the table: the repository of riff, and a repository
717    /// whose riff config asks for them.
718    All,
719    /// Only the rules for what riff reads: `link` and `issue-trailer`.
720    Read,
721}
722
723impl Rules {
724    /// True when the rule named `rule` is one of these rules.
725    pub fn applies(self, rule: &str) -> bool {
726        match self {
727            Self::All => true,
728            Self::Read => matches!(rule, "link" | "issue-trailer"),
729        }
730    }
731}
732
733/// Checks a pull request and its issue with the rules `rules`
734/// ([`check_pr`] with [`Rules::All`]).
735///
736/// ```
737/// use hygiene::{Issue, PullRequest, Rules};
738///
739/// // A title with `(#12)`, a keyword and no milestone: only `Rules::All` finds them.
740/// let pr = PullRequest::new("Fix it (#12)", "Closes #12\n\nFixes #13\n\nIssue: #12\n", None);
741/// let issue = Issue::new(12, "OPEN", None);
742/// let rules = |set| -> Vec<_> {
743///     hygiene::check_pr_for(set, &pr, Some(&issue)).iter().map(|e| e.rule).collect()
744/// };
745/// assert_eq!(rules(Rules::All), ["keyword", "title", "milestone-trailer", "milestone", "milestone"]);
746/// assert!(rules(Rules::Read).is_empty());
747///
748/// // What riff reads still holds.
749/// let pr = PullRequest::new("Fix it", "Closes #12\n\nIssue: #9\n", None);
750/// let found: Vec<_> = hygiene::check_pr_for(Rules::Read, &pr, None).iter().map(|e| e.rule).collect();
751/// assert_eq!(found, ["issue-trailer"]);
752/// ```
753pub fn check_pr_for(rules: Rules, pr: &PullRequest, issue: Option<&Issue>) -> Vec<Error> {
754    let mut errors = all_errors(pr, issue);
755    errors.retain(|e| rules.applies(e.rule));
756    errors
757}
758
759/// Each broken rule of a pull request, whatever the rules.
760fn all_errors(pr: &PullRequest, issue: Option<&Issue>) -> Vec<Error> {
761    let body = pr.body.replace("\r\n", "\n");
762    let mut errors = Vec::new();
763
764    let links: Vec<Link> = body.lines().filter_map(Link::parse).collect();
765    let link = match links[..] {
766        [] => {
767            errors.push(Error::new(
768                "link",
769                "the body has no line `Closes #N` or `Refs #N`",
770            ));
771            None
772        }
773        [link] => Some(link),
774        ref many => {
775            errors.push(Error::new(
776                "link",
777                format!(
778                    "the body has {} link lines; keep one: `Closes #N` in the last pull request of the issue, `Refs #N` in each other one",
779                    many.len()
780                ),
781            ));
782            None
783        }
784    };
785    for keyword in stray_keywords(&body) {
786        errors.push(Error::new(
787            "keyword",
788            format!("`{keyword}` closes an issue with no check; use the link line"),
789        ));
790    }
791    if let Some(n) = title_number(&pr.title) {
792        errors.push(Error::new(
793            "title",
794            format!("the title ends with `(#{n})`; GitHub adds the number of the pull request"),
795        ));
796    }
797
798    // The other rules judge the trailers as the author wrote them. A line
799    // that the merge wraps has its own error.
800    let trailers = trailers(&pr.written_message());
801    let named = issue_trailer(&trailers, &mut errors);
802    if let (Some(named), Some(link)) = (named, link)
803        && named != link.issue()
804    {
805        errors.push(Error::new(
806            "issue-trailer",
807            format!(
808                "`Issue: #{named}` names another issue than the link line (#{})",
809                link.issue()
810            ),
811        ));
812    }
813    let wave = milestone_trailer(&trailers, &mut errors);
814    let pr_milestone = pr.milestone.as_ref().map(|m| m.title.as_str());
815    if let (Some(wave), Some(own)) = (wave, pr_milestone)
816        && !names_milestone(wave, own)
817    {
818        errors.push(Error::new(
819            "milestone-trailer",
820            format!("`Milestone: {wave}` is not the milestone of the pull request ({own})"),
821        ));
822    }
823
824    for line in wrapped_trailer_lines(&body) {
825        errors.push(Error::new(
826            "trailer-wrap",
827            format!(
828                "the trailer line `{line}` has {} characters, and the merge wraps a line over {MERGE_WIDTH}: the wrapped part is no trailer line, and the block of trailers ends there; keep the line to {MERGE_WIDTH} characters",
829                line.chars().count()
830            ),
831        ));
832    }
833
834    if pr_milestone.is_none() {
835        errors.push(Error::new("milestone", "the pull request has no milestone"));
836    }
837    if let Some(issue) = issue {
838        let n = issue.number;
839        match (
840            pr_milestone,
841            issue.milestone.as_ref().map(|m| m.title.as_str()),
842        ) {
843            (_, None) => errors.push(Error::new(
844                "milestone",
845                format!("issue #{n} has no milestone"),
846            )),
847            (Some(own), Some(its)) if own != its => errors.push(Error::new(
848                "milestone",
849                format!("the pull request has milestone `{own}`, and issue #{n} has `{its}`"),
850            )),
851            _ => {}
852        }
853        if issue.state != "OPEN" {
854            errors.push(Error::new("issue-open", format!("issue #{n} is closed")));
855        }
856    }
857    errors
858}
859
860/// Checks the message of a commit on `main`. Returns each broken rule.
861///
862/// ```
863/// let errors = hygiene::check_commit("Fix the tail (#48)\n\nIssue: #48\n");
864/// let rules: Vec<_> = errors.iter().map(|e| e.rule).collect();
865/// assert_eq!(rules, ["milestone-trailer", "commit-title"]);
866/// ```
867pub fn check_commit(message: &str) -> Vec<Error> {
868    let message = message.replace("\r\n", "\n");
869    let (title, body) = message.split_once('\n').unwrap_or((&message, ""));
870    let mut errors = Vec::new();
871    let trailers = trailers(body);
872    let issue = issue_trailer(&trailers, &mut errors);
873    milestone_trailer(&trailers, &mut errors);
874    match title_number(title) {
875        None => errors.push(Error::new(
876            "commit-title",
877            "the title does not end with `(#PR)`; GitHub adds it at the squash merge of a pull request",
878        )),
879        Some(n) if Some(n) == issue => errors.push(Error::new(
880            "commit-title",
881            format!("the title ends with issue #{n}, not with a pull request"),
882        )),
883        Some(_) => {}
884    }
885    errors
886}
887
888#[cfg(test)]
889mod tests {
890    use super::*;
891
892    const GOOD: &str = "Closes #77\n\nPause the riff.\n\nIssue: #77\nMilestone: Wave 3\n";
893
894    fn pr(body: &str, milestone: Option<&str>) -> PullRequest {
895        PullRequest::new("Pause and resume the riff", body, milestone)
896    }
897
898    fn open(milestone: Option<&str>) -> Issue {
899        Issue::new(77, "OPEN", milestone)
900    }
901
902    /// The rules of the errors of a pull request with an open issue 77.
903    fn rules(pr: &PullRequest, issue: &Issue) -> Vec<&'static str> {
904        check_pr(pr, Some(issue)).iter().map(|e| e.rule).collect()
905    }
906
907    #[test]
908    fn a_good_pull_request_passes() {
909        assert_eq!(
910            check_pr(&pr(GOOD, Some("Wave 3")), Some(&open(Some("Wave 3")))),
911            []
912        );
913        let refs = GOOD.replace("Closes #77", "Refs #77");
914        assert_eq!(
915            check_pr(&pr(&refs, Some("Wave 3")), Some(&open(Some("Wave 3")))),
916            []
917        );
918    }
919
920    #[test]
921    fn a_body_from_the_web_with_crlf_passes() {
922        let body = GOOD.replace('\n', "\r\n");
923        assert_eq!(
924            check_pr(&pr(&body, Some("Wave 3")), Some(&open(Some("Wave 3")))),
925            []
926        );
927    }
928
929    #[test]
930    fn other_trailers_may_follow() {
931        let body = format!("{GOOD}Claude-Session: https://claude.ai/code/session_x\n");
932        assert!(rules(&pr(&body, Some("Wave 3")), &open(Some("Wave 3"))).is_empty());
933    }
934
935    #[test]
936    fn no_link_line_fails() {
937        let body = GOOD.replace("Closes #77\n\n", "");
938        let errors = check_pr(&pr(&body, Some("Wave 3")), Some(&open(Some("Wave 3"))));
939        assert_eq!(
940            errors,
941            [Error::new(
942                "link",
943                "the body has no line `Closes #N` or `Refs #N`"
944            )]
945        );
946    }
947
948    #[test]
949    fn two_link_lines_fail() {
950        let body = GOOD.replace("Closes #77\n", "Closes #77\nRefs #77\n");
951        let errors = check_pr(&pr(&body, Some("Wave 3")), Some(&open(Some("Wave 3"))));
952        assert_eq!(errors.len(), 1);
953        assert_eq!(errors[0].rule, "link");
954        assert!(
955            errors[0].text.starts_with("the body has 2 link lines"),
956            "{}",
957            errors[0]
958        );
959    }
960
961    #[test]
962    fn another_closing_keyword_fails() {
963        let body = GOOD.replace("Pause the riff.", "It also fixes #12.");
964        let errors = check_pr(&pr(&body, Some("Wave 3")), Some(&open(Some("Wave 3"))));
965        assert_eq!(
966            errors,
967            [Error::new(
968                "keyword",
969                "`fixes #12` closes an issue with no check; use the link line"
970            )]
971        );
972    }
973
974    #[test]
975    fn a_title_with_a_number_fails() {
976        let mut with_number = pr(GOOD, Some("Wave 3"));
977        with_number.title = "Pause and resume the riff (#77)".into();
978        assert_eq!(rules(&with_number, &open(Some("Wave 3"))), ["title"]);
979    }
980
981    #[test]
982    fn no_issue_trailer_fails() {
983        let body = GOOD.replace("Issue: #77\n", "");
984        let errors = check_pr(&pr(&body, Some("Wave 3")), Some(&open(Some("Wave 3"))));
985        assert_eq!(
986            errors,
987            [Error::new(
988                "issue-trailer",
989                "the message does not end with the trailer `Issue: #N`"
990            )]
991        );
992    }
993
994    #[test]
995    fn an_issue_trailer_of_another_issue_fails() {
996        let body = GOOD.replace("Issue: #77", "Issue: #78");
997        let errors = check_pr(&pr(&body, Some("Wave 3")), Some(&open(Some("Wave 3"))));
998        assert_eq!(
999            errors,
1000            [Error::new(
1001                "issue-trailer",
1002                "`Issue: #78` names another issue than the link line (#77)"
1003            )]
1004        );
1005    }
1006
1007    #[test]
1008    fn an_issue_trailer_that_is_not_a_number_fails() {
1009        let body = GOOD.replace("Issue: #77", "Issue: 77");
1010        assert_eq!(
1011            rules(&pr(&body, Some("Wave 3")), &open(Some("Wave 3"))),
1012            ["issue-trailer"]
1013        );
1014    }
1015
1016    #[test]
1017    fn two_issue_trailers_fail() {
1018        let body = GOOD.replace("Issue: #77\n", "Issue: #77\nIssue: #77\n");
1019        assert_eq!(
1020            rules(&pr(&body, Some("Wave 3")), &open(Some("Wave 3"))),
1021            ["issue-trailer"]
1022        );
1023    }
1024
1025    #[test]
1026    fn trailers_in_the_middle_do_not_count() {
1027        let body = format!("{GOOD}\nA last paragraph of text.\n");
1028        assert_eq!(
1029            rules(&pr(&body, Some("Wave 3")), &open(Some("Wave 3"))),
1030            ["issue-trailer", "milestone-trailer"]
1031        );
1032    }
1033
1034    #[test]
1035    fn no_milestone_trailer_fails() {
1036        let body = GOOD.replace("Milestone: Wave 3\n", "");
1037        let errors = check_pr(&pr(&body, Some("Wave 3")), Some(&open(Some("Wave 3"))));
1038        assert_eq!(
1039            errors,
1040            [Error::new(
1041                "milestone-trailer",
1042                "the message does not end with the trailer `Milestone: M`"
1043            )]
1044        );
1045    }
1046
1047    #[test]
1048    fn a_milestone_trailer_of_another_wave_fails() {
1049        let body = GOOD.replace("Wave 3", "Wave 4");
1050        let errors = check_pr(&pr(&body, Some("Wave 3")), Some(&open(Some("Wave 3"))));
1051        assert_eq!(
1052            errors,
1053            [Error::new(
1054                "milestone-trailer",
1055                "`Milestone: Wave 4` is not the milestone of the pull request (Wave 3)"
1056            )]
1057        );
1058    }
1059
1060    /// 01M518A04JWYJE5FA1VR309SAA: the lead renamed `Wave 28: after
1061    /// 1.4.0` while a pull request had its old title in the trailer.
1062    #[test]
1063    fn a_trailer_of_the_old_title_passes_after_a_rename_of_the_wave() {
1064        let renamed = Some("Wave 28: cloud, fixes that slow the work");
1065        let body = GOOD.replace("Wave 3", "Wave 28: after 1.4.0");
1066        assert_eq!(check_pr(&pr(&body, renamed), Some(&open(renamed))), []);
1067        let number = GOOD.replace("Wave 3", "Wave 28");
1068        assert_eq!(check_pr(&pr(&number, renamed), Some(&open(renamed))), []);
1069
1070        let other = GOOD.replace("Wave 3", "Wave 27: after 1.4.0");
1071        assert_eq!(
1072            check_pr(&pr(&other, renamed), Some(&open(renamed))),
1073            [Error::new(
1074                "milestone-trailer",
1075                "`Milestone: Wave 27: after 1.4.0` is not the milestone of the pull request \
1076                 (Wave 28: cloud, fixes that slow the work)"
1077            )]
1078        );
1079    }
1080
1081    #[test]
1082    fn a_milestone_that_is_not_a_wave_needs_its_whole_title() {
1083        let body = GOOD.replace("Wave 3", "Backlog");
1084        assert!(check_pr(&pr(&body, Some("Backlog")), Some(&open(Some("Backlog")))).is_empty());
1085        let body = GOOD.replace("Wave 3", "Backlog 2");
1086        assert_eq!(
1087            rules(&pr(&body, Some("Backlog")), &open(Some("Backlog"))),
1088            ["milestone-trailer"]
1089        );
1090    }
1091
1092    #[test]
1093    fn a_wave_title_has_a_number_and_an_optional_name() {
1094        for (title, number) in [
1095            ("Wave 1", Some(1)),
1096            ("Wave 27: 3.0 Wave A, local sessions", Some(27)),
1097            ("Wave 007", Some(7)),
1098            ("Wave", None),
1099            ("Wave ", None),
1100            ("wave 3", None),
1101            ("Wave 3 ", None),
1102            ("Wave 3:x", None),
1103            ("Wave 3:  ", None),
1104            ("Wave 99999999999999999999999", None),
1105            ("Backlog", None),
1106        ] {
1107            assert_eq!(wave_number(title), number, "{title}");
1108        }
1109    }
1110
1111    #[test]
1112    fn a_milestone_of_the_pull_request_that_differs_from_the_issue_fails() {
1113        let errors = check_pr(&pr(GOOD, Some("Wave 3")), Some(&open(Some("Wave 4"))));
1114        assert_eq!(
1115            errors,
1116            [Error::new(
1117                "milestone",
1118                "the pull request has milestone `Wave 3`, and issue #77 has `Wave 4`"
1119            )]
1120        );
1121    }
1122
1123    #[test]
1124    fn no_milestone_fails() {
1125        assert_eq!(rules(&pr(GOOD, None), &open(Some("Wave 3"))), ["milestone"]);
1126        assert_eq!(rules(&pr(GOOD, Some("Wave 3")), &open(None)), ["milestone"]);
1127    }
1128
1129    #[test]
1130    fn a_closed_issue_fails() {
1131        let closed = Issue::new(77, "CLOSED", Some("Wave 3"));
1132        let errors = check_pr(&pr(GOOD, Some("Wave 3")), Some(&closed));
1133        assert_eq!(errors, [Error::new("issue-open", "issue #77 is closed")]);
1134    }
1135
1136    #[test]
1137    fn with_no_issue_only_the_form_is_checked() {
1138        let errors = check_pr(&pr("Text only.", None), None);
1139        let rules: Vec<_> = errors.iter().map(|e| e.rule).collect();
1140        assert_eq!(
1141            rules,
1142            ["link", "issue-trailer", "milestone-trailer", "milestone"]
1143        );
1144    }
1145
1146    #[test]
1147    fn a_good_commit_passes() {
1148        let message = format!("Pause and resume the riff (#90)\n\n{GOOD}");
1149        assert_eq!(check_commit(&message), []);
1150    }
1151
1152    #[test]
1153    fn a_commit_with_no_number_or_trailers_fails() {
1154        let errors = check_commit("Pause and resume the riff\n\nText.\n");
1155        let rules: Vec<_> = errors.iter().map(|e| e.rule).collect();
1156        assert_eq!(
1157            rules,
1158            ["issue-trailer", "milestone-trailer", "commit-title"]
1159        );
1160    }
1161
1162    #[test]
1163    fn a_commit_of_the_old_flow_fails() {
1164        let errors =
1165            check_commit("Pause and resume the riff (#77)\n\nIssue: #77\nMilestone: Wave 3\n");
1166        assert_eq!(
1167            errors,
1168            [Error::new(
1169                "commit-title",
1170                "the title ends with issue #77, not with a pull request"
1171            )]
1172        );
1173    }
1174
1175    #[test]
1176    fn a_commit_with_only_a_title_has_no_trailers() {
1177        let rules: Vec<_> = check_commit("docs: fix a word (#90)")
1178            .iter()
1179            .map(|e| e.rule)
1180            .collect();
1181        assert_eq!(rules, ["issue-trailer", "milestone-trailer"]);
1182    }
1183
1184    /// A squash message of a pull request with a co-author: the trailers
1185    /// with a line `Generated-by:`, then the paragraph `Co-authored-by:`
1186    /// that GitHub adds.
1187    const SQUASH: &str = "Make the link test count (#1348)\n\nCloses #692\n\nText.\n\n\
1188        Issue: #692\nMilestone: Wave 41\nGenerated-by: GLM-5.3-Flash\n\n\
1189        Co-authored-by: GLM-5.3-Flash <noreply@z.ai>\n";
1190
1191    #[test]
1192    fn a_commit_with_a_co_author_after_the_trailers_passes() {
1193        for message in [
1194            // The lines of one block.
1195            "T (#9)\n\nIssue: #7\nMilestone: Wave 3\nCo-authored-by: A <a@b.c>\n",
1196            // GitHub adds a blank line before its lines.
1197            "T (#9)\n\nIssue: #7\nMilestone: Wave 3\n\nCo-authored-by: A <a@b.c>\n",
1198            // Other trailers stand before and after.
1199            "T (#9)\n\nSigned-off-by: A <a@b.c>\nIssue: #7\nMilestone: Wave 3\nGenerated-by: X\n",
1200            SQUASH,
1201        ] {
1202            assert_eq!(check_commit(message), [], "{message}");
1203        }
1204    }
1205
1206    #[test]
1207    fn a_commit_with_the_trailers_before_the_last_block_fails() {
1208        for message in [
1209            // A paragraph of text after the trailers.
1210            "T (#9)\n\nIssue: #7\nMilestone: Wave 3\n\nText.\n",
1211            // A paragraph of text between the trailers and the co-author.
1212            "T (#9)\n\nIssue: #7\nMilestone: Wave 3\n\nText.\n\nCo-authored-by: A <a@b.c>\n",
1213            // A line of text in the paragraph of the trailers.
1214            "T (#9)\n\nIssue: #7\nMilestone: Wave 3\nmore text\n\nCo-authored-by: A <a@b.c>\n",
1215        ] {
1216            let rules: Vec<_> = check_commit(message).iter().map(|e| e.rule).collect();
1217            assert_eq!(rules, ["issue-trailer", "milestone-trailer"], "{message}");
1218        }
1219    }
1220
1221    /// The body of the outside pull request #1348 in short, as its author wrote it:
1222    /// the trailer `Generated-by:` is on one line of 78 characters.
1223    const BODY_1348: &str = "Closes #692\n\n\
1224        The count check accepts `tries` or `tries + 1` accepts. The call still ends after its budget, with the text `no_reply`, after at least 2 tries, each on a new connection. Nothing else changed.\n\n\
1225        Issue: #692\nMilestone: Wave 41\n\
1226        Generated-by: GLM-5.3-Flash (brief, implementation, task review, final review)\n";
1227
1228    /// The commit that the merge made from the whole body (c60887ef).
1229    const COMMIT_1348: &str = "The link test counts an accepted connection with no read (#1348)\n\n\
1230        Closes #692\n\n\
1231        The count check accepts `tries` or `tries + 1` accepts. The call still\n\
1232        ends after its budget, with the text `no_reply`, after at least 2 tries,\n\
1233        each on a new connection. Nothing else changed.\n\n\
1234        Issue: #692\nMilestone: Wave 41\n\
1235        Generated-by: GLM-5.3-Flash (brief, implementation, task review, final\nreview)\n\n\
1236        Co-authored-by: GLM-5.3-Flash <noreply@z.ai>\n";
1237
1238    #[test]
1239    fn the_merge_wraps_a_long_line_as_it_did_for_the_commit_1348() {
1240        let pr = PullRequest::new(
1241            "The link test counts an accepted connection with no read",
1242            BODY_1348,
1243            Some("Wave 41"),
1244        )
1245        .with_commit_by(person("GLM-5.3-Flash", "noreply@z.ai"));
1246        let title_and_body = format!("{} (#1348)\n\n{}", pr.title, pr.squash_message());
1247        assert_eq!(title_and_body, COMMIT_1348);
1248    }
1249
1250    #[test]
1251    fn the_commit_1348_fails_the_commit_rule() {
1252        let rules: Vec<_> = check_commit(COMMIT_1348).iter().map(|e| e.rule).collect();
1253        assert_eq!(rules, ["issue-trailer", "milestone-trailer"]);
1254    }
1255
1256    #[test]
1257    fn the_pull_request_1348_fails_with_the_reason_before_the_merge() {
1258        let pr = PullRequest::new(
1259            "The link test counts an accepted connection with no read",
1260            BODY_1348,
1261            Some("Wave 41"),
1262        )
1263        .with_commit_by(person("GLM-5.3-Flash", "noreply@z.ai"));
1264        let issue = Issue::new(692, "OPEN", Some("Wave 41"));
1265        let errors = check_pr(&pr, Some(&issue));
1266        let rules: Vec<_> = errors.iter().map(|e| e.rule).collect();
1267        assert_eq!(rules, ["trailer-wrap"], "{errors:?}");
1268        let text = errors[0].to_string();
1269        assert!(
1270            text.contains("Generated-by: GLM-5.3-Flash (brief"),
1271            "{text}"
1272        );
1273        assert!(text.contains("78 characters"), "{text}");
1274        assert!(text.contains("keep the line to 72 characters"), "{text}");
1275    }
1276
1277    #[test]
1278    fn a_trailer_line_of_the_width_passes_and_one_character_more_fails() {
1279        let value = |n: usize| "x".repeat(n - "Generated-by: ".len());
1280        for (length, rules_found) in [
1281            (MERGE_WIDTH, vec![]),
1282            (MERGE_WIDTH + 1, vec!["trailer-wrap"]),
1283        ] {
1284            let body = format!("{GOOD}Generated-by: {}\n", value(length));
1285            assert_eq!(
1286                rules(&pr(&body, Some("Wave 3")), &open(Some("Wave 3"))),
1287                rules_found,
1288                "{length}"
1289            );
1290        }
1291    }
1292
1293    #[test]
1294    fn each_long_trailer_line_has_an_error() {
1295        let long = "word ".repeat(15);
1296        let body = format!("{GOOD}Generated-by: {long}\nSigned-off-by: {long}\n");
1297        assert_eq!(
1298            rules(&pr(&body, Some("Wave 3")), &open(Some("Wave 3"))),
1299            ["trailer-wrap", "trailer-wrap"]
1300        );
1301    }
1302
1303    #[test]
1304    fn a_long_line_of_text_is_no_error() {
1305        let text = format!(
1306            "Closes #77\n\n{}\n\nIssue: #77\nMilestone: Wave 3\n",
1307            "word ".repeat(30)
1308        );
1309        assert_eq!(
1310            check_pr(&pr(&text, Some("Wave 3")), Some(&open(Some("Wave 3")))),
1311            []
1312        );
1313    }
1314
1315    #[test]
1316    fn the_merge_leaves_a_line_of_the_width_and_the_lines_after_it() {
1317        assert_eq!(merge_wrap(GOOD), GOOD);
1318        let kept = format!("{}\n  indent  two\n", "word ".repeat(14) + "wo");
1319        assert_eq!(merge_wrap(&kept), kept);
1320    }
1321
1322    #[test]
1323    fn the_merge_fills_a_long_line_again_with_whole_words() {
1324        let line = format!("  {}", "word ".repeat(30));
1325        let wrapped = merge_wrap(&line);
1326        assert!(wrapped.lines().count() > 1);
1327        for wrapped_line in wrapped.lines() {
1328            assert!(
1329                wrapped_line.chars().count() <= MERGE_WIDTH,
1330                "{wrapped_line}"
1331            );
1332            assert!(wrapped_line.starts_with("word") && wrapped_line.ends_with("word"));
1333        }
1334        assert_eq!(wrapped.split_whitespace().count(), 30);
1335        // A word over the width stays whole on its line.
1336        let url = "u".repeat(80);
1337        assert_eq!(
1338            merge_wrap(&format!("see {url} here")),
1339            format!("see\n{url}\nhere")
1340        );
1341    }
1342
1343    #[test]
1344    fn the_trailers_of_a_block_of_two_paragraphs_keep_their_order() {
1345        let found =
1346            trailers("Text.\n\nIssue: #7\nMilestone: Wave 3\n\nCo-authored-by: A <a@b.c>\n");
1347        let keys: Vec<_> = found.iter().map(|(k, _)| k.as_str()).collect();
1348        assert_eq!(keys, ["Issue", "Milestone", "Co-authored-by"]);
1349    }
1350
1351    #[test]
1352    fn a_message_of_one_paragraph_of_text_has_no_trailer() {
1353        assert!(trailers("").is_empty());
1354        assert!(trailers("Text.\n").is_empty());
1355        assert!(trailers("Text.\n\nMore.\n").is_empty());
1356    }
1357
1358    fn person(name: &str, email: &str) -> Person {
1359        Person {
1360            name: name.to_owned(),
1361            email: email.to_owned(),
1362            ..Person::default()
1363        }
1364    }
1365
1366    fn glm() -> Person {
1367        person("GLM-5.3-Flash", "noreply@z.ai")
1368    }
1369
1370    #[test]
1371    fn a_pull_request_with_a_co_author_passes() {
1372        let pr = pr(GOOD, Some("Wave 3")).with_commit_by(glm());
1373        assert_eq!(
1374            pr.co_authors(),
1375            ["Co-authored-by: GLM-5.3-Flash <noreply@z.ai>"]
1376        );
1377        assert_eq!(check_pr(&pr, Some(&open(Some("Wave 3")))), []);
1378    }
1379
1380    #[test]
1381    fn a_pull_request_with_a_body_that_ends_with_text_fails_also_with_a_co_author() {
1382        let body = format!("{GOOD}\nA last paragraph of text.\n");
1383        let pr = pr(&body, Some("Wave 3")).with_commit_by(glm());
1384        assert_eq!(
1385            rules(&pr, &open(Some("Wave 3"))),
1386            ["issue-trailer", "milestone-trailer"]
1387        );
1388    }
1389
1390    #[test]
1391    fn the_author_of_the_pull_request_is_no_co_author() {
1392        let ana = Person {
1393            login: "ana".to_owned(),
1394            ..person("Ana", "ana@example.com")
1395        };
1396        let pr = pr(GOOD, Some("Wave 3"))
1397            .by(ana.clone())
1398            .with_commit_by(ana)
1399            .with_commit_by(glm())
1400            .with_commit_by(glm());
1401        assert_eq!(pr.co_authors().len(), 1);
1402        assert_eq!(
1403            pr.squash_message(),
1404            format!("{GOOD}\nCo-authored-by: GLM-5.3-Flash <noreply@z.ai>\n")
1405        );
1406    }
1407
1408    #[test]
1409    fn a_pull_request_with_no_co_author_has_its_body_as_message() {
1410        assert_eq!(pr(GOOD, None).squash_message(), GOOD);
1411        let web = GOOD.replace('\n', "\r\n");
1412        assert_eq!(pr(&web, None).squash_message(), GOOD);
1413    }
1414
1415    #[test]
1416    fn gh_json_of_a_pull_request_with_commits_has_its_co_author() {
1417        let json = r#"{
1418            "title": "T", "body": "Closes #1\n\nIssue: #1\nMilestone: Wave 3",
1419            "milestone": {"title": "Wave 3"},
1420            "author": {"id": "x", "is_bot": false, "login": "ana", "name": "Ana"},
1421            "commits": [
1422                {"oid": "a", "authors": [{"email": "ana@example.com", "id": "x", "login": "ana", "name": "Ana"}]},
1423                {"oid": "b", "authors": [
1424                    {"email": "ana@example.com", "id": "x", "login": "ana", "name": "Ana"},
1425                    {"email": "noreply@z.ai", "id": "", "login": "", "name": "GLM-5.3-Flash"}
1426                ]}
1427            ]
1428        }"#;
1429        let pr: PullRequest = serde_json::from_str(json).unwrap();
1430        assert_eq!(
1431            pr.co_authors(),
1432            ["Co-authored-by: GLM-5.3-Flash <noreply@z.ai>"]
1433        );
1434        assert_eq!(check_pr(&pr, Some(&open(Some("Wave 3")))), []);
1435    }
1436
1437    #[test]
1438    fn the_rules_that_riff_reads_pass_a_pull_request_of_another_habit() {
1439        let plain = PullRequest::new(
1440            "Fix it (#77)",
1441            "Closes #77\n\nFixes #78.\n\nIssue: #77\n",
1442            None,
1443        );
1444        let issue = Issue::new(77, "CLOSED", None);
1445        assert_eq!(check_pr_for(Rules::Read, &plain, Some(&issue)), []);
1446        assert!(!check_pr_for(Rules::All, &plain, Some(&issue)).is_empty());
1447    }
1448
1449    #[test]
1450    fn the_rules_that_riff_reads_find_a_missing_link_and_a_wrong_issue_line() {
1451        let rules = |body: &str| -> Vec<&'static str> {
1452            check_pr_for(Rules::Read, &pr(body, None), None)
1453                .iter()
1454                .map(|e| e.rule)
1455                .collect()
1456        };
1457        assert_eq!(rules("Text.\n\nIssue: #77\n"), ["link"]);
1458        assert_eq!(rules("Closes #77\n\nText.\n"), ["issue-trailer"]);
1459        assert_eq!(rules("Closes #77\n\nIssue: #9\n"), ["issue-trailer"]);
1460        assert!(rules("Refs #77\n\nIssue: #77\n").is_empty());
1461    }
1462}