1use itertools::Itertools as _;
18use thiserror::Error;
19
20#[derive(Debug, PartialEq, Eq, Clone)]
23pub struct Trailer {
24 pub key: String,
26 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
42pub 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 vec![]
66 } else if non_trailer.is_some() && !found_git_trailer {
67 vec![]
71 } else {
72 trailers
73 }
74}
75
76pub 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 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 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 found_blank = true;
125 break;
126 } else {
127 multiline_value.clear();
131 non_trailer_line = Some(line.to_owned());
132 }
133 }
134 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 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 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}