use itertools::Itertools as _;
use thiserror::Error;
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct Trailer {
pub key: String,
pub value: String,
}
#[expect(missing_docs)]
#[derive(Error, Debug)]
pub enum TrailerParseError {
#[error("The trailer paragraph can't contain a blank line")]
BlankLine,
#[error("Invalid trailer line: {line}")]
NonTrailerLine { line: String },
}
pub fn parse_description_trailers(body: &str) -> Vec<Trailer> {
let (trailers, blank, found_git_trailer, non_trailer) = parse_trailers_impl(body);
if !blank {
vec![]
} else if non_trailer.is_some() && !found_git_trailer {
vec![]
} else {
trailers
}
}
pub fn parse_trailers(body: &str) -> Result<Vec<Trailer>, TrailerParseError> {
let (trailers, blank, _, non_trailer) = parse_trailers_impl(body);
if blank {
return Err(TrailerParseError::BlankLine);
}
if let Some(line) = non_trailer {
return Err(TrailerParseError::NonTrailerLine { line });
}
Ok(trailers)
}
fn parse_trailers_impl(body: &str) -> (Vec<Trailer>, bool, bool, Option<String>) {
let lines = body.trim_ascii_end().lines().rev();
let trailer_re =
regex::Regex::new(r"^([a-zA-Z0-9-]+) *: *(.*)$").expect("Trailer regex should be valid");
let mut trailers: Vec<Trailer> = Vec::new();
let mut multiline_value = vec![];
let mut found_blank = false;
let mut found_git_trailer = false;
let mut non_trailer_line = None;
for line in lines {
if line.starts_with(' ') {
multiline_value.push(line);
} else if let Some(groups) = trailer_re.captures(line) {
let key = groups[1].to_string();
multiline_value.push(groups.get(2).unwrap().as_str());
multiline_value[0] = multiline_value[0].trim_ascii_end();
let value = multiline_value.iter().rev().join("\n");
multiline_value.clear();
if key == "Signed-off-by" {
found_git_trailer = true;
}
trailers.push(Trailer { key, value });
} else if line.starts_with("(cherry picked from commit ") {
found_git_trailer = true;
non_trailer_line = Some(line.to_owned());
multiline_value.clear();
} else if line.trim_ascii().is_empty() {
found_blank = true;
break;
} else {
multiline_value.clear();
non_trailer_line = Some(line.to_owned());
}
}
trailers.reverse();
(trailers, found_blank, found_git_trailer, non_trailer_line)
}
#[cfg(test)]
mod tests {
use indoc::indoc;
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_simple_trailers() {
let descriptions = indoc! {r#"
chore: update itertools to version 0.14.0
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed
do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Co-authored-by: Alice <alice@example.com>
Co-authored-by: Bob <bob@example.com>
Reviewed-by: Charlie <charlie@example.com>
Change-Id: I1234567890abcdef1234567890abcdef12345678
"#};
let trailers = parse_description_trailers(descriptions);
assert_eq!(trailers.len(), 4);
assert_eq!(trailers[0].key, "Co-authored-by");
assert_eq!(trailers[0].value, "Alice <alice@example.com>");
assert_eq!(trailers[1].key, "Co-authored-by");
assert_eq!(trailers[1].value, "Bob <bob@example.com>");
assert_eq!(trailers[2].key, "Reviewed-by");
assert_eq!(trailers[2].value, "Charlie <charlie@example.com>");
assert_eq!(trailers[3].key, "Change-Id");
assert_eq!(
trailers[3].value,
"I1234567890abcdef1234567890abcdef12345678"
);
}
#[test]
fn test_trailers_with_colon_in_body() {
let descriptions = indoc! {r#"
chore: update itertools to version 0.14.0
Summary: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua.
Change-Id: I1234567890abcdef1234567890abcdef12345678
"#};
let trailers = parse_description_trailers(descriptions);
assert_eq!(trailers.len(), 1);
assert_eq!(trailers[0].key, "Change-Id");
}
#[test]
fn test_multiline_trailer() {
let description = indoc! {r#"
chore: update itertools to version 0.14.0
key: This is a very long value, with spaces and
newlines in it.
"#};
let trailers = parse_description_trailers(description);
assert_eq!(trailers.len(), 1);
assert_eq!(trailers[0].key, "key");
assert_eq!(
trailers[0].value,
indoc! {r"
This is a very long value, with spaces and
newlines in it."}
);
}
#[test]
fn test_ignore_line_in_trailer() {
let description = indoc! {r#"
chore: update itertools to version 0.14.0
Signed-off-by: Random J Developer <random@developer.example.org>
[lucky@maintainer.example.org: struct foo moved from foo.c to foo.h]
Signed-off-by: Lucky K Maintainer <lucky@maintainer.example.org>
"#};
let trailers = parse_description_trailers(description);
assert_eq!(trailers.len(), 2);
}
#[test]
fn test_trailers_with_single_line_description() {
let description = r#"chore: update itertools to version 0.14.0"#;
let trailers = parse_description_trailers(description);
assert_eq!(trailers.len(), 0);
}
#[test]
fn test_parse_trailers() {
let trailers_txt = indoc! {r#"
foo: 1
bar: 2
"#};
let res = parse_trailers(trailers_txt);
let trailers = res.expect("trailers to be valid");
assert_eq!(trailers.len(), 2);
assert_eq!(trailers[0].key, "foo");
assert_eq!(trailers[0].value, "1");
assert_eq!(trailers[1].key, "bar");
assert_eq!(trailers[1].value, "2");
}
#[test]
fn test_blank_line_in_trailers() {
let trailers = indoc! {r#"
foo: 1
foo: 2
"#};
let res = parse_trailers(trailers);
assert!(matches!(res, Err(TrailerParseError::BlankLine)));
}
#[test]
fn test_non_trailer_line_in_trailers() {
let trailers = indoc! {r#"
bar
foo: 1
"#};
let res = parse_trailers(trailers);
assert!(matches!(
res,
Err(TrailerParseError::NonTrailerLine { line: _ })
));
}
#[test]
fn test_blank_line_after_trailer() {
let description = indoc! {r#"
subject
foo: 1
"#};
let trailers = parse_description_trailers(description);
assert_eq!(trailers.len(), 1);
}
#[test]
fn test_blank_line_inbetween() {
let description = indoc! {r#"
subject
foo: 1
bar: 2
"#};
let trailers = parse_description_trailers(description);
assert_eq!(trailers.len(), 1);
}
#[test]
fn test_no_blank_line() {
let description = indoc! {r#"
subject: whatever
foo: 1
"#};
let trailers = parse_description_trailers(description);
assert_eq!(trailers.len(), 0);
}
#[test]
fn test_whitespace_before_key() {
let description = indoc! {r#"
subject
foo: 1
"#};
let trailers = parse_description_trailers(description);
assert_eq!(trailers.len(), 0);
}
#[test]
fn test_whitespace_after_key() {
let description = indoc! {r#"
subject
foo : 1
"#};
let trailers = parse_description_trailers(description);
assert_eq!(trailers.len(), 1);
assert_eq!(trailers[0].key, "foo");
}
#[test]
fn test_whitespace_around_value() {
let description = indoc! {"
subject
foo: 1\x20
"};
let trailers = parse_description_trailers(description);
assert_eq!(trailers.len(), 1);
assert_eq!(trailers[0].value, "1");
}
#[test]
fn test_whitespace_around_multiline_value() {
let description = indoc! {"
subject
foo: 1\x20
2\x20
"};
let trailers = parse_description_trailers(description);
assert_eq!(trailers.len(), 1);
assert_eq!(trailers[0].value, "1 \n 2");
}
#[test]
fn test_whitespace_around_multiliple_trailers() {
let description = indoc! {"
subject
foo: 1\x20
bar: 2\x20
"};
let trailers = parse_description_trailers(description);
assert_eq!(trailers.len(), 2);
assert_eq!(trailers[0].value, "1");
assert_eq!(trailers[1].value, "2");
}
#[test]
fn test_no_whitespace_before_value() {
let description = indoc! {r#"
subject
foo:1
"#};
let trailers = parse_description_trailers(description);
assert_eq!(trailers.len(), 1);
}
#[test]
fn test_empty_value() {
let description = indoc! {r#"
subject
foo:
"#};
let trailers = parse_description_trailers(description);
assert_eq!(trailers.len(), 1);
}
#[test]
fn test_invalid_key() {
let description = indoc! {r#"
subject
f_o_o: bar
"#};
let trailers = parse_description_trailers(description);
assert_eq!(trailers.len(), 0);
}
#[test]
fn test_content_after_trailer() {
let description = indoc! {r#"
subject
foo: bar
baz
"#};
let trailers = parse_description_trailers(description);
assert_eq!(trailers.len(), 0);
}
#[test]
fn test_invalid_content_after_trailer() {
let description = indoc! {r#"
subject
foo: bar
baz
"#};
let trailers = parse_description_trailers(description);
assert_eq!(trailers.len(), 0);
}
#[test]
fn test_empty_description() {
let description = "";
let trailers = parse_description_trailers(description);
assert_eq!(trailers.len(), 0);
}
#[test]
fn test_cherry_pick_trailer() {
let description = indoc! {r#"
subject
some non-trailer text
foo: bar
(cherry picked from commit 72bb9f9cf4bbb6bbb11da9cda4499c55c44e87b9)
"#};
let trailers = parse_description_trailers(description);
assert_eq!(trailers.len(), 1);
assert_eq!(trailers[0].key, "foo");
assert_eq!(trailers[0].value, "bar");
}
}