a73x

src/trailer.rs

Ref:   Size: 7.9 KiB   History

//! Git trailer parsing, shared by everything that reads a `<Token>: <value>`
//! line out of a commit message.
//!
//! Two features use it: `Issue:` trailers link commits to issues
//! (`commit_link`), and `Patch:` trailers record that a patch landed on its
//! base branch (`merge_scan`). They differ only in the token, so they share one
//! parser and one set of tests (`tests/trailer_test.rs`) — a fix to the block
//! semantics or the value rules cannot land for one and miss the other.
//!
//! See: docs/superpowers/specs/2026-04-12-commit-issue-link-design.md
//! See: docs/superpowers/specs/2026-08-09-merge-recording-design.md

/// Parse `<token>: <value>` trailers out of a commit message.
///
/// Returns the values in order of appearance. Follows git's own trailer-block
/// semantics: only the final paragraph is considered, and *every* non-empty
/// line in it must be trailer-shaped (a `token: value` line) for the paragraph
/// to qualify. Any prose line in the final paragraph disqualifies the whole
/// paragraph — this prevents false positives like `"Thanks Bob.\nIssue: abc"`
/// in commit bodies.
///
/// The key match is case-insensitive. The value must be a single
/// non-whitespace token followed by optional trailing whitespace and
/// end-of-line; values like `abc fixes thing` are rejected so that loose
/// commentary never becomes a silent prefix lookup that warns every sync
/// forever.
pub fn parse_trailers(message: &str, token: &str) -> Vec<String> {
    // 1. Split into paragraphs (blank-line separated), preserving order.
    //    Trim trailing whitespace from each line for the trailer-shape check,
    //    but keep enough structure to recognize blank lines.
    let lines: Vec<&str> = message.lines().collect();

    // 2. Find the last paragraph: the longest tail slice that contains at
    //    least one non-empty line and has no blank line *before* its first
    //    non-empty line in the tail.
    //
    //    Walking from the end: skip trailing blank/whitespace-only lines,
    //    then collect lines until we hit a blank line.
    let Some((start, end)) = final_paragraph_bounds(&lines) else {
        return Vec::new();
    };
    let paragraph = &lines[start..end];

    // 3. Validate every non-empty line in the paragraph is trailer-shaped.
    if !is_trailer_block(paragraph) {
        return Vec::new();
    }

    // 4. Extract the values whose key is `token`.
    let mut out = Vec::new();
    for line in paragraph {
        if let Some(value) = match_trailer_line(line, token) {
            out.push(value);
        }
    }
    out
}

/// The span of the final paragraph of `lines` — the tail run of non-blank
/// lines, ignoring trailing blanks. `None` when there is no content at all.
fn final_paragraph_bounds(lines: &[&str]) -> Option<(usize, usize)> {
    let mut end = lines.len();
    while end > 0 && lines[end - 1].trim().is_empty() {
        end -= 1;
    }
    if end == 0 {
        return None;
    }
    let mut start = end;
    while start > 0 && !lines[start - 1].trim().is_empty() {
        start -= 1;
    }
    Some((start, end))
}

/// Whether every non-empty line of `paragraph` is trailer-shaped — git's rule
/// for a paragraph being a trailer block, and the one [`parse_trailers`]
/// enforces before reading anything out of it.
fn is_trailer_block(paragraph: &[&str]) -> bool {
    paragraph
        .iter()
        .all(|line| line.trim().is_empty() || is_trailer_shaped(line))
}

/// What the writer of a trailer needs to know about the message it is about to
/// append to. See [`final_paragraph`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FinalParagraph {
    /// The final paragraph is also the first — a message with no blank line in
    /// it, i.e. a bare subject. Appending to it would fold the trailer into the
    /// subject line, since git takes the whole first paragraph as the subject.
    pub starts_the_message: bool,
    /// Every non-empty line in it is trailer-shaped, so a trailer appended
    /// directly to it stays readable — *and* so do the trailers already there,
    /// which starting a new paragraph instead would strand outside the final
    /// paragraph and silently unlink.
    pub is_trailer_block: bool,
}

