pub(crate) fn split_trailers(text: &str) -> (&str, Option<&str>) {
let trimmed = text.trim_end();
if trimmed.is_empty() {
return (trimmed, None);
}
let bytes = trimmed.as_bytes();
let mut i = bytes.len();
while i > 0 {
let line_end = i;
let line_start = trimmed[..i].rfind('\n').map_or(0, |pos| pos + 1);
let line = &trimmed[line_start..line_end];
if line.trim().is_empty() {
break;
}
i = if line_start == 0 { 0 } else { line_start - 1 };
}
if i == 0 {
return (trimmed, None);
}
let last_paragraph_start = trimmed[i..].find(|c: char| c != '\n' && c != '\r');
let last_paragraph_start = match last_paragraph_start {
Some(offset) => i + offset,
None => return (trimmed, None),
};
let last_paragraph = &trimmed[last_paragraph_start..];
let all_trailers = last_paragraph
.lines()
.filter(|l| !l.trim().is_empty())
.all(is_trailer_line);
if !all_trailers {
return (trimmed, None);
}
(trimmed[..i].trim_end(), Some(last_paragraph))
}
fn is_trailer_line(line: &str) -> bool {
let Some(colon_pos) = line.find(": ") else {
return false;
};
let key = &line[..colon_pos];
!key.is_empty()
&& key.starts_with(|c: char| c.is_ascii_alphabetic())
&& key.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
}
#[cfg(test)]
mod tests {
use super::*;
fn body(text: &str) -> &str {
split_trailers(text).0
}
fn block(text: &str) -> Option<&str> {
split_trailers(text).1
}
#[test]
fn body_with_trailers() {
let input = "feat: add caching\n\nThis adds Redis caching.\n\nSigned-off-by: Alice \
<a@b>\nRefs: DAT-123";
assert_eq!(body(input), "feat: add caching\n\nThis adds Redis caching.");
assert_eq!(
block(input),
Some("Signed-off-by: Alice <a@b>\nRefs: DAT-123")
);
}
#[test]
fn no_trailers() {
let input = "feat: add caching\n\nThis adds Redis caching.";
assert_eq!(body(input), input);
assert_eq!(block(input), None);
}
#[test]
fn title_plus_trailers_no_body() {
let input = "feat: add caching\n\nSigned-off-by: Alice <a@b>";
assert_eq!(body(input), "feat: add caching");
assert_eq!(block(input), Some("Signed-off-by: Alice <a@b>"));
}
#[test]
fn title_only() {
let input = "feat: add caching";
assert_eq!(body(input), input);
assert_eq!(block(input), None);
}
#[test]
fn mixed_trailer_and_non_trailer_last_paragraph() {
let input = "title\n\nbody\n\nSigned-off-by: Alice <a@b>\nThis is not a trailer";
assert_eq!(body(input), input.trim_end());
assert_eq!(block(input), None);
}
#[test]
fn multiple_paragraphs_only_last_is_trailers() {
let input = "title\n\nbody\n\nRefs: X\n\nSigned-off-by: Alice <a@b>";
assert_eq!(body(input), "title\n\nbody\n\nRefs: X");
assert_eq!(block(input), Some("Signed-off-by: Alice <a@b>"));
}
#[test]
fn empty_input() {
assert_eq!(body(""), "");
assert_eq!(block(""), None);
}
#[test]
fn single_paragraph_no_blank_lines() {
let input = "Signed-off-by: Alice <a@b>\nRefs: DAT-123";
assert_eq!(body(input), input);
assert_eq!(block(input), None);
}
#[test]
fn multiple_blank_lines_before_trailers() {
let input = "feat: add caching\n\nBody text.\n\n\nSigned-off-by: Alice <a@b>";
assert_eq!(body(input), "feat: add caching\n\nBody text.");
assert_eq!(block(input), Some("Signed-off-by: Alice <a@b>"));
}
#[test]
fn trailing_whitespace_in_input() {
let input = "feat: add caching\n\nBody.\n\nRefs: DAT-123\n ";
assert_eq!(body(input), "feat: add caching\n\nBody.");
assert_eq!(block(input), Some("Refs: DAT-123"));
}
#[test]
fn co_authored_by_trailer() {
let input = "fix: resolve deadlock\n\nCo-authored-by: Bob <bob@example.com>";
assert_eq!(body(input), "fix: resolve deadlock");
assert_eq!(block(input), Some("Co-authored-by: Bob <bob@example.com>"));
}
#[test]
fn key_with_no_space_after_colon_is_not_trailer() {
let input = "title\n\nbody\n\nNot-a-trailer:no space here";
assert_eq!(body(input), input);
assert_eq!(block(input), None);
}
#[test]
fn key_with_spaces_is_not_trailer() {
let input = "title\n\nbody\n\nNot a trailer: value here";
assert_eq!(body(input), input);
assert_eq!(block(input), None);
}
#[test]
fn trailer_value_can_contain_colons() {
let input = "title\n\nbody\n\nRefs: https://example.com:8080/path";
assert_eq!(body(input), "title\n\nbody");
assert_eq!(block(input), Some("Refs: https://example.com:8080/path"));
}
}