1use lazy_regex::regex_captures;
4
5#[derive(Default, Debug, PartialEq, Eq)]
15struct Section<'a> {
16 title: Option<&'a str>,
18
19 linenos: Vec<usize>,
21
22 changes: Vec<Vec<(usize, &'a str)>>,
24}
25
26fn changes_sections<'a>(
36 changes: impl Iterator<Item = &'a str>,
37) -> impl Iterator<Item = Section<'a>> {
38 let mut ret: Vec<Section<'a>> = vec![];
39 let mut section = Section::<'a>::default();
40 let mut change = Vec::<(usize, &'a str)>::new();
41 let mut saw_empty = false;
42
43 for (i, line) in changes.enumerate() {
44 if line.is_empty() && i == 0 {
45 continue;
47 }
48
49 if line.is_empty() {
50 section.linenos.push(i);
51 saw_empty = true;
52 continue;
53 }
54
55 if let Some(author) = extract_author_name(line) {
57 if !change.is_empty() {
58 section.changes.push(change);
59 change = Vec::new();
60 }
61 if !section.changes.is_empty() {
62 ret.push(section);
63 }
64 section = Section {
65 title: Some(author),
66 linenos: vec![i],
67 changes: vec![],
68 };
69 saw_empty = false;
70 } else if !line.starts_with("* ") {
71 change.push((i, line));
72 section.linenos.push(i);
73 saw_empty = false;
74 } else {
75 if saw_empty && section.title.is_some() && !change.is_empty() {
78 section.changes.push(change);
79 change = Vec::new();
80 ret.push(section);
81 section = Section {
82 title: None,
83 linenos: vec![],
84 changes: vec![],
85 };
86 }
87
88 if !change.is_empty() {
89 section.changes.push(change);
90 }
91 change = vec![(i, line)];
92 section.linenos.push(i);
93 saw_empty = false;
94 }
95 }
96 if !change.is_empty() {
97 section.changes.push(change);
98 }
99 if !section.changes.is_empty() {
100 ret.push(section);
101 }
102
103 ret.into_iter()
104}
105
106pub fn changes_by_author<'a>(
115 changes: impl Iterator<Item = &'a str>,
116) -> impl Iterator<Item = (Option<&'a str>, Vec<usize>, Vec<&'a str>)> {
117 changes_sections(changes).map(|section| {
118 let mut all_linenos = Vec::new();
119 let mut all_lines = Vec::new();
120
121 for change_entry in section.changes {
122 for (lineno, line) in change_entry {
123 all_linenos.push(lineno);
124 all_lines.push(line);
125 }
126 }
127
128 (section.title, all_linenos, all_lines)
129 })
130}
131
132#[cfg(test)]
133mod changes_sections_tests {
134 #[test]
135 fn test_simple() {
136 let iter =
137 super::changes_sections(vec!["", "* Change 1", "* Change 2", " rest", ""].into_iter());
138 assert_eq!(
139 vec![super::Section {
140 title: None,
141 linenos: vec![1, 2, 3, 4],
142 changes: vec![
143 (vec![(1, "* Change 1")]),
144 (vec![(2, "* Change 2"), (3, " rest")])
145 ]
146 }],
147 iter.collect::<Vec<_>>()
148 );
149 }
150
151 #[test]
152 fn test_with_header() {
153 assert_eq!(
154 vec![
155 super::Section {
156 title: Some("Author 1"),
157 linenos: vec![1, 2, 3],
158 changes: vec![(vec![(2, "* Change 1")])]
159 },
160 super::Section {
161 title: Some("Author 2"),
162 linenos: vec![4, 5, 6, 7],
163 changes: vec![(vec![(5, "* Change 2"), (6, " rest")])]
164 },
165 ],
166 super::changes_sections(
167 vec![
168 "",
169 "[ Author 1 ]",
170 "* Change 1",
171 "",
172 "[ Author 2 ]",
173 "* Change 2",
174 " rest",
175 "",
176 ]
177 .into_iter()
178 )
179 .collect::<Vec<_>>()
180 );
181 }
182}
183
184pub fn strip_for_commit_message(mut changes: Vec<&str>) -> Vec<&str> {
196 if changes.is_empty() {
197 return vec![];
198 }
199 while let Some(last) = changes.last() {
200 if last.trim().is_empty() {
201 changes.pop();
202 } else {
203 break;
204 }
205 }
206
207 while let Some(first) = changes.first() {
208 if first.trim().is_empty() {
209 changes.remove(0);
210 } else {
211 break;
212 }
213 }
214
215 let changes = changes
216 .into_iter()
217 .map(|mut line| loop {
218 if line.starts_with(" ") {
219 line = &line[2..];
220 } else if line.starts_with('\t') {
221 line = &line[1..];
222 } else {
223 break line;
224 }
225 })
226 .collect::<Vec<_>>();
227
228 let bullet_points_dropped = changes
230 .iter()
231 .map(|line| {
232 let line = line.trim_start();
233 if line.starts_with("* ") || line.starts_with("+ ") || line.starts_with("- ") {
234 line[1..].trim_start()
235 } else {
236 line
237 }
238 })
239 .collect::<Vec<_>>();
240 if bullet_points_dropped.len() == 1 {
241 bullet_points_dropped
242 } else {
243 changes
244 }
245}
246
247#[cfg(test)]
248mod strip_for_commit_message_tests {
249 #[test]
250 fn test_no_changes() {
251 assert_eq!(super::strip_for_commit_message(vec![]), Vec::<&str>::new());
252 }
253
254 #[test]
255 fn test_empty_changes() {
256 assert_eq!(
257 super::strip_for_commit_message(vec![""]),
258 Vec::<&str>::new()
259 );
260 }
261
262 #[test]
263 fn test_removes_leading_whitespace() {
264 assert_eq!(
265 super::strip_for_commit_message(vec!["foo", "bar", "\tbaz", " bang"]),
266 vec!["foo", "bar", "baz", " bang"]
267 );
268 }
269
270 #[test]
271 fn test_removes_star_if_one() {
272 assert_eq!(super::strip_for_commit_message(vec!["* foo"]), vec!["foo"]);
273 assert_eq!(
274 super::strip_for_commit_message(vec!["\t* foo"]),
275 vec!["foo"]
276 );
277 assert_eq!(super::strip_for_commit_message(vec!["+ foo"]), vec!["foo"]);
278 assert_eq!(super::strip_for_commit_message(vec!["- foo"]), vec!["foo"]);
279 assert_eq!(super::strip_for_commit_message(vec!["* foo"]), vec!["foo"]);
280 assert_eq!(
281 super::strip_for_commit_message(vec!["* foo", " bar"]),
282 vec!["* foo", " bar"]
283 );
284 }
285
286 #[test]
287 fn test_leaves_start_if_multiple() {
288 assert_eq!(
289 super::strip_for_commit_message(vec!["* foo", "* bar"]),
290 vec!["* foo", "* bar"]
291 );
292 assert_eq!(
293 super::strip_for_commit_message(vec!["* foo", "+ bar"]),
294 vec!["* foo", "+ bar"]
295 );
296 assert_eq!(
297 super::strip_for_commit_message(vec!["* foo", "bar", "* baz"]),
298 vec!["* foo", "bar", "* baz"]
299 );
300 }
301}
302
303pub fn format_section_title(title: &str) -> String {
305 format!("[ {} ]", title)
306}
307
308#[cfg(test)]
309mod format_section_title_tests {
310 #[test]
311 fn test() {
312 assert_eq!(super::format_section_title("foo"), "[ foo ]");
313 }
314}
315
316pub fn extract_author_name(line: &str) -> Option<&str> {
329 regex_captures!(r"^\s*\[\s*(.*?)\s*\]\s*$", line).map(|(_, author)| author)
330}
331
332#[cfg(test)]
333mod extract_author_name_tests {
334 #[test]
335 fn test() {
336 assert_eq!(super::extract_author_name("[ Alice ]"), Some("Alice"));
337 assert_eq!(super::extract_author_name(" [ Bob ] "), Some("Bob"));
338 assert_eq!(
339 super::extract_author_name("[ Multi Word Name ]"),
340 Some("Multi Word Name")
341 );
342 assert_eq!(super::extract_author_name("* Change line"), None);
343 assert_eq!(super::extract_author_name("Regular text"), None);
344 assert_eq!(super::extract_author_name(""), None);
345 }
346}
347
348pub fn try_add_change_for_author(
362 changes: &mut Vec<String>,
363 author_name: &str,
364 change: Vec<&str>,
365 default_author: Option<(String, String)>,
366) -> Result<(), crate::textwrap::Error> {
367 let by_author = changes_by_author(changes.iter().map(|s| s.as_str())).collect::<Vec<_>>();
368
369 if by_author.iter().all(|(a, _, _)| a.is_none()) {
371 if let Some((default_name, _default_email)) = default_author {
372 if author_name != default_name.as_str() {
373 if !changes.is_empty() {
374 changes.insert(0, format_section_title(default_name.as_str()));
375 if !changes.last().unwrap().is_empty() {
376 changes.push("".to_string());
377 }
378 }
379 changes.push(format_section_title(author_name));
380 }
381 }
382 } else if let Some(last_section) = by_author.last().as_ref() {
383 if last_section.0 != Some(author_name) {
385 changes.push("".to_string());
386 changes.push(format_section_title(author_name));
387 }
388 }
389
390 changes.extend(
391 crate::textwrap::try_rewrap_changes(change.into_iter())?
392 .iter()
393 .map(|s| s.to_string()),
394 );
395 Ok(())
396}
397
398#[deprecated(
418 since = "0.2.10",
419 note = "Use try_add_change_for_author for proper error handling"
420)]
421pub fn add_change_for_author(
422 changes: &mut Vec<String>,
423 author_name: &str,
424 change: Vec<&str>,
425 default_author: Option<(String, String)>,
426) {
427 try_add_change_for_author(changes, author_name, change, default_author).unwrap()
428}
429
430#[cfg(test)]
431mod add_change_for_author_tests {
432 use super::*;
433
434 #[test]
435 fn test_matches_default() {
436 let mut changes = vec![];
437 try_add_change_for_author(
438 &mut changes,
439 "Author 1",
440 vec!["* Change 1"],
441 Some(("Author 1".to_string(), "jelmer@debian.org".to_string())),
442 )
443 .unwrap();
444 assert_eq!(changes, vec!["* Change 1"]);
445 }
446
447 #[test]
448 fn test_not_matches_default() {
449 let mut changes = vec![];
450 try_add_change_for_author(
451 &mut changes,
452 "Author 1",
453 vec!["* Change 1"],
454 Some((
455 "Default Author".to_string(),
456 "jelmer@debian.org".to_string(),
457 )),
458 )
459 .unwrap();
460 assert_eq!(changes, vec!["[ Author 1 ]", "* Change 1"]);
461 }
462}
463
464pub fn find_extra_authors<'a>(changes: &'a [&'a str]) -> std::collections::HashSet<&'a str> {
466 changes_by_author(changes.iter().copied())
467 .filter_map(|(author, _, _)| author)
468 .collect::<std::collections::HashSet<_>>()
469}
470
471#[test]
472fn test_find_extra_authors() {
473 assert_eq!(
474 find_extra_authors(&["[ Author 1 ]", "* Change 1"]),
475 maplit::hashset! {"Author 1"}
476 );
477 assert_eq!(
478 find_extra_authors(&["[ Author 1 ]", "[ Author 2 ]", "* Change 1"]),
479 maplit::hashset! {"Author 2"}
480 );
481 assert_eq!(
482 find_extra_authors(&["[ Author 1 ]", "[ Author 2 ]", "* Change 1", "* Change 2"]),
483 maplit::hashset! {"Author 2"}
484 );
485 assert_eq!(
486 find_extra_authors(&["[ Author 1 ]", "* Change 1", "[ Author 2 ]", "* Change 2"]),
487 maplit::hashset! {"Author 1", "Author 2"}
488 );
489
490 assert_eq!(
491 find_extra_authors(&["* Change 1", "* Change 2",]),
492 maplit::hashset! {}
493 );
494}
495
496pub fn find_thanks<'a>(changes: &'a [&'a str]) -> std::collections::HashSet<&'a str> {
498 let regex = lazy_regex::regex!(
499 r"[tT]hank(?:(?:s)|(?:you))(?:\s*to)?((?:\s+(?:(?:\w\.)|(?:\w+(?:-\w+)*)))+(?:\s+<[^@>]+@[^@>]+>)?)"
500 );
501 changes_by_author(changes.iter().copied())
502 .flat_map(|(_, _, lines)| {
503 lines.into_iter().map(|line| {
504 regex
505 .captures_iter(line)
506 .map(|m| m.get(1).unwrap().as_str().trim())
507 })
508 })
509 .flatten()
510 .collect::<std::collections::HashSet<_>>()
511}
512
513#[test]
514fn test_find_thanks() {
515 assert_eq!(find_thanks(&[]), maplit::hashset! {});
516 assert_eq!(find_thanks(&["* Do foo", "* Do bar"]), maplit::hashset! {});
517 assert_eq!(
518 find_thanks(&["* Thanks to A. Hacker"]),
519 maplit::hashset! {"A. Hacker"}
520 );
521 assert_eq!(
522 find_thanks(&["* Thanks to James A. Hacker"]),
523 maplit::hashset! {"James A. Hacker"}
524 );
525 assert_eq!(
526 find_thanks(&["* Thankyou to B. Hacker"]),
527 maplit::hashset! {"B. Hacker"}
528 );
529 assert_eq!(
530 find_thanks(&["* thanks to A. Hacker"]),
531 maplit::hashset! {"A. Hacker"}
532 );
533 assert_eq!(
534 find_thanks(&["* thankyou to B. Hacker"]),
535 maplit::hashset! {"B. Hacker"}
536 );
537 assert_eq!(
538 find_thanks(&["* Thanks A. Hacker"]),
539 maplit::hashset! {"A. Hacker"}
540 );
541 assert_eq!(
542 find_thanks(&["* Thankyou B. Hacker"]),
543 maplit::hashset! {"B. Hacker"}
544 );
545 assert_eq!(
546 find_thanks(&["* Thanks to Mark A. Super-Hacker"]),
547 maplit::hashset! {"Mark A. Super-Hacker"}
548 );
549 assert_eq!(
550 find_thanks(&["* Thanks to A. Hacker <ahacker@example.com>"]),
551 maplit::hashset! {"A. Hacker <ahacker@example.com>"}
552 );
553 assert_eq!(
554 find_thanks(&["* Thanks to Adeodato Simó"]),
555 maplit::hashset! {"Adeodato Simó"}
556 );
557}
558
559pub fn all_sha_prefixed(changes: &[&str]) -> bool {
563 changes_sections(changes.iter().cloned())
564 .flat_map(|section| {
565 section
566 .changes
567 .into_iter()
568 .flat_map(|ls| ls.into_iter().map(|(_, l)| l))
569 })
570 .all(|line| lazy_regex::regex_is_match!(r"^\* \[[0-9a-f]{7}\] ", line))
571}
572
573#[test]
574fn test_all_sha_prefixed() {
575 assert!(all_sha_prefixed(&[
576 "* [a1b2c3d] foo",
577 "* [a1b2c3d] bar",
578 "* [a1b2c3d] baz",
579 ]));
580 assert!(!all_sha_prefixed(&[
581 "* [a1b2c3d] foo",
582 "* bar",
583 "* [a1b2c3d] baz",
584 ]));
585}