/// Describe the final paragraph of `message`, or `None` if it has no content.
///
/// This exists so that whatever *writes* a trailer decides where to put it
/// using the same notion of "trailer block" that [`parse_trailers`] uses to
/// read it back. Those are two implementations of the same rule when they are
/// written separately, and two implementations of one rule is how a writer
/// starts emitting trailers the reader cannot see.
pub fn final_paragraph(message: &str) -> Option<FinalParagraph> {
    let lines: Vec<&str> = message.lines().collect();
    let (start, end) = final_paragraph_bounds(&lines)?;
    Some(FinalParagraph {
        starts_the_message: start == 0,
        is_trailer_block: is_trailer_block(&lines[start..end]),
    })
}

/// Whether *any* line of `message`, in any paragraph, is a `<token>:` trailer
/// line.
///
/// Deliberately laxer than [`parse_trailers`], and only ever used to decide not
/// to write: a `Patch:` line that the parser refuses — because it sits in a
/// paragraph with prose, or carries a value with interior whitespace — is still
/// a line the author wrote, and stapling a second one underneath it helps
/// nobody. "Already mentions it" is the right test for leaving a message alone;
/// "already parses" is not.
pub fn contains_trailer_line(message: &str, token: &str) -> bool {
    message
        .lines()
        .any(|line| trailer_key(line).is_some_and(|key| key.eq_ignore_ascii_case(token)))
}

/// The key of a trailer-shaped line: `<token>: <value>`, where token starts
/// with a letter and consists of `[A-Za-z0-9-]`, and value is at least one
/// non-whitespace character. `None` if the line is not trailer-shaped.
fn trailer_key(line: &str) -> Option<&str> {
    let trimmed = line.trim_start();
    let colon_pos = trimmed.find(':')?;
    // Use trim_end() so that `ISSUE : abc` is recognized as the token `ISSUE`
    // — matching what `match_trailer_line` does. Without this, the space before
    // the colon would disqualify the line and make the whole paragraph fail
    // the trailer-shape check.
    let token = trimmed[..colon_pos].trim_end();
    if token.is_empty() {
        return None;
    }
    let mut chars = token.chars();
    let first = chars.next().unwrap();
    if !first.is_ascii_alphabetic() {
        return None;
    }
    if !chars.all(|c| c.is_ascii_alphanumeric() || c == '-') {
        return None;
    }
    if trimmed[colon_pos + 1..].trim().is_empty() {
        return None;
    }
    Some(token)
}

/// Returns true if a line looks like a git trailer.
fn is_trailer_shaped(line: &str) -> bool {
    trailer_key(line).is_some()
}

/// If `line` is a `<token>: <value>` trailer with exactly one non-whitespace
/// token in its value, returns the value. Otherwise returns None.
fn match_trailer_line(line: &str, token: &str) -> Option<String> {
    let trimmed = line.trim_start();
    let colon_pos = trimmed.find(':')?;
    let key = trimmed[..colon_pos].trim_end();
    if !key.eq_ignore_ascii_case(token) {
        return None;
    }
    let value_region = &trimmed[colon_pos + 1..];
    let value = value_region.trim();
    if value.is_empty() {
        return None;
    }
    // Reject values with interior whitespace: `abc fixes thing` must not
    // parse to `abc` silently — it must parse to nothing so the user sees
    // that their commentary is being ignored. This matters more for `Patch:`
    // than for `Issue:`, because there the silent reading would record a
    // merge under an id the author never wrote on its own.
    if value.split_whitespace().count() != 1 {
        return None;
    }
    Some(value.to_string())
}

/// The token `Issue:` trailers use.
pub const ISSUE_TOKEN: &str = "issue";

/// The token `Patch:` trailers use.
pub const PATCH_TOKEN: &str = "patch";