debian_changelog/
changes.rs

1//! Functions to parse the changes from a changelog entry.
2
3use lazy_regex::regex_captures;
4
5// A specific section in a changelog entry, e.g.:
6//
7// ```
8// [ Joe Example]
9// * Foo, bar
10//  + Blah
11// * Foo
12// * Foo
13// ```
14#[derive(Default, Debug, PartialEq, Eq)]
15struct Section<'a> {
16    // Title of the section, if any
17    title: Option<&'a str>,
18
19    // Line numbers of the section
20    linenos: Vec<usize>,
21
22    // List of changes in the section
23    changes: Vec<Vec<(usize, &'a str)>>,
24}
25
26/// Return the different sections from a set of changelog entries.
27///
28/// # Arguments
29/// * `changes`: list of changes from a changelog entry
30///
31/// # Returns
32///
33/// An iterator over tuples with:
34///    (author, list of line numbers, list of list of (lineno, line) tuples
35fn 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            // Skip the first line
46            continue;
47        }
48
49        if line.is_empty() {
50            section.linenos.push(i);
51            saw_empty = true;
52            continue;
53        }
54
55        // Check for author section header
56        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            // Starting a new bullet point
76            // If we saw an empty line and we're in a titled section, start a new anonymous section
77            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
106/// Iterate over changes by author
107///
108/// # Arguments
109/// * `changes`: list of changes from a changelog entry
110///
111/// # Returns
112/// An iterator over tuples with:
113///   (author, list of line numbers, list of lines)
114pub 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
184/// Strip a changelog message like debcommit does.
185///
186/// Takes a list of changes from a changelog entry and applies a transformation
187/// so the message is well formatted for a commit message.
188///
189/// # Arguments:
190/// * `changes` - a list of lines from the changelog entry
191///
192/// # Returns
193/// Another list of lines with blank lines stripped from the start and the
194/// spaces the start of the lines split if there is only one logical entry.
195pub 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    // Drop bullet points
229    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
303/// Format a section title.
304pub 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
316/// Extract the author name from an author section header.
317///
318/// Returns `Some(author)` if the line is an author section header,
319/// or `None` if it's not.
320///
321/// # Example
322///
323/// ```
324/// assert_eq!(debian_changelog::changes::extract_author_name("[ Alice ]"), Some("Alice"));
325/// assert_eq!(debian_changelog::changes::extract_author_name("  [ Bob Smith ]  "), Some("Bob Smith"));
326/// assert_eq!(debian_changelog::changes::extract_author_name("* Change line"), None);
327/// ```
328pub 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
348/// Add a change to the list of changes, attributed to a specific author.
349///
350/// This will add a new section for the author if there are no sections yet.
351///
352/// Returns an error if text rewrapping fails.
353///
354/// # Example
355///
356/// ```
357/// let mut changes = vec![];
358/// debian_changelog::changes::try_add_change_for_author(&mut changes, "Author 1", vec!["* Change 1"], None);
359/// assert_eq!(changes, vec!["* Change 1"]);
360/// ```
361pub 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    // There are no per author sections yet, so attribute current changes to changelog entry author
370    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        // There is a last section, so add a new section only if it is not for the same author
384        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/// Add a change to the list of changes, attributed to a specific author.
399///
400/// This will add a new section for the author if there are no sections yet.
401///
402/// # Deprecated
403///
404/// This function panics on errors. Use [`try_add_change_for_author`] instead for proper error handling.
405///
406/// # Panics
407///
408/// Panics if text rewrapping fails.
409///
410/// # Example
411///
412/// ```
413/// let mut changes = vec![];
414/// debian_changelog::changes::add_change_for_author(&mut changes, "Author 1", vec!["* Change 1"], None);
415/// assert_eq!(changes, vec!["* Change 1"]);
416/// ```
417#[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
464/// Find additional authors from a changelog entry
465pub 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
496/// Find authors that are thanked in a changelog entry
497pub 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
559/// Check if all lines in a changelog entry are prefixed with a sha.
560///
561/// This is generally done by gbp-dch(1).
562pub 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}