mit_commit/
commit_message.rs

1use std::{
2    borrow::Cow,
3    convert::TryFrom,
4    fs::File,
5    io,
6    io::Read,
7    path::{Path, PathBuf},
8};
9
10use miette::Diagnostic;
11use regex::Regex;
12use thiserror::Error;
13
14use super::{
15    bodies::Bodies, body::Body, comment::Comment, comments::Comments, fragment::Fragment,
16    subject::Subject, trailers::Trailers,
17};
18use crate::{Trailer, scissors::Scissors};
19
20/// A [`Self`], the primary entry point to the library
21#[derive(Debug, PartialEq, Eq, Clone, Default)]
22pub struct CommitMessage<'a> {
23    scissors: Option<Scissors<'a>>,
24    ast: Vec<Fragment<'a>>,
25    subject: Subject<'a>,
26    trailers: Trailers<'a>,
27    comments: Comments<'a>,
28    bodies: Bodies<'a>,
29}
30
31impl<'a> CommitMessage<'a> {
32    /// Convert from [`Fragment`] back into a full [`CommitMessage`]
33    ///
34    /// Get back to a [`CommitMessage`] from an ast, usually after you've been
35    /// editing the text.
36    ///
37    /// # Arguments
38    ///
39    /// * `fragments` - Vector of Fragment objects representing the abstract syntax tree
40    /// * `scissors` - Optional Scissors section to include in the commit message
41    ///
42    /// # Returns
43    ///
44    /// A new `CommitMessage` instance constructed from the provided fragments and scissors
45    ///
46    /// # Examples
47    ///
48    /// ```
49    /// use indoc::indoc;
50    /// use mit_commit::{Bodies, CommitMessage, Subject};
51    ///
52    /// let message = CommitMessage::from(indoc!(
53    ///     "
54    ///     Update bashrc to include kubernetes completions
55    ///
56    ///     This should make it easier to deploy things for the developers.
57    ///     Benchmarked with Hyperfine, no noticable performance decrease.
58    ///
59    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
60    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
61    ///     ; bricht den Commit ab.
62    ///     ;
63    ///     ; Datum:            Sat Jun 27 21:40:14 2020 +0200
64    ///     ;
65    ///     ; Auf Branch master
66    ///     ;
67    ///     ; Initialer Commit
68    ///     ;
69    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
70    ///     ;    neue Datei:     .bashrc
71    ///     ;"
72    /// ));
73    /// assert_eq!(
74    ///     CommitMessage::from_fragments(message.get_ast(), message.get_scissors()),
75    ///     message,
76    /// )
77    /// ```
78    #[must_use]
79    pub fn from_fragments(fragments: Vec<Fragment<'_>>, scissors: Option<Scissors<'_>>) -> Self {
80        let body = fragments
81            .into_iter()
82            .map(|x| match x {
83                Fragment::Body(contents) => String::from(contents),
84                Fragment::Comment(contents) => String::from(contents),
85            })
86            .collect::<Vec<String>>()
87            .join("\n");
88
89        let scissors: String = scissors
90            .map(|contents| format!("\n{}", String::from(contents)))
91            .unwrap_or_default();
92
93        Self::from(format!("{body}{scissors}"))
94    }
95
96    /// A helper method to let you insert [`Trailer`]
97    ///
98    /// # Arguments
99    ///
100    /// * `trailer` - The trailer to add to the commit message
101    ///
102    /// # Returns
103    ///
104    /// A new `CommitMessage` with the trailer added in the appropriate location
105    ///
106    /// # Examples
107    ///
108    /// ```
109    /// use indoc::indoc;
110    /// use mit_commit::{CommitMessage, Trailer};
111    /// let commit = CommitMessage::from(indoc!(
112    ///     "
113    ///     Example Commit Message
114    ///
115    ///     This is an example commit message for linting
116    ///
117    ///     Relates-to: #153
118    ///
119    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
120    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
121    ///     ; bricht den Commit ab.
122    ///     ;
123    ///     ; Auf Branch main
124    ///     ; Ihr Branch ist auf demselben Stand wie 'origin/main'.
125    ///     ;
126    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
127    ///     ;    neue Datei:     file
128    ///     ;
129    ///     "
130    /// ));
131    ///
132    /// assert_eq!(
133    ///     String::from(commit.add_trailer(Trailer::new(
134    ///         "Co-authored-by".into(),
135    ///         "Test Trailer <test@example.com>".into()
136    ///     ))),
137    ///     String::from(CommitMessage::from(indoc!(
138    ///         "
139    ///         Example Commit Message
140    ///
141    ///         This is an example commit message for linting
142    ///
143    ///         Relates-to: #153
144    ///         Co-authored-by: Test Trailer <test@example.com>
145    ///
146    ///         ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
147    ///         ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
148    ///         ; bricht den Commit ab.
149    ///         ;
150    ///         ; Auf Branch main
151    ///         ; Ihr Branch ist auf demselben Stand wie 'origin/main'.
152    ///         ;
153    ///         ; Zum Commit vorgemerkte \u{00E4}nderungen:
154    ///         ;    neue Datei:     file
155    ///         ;
156    ///         "
157    ///     )))
158    /// );
159    /// ```
160    #[must_use]
161    pub fn add_trailer(&self, trailer: Trailer<'_>) -> Self {
162        // Preallocate with capacity to avoid reallocations
163        let mut fragments = Vec::with_capacity(2);
164
165        // Only add an empty body if we have no bodies or all bodies are empty,
166        // and we have no trailers
167        let needs_empty_body = self.bodies.iter().all(Body::is_empty) && self.trailers.is_empty();
168
169        if needs_empty_body {
170            fragments.push(Body::default().into());
171            fragments.push(Body::default().into());
172        } else if self.trailers.is_empty() {
173            // Only add a separator if we have non-empty bodies but no trailers
174            fragments.push(Body::default().into());
175        }
176
177        fragments.push(trailer.into());
178
179        self.insert_after_last_full_body(fragments)
180    }
181
182    /// Insert text in the place you're most likely to want it
183    ///
184    /// In the case you don't have any full [`Body`] in there, it inserts it at
185    /// the top of the commit, in the [`Subject`] line.
186    ///
187    /// # Arguments
188    ///
189    /// * `fragment` - Vector of Fragment objects to insert after the last non-empty body
190    ///
191    /// # Returns
192    ///
193    /// A new `CommitMessage` with the fragments inserted after the last non-empty body
194    ///
195    /// # Examples
196    ///
197    /// ```
198    /// use mit_commit::{Fragment, Body, CommitMessage, Comment};
199    ///
200    ///         let ast: Vec<Fragment> = vec![
201    ///             Fragment::Body(Body::from("Add file")),
202    ///             Fragment::Body(Body::default()),
203    ///             Fragment::Body(Body::from("Looks-like-a-trailer: But isn\'t")),
204    ///             Fragment::Body(Body::default()),
205    ///             Fragment::Body(Body::from("This adds file primarily for demonstration purposes. It might not be\nuseful as an actual commit, but it\'s very useful as a example to use in\ntests.")),
206    ///             Fragment::Body(Body::default()),
207    ///             Fragment::Body(Body::from("Relates-to: #128")),
208    ///             Fragment::Body(Body::default()),
209    ///             Fragment::Comment(Comment::from("# Short (50 chars or less) summary of changes\n#\n# More detailed explanatory text, if necessary.  Wrap it to\n# about 72 characters or so.  In some contexts, the first\n# line is treated as the subject of an email and the rest of\n# the text as the body.  The blank line separating the\n# summary from the body is critical (unless you omit the body\n# entirely); tools like rebase can get confused if you run\n# the two together.\n#\n# Further paragraphs come after blank lines.\n#\n#   - Bullet points are okay, too\n#\n#   - Typically a hyphen or asterisk is used for the bullet,\n#     preceded by a single space, with blank lines in\n#     between, but conventions vary here")),
210    ///             Fragment::Body(Body::default()),
211    ///             Fragment::Comment(Comment::from("# Bitte geben Sie eine Commit-Beschreibung f\u{fc}r Ihre \u{e4}nderungen ein. Zeilen,\n# die mit \'#\' beginnen, werden ignoriert, und eine leere Beschreibung\n# bricht den Commit ab.\n#\n# Auf Branch main\n# Ihr Branch ist auf demselben Stand wie \'origin/main\'.\n#\n# Zum Commit vorgemerkte \u{e4}nderungen:\n#\tneue Datei:     file\n#"))
212    ///         ];
213    ///         let commit = CommitMessage::from_fragments(ast, None);
214    ///
215    ///         assert_eq!(commit.insert_after_last_full_body(vec![Fragment::Body(Body::from("Relates-to: #656"))]).get_ast(), vec![
216    ///             Fragment::Body(Body::from("Add file")),
217    ///             Fragment::Body(Body::default()),
218    ///             Fragment::Body(Body::from("Looks-like-a-trailer: But isn\'t")),
219    ///             Fragment::Body(Body::default()),
220    ///             Fragment::Body(Body::from("This adds file primarily for demonstration purposes. It might not be\nuseful as an actual commit, but it\'s very useful as a example to use in\ntests.")),
221    ///             Fragment::Body(Body::default()),
222    ///             Fragment::Body(Body::from("Relates-to: #128\nRelates-to: #656")),
223    ///             Fragment::Body(Body::default()),
224    ///             Fragment::Comment(Comment::from("# Short (50 chars or less) summary of changes\n#\n# More detailed explanatory text, if necessary.  Wrap it to\n# about 72 characters or so.  In some contexts, the first\n# line is treated as the subject of an email and the rest of\n# the text as the body.  The blank line separating the\n# summary from the body is critical (unless you omit the body\n# entirely); tools like rebase can get confused if you run\n# the two together.\n#\n# Further paragraphs come after blank lines.\n#\n#   - Bullet points are okay, too\n#\n#   - Typically a hyphen or asterisk is used for the bullet,\n#     preceded by a single space, with blank lines in\n#     between, but conventions vary here")),
225    ///             Fragment::Body(Body::default()),
226    ///             Fragment::Comment(Comment::from("# Bitte geben Sie eine Commit-Beschreibung f\u{fc}r Ihre \u{e4}nderungen ein. Zeilen,\n# die mit \'#\' beginnen, werden ignoriert, und eine leere Beschreibung\n# bricht den Commit ab.\n#\n# Auf Branch main\n# Ihr Branch ist auf demselben Stand wie \'origin/main\'.\n#\n# Zum Commit vorgemerkte \u{e4}nderungen:\n#\tneue Datei:     file\n#"))
227    ///         ])
228    /// ```
229    #[must_use]
230    pub fn insert_after_last_full_body(&self, fragment: Vec<Fragment<'_>>) -> Self {
231        let position = self.ast.iter().rposition(|fragment| match fragment {
232            Fragment::Body(body) => !body.is_empty(),
233            Fragment::Comment(_) => false,
234        });
235
236        // Preallocate with capacity to avoid reallocations
237        let mut new_ast = Vec::with_capacity(self.ast.len() + fragment.len());
238
239        if let Some(position) = position {
240            // Copy elements up to and including the position
241            new_ast.extend_from_slice(&self.ast[..=position]);
242            // Add the new fragments
243            new_ast.extend(fragment);
244            // Add the remaining elements
245            if position + 1 < self.ast.len() {
246                new_ast.extend_from_slice(&self.ast[position + 1..]);
247            }
248        } else {
249            // If no non-empty body found, add fragments at the beginning
250            new_ast.extend(fragment);
251            new_ast.extend_from_slice(&self.ast);
252        }
253
254        Self::from_fragments(new_ast, self.get_scissors())
255    }
256
257    fn convert_to_per_line_ast(comment_character: Option<char>, rest: &str) -> Vec<Fragment<'a>> {
258        rest.lines()
259            .map(|line| {
260                comment_character.map_or_else(
261                    || Body::from(line.to_string()).into(),
262                    |comment_character| {
263                        if line.starts_with(comment_character) {
264                            Comment::from(line.to_string()).into()
265                        } else {
266                            Body::from(line.to_string()).into()
267                        }
268                    },
269                )
270            })
271            .collect()
272    }
273
274    /// Group consecutive fragments of the same type
275    fn group_ast(ungrouped_ast: Vec<Fragment<'a>>) -> Vec<Fragment<'a>> {
276        // Using a more functional approach with fold
277        ungrouped_ast
278            .into_iter()
279            .fold(Vec::new(), |mut acc, fragment| {
280                match (acc.last_mut(), fragment) {
281                    // Consecutive Comment fragments
282                    (Some(Fragment::Comment(existing)), Fragment::Comment(new)) => {
283                        *existing = existing.append(&new);
284                    }
285
286                    // Consecutive Body fragments
287                    (Some(Fragment::Body(existing)), Fragment::Body(new)) => {
288                        if new.is_empty() || existing.is_empty() {
289                            acc.push(Fragment::from(new));
290                        } else {
291                            *existing = existing.append(&new);
292                        }
293                    }
294
295                    // First fragment or different fragment types
296                    (_, fragment) => {
297                        acc.push(fragment);
298                    }
299                }
300
301                acc
302            })
303    }
304
305    /// Get the [`Subject`] line from the [`CommitMessage`]
306    ///
307    /// It's possible to get this from the ast, but it's a bit of a faff, so
308    /// this is a convenience method
309    ///
310    /// # Returns
311    ///
312    /// The Subject of the commit message
313    ///
314    /// # Examples
315    ///
316    /// ```
317    /// use indoc::indoc;
318    /// use mit_commit::{Bodies, CommitMessage, Subject};
319    ///
320    /// let message = CommitMessage::from(indoc!(
321    ///     "
322    ///     Update bashrc to include kubernetes completions
323    ///
324    ///     This should make it easier to deploy things for the developers.
325    ///     Benchmarked with Hyperfine, no noticable performance decrease.
326    ///
327    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
328    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
329    ///     ; bricht den Commit ab.
330    ///     ;
331    ///     ; Datum:            Sat Jun 27 21:40:14 2020 +0200
332    ///     ;
333    ///     ; Auf Branch master
334    ///     ;
335    ///     ; Initialer Commit
336    ///     ;
337    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
338    ///     ;    neue Datei:     .bashrc
339    ///     ;"
340    /// ));
341    /// assert_eq!(
342    ///     message.get_subject(),
343    ///     Subject::from("Update bashrc to include kubernetes completions")
344    /// )
345    /// ```
346    #[must_use]
347    pub fn get_subject(&self) -> Subject<'a> {
348        self.subject.clone()
349    }
350
351    /// Get the underlying data structure that represents the [`CommitMessage`]
352    ///
353    /// This is the underlying datastructure for the [`CommitMessage`]. You
354    /// might want this to create a complicated linter, or modify the
355    /// [`CommitMessage`] to your liking.
356    ///
357    /// Notice how it doesn't include the [`Scissors`] section.
358    ///
359    /// # Returns
360    ///
361    /// A vector of Fragment objects representing the abstract syntax tree of the commit message
362    ///
363    /// # Examples
364    ///
365    /// ```
366    /// use indoc::indoc;
367    /// use mit_commit::{Body, CommitMessage, Fragment, Trailer, Trailers, Comment};
368    ///
369    /// let message = CommitMessage::from(indoc!(
370    ///     "
371    ///     Add file
372    ///
373    ///     Looks-like-a-trailer: But isn't
374    ///
375    ///     This adds file primarily for demonstration purposes. It might not be
376    ///     useful as an actual commit, but it's very useful as a example to use in
377    ///     tests.
378    ///
379    ///     Relates-to: #128
380    ///     Relates-to: #129
381    ///
382    ///     ; Short (50 chars or less) summary of changes
383    ///     ;
384    ///     ; More detailed explanatory text, if necessary.  Wrap it to
385    ///     ; about 72 characters or so.  In some contexts, the first
386    ///     ; line is treated as the subject of an email and the rest of
387    ///     ; the text as the body.  The blank line separating the
388    ///     ; summary from the body is critical (unless you omit the body
389    ///     ; entirely); tools like rebase can get confused if you run
390    ///     ; the two together.
391    ///     ;
392    ///     ; Further paragraphs come after blank lines.
393    ///     ;
394    ///     ;   - Bullet points are okay, too
395    ///     ;
396    ///     ;   - Typically a hyphen or asterisk is used for the bullet,
397    ///     ;     preceded by a single space, with blank lines in
398    ///     ;     between, but conventions vary here
399    ///
400    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
401    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
402    ///     ; bricht den Commit ab.
403    ///     ;
404    ///     ; Auf Branch main
405    ///     ; Ihr Branch ist auf demselben Stand wie 'origin/main'.
406    ///     ;
407    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
408    ///     ;   neue Datei:     file
409    ///     ;
410    ///     ; ------------------------ >8 ------------------------
411    ///     ; \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
412    ///     ; Alles unterhalb von ihr wird ignoriert.
413    ///     diff --git a/file b/file
414    ///     new file mode 100644
415    ///     index 0000000..e69de29
416    ///     "
417    /// ));
418    /// let ast = vec![
419    ///     Fragment::Body(Body::from("Add file")),
420    ///     Fragment::Body(Body::default()),
421    ///     Fragment::Body(Body::from("Looks-like-a-trailer: But isn't")),
422    ///     Fragment::Body(Body::default()),
423    ///     Fragment::Body(Body::from("This adds file primarily for demonstration purposes. It might not be\nuseful as an actual commit, but it\'s very useful as a example to use in\ntests.")),
424    ///     Fragment::Body(Body::default()),
425    ///     Fragment::Body(Body::from("Relates-to: #128\nRelates-to: #129")),
426    ///     Fragment::Body(Body::default()),
427    ///     Fragment::Comment(Comment::from("; Short (50 chars or less) summary of changes\n;\n; More detailed explanatory text, if necessary.  Wrap it to\n; about 72 characters or so.  In some contexts, the first\n; line is treated as the subject of an email and the rest of\n; the text as the body.  The blank line separating the\n; summary from the body is critical (unless you omit the body\n; entirely); tools like rebase can get confused if you run\n; the two together.\n;\n; Further paragraphs come after blank lines.\n;\n;   - Bullet points are okay, too\n;\n;   - Typically a hyphen or asterisk is used for the bullet,\n;     preceded by a single space, with blank lines in\n;     between, but conventions vary here")),
428    ///     Fragment::Body(Body::default()),
429    ///     Fragment::Comment(Comment::from("; Bitte geben Sie eine Commit-Beschreibung für Ihre änderungen ein. Zeilen,\n; die mit \';\' beginnen, werden ignoriert, und eine leere Beschreibung\n; bricht den Commit ab.\n;\n; Auf Branch main\n; Ihr Branch ist auf demselben Stand wie \'origin/main\'.\n;\n; Zum Commit vorgemerkte änderungen:\n;   neue Datei:     file\n;"))
430    /// ];
431    /// assert_eq!(message.get_ast(), ast)
432    /// ```
433    #[must_use]
434    pub fn get_ast(&self) -> Vec<Fragment<'_>> {
435        self.ast.clone()
436    }
437
438    /// Get the `Bodies` from the [`CommitMessage`]
439    ///
440    /// This gets the [`Bodies`] from the [`CommitMessage`] in easy to use
441    /// paragraphs, we add in blank bodies because starting a new paragraph
442    /// is a visual delimiter so we want to make that easy to detect.
443    ///
444    /// It doesn't include the [`Subject`] line, but if there's a blank line
445    /// after it (as is recommended by the manual), the [`Bodies`] will
446    /// start with a new empty [`Body`].
447    ///
448    /// # Returns
449    ///
450    /// The Bodies of the commit message, containing all body paragraphs
451    ///
452    /// # Examples
453    ///
454    /// ```
455    /// use indoc::indoc;
456    /// use mit_commit::{Bodies, Body, CommitMessage, Subject};
457    ///
458    /// let message = CommitMessage::from(indoc!(
459    ///     "
460    ///     Update bashrc to include kubernetes completions
461    ///
462    ///     This should make it easier to deploy things for the developers.
463    ///     Benchmarked with Hyperfine, no noticable performance decrease.
464    ///
465    ///     I am unsure as to why this wasn't being automatically discovered from Brew.
466    ///     I've filed a bug report with them.
467    ///
468    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
469    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
470    ///     ; bricht den Commit ab.
471    ///     ;
472    ///     ; Datum:            Sat Jun 27 21:40:14 2020 +0200
473    ///     ;
474    ///     ; Auf Branch master
475    ///     ;
476    ///     ; Initialer Commit
477    ///     ;
478    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
479    ///     ;    neue Datei:     .bashrc
480    ///     ;"
481    /// ));
482    /// let bodies = vec![
483    ///     Body::default(),
484    ///     Body::from(indoc!(
485    ///         "
486    ///         This should make it easier to deploy things for the developers.
487    ///         Benchmarked with Hyperfine, no noticable performance decrease."
488    ///     )),
489    ///     Body::default(),
490    ///     Body::from(indoc!(
491    ///         "
492    ///         I am unsure as to why this wasn't being automatically discovered from Brew.
493    ///         I've filed a bug report with them."
494    ///     )),
495    /// ];
496    /// assert_eq!(message.get_body(), Bodies::from(bodies))
497    /// ```
498    #[must_use]
499    pub fn get_body(&self) -> Bodies<'_> {
500        self.bodies.clone()
501    }
502
503    /// Get the [`Comments`] from the [`CommitMessage`]
504    ///
505    /// We this will get you all the comments before the `Scissors` section. The
506    /// [`Scissors`] section is the bit that appears when you run `git commit
507    /// --verbose`, that contains the diffs.
508    ///
509    /// If there's [`Comment`] mixed in with the body, it'll return those too,
510    /// but not any of the [`Body`] around them.
511    ///
512    /// # Returns
513    ///
514    /// The Comments from the commit message, excluding those in the Scissors section
515    ///
516    /// # Examples
517    ///
518    /// ```
519    /// use indoc::indoc;
520    /// use mit_commit::{Body, Comment, Comments, CommitMessage, Subject};
521    ///
522    /// let message = CommitMessage::from(indoc!(
523    ///     "
524    ///     Update bashrc to include kubernetes completions
525    ///
526    ///     This should make it easier to deploy things for the developers.
527    ///     Benchmarked with Hyperfine, no noticable performance decrease.
528    ///
529    ///     I am unsure as to why this wasn't being automatically discovered from Brew.
530    ///     I've filed a bug report with them.
531    ///
532    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
533    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
534    ///     ; bricht den Commit ab.
535    ///     ;
536    ///     ; Datum:            Sat Jun 27 21:40:14 2020 +0200
537    ///     ;
538    ///     ; Auf Branch master
539    ///     ;
540    ///     ; Initialer Commit
541    ///     ;
542    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
543    ///     ;    neue Datei:     .bashrc
544    ///     ;"
545    /// ));
546    /// let comments = vec![Comment::from(indoc!(
547    ///     "
548    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
549    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
550    ///     ; bricht den Commit ab.
551    ///     ;
552    ///     ; Datum:            Sat Jun 27 21:40:14 2020 +0200
553    ///     ;
554    ///     ; Auf Branch master
555    ///     ;
556    ///     ; Initialer Commit
557    ///     ;
558    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
559    ///     ;    neue Datei:     .bashrc
560    ///     ;"
561    /// ))];
562    /// assert_eq!(message.get_comments(), Comments::from(comments))
563    /// ```
564    #[must_use]
565    pub fn get_comments(&self) -> Comments<'_> {
566        self.comments.clone()
567    }
568
569    /// Get the [`Scissors`] from the [`CommitMessage`]
570    ///
571    /// We this will get you all the comments in the [`Scissors`] section. The
572    /// [`Scissors`] section is the bit that appears when you run `git commit
573    /// --verbose`, that contains the diffs, and is not preserved when you
574    /// save the commit.
575    ///
576    /// # Returns
577    ///
578    /// An optional Scissors section if present in the commit message, or None if not present
579    ///
580    /// # Examples
581    ///
582    /// ```
583    /// use indoc::indoc;
584    /// use mit_commit::{Body, CommitMessage, Scissors, Subject};
585    ///
586    /// let message = CommitMessage::from(indoc!(
587    ///     "
588    ///     Add file
589    ///
590    ///     This adds file primarily for demonstration purposes. It might not be
591    ///     useful as an actual commit, but it's very useful as a example to use in
592    ///     tests.
593    ///
594    ///     Relates-to: #128
595    ///
596    ///     ; Short (50 chars or less) summary of changes
597    ///     ;
598    ///     ; More detailed explanatory text, if necessary.  Wrap it to
599    ///     ; about 72 characters or so.  In some contexts, the first
600    ///     ; line is treated as the subject of an email and the rest of
601    ///     ; the text as the body.  The blank line separating the
602    ///     ; summary from the body is critical (unless you omit the body
603    ///     ; entirely); tools like rebase can get confused if you run
604    ///     ; the two together.
605    ///     ;
606    ///     ; Further paragraphs come after blank lines.
607    ///     ;
608    ///     ;   - Bullet points are okay, too
609    ///     ;
610    ///     ;   - Typically a hyphen or asterisk is used for the bullet,
611    ///     ;     preceded by a single space, with blank lines in
612    ///     ;     between, but conventions vary here
613    ///
614    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
615    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
616    ///     ; bricht den Commit ab.
617    ///     ;
618    ///     ; Auf Branch main
619    ///     ; Ihr Branch ist auf demselben Stand wie 'origin/main'.
620    ///     ;
621    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
622    ///     ;   neue Datei:     file
623    ///     ;
624    ///     ; ------------------------ >8 ------------------------
625    ///     ; \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
626    ///     ; Alles unterhalb von ihr wird ignoriert.
627    ///     diff --git a/file b/file
628    ///     new file mode 100644
629    ///     index 0000000..e69de29
630    ///     "
631    /// ));
632    /// let scissors = Scissors::from(indoc!(
633    ///     "
634    ///     ; ------------------------ >8 ------------------------
635    ///     ; \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
636    ///     ; Alles unterhalb von ihr wird ignoriert.
637    ///     diff --git a/file b/file
638    ///     new file mode 100644
639    ///     index 0000000..e69de29
640    ///     "
641    /// ));
642    /// assert_eq!(message.get_scissors(), Some(scissors))
643    /// ```
644    #[must_use]
645    pub fn get_scissors(&self) -> Option<Scissors<'_>> {
646        self.scissors.clone()
647    }
648
649    /// Get the [`Trailers`] from the [`CommitMessage`]
650    ///
651    /// This will get you all the trailers in the commit message. Trailers are
652    /// special metadata lines at the end of the commit message, like "Signed-off-by:"
653    /// or "Relates-to:".
654    ///
655    /// # Returns
656    ///
657    /// The Trailers found in the commit message
658    ///
659    /// # Examples
660    ///
661    /// ```
662    /// use indoc::indoc;
663    /// use mit_commit::{Body, CommitMessage, Trailer, Trailers};
664    ///
665    /// let message = CommitMessage::from(indoc!(
666    ///     "
667    ///     Add file
668    ///
669    ///     Looks-like-a-trailer: But isn't
670    ///
671    ///     This adds file primarily for demonstration purposes. It might not be
672    ///     useful as an actual commit, but it's very useful as a example to use in
673    ///     tests.
674    ///
675    ///     Relates-to: #128
676    ///     Relates-to: #129
677    ///
678    ///     ; Short (50 chars or less) summary of changes
679    ///     ;
680    ///     ; More detailed explanatory text, if necessary.  Wrap it to
681    ///     ; about 72 characters or so.  In some contexts, the first
682    ///     ; line is treated as the subject of an email and the rest of
683    ///     ; the text as the body.  The blank line separating the
684    ///     ; summary from the body is critical (unless you omit the body
685    ///     ; entirely); tools like rebase can get confused if you run
686    ///     ; the two together.
687    ///     ;
688    ///     ; Further paragraphs come after blank lines.
689    ///     ;
690    ///     ;   - Bullet points are okay, too
691    ///     ;
692    ///     ;   - Typically a hyphen or asterisk is used for the bullet,
693    ///     ;     preceded by a single space, with blank lines in
694    ///     ;     between, but conventions vary here
695    ///
696    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
697    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
698    ///     ; bricht den Commit ab.
699    ///     ;
700    ///     ; Auf Branch main
701    ///     ; Ihr Branch ist auf demselben Stand wie 'origin/main'.
702    ///     ;
703    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
704    ///     ;   neue Datei:     file
705    ///     ;
706    ///     ; ------------------------ >8 ------------------------
707    ///     ; \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
708    ///     ; Alles unterhalb von ihr wird ignoriert.
709    ///     diff --git a/file b/file
710    ///     new file mode 100644
711    ///     index 0000000..e69de29
712    ///     "
713    /// ));
714    /// let trailers = vec![
715    ///     Trailer::new("Relates-to".into(), "#128".into()),
716    ///     Trailer::new("Relates-to".into(), "#129".into()),
717    /// ];
718    /// assert_eq!(message.get_trailers(), Trailers::from(trailers))
719    /// ```
720    #[must_use]
721    pub fn get_trailers(&self) -> Trailers<'_> {
722        self.trailers.clone()
723    }
724
725    /// Checks if the [`CommitMessage`] matches a given pattern in the saved portions
726    ///
727    /// This takes a regex and matches it to the visible portions of the
728    /// commits, so it excludes comments, and everything after the scissors.
729    ///
730    /// # Arguments
731    ///
732    /// * `re` - The regex pattern to match against the commit message
733    ///
734    /// # Returns
735    ///
736    /// `true` if the pattern matches any part of the visible commit message, `false` otherwise
737    ///
738    /// # Examples
739    ///
740    /// ```
741    /// use indoc::indoc;
742    /// use mit_commit::CommitMessage;
743    /// use regex::Regex;
744    ///
745    /// let commit = CommitMessage::from(indoc!(
746    ///     "
747    ///     Example Commit Message
748    ///
749    ///     This is an example commit message for linting
750    ///
751    ///
752    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
753    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
754    ///     ; bricht den Commit ab.
755    ///     ;
756    ///     ; Auf Branch main
757    ///     ; Ihr Branch ist auf demselben Stand wie 'origin/main'.
758    ///     ;
759    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
760    ///     ;    neue Datei:     file
761    ///     ;
762    ///     "
763    /// ));
764    ///
765    /// let re = Regex::new("[Bb]itte").unwrap();
766    /// assert_eq!(commit.matches_pattern(&re), false);
767    ///
768    /// let re = Regex::new("f[o\u{00FC}]r linting").unwrap();
769    /// assert_eq!(commit.matches_pattern(&re), true);
770    ///
771    /// let re = Regex::new("[Ee]xample Commit Message").unwrap();
772    /// assert_eq!(commit.matches_pattern(&re), true);
773    /// ```
774    #[must_use]
775    pub fn matches_pattern(&self, re: &Regex) -> bool {
776        let text = self
777            .clone()
778            .get_ast()
779            .into_iter()
780            .filter_map(|fragment| match fragment {
781                Fragment::Body(body) => Some(String::from(body)),
782                Fragment::Comment(_) => None,
783            })
784            .collect::<Vec<_>>()
785            .join("\n");
786        re.is_match(&text)
787    }
788
789    fn guess_comment_character(message: &str) -> Option<char> {
790        Scissors::guess_comment_character(message)
791    }
792
793    /// Give you a new [`CommitMessage`] with the provided subject
794    ///
795    /// # Arguments
796    ///
797    /// * `subject` - The new Subject to use for the commit message
798    ///
799    /// # Returns
800    ///
801    /// A new `CommitMessage` with the updated subject
802    ///
803    /// # Examples
804    ///
805    /// ```
806    /// use indoc::indoc;
807    /// use mit_commit::{CommitMessage, Subject};
808    /// use regex::Regex;
809    ///
810    /// let commit = CommitMessage::from(indoc!(
811    ///     "
812    ///     Example Commit Message
813    ///
814    ///     This is an example commit message
815    ///     "
816    /// ));
817    ///
818    /// assert_eq!(
819    ///     commit.with_subject("Subject".into()).get_subject(),
820    ///     Subject::from("Subject")
821    /// );
822    /// ```
823    #[must_use]
824    pub fn with_subject(self, subject: Subject<'a>) -> Self {
825        let mut ast: Vec<Fragment<'a>> = self.ast.clone();
826
827        if !ast.is_empty() {
828            ast.remove(0);
829        }
830        ast.insert(0, Body::from(subject.to_string()).into());
831
832        Self {
833            scissors: self.scissors,
834            ast,
835            subject,
836            trailers: self.trailers,
837            comments: self.comments,
838            bodies: self.bodies,
839        }
840    }
841
842    /// Give you a new [`CommitMessage`] with the provided body
843    ///
844    /// # Arguments
845    ///
846    /// * `contents` - The new body content to use for the commit message
847    ///
848    /// # Returns
849    ///
850    /// A new `CommitMessage` with the updated body contents
851    ///
852    /// # Examples
853    ///
854    /// ```
855    /// use indoc::indoc;
856    /// use mit_commit::{CommitMessage, Subject};
857    /// use regex::Regex;
858    ///
859    /// let commit = CommitMessage::from(indoc!(
860    ///     "
861    ///     Example Commit Message
862    ///
863    ///     This is an example commit message
864    ///     "
865    /// ));
866    /// let expected = CommitMessage::from(indoc!(
867    ///     "
868    ///     Example Commit Message
869    ///
870    ///     New body"
871    /// ));
872    ///
873    /// assert_eq!(commit.with_body_contents("New body"), expected);
874    /// ```
875    ///
876    /// A note on what we consider the body. The body is what falls after the
877    /// gutter. This means the following behaviour might happen
878    ///
879    /// ```
880    /// use indoc::indoc;
881    /// use mit_commit::{CommitMessage, Subject};
882    /// use regex::Regex;
883    /// let commit = CommitMessage::from(indoc!(
884    ///     "
885    ///     Example Commit Message
886    ///     without gutter"
887    /// ));
888    /// let expected = CommitMessage::from(indoc!(
889    ///     "
890    ///     Example Commit Message
891    ///     without gutter
892    ///
893    ///     New body"
894    /// ));
895    ///
896    /// assert_eq!(commit.with_body_contents("New body"), expected);
897    /// ```
898    #[must_use]
899    pub fn with_body_contents(self, contents: &'a str) -> Self {
900        let existing_subject: Subject<'a> = self.get_subject();
901        let body = format!("Unused\n\n{contents}");
902        let commit = Self::from(body);
903
904        commit.with_subject(existing_subject)
905    }
906
907    /// Get the comment character used in the commit message
908    ///
909    /// # Returns
910    ///
911    /// The character used for comments in the commit message, or None if there are no comments
912    ///
913    /// # Examples
914    ///
915    /// ```
916    /// use mit_commit::{CommitMessage, Subject};
917    /// let commit = CommitMessage::from("No comment\n\n# Some Comment");
918    ///
919    /// assert_eq!(commit.get_comment_char().unwrap(), '#');
920    /// ```
921    ///
922    /// We return none is there is no comments
923    ///
924    /// ```
925    /// use mit_commit::{CommitMessage, Subject};
926    /// let commit = CommitMessage::from("No comment");
927    ///
928    /// assert!(commit.get_comment_char().is_none());
929    /// ```
930    #[must_use]
931    pub fn get_comment_char(&self) -> Option<char> {
932        self.comments
933            .iter()
934            .next()
935            .map(|comment| -> String { comment.clone().into() })
936            .and_then(|comment| comment.chars().next())
937    }
938}
939
940impl From<CommitMessage<'_>> for String {
941    fn from(commit_message: CommitMessage<'_>) -> Self {
942        let basic_commit = commit_message
943            .get_ast()
944            .iter()
945            .map(|item| match item {
946                Fragment::Body(contents) => Self::from(contents.clone()),
947                Fragment::Comment(contents) => Self::from(contents.clone()),
948            })
949            .collect::<Vec<_>>()
950            .join("\n");
951
952        if let Some(scissors) = commit_message.get_scissors() {
953            format!("{basic_commit}\n{}", Self::from(scissors))
954        } else {
955            basic_commit
956        }
957    }
958}
959
960/// Parse a commit message using parsers
961impl CommitMessage<'_> {
962    fn parse_commit_message(message: &str) -> Self {
963        // Step 1: Split the message into body and scissors sections
964        let (rest, scissors) = Scissors::parse_sections(message);
965
966        // Step 2: Guess the comment character
967        let comment_character = Self::guess_comment_character(message);
968
969        // Step 3: Convert the body to a per-line AST
970        let per_line_ast = Self::convert_to_per_line_ast(comment_character, &rest);
971
972        // Step 4: Extract trailers before grouping to avoid cloning the entire AST
973        let trailers = Trailers::from(per_line_ast.clone());
974
975        // Step 5: Group consecutive fragments of the same type
976        let mut ast: Vec<Fragment<'_>> = Self::group_ast(per_line_ast);
977
978        // Step 6: Handle trailing newline case
979        if message.ends_with('\n') && scissors.is_none() {
980            ast.push(Body::default().into());
981        }
982
983        // Step 7: Create subject, comments, and bodies from the AST
984        // We need to clone here because the From implementations require owned vectors
985        let subject = Subject::from(ast.clone());
986        let comments = Comments::from(ast.clone());
987        let bodies = Bodies::from(ast.clone());
988
989        // Step 8: Create and return the CommitMessage
990        Self {
991            scissors,
992            ast,
993            subject,
994            trailers,
995            comments,
996            bodies,
997        }
998    }
999}
1000
1001impl<'a> From<Cow<'a, str>> for CommitMessage<'a> {
1002    /// Create a new [`CommitMessage`]
1003    ///
1004    /// Create a commit message from a string. It's expected that you'll be
1005    /// reading this during some sort of Git Hook
1006    ///
1007    /// # Examples
1008    ///
1009    /// ```
1010    /// use indoc::indoc;
1011    /// use mit_commit::{Bodies, CommitMessage, Subject};
1012    ///
1013    /// let message = CommitMessage::from(indoc!(
1014    ///     "
1015    ///     Update bashrc to include kubernetes completions
1016    ///
1017    ///     This should make it easier to deploy things for the developers.
1018    ///     Benchmarked with Hyperfine, no noticable performance decrease.
1019    ///
1020    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1021    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
1022    ///     ; bricht den Commit ab.
1023    ///     ;
1024    ///     ; Datum:            Sat Jun 27 21:40:14 2020 +0200
1025    ///     ;
1026    ///     ; Auf Branch master
1027    ///     ;
1028    ///     ; Initialer Commit
1029    ///     ;
1030    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
1031    ///     ;    neue Datei:     .bashrc
1032    ///     ;"
1033    /// ));
1034    /// assert_eq!(
1035    ///     message.get_subject(),
1036    ///     Subject::from("Update bashrc to include kubernetes completions")
1037    /// )
1038    /// ```
1039    ///
1040    ///  # Comment Character
1041    ///
1042    /// We load the comment character for the commit message
1043    ///
1044    /// Valid options are in [`crate::comment::LEGAL_CHARACTERS`], these are based on the auto-selection logic in the git codebase's [`adjust_comment_line_char` function](https://github.com/git/git/blob/master/builtin/commit.c#L667-L695).
1045    ///
1046    /// This does mean that we aren't making 100% of characters available, which
1047    /// is technically possible, but given we don't have access to the users git
1048    /// config this feels like a reasonable compromise, there are a lot of
1049    /// non-whitespace characters as options otherwise, and we don't want to
1050    /// confuse a genuine body with a comment
1051    fn from(message: Cow<'a, str>) -> Self {
1052        Self::parse_commit_message(&message)
1053    }
1054}
1055
1056impl TryFrom<PathBuf> for CommitMessage<'_> {
1057    type Error = Error;
1058
1059    /// Creates a `CommitMessage` from a file path.
1060    ///
1061    /// # Arguments
1062    ///
1063    /// * `value` - The path to the file containing the commit message
1064    ///
1065    /// # Returns
1066    ///
1067    /// A `CommitMessage` parsed from the file contents
1068    ///
1069    /// # Examples
1070    ///
1071    /// ```
1072    /// use std::path::PathBuf;
1073    /// use std::convert::TryFrom;
1074    /// use std::io::Write;
1075    /// use mit_commit::CommitMessage;
1076    ///
1077    /// // Create a temporary file for the example
1078    /// let mut temp_file = tempfile::NamedTempFile::new().unwrap();
1079    /// write!(temp_file.as_file(), "Example commit message").unwrap();
1080    ///
1081    /// // Use the temporary file path
1082    /// let path = temp_file.path().to_path_buf();
1083    /// let commit_message = CommitMessage::try_from(path).expect("Failed to read commit message");
1084    /// assert_eq!(commit_message.get_subject().to_string(), "Example commit message");
1085    /// ```
1086    ///
1087    /// # Errors
1088    ///
1089    /// Returns an Error if the file cannot be read or if the file contents cannot be parsed as UTF-8
1090    fn try_from(value: PathBuf) -> Result<Self, Self::Error> {
1091        let mut file = File::open(value)?;
1092        let mut buffer = String::new();
1093
1094        file.read_to_string(&mut buffer)
1095            .map_err(Error::from)
1096            .map(move |_| Self::from(buffer))
1097    }
1098}
1099
1100impl<'a> TryFrom<&'a Path> for CommitMessage<'a> {
1101    type Error = Error;
1102
1103    /// Creates a `CommitMessage` from a file path reference.
1104    ///
1105    /// # Arguments
1106    ///
1107    /// * `value` - The path reference to the file containing the commit message
1108    ///
1109    /// # Returns
1110    ///
1111    /// A `CommitMessage` parsed from the file contents
1112    ///
1113    /// # Examples
1114    ///
1115    /// ```
1116    /// use std::path::Path;
1117    /// use std::convert::TryFrom;
1118    /// use std::io::Write;
1119    /// use mit_commit::CommitMessage;
1120    ///
1121    /// // Create a temporary file for the example
1122    /// let mut temp_file = tempfile::NamedTempFile::new().unwrap();
1123    /// write!(temp_file.as_file(), "Example commit message").unwrap();
1124    ///
1125    /// // Use the temporary file path
1126    /// let path = temp_file.path();
1127    /// let commit_message = CommitMessage::try_from(path).expect("Failed to read commit message");
1128    /// assert_eq!(commit_message.get_subject().to_string(), "Example commit message");
1129    /// ```
1130    ///
1131    /// # Errors
1132    ///
1133    /// Returns an Error if the file cannot be read or if the file contents cannot be parsed as UTF-8
1134    fn try_from(value: &'a Path) -> Result<Self, Self::Error> {
1135        let mut file = File::open(value)?;
1136        let mut buffer = String::new();
1137
1138        file.read_to_string(&mut buffer)
1139            .map_err(Error::from)
1140            .map(move |_| Self::from(buffer))
1141    }
1142}
1143
1144impl<'a> From<&'a str> for CommitMessage<'a> {
1145    fn from(message: &'a str) -> Self {
1146        CommitMessage::from(Cow::from(message))
1147    }
1148}
1149
1150impl From<String> for CommitMessage<'_> {
1151    fn from(message: String) -> Self {
1152        Self::from(Cow::from(message))
1153    }
1154}
1155
1156/// Errors on reading commit messages
1157#[derive(Error, Debug, Diagnostic)]
1158pub enum Error {
1159    /// Failed to read a commit message
1160    #[error("failed to read commit file {0}")]
1161    #[diagnostic(
1162        url(docsrs),
1163        code(mit_commit::commit_message::error::io),
1164        help("check the file is readable")
1165    )]
1166    Io(#[from] io::Error),
1167}
1168
1169#[cfg(test)]
1170mod tests {
1171    use std::{convert::TryInto, io::Write};
1172
1173    use indoc::indoc;
1174    use quickcheck::TestResult;
1175    use regex::Regex;
1176    use tempfile::NamedTempFile;
1177
1178    use super::*;
1179    use crate::{
1180        Fragment, bodies::Bodies, body::Body, comment::Comment, scissors::Scissors,
1181        subject::Subject, trailer::Trailer,
1182    };
1183
1184    #[test]
1185    fn test_default_returns_empty_string() {
1186        let commit = CommitMessage::default();
1187        let actual: String = commit.into();
1188
1189        assert_eq!(
1190            actual,
1191            String::new(),
1192            "Default CommitMessage should convert to an empty string"
1193        );
1194    }
1195
1196    #[test]
1197    fn test_matches_pattern_returns_correct_results() {
1198        let commit = CommitMessage::from(indoc!(
1199                "
1200                Example Commit Message
1201
1202                This is an example commit message for linting
1203
1204                Relates-to: #153
1205                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1206                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1207                # bricht den Commit ab.
1208                #
1209                # Auf Branch main
1210                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1211                #
1212                # Zum Commit vorgemerkte \u{00E4}nderungen:
1213                #	neue Datei:     file
1214                #
1215                "
1216            ));
1217
1218        let re = Regex::new("[Bb]itte").unwrap();
1219        assert!(
1220            !commit.matches_pattern(&re),
1221            "Pattern should not match in comments"
1222        );
1223
1224        let re = Regex::new("f[o\u{00FC}]r linting").unwrap();
1225        assert!(
1226            commit.matches_pattern(&re),
1227            "Pattern should match in body text"
1228        );
1229
1230        let re = Regex::new("[Ee]xample Commit Message").unwrap();
1231        assert!(
1232            commit.matches_pattern(&re),
1233            "Pattern should match in subject"
1234        );
1235
1236        let re = Regex::new("Relates[- ]to").unwrap();
1237        assert!(
1238            commit.matches_pattern(&re),
1239            "Pattern should match in trailers"
1240        );
1241    }
1242
1243    #[test]
1244    fn test_parse_message_without_gutter_succeeds() {
1245        let commit = CommitMessage::from(indoc!(
1246                "
1247                Example Commit Message
1248                This is an example commit message for linting
1249
1250                This is another line
1251                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1252                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1253                # bricht den Commit ab.
1254                #
1255                # Auf Branch main
1256                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1257                #
1258                # Zum Commit vorgemerkte \u{00E4}nderungen:
1259                #	neue Datei:     file
1260                #
1261                "
1262            ));
1263
1264        assert_eq!(
1265            commit.get_subject(),
1266            Subject::from("Example Commit Message\nThis is an example commit message for linting"),
1267            "Subject should include both lines when there's no gutter"
1268        );
1269        assert_eq!(
1270            commit.get_body(),
1271            Bodies::from(vec![Body::default(), Body::from("This is another line")]),
1272            "Body should contain the line after the empty line"
1273        );
1274    }
1275
1276    #[test]
1277    fn test_add_trailer_to_normal_commit_appends_correctly() {
1278        let commit = CommitMessage::from(indoc!(
1279            "
1280            Example Commit Message
1281
1282            This is an example commit message for linting
1283
1284            Relates-to: #153
1285
1286            # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1287            # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1288            # bricht den Commit ab.
1289            #
1290            # Auf Branch main
1291            # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1292            #
1293            # Zum Commit vorgemerkte \u{00E4}nderungen:
1294            #	neue Datei:     file
1295            #
1296            "
1297        ));
1298
1299        let expected = CommitMessage::from(indoc!(
1300            "
1301            Example Commit Message
1302
1303            This is an example commit message for linting
1304
1305            Relates-to: #153
1306            Co-authored-by: Test Trailer <test@example.com>
1307
1308            # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1309            # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1310            # bricht den Commit ab.
1311            #
1312            # Auf Branch main
1313            # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1314            #
1315            # Zum Commit vorgemerkte \u{00E4}nderungen:
1316            #	neue Datei:     file
1317            #
1318            "
1319        ));
1320
1321        let actual = commit.add_trailer(Trailer::new(
1322            "Co-authored-by".into(),
1323            "Test Trailer <test@example.com>".into(),
1324        ));
1325
1326        assert_eq!(
1327            String::from(actual),
1328            String::from(expected),
1329            "Adding a trailer to a commit with existing trailers should append the new trailer after the last trailer"
1330        );
1331    }
1332
1333    #[test]
1334    fn test_add_trailer_to_conventional_commit_appends_correctly() {
1335        let commit = CommitMessage::from(indoc!(
1336            "
1337            feat: Example Commit Message
1338
1339            This is an example commit message for linting
1340
1341            # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1342            # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1343            # bricht den Commit ab.
1344            #
1345            # Auf Branch main
1346            # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1347            #
1348            # Zum Commit vorgemerkte \u{00E4}nderungen:
1349            #	neue Datei:     file
1350            #
1351            "
1352        ));
1353
1354        let expected = CommitMessage::from(indoc!(
1355            "
1356            feat: Example Commit Message
1357
1358            This is an example commit message for linting
1359
1360            Co-authored-by: Test Trailer <test@example.com>
1361
1362            # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1363            # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1364            # bricht den Commit ab.
1365            #
1366            # Auf Branch main
1367            # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1368            #
1369            # Zum Commit vorgemerkte \u{00E4}nderungen:
1370            #	neue Datei:     file
1371            #
1372            "
1373        ));
1374
1375        let actual = commit.add_trailer(Trailer::new(
1376            "Co-authored-by".into(),
1377            "Test Trailer <test@example.com>".into(),
1378        ));
1379
1380        assert_eq!(
1381            String::from(actual),
1382            String::from(expected),
1383            "Adding a trailer to a conventional commit should append the trailer after the body"
1384        );
1385    }
1386
1387    #[test]
1388    fn test_add_trailer_to_commit_without_trailers_creates_trailer_section() {
1389        let commit = CommitMessage::from(indoc!(
1390                "
1391                Example Commit Message
1392
1393                This is an example commit message for linting
1394
1395                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1396                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1397                # bricht den Commit ab.
1398                #
1399                # Auf Branch main
1400                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1401                #
1402                # Zum Commit vorgemerkte \u{00E4}nderungen:
1403                #	neue Datei:     file
1404                #
1405                "
1406            ));
1407
1408        let expected = CommitMessage::from(indoc!(
1409                "
1410                Example Commit Message
1411
1412                This is an example commit message for linting
1413
1414                Co-authored-by: Test Trailer <test@example.com>
1415
1416                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1417                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1418                # bricht den Commit ab.
1419                #
1420                # Auf Branch main
1421                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1422                #
1423                # Zum Commit vorgemerkte \u{00E4}nderungen:
1424                #	neue Datei:     file
1425                #
1426                "
1427            ));
1428        assert_eq!(
1429            String::from(commit.add_trailer(Trailer::new(
1430                "Co-authored-by".into(),
1431                "Test Trailer <test@example.com>".into(),
1432            ))),
1433            String::from(expected),
1434            "Adding a trailer to a commit without existing trailers should create a new trailer section after the body"
1435        );
1436    }
1437
1438    #[test]
1439    fn test_add_trailer_to_empty_commit_creates_trailer_section() {
1440        let commit = CommitMessage::from(indoc!(
1441                "
1442
1443                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1444                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1445                # bricht den Commit ab.
1446                #
1447                # Auf Branch main
1448                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1449                #
1450                # Zum Commit vorgemerkte \u{00E4}nderungen:
1451                #	neue Datei:     file
1452                #
1453                "
1454            ));
1455
1456        let expected = CommitMessage::from(indoc!(
1457                "
1458
1459
1460                Co-authored-by: Test Trailer <test@example.com>
1461
1462                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1463                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1464                # bricht den Commit ab.
1465                #
1466                # Auf Branch main
1467                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1468                #
1469                # Zum Commit vorgemerkte \u{00E4}nderungen:
1470                #	neue Datei:     file
1471                #
1472                "
1473            ));
1474        assert_eq!(
1475            String::from(commit.add_trailer(Trailer::new(
1476                "Co-authored-by".into(),
1477                "Test Trailer <test@example.com>".into(),
1478            ))),
1479            String::from(expected),
1480            "Adding a trailer to an empty commit should create a trailer section at the beginning"
1481        );
1482    }
1483
1484    #[test]
1485    fn test_add_trailer_to_empty_commit_with_trailer_appends_correctly() {
1486        let commit = CommitMessage::from(indoc!(
1487                "
1488
1489
1490                Co-authored-by: Test Trailer <test@example.com>
1491
1492                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1493                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1494                # bricht den Commit ab.
1495                #
1496                # Auf Branch main
1497                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1498                #
1499                # Zum Commit vorgemerkte \u{00E4}nderungen:
1500                #	neue Datei:     file
1501                #
1502                "
1503            ));
1504
1505        let expected = CommitMessage::from(indoc!(
1506                "
1507
1508
1509                Co-authored-by: Test Trailer <test@example.com>
1510                Co-authored-by: Someone Else <someone@example.com>
1511
1512                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1513                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1514                # bricht den Commit ab.
1515                #
1516                # Auf Branch main
1517                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1518                #
1519                # Zum Commit vorgemerkte \u{00E4}nderungen:
1520                #	neue Datei:     file
1521                #
1522                "
1523            ));
1524        assert_eq!(
1525            String::from(commit.add_trailer(Trailer::new(
1526                "Co-authored-by".into(),
1527                "Someone Else <someone@example.com>".into(),
1528            ))),
1529            String::from(expected),
1530            "Adding a trailer to an empty commit with an existing trailer should append the new trailer after the existing one"
1531        );
1532    }
1533
1534    #[test]
1535    fn test_from_fragments_generates_correct_commit() {
1536        let message = CommitMessage::from_fragments(
1537            vec![
1538                Fragment::Body(Body::from("Example Commit")),
1539                Fragment::Body(Body::default()),
1540                Fragment::Body(Body::from("Here is a body")),
1541                Fragment::Comment(Comment::from("# Example Commit")),
1542            ],
1543            Some(Scissors::from(indoc!(
1544                "
1545                # ------------------------ >8 ------------------------
1546                # \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
1547                # Alles unterhalb von ihr wird ignoriert.
1548                diff --git a/file b/file
1549                new file mode 100644
1550                index 0000000..e69de29
1551                "
1552            ))),
1553        );
1554
1555        assert_eq!(
1556            String::from(message),
1557            String::from(indoc!(
1558                "
1559                Example Commit
1560
1561                Here is a body
1562                # Example Commit
1563                # ------------------------ >8 ------------------------
1564                # \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
1565                # Alles unterhalb von ihr wird ignoriert.
1566                diff --git a/file b/file
1567                new file mode 100644
1568                index 0000000..e69de29
1569                "
1570            )),
1571            "Creating a CommitMessage from fragments should generate the correct string representation"
1572        );
1573    }
1574
1575    #[test]
1576    fn test_insert_after_last_body_appends_correctly() {
1577        let ast: Vec<Fragment<'_>> = vec![
1578            Fragment::Body(Body::from("Add file")),
1579            Fragment::Body(Body::default()),
1580            Fragment::Body(Body::from("Looks-like-a-trailer: But isn\'t")),
1581            Fragment::Body(Body::default()),
1582            Fragment::Body(Body::from(
1583                "This adds file primarily for demonstration purposes. It might not be\nuseful as an actual commit, but it\'s very useful as a example to use in\ntests.",
1584            )),
1585            Fragment::Body(Body::default()),
1586            Fragment::Body(Body::from("Relates-to: #128")),
1587            Fragment::Body(Body::default()),
1588            Fragment::Comment(Comment::from(
1589                "# Short (50 chars or less) summary of changes\n#\n# More detailed explanatory text, if necessary.  Wrap it to\n# about 72 characters or so.  In some contexts, the first\n# line is treated as the subject of an email and the rest of\n# the text as the body.  The blank line separating the\n# summary from the body is critical (unless you omit the body\n# entirely); tools like rebase can get confused if you run\n# the two together.\n#\n# Further paragraphs come after blank lines.\n#\n#   - Bullet points are okay, too\n#\n#   - Typically a hyphen or asterisk is used for the bullet,\n#     preceded by a single space, with blank lines in\n#     between, but conventions vary here",
1590            )),
1591            Fragment::Body(Body::default()),
1592            Fragment::Comment(Comment::from(
1593                "# Bitte geben Sie eine Commit-Beschreibung f\u{fc}r Ihre \u{e4}nderungen ein. Zeilen,\n# die mit \'#\' beginnen, werden ignoriert, und eine leere Beschreibung\n# bricht den Commit ab.\n#\n# Auf Branch main\n# Ihr Branch ist auf demselben Stand wie \'origin/main\'.\n#\n# Zum Commit vorgemerkte \u{e4}nderungen:\n#\tneue Datei:     file\n#",
1594            )),
1595        ];
1596        let commit = CommitMessage::from_fragments(ast, None);
1597
1598        assert_eq!(
1599            commit
1600                .insert_after_last_full_body(vec![Fragment::Body(Body::from("Relates-to: #656"))])
1601                .get_ast(),
1602            vec![
1603                Fragment::Body(Body::from("Add file")),
1604                Fragment::Body(Body::default()),
1605                Fragment::Body(Body::from("Looks-like-a-trailer: But isn\'t")),
1606                Fragment::Body(Body::default()),
1607                Fragment::Body(Body::from(
1608                    "This adds file primarily for demonstration purposes. It might not be\nuseful as an actual commit, but it\'s very useful as a example to use in\ntests."
1609                )),
1610                Fragment::Body(Body::default()),
1611                Fragment::Body(Body::from("Relates-to: #128\nRelates-to: #656")),
1612                Fragment::Body(Body::default()),
1613                Fragment::Comment(Comment::from(
1614                    "# Short (50 chars or less) summary of changes\n#\n# More detailed explanatory text, if necessary.  Wrap it to\n# about 72 characters or so.  In some contexts, the first\n# line is treated as the subject of an email and the rest of\n# the text as the body.  The blank line separating the\n# summary from the body is critical (unless you omit the body\n# entirely); tools like rebase can get confused if you run\n# the two together.\n#\n# Further paragraphs come after blank lines.\n#\n#   - Bullet points are okay, too\n#\n#   - Typically a hyphen or asterisk is used for the bullet,\n#     preceded by a single space, with blank lines in\n#     between, but conventions vary here"
1615                )),
1616                Fragment::Body(Body::default()),
1617                Fragment::Comment(Comment::from(
1618                    "# Bitte geben Sie eine Commit-Beschreibung f\u{fc}r Ihre \u{e4}nderungen ein. Zeilen,\n# die mit \'#\' beginnen, werden ignoriert, und eine leere Beschreibung\n# bricht den Commit ab.\n#\n# Auf Branch main\n# Ihr Branch ist auf demselben Stand wie \'origin/main\'.\n#\n# Zum Commit vorgemerkte \u{e4}nderungen:\n#\tneue Datei:     file\n#"
1619                )),
1620            ],
1621            "Inserting after the last body should append the new fragment after the last non-empty body fragment"
1622        );
1623    }
1624
1625    #[test]
1626    fn test_insert_after_last_body_with_no_body_inserts_at_beginning() {
1627        let ast: Vec<Fragment<'_>> = vec![
1628            Fragment::Comment(Comment::from(
1629                "# Short (50 chars or less) summary of changes\n#\n# More detailed explanatory text, if necessary.  Wrap it to\n# about 72 characters or so.  In some contexts, the first\n# line is treated as the subject of an email and the rest of\n# the text as the body.  The blank line separating the\n# summary from the body is critical (unless you omit the body\n# entirely); tools like rebase can get confused if you run\n# the two together.\n#\n# Further paragraphs come after blank lines.\n#\n#   - Bullet points are okay, too\n#\n#   - Typically a hyphen or asterisk is used for the bullet,\n#     preceded by a single space, with blank lines in\n#     between, but conventions vary here",
1630            )),
1631            Fragment::Body(Body::default()),
1632            Fragment::Comment(Comment::from(
1633                "# Bitte geben Sie eine Commit-Beschreibung f\u{fc}r Ihre \u{e4}nderungen ein. Zeilen,\n# die mit \'#\' beginnen, werden ignoriert, und eine leere Beschreibung\n# bricht den Commit ab.\n#\n# Auf Branch main\n# Ihr Branch ist auf demselben Stand wie \'origin/main\'.\n#\n# Zum Commit vorgemerkte \u{e4}nderungen:\n#\tneue Datei:     file\n#",
1634            )),
1635        ];
1636        let commit = CommitMessage::from_fragments(ast, None);
1637
1638        assert_eq!(
1639            commit
1640                .insert_after_last_full_body(vec![Fragment::Body(Body::from("Relates-to: #656"))])
1641                .get_ast(),
1642            vec![
1643                Fragment::Body(Body::from("Relates-to: #656")),
1644                Fragment::Comment(Comment::from(
1645                    "# Short (50 chars or less) summary of changes\n#\n# More detailed explanatory text, if necessary.  Wrap it to\n# about 72 characters or so.  In some contexts, the first\n# line is treated as the subject of an email and the rest of\n# the text as the body.  The blank line separating the\n# summary from the body is critical (unless you omit the body\n# entirely); tools like rebase can get confused if you run\n# the two together.\n#\n# Further paragraphs come after blank lines.\n#\n#   - Bullet points are okay, too\n#\n#   - Typically a hyphen or asterisk is used for the bullet,\n#     preceded by a single space, with blank lines in\n#     between, but conventions vary here"
1646                )),
1647                Fragment::Body(Body::default()),
1648                Fragment::Comment(Comment::from(
1649                    "# Bitte geben Sie eine Commit-Beschreibung f\u{fc}r Ihre \u{e4}nderungen ein. Zeilen,\n# die mit \'#\' beginnen, werden ignoriert, und eine leere Beschreibung\n# bricht den Commit ab.\n#\n# Auf Branch main\n# Ihr Branch ist auf demselben Stand wie \'origin/main\'.\n#\n# Zum Commit vorgemerkte \u{e4}nderungen:\n#\tneue Datei:     file\n#"
1650                )),
1651            ],
1652            "When there is no body, inserting after the last body should insert at the beginning of the AST"
1653        );
1654    }
1655
1656    #[allow(clippy::needless_pass_by_value)]
1657    #[quickcheck]
1658    fn test_with_subject_preserves_input_string(input: String) -> bool {
1659        let commit: CommitMessage<'_> = "Some Subject".into();
1660        let actual: String = commit
1661            .with_subject(input.clone().into())
1662            .get_subject()
1663            .into();
1664        // Property: The subject should be exactly the input string after setting it
1665        actual == input
1666    }
1667
1668    #[test]
1669    fn test_with_subject_on_default_commit_sets_subject_correctly() {
1670        let commit = CommitMessage::default().with_subject("Subject".into());
1671        assert_eq!(
1672            commit.get_subject(),
1673            Subject::from("Subject"),
1674            "Setting subject on default commit should update the subject correctly"
1675        );
1676    }
1677
1678    #[allow(clippy::needless_pass_by_value)]
1679    #[quickcheck]
1680    fn test_with_body_contents_replaces_body_correctly(input: String) -> TestResult {
1681        if input.contains('\r') {
1682            return TestResult::discard();
1683        }
1684
1685        let commit: CommitMessage<'_> = "Some Subject\n\nSome Body".into();
1686        let expected: String = format!("Some Subject\n\n{input}");
1687        let actual: String = commit.with_body_contents(&input).into();
1688        // Property: The body should be replaced with the input string while preserving the subject
1689        TestResult::from_bool(actual == expected)
1690    }
1691
1692    #[allow(clippy::needless_pass_by_value)]
1693    #[quickcheck]
1694    fn test_with_body_contents_preserves_multiline_subject(input: String) -> TestResult {
1695        if input.contains('\r') {
1696            return TestResult::discard();
1697        }
1698
1699        let commit: CommitMessage<'_> = "Some Subject\nSome More Subject\n\nBody".into();
1700        let expected: String = format!("Some Subject\nSome More Subject\n\n{input}");
1701        let actual: String = commit.with_body_contents(&input).into();
1702        // Property: The body should be replaced with the input string while preserving the multi-line subject
1703        TestResult::from_bool(actual == expected)
1704    }
1705
1706    #[test]
1707    fn test_get_comment_char_returns_none_when_no_comments() {
1708        let commit_character = CommitMessage::from("Example Commit Message");
1709        assert!(
1710            commit_character.get_comment_char().is_none(),
1711            "Comment character should be None when there are no comments in the message"
1712        );
1713    }
1714
1715    #[test]
1716    fn test_try_from_path_buf_reads_file_correctly() {
1717        let temp_file = NamedTempFile::new().expect("failed to create temp file");
1718        write!(temp_file.as_file(), "Some Subject").expect("Failed to write file");
1719
1720        let commit_character: CommitMessage<'_> = temp_file
1721            .path()
1722            .to_path_buf()
1723            .try_into()
1724            .expect("Could not read commit message");
1725        assert_eq!(
1726            commit_character.get_subject().to_string(),
1727            "Some Subject",
1728            "Reading from PathBuf should correctly parse the file contents into a CommitMessage"
1729        );
1730    }
1731
1732    #[test]
1733    fn test_try_from_path_reads_file_correctly() {
1734        let temp_file = NamedTempFile::new().expect("failed to create temp file");
1735        write!(temp_file.as_file(), "Some Subject").expect("Failed to write file");
1736
1737        let commit_character: CommitMessage<'_> = temp_file
1738            .path()
1739            .try_into()
1740            .expect("Could not read commit message");
1741        assert_eq!(
1742            commit_character.get_subject().to_string(),
1743            "Some Subject",
1744            "Reading from Path should correctly parse the file contents into a CommitMessage"
1745        );
1746    }
1747}