jj_lib/
trailer.rs

1// Copyright 2024 The Jujutsu Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Parsing trailers from commit messages.
16
17use itertools::Itertools as _;
18use thiserror::Error;
19
20/// A key-value pair representing a trailer in a commit message, of the
21/// form `Key: Value`.
22#[derive(Debug, PartialEq, Eq, Clone)]
23pub struct Trailer {
24    /// trailer key
25    pub key: String,
26    /// trailer value
27    ///
28    /// It is trimmed at the start and the end but includes new line characters
29    /// (\n) and multi-line escape chars ( ) for multi line values.
30    pub value: String,
31}
32
33#[allow(missing_docs)]
34#[derive(Error, Debug)]
35pub enum TrailerParseError {
36    #[error("The trailer paragraph can't contain a blank line")]
37    BlankLine,
38    #[error("Invalid trailer line: {line}")]
39    NonTrailerLine { line: String },
40}
41
42/// Parse the trailers from a commit message; these are simple key-value
43/// pairs, separated by a colon, describing extra information in a commit
44/// message; an example is the following:
45///
46/// ```text
47/// chore: update itertools to version 0.14.0
48///
49/// Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
50/// tempor incididunt ut labore et dolore magna aliqua.
51///
52/// Co-authored-by: Alice <alice@example.com>
53/// Co-authored-by: Bob <bob@example.com>
54/// Reviewed-by: Charlie <charlie@example.com>
55/// Change-Id: I1234567890abcdef1234567890abcdef12345678
56/// ```
57///
58/// In this case, there are four trailers: two `Co-authored-by` lines, one
59/// `Reviewed-by` line, and one `Change-Id` line.
60pub fn parse_description_trailers(body: &str) -> Vec<Trailer> {
61    let (trailers, blank, found_git_trailer, non_trailer) = parse_trailers_impl(body);
62    if !blank {
63        // no blank found, this means there was a single paragraph, so whatever
64        // was found can't come from the trailer
65        vec![]
66    } else if non_trailer.is_some() && !found_git_trailer {
67        // at least one non trailer line was found in the trailers paragraph
68        // the trailers are considered as trailers only if there is a predefined
69        // trailers from git
70        vec![]
71    } else {
72        trailers
73    }
74}
75
76/// Parse the trailers from a trailer paragraph. This function behaves like
77/// `parse_description_trailer`, but will return an error if a blank or
78/// non trailer line is found.
79pub fn parse_trailers(body: &str) -> Result<Vec<Trailer>, TrailerParseError> {
80    let (trailers, blank, _, non_trailer) = parse_trailers_impl(body);
81    if blank {
82        return Err(TrailerParseError::BlankLine);
83    }
84    if let Some(line) = non_trailer {
85        return Err(TrailerParseError::NonTrailerLine { line });
86    }
87    Ok(trailers)
88}
89
90fn parse_trailers_impl(body: &str) -> (Vec<Trailer>, bool, bool, Option<String>) {
91    // a trailer always comes at the end of a message; we can split the message
92    // by newline, but we need to immediately reverse the order of the lines
93    // to ensure we parse the trailer in an unambiguous manner; this avoids cases
94    // where a colon in the body of the message is mistaken for a trailer
95    let lines = body.trim_ascii_end().lines().rev();
96    let trailer_re =
97        regex::Regex::new(r"^([a-zA-Z0-9-]+) *: *(.*)$").expect("Trailer regex should be valid");
98    let mut trailers: Vec<Trailer> = Vec::new();
99    let mut multiline_value = vec![];
100    let mut found_blank = false;
101    let mut found_git_trailer = false;
102    let mut non_trailer_line = None;
103    for line in lines {
104        if line.starts_with(' ') {
105            multiline_value.push(line);
106        } else if let Some(groups) = trailer_re.captures(line) {
107            let key = groups[1].to_string();
108            multiline_value.push(groups.get(2).unwrap().as_str());
109            // trim the end of the multiline value
110            // the start is already trimmed with the regex
111            multiline_value[0] = multiline_value[0].trim_ascii_end();
112            let value = multiline_value.iter().rev().join("\n");
113            multiline_value.clear();
114            if key == "Signed-off-by" {
115                found_git_trailer = true;
116            }
117            trailers.push(Trailer { key, value });
118        } else if line.starts_with("(cherry picked from commit ") {
119            found_git_trailer = true;
120            non_trailer_line = Some(line.to_owned());
121            multiline_value.clear();
122        } else if line.trim_ascii().is_empty() {
123            // end of the trailer
124            found_blank = true;
125            break;
126        } else {
127            // a non trailer in the trailer paragraph
128            // the line is ignored, as well as the multiline value that may
129            // have previously been accumulated
130            multiline_value.clear();
131            non_trailer_line = Some(line.to_owned());
132        }
133    }
134    // reverse the insert order, since we parsed the trailer in reverse
135    trailers.reverse();
136    (trailers, found_blank, found_git_trailer, non_trailer_line)
137}
138
139#[cfg(test)]
140mod tests {
141    use indoc::indoc;
142    use pretty_assertions::assert_eq;
143
144    use super::*;
145
146    #[test]
147    fn test_simple_trailers() {
148        let descriptions = indoc! {r#"
149            chore: update itertools to version 0.14.0
150
151            Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed
152            do eiusmod tempor incididunt ut labore et dolore magna aliqua.
153
154            Co-authored-by: Alice <alice@example.com>
155            Co-authored-by: Bob <bob@example.com>
156            Reviewed-by: Charlie <charlie@example.com>
157            Change-Id: I1234567890abcdef1234567890abcdef12345678
158        "#};
159
160        let trailers = parse_description_trailers(descriptions);
161        assert_eq!(trailers.len(), 4);
162
163        assert_eq!(trailers[0].key, "Co-authored-by");
164        assert_eq!(trailers[0].value, "Alice <alice@example.com>");
165
166        assert_eq!(trailers[1].key, "Co-authored-by");
167        assert_eq!(trailers[1].value, "Bob <bob@example.com>");
168
169        assert_eq!(trailers[2].key, "Reviewed-by");
170        assert_eq!(trailers[2].value, "Charlie <charlie@example.com>");
171
172        assert_eq!(trailers[3].key, "Change-Id");
173        assert_eq!(
174            trailers[3].value,
175            "I1234567890abcdef1234567890abcdef12345678"
176        );
177    }
178
179    #[test]
180    fn test_trailers_with_colon_in_body() {
181        let descriptions = indoc! {r#"
182            chore: update itertools to version 0.14.0
183
184            Summary: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
185            tempor incididunt ut labore et dolore magna aliqua.
186
187            Change-Id: I1234567890abcdef1234567890abcdef12345678
188        "#};
189
190        let trailers = parse_description_trailers(descriptions);
191
192        // should only have Change-Id
193        assert_eq!(trailers.len(), 1);
194        assert_eq!(trailers[0].key, "Change-Id");
195    }
196
197    #[test]
198    fn test_multiline_trailer() {
199        let description = indoc! {r#"
200            chore: update itertools to version 0.14.0
201
202            key: This is a very long value, with spaces and
203              newlines in it.
204        "#};
205
206        let trailers = parse_description_trailers(description);
207
208        // should only have Change-Id
209        assert_eq!(trailers.len(), 1);
210        assert_eq!(trailers[0].key, "key");
211        assert_eq!(
212            trailers[0].value,
213            indoc! {r"
214                This is a very long value, with spaces and
215                  newlines in it."}
216        );
217    }
218
219    #[test]
220    fn test_ignore_line_in_trailer() {
221        let description = indoc! {r#"
222            chore: update itertools to version 0.14.0
223
224            Signed-off-by: Random J Developer <random@developer.example.org>
225            [lucky@maintainer.example.org: struct foo moved from foo.c to foo.h]
226            Signed-off-by: Lucky K Maintainer <lucky@maintainer.example.org>
227        "#};
228
229        let trailers = parse_description_trailers(description);
230        assert_eq!(trailers.len(), 2);
231    }
232
233    #[test]
234    fn test_trailers_with_single_line_description() {
235        let description = r#"chore: update itertools to version 0.14.0"#;
236        let trailers = parse_description_trailers(description);
237        assert_eq!(trailers.len(), 0);
238    }
239
240    #[test]
241    fn test_parse_trailers() {
242        let trailers_txt = indoc! {r#"
243            foo: 1
244            bar: 2
245        "#};
246        let res = parse_trailers(trailers_txt);
247        let trailers = res.expect("trailers to be valid");
248        assert_eq!(trailers.len(), 2);
249        assert_eq!(trailers[0].key, "foo");
250        assert_eq!(trailers[0].value, "1");
251        assert_eq!(trailers[1].key, "bar");
252        assert_eq!(trailers[1].value, "2");
253    }
254
255    #[test]
256    fn test_blank_line_in_trailers() {
257        let trailers = indoc! {r#"
258            foo: 1
259
260            foo: 2
261        "#};
262        let res = parse_trailers(trailers);
263        assert!(matches!(res, Err(TrailerParseError::BlankLine)));
264    }
265
266    #[test]
267    fn test_non_trailer_line_in_trailers() {
268        let trailers = indoc! {r#"
269            bar
270            foo: 1
271        "#};
272        let res = parse_trailers(trailers);
273        assert!(matches!(
274            res,
275            Err(TrailerParseError::NonTrailerLine { line: _ })
276        ));
277    }
278
279    #[test]
280    fn test_blank_line_after_trailer() {
281        let description = indoc! {r#"
282            subject
283
284            foo: 1
285
286        "#};
287        let trailers = parse_description_trailers(description);
288        assert_eq!(trailers.len(), 1);
289    }
290
291    #[test]
292    fn test_blank_line_inbetween() {
293        let description = indoc! {r#"
294            subject
295
296            foo: 1
297
298            bar: 2
299        "#};
300        let trailers = parse_description_trailers(description);
301        assert_eq!(trailers.len(), 1);
302    }
303
304    #[test]
305    fn test_no_blank_line() {
306        let description = indoc! {r#"
307            subject: whatever
308            foo: 1
309        "#};
310        let trailers = parse_description_trailers(description);
311        assert_eq!(trailers.len(), 0);
312    }
313
314    #[test]
315    fn test_whitespace_before_key() {
316        let description = indoc! {r#"
317            subject
318
319             foo: 1
320        "#};
321        let trailers = parse_description_trailers(description);
322        assert_eq!(trailers.len(), 0);
323    }
324
325    #[test]
326    fn test_whitespace_after_key() {
327        let description = indoc! {r#"
328            subject
329
330            foo : 1
331        "#};
332        let trailers = parse_description_trailers(description);
333        assert_eq!(trailers.len(), 1);
334        assert_eq!(trailers[0].key, "foo");
335    }
336
337    #[test]
338    fn test_whitespace_around_value() {
339        let description = indoc! {"
340            subject
341
342            foo:  1\x20
343        "};
344        let trailers = parse_description_trailers(description);
345        assert_eq!(trailers.len(), 1);
346        assert_eq!(trailers[0].value, "1");
347    }
348
349    #[test]
350    fn test_whitespace_around_multiline_value() {
351        let description = indoc! {"
352            subject
353
354            foo:  1\x20
355             2\x20
356        "};
357        let trailers = parse_description_trailers(description);
358        assert_eq!(trailers.len(), 1);
359        assert_eq!(trailers[0].value, "1 \n 2");
360    }
361
362    #[test]
363    fn test_whitespace_around_multiliple_trailers() {
364        let description = indoc! {"
365            subject
366
367            foo:  1\x20
368            bar:  2\x20
369        "};
370        let trailers = parse_description_trailers(description);
371        assert_eq!(trailers.len(), 2);
372        assert_eq!(trailers[0].value, "1");
373        assert_eq!(trailers[1].value, "2");
374    }
375
376    #[test]
377    fn test_no_whitespace_before_value() {
378        let description = indoc! {r#"
379            subject
380
381            foo:1
382        "#};
383        let trailers = parse_description_trailers(description);
384        assert_eq!(trailers.len(), 1);
385    }
386
387    #[test]
388    fn test_empty_value() {
389        let description = indoc! {r#"
390            subject
391
392            foo:
393        "#};
394        let trailers = parse_description_trailers(description);
395        assert_eq!(trailers.len(), 1);
396    }
397
398    #[test]
399    fn test_invalid_key() {
400        let description = indoc! {r#"
401            subject
402
403            f_o_o: bar
404        "#};
405        let trailers = parse_description_trailers(description);
406        assert_eq!(trailers.len(), 0);
407    }
408
409    #[test]
410    fn test_content_after_trailer() {
411        let description = indoc! {r#"
412            subject
413
414            foo: bar
415            baz
416        "#};
417        let trailers = parse_description_trailers(description);
418        assert_eq!(trailers.len(), 0);
419    }
420
421    #[test]
422    fn test_invalid_content_after_trailer() {
423        let description = indoc! {r#"
424            subject
425
426            foo: bar
427
428            baz
429        "#};
430        let trailers = parse_description_trailers(description);
431        assert_eq!(trailers.len(), 0);
432    }
433
434    #[test]
435    fn test_empty_description() {
436        let description = "";
437        let trailers = parse_description_trailers(description);
438        assert_eq!(trailers.len(), 0);
439    }
440
441    #[test]
442    fn test_cherry_pick_trailer() {
443        let description = indoc! {r#"
444            subject
445
446            some non-trailer text
447            foo: bar
448            (cherry picked from commit 72bb9f9cf4bbb6bbb11da9cda4499c55c44e87b9)
449        "#};
450        let trailers = parse_description_trailers(description);
451        assert_eq!(trailers.len(), 1);
452        assert_eq!(trailers[0].key, "foo");
453        assert_eq!(trailers[0].value, "bar");
454    }
455}