// Copyright 2024 The Jujutsu Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //! Parsing trailers from commit messages. use itertools::Itertools as _; use thiserror::Error; /// A key-value pair representing a trailer in a commit message, of the /// form `Key: Value`. #[derive(Debug, PartialEq, Eq, Clone)] pub struct Trailer { /// trailer key pub key: String, /// trailer value /// /// It is trimmed at the start and the end but includes new line characters /// (\n) and multi-line escape chars ( ) for multi line values. 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 }, } /// Parse the trailers from a commit message; these are simple key-value /// pairs, separated by a colon, describing extra information in a commit /// message; an example is the following: /// /// ```text /// 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 /// Co-authored-by: Bob /// Reviewed-by: Charlie /// Change-Id: I1234567890abcdef1234567890abcdef12345678 /// ``` /// /// In this case, there are four trailers: two `Co-authored-by` lines, one /// `Reviewed-by` line, and one `Change-Id` line. pub fn parse_description_trailers(body: &str) -> Vec { let (trailers, blank, found_git_trailer, non_trailer) = parse_trailers_impl(body); if !blank { // no blank found, this means there was a single paragraph, so whatever // was found can't come from the trailer vec![] } else if non_trailer.is_some() && !found_git_trailer { // at least one non trailer line was found in the trailers paragraph // the trailers are considered as trailers only if there is a predefined // trailers from git vec![] } else { trailers } } /// Parse the trailers from a trailer paragraph. This function behaves like /// `parse_description_trailer`, but will return an error if a blank or /// non trailer line is found. pub fn parse_trailers(body: &str) -> Result, 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, bool, bool, Option) { // a trailer always comes at the end of a message; we can split the message // by newline, but we need to immediately reverse the order of the lines // to ensure we parse the trailer in an unambiguous manner; this avoids cases // where a colon in the body of the message is mistaken for a trailer 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 = 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()); // trim the end of the multiline value // the start is already trimmed with the regex 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() { // end of the trailer found_blank = true; break; } else { // a non trailer in the trailer paragraph // the line is ignored, as well as the multiline value that may // have previously been accumulated multiline_value.clear(); non_trailer_line = Some(line.to_owned()); } } // reverse the insert order, since we parsed the trailer in reverse 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 Co-authored-by: Bob Reviewed-by: Charlie 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 "); assert_eq!(trailers[1].key, "Co-authored-by"); assert_eq!(trailers[1].value, "Bob "); assert_eq!(trailers[2].key, "Reviewed-by"); assert_eq!(trailers[2].value, "Charlie "); 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); // should only have Change-Id 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); // should only have Change-Id 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 [lucky@maintainer.example.org: struct foo moved from foo.c to foo.h] Signed-off-by: Lucky K Maintainer "#}; 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"); } }