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
960impl From<&CommitMessage<'_>> for String {
961    fn from(commit_message: &CommitMessage<'_>) -> Self {
962        let basic_commit = commit_message
963            .get_ast()
964            .iter()
965            .map(|item| match item {
966                Fragment::Body(contents) => Self::from(contents.clone()),
967                Fragment::Comment(contents) => Self::from(contents.clone()),
968            })
969            .collect::<Vec<_>>()
970            .join("\n");
971
972        if let Some(scissors) = commit_message.get_scissors() {
973            format!("{basic_commit}\n{}", Self::from(scissors))
974        } else {
975            basic_commit
976        }
977    }
978}
979
980/// Parse a commit message using parsers
981impl CommitMessage<'_> {
982    fn parse_commit_message(message: &str) -> Self {
983        // Step 1: Split the message into body and scissors sections
984        let (rest, scissors) = Scissors::parse_sections(message);
985
986        // Step 2: Guess the comment character
987        let comment_character = Self::guess_comment_character(message);
988
989        // Step 3: Convert the body to a per-line AST
990        let per_line_ast = Self::convert_to_per_line_ast(comment_character, &rest);
991
992        // Step 4: Extract trailers before grouping to avoid cloning the entire AST
993        let trailers = Trailers::from(per_line_ast.clone());
994
995        // Step 5: Group consecutive fragments of the same type
996        let mut ast: Vec<Fragment<'_>> = Self::group_ast(per_line_ast);
997
998        // Step 6: Handle trailing newline case
999        if message.ends_with('\n') && scissors.is_none() {
1000            ast.push(Body::default().into());
1001        }
1002
1003        // Step 7: Create subject, comments, and bodies from the AST
1004        // We need to clone here because the From implementations require owned vectors
1005        let subject = Subject::from(ast.clone());
1006        let comments = Comments::from(ast.clone());
1007        let bodies = Bodies::from(ast.clone());
1008
1009        // Step 8: Create and return the CommitMessage
1010        Self {
1011            scissors,
1012            ast,
1013            subject,
1014            trailers,
1015            comments,
1016            bodies,
1017        }
1018    }
1019}
1020
1021impl<'a> From<Cow<'a, str>> for CommitMessage<'a> {
1022    /// Create a new [`CommitMessage`]
1023    ///
1024    /// Create a commit message from a string. It's expected that you'll be
1025    /// reading this during some sort of Git Hook
1026    ///
1027    /// # Examples
1028    ///
1029    /// ```
1030    /// use indoc::indoc;
1031    /// use mit_commit::{Bodies, CommitMessage, Subject};
1032    ///
1033    /// let message = CommitMessage::from(indoc!(
1034    ///     "
1035    ///     Update bashrc to include kubernetes completions
1036    ///
1037    ///     This should make it easier to deploy things for the developers.
1038    ///     Benchmarked with Hyperfine, no noticable performance decrease.
1039    ///
1040    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1041    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
1042    ///     ; bricht den Commit ab.
1043    ///     ;
1044    ///     ; Datum:            Sat Jun 27 21:40:14 2020 +0200
1045    ///     ;
1046    ///     ; Auf Branch master
1047    ///     ;
1048    ///     ; Initialer Commit
1049    ///     ;
1050    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
1051    ///     ;    neue Datei:     .bashrc
1052    ///     ;"
1053    /// ));
1054    /// assert_eq!(
1055    ///     message.get_subject(),
1056    ///     Subject::from("Update bashrc to include kubernetes completions")
1057    /// )
1058    /// ```
1059    ///
1060    ///  # Comment Character
1061    ///
1062    /// We load the comment character for the commit message
1063    ///
1064    /// 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).
1065    ///
1066    /// This does mean that we aren't making 100% of characters available, which
1067    /// is technically possible, but given we don't have access to the users git
1068    /// config this feels like a reasonable compromise, there are a lot of
1069    /// non-whitespace characters as options otherwise, and we don't want to
1070    /// confuse a genuine body with a comment
1071    fn from(message: Cow<'a, str>) -> Self {
1072        Self::parse_commit_message(&message)
1073    }
1074}
1075
1076impl TryFrom<PathBuf> for CommitMessage<'_> {
1077    type Error = Error;
1078
1079    /// Creates a `CommitMessage` from a file path.
1080    ///
1081    /// # Arguments
1082    ///
1083    /// * `value` - The path to the file containing the commit message
1084    ///
1085    /// # Returns
1086    ///
1087    /// A `CommitMessage` parsed from the file contents
1088    ///
1089    /// # Examples
1090    ///
1091    /// ```
1092    /// use std::path::PathBuf;
1093    /// use std::convert::TryFrom;
1094    /// use std::io::Write;
1095    /// use mit_commit::CommitMessage;
1096    ///
1097    /// // Create a temporary file for the example
1098    /// let mut temp_file = tempfile::NamedTempFile::new().unwrap();
1099    /// write!(temp_file.as_file(), "Example commit message").unwrap();
1100    ///
1101    /// // Use the temporary file path
1102    /// let path = temp_file.path().to_path_buf();
1103    /// let commit_message = CommitMessage::try_from(path).expect("Failed to read commit message");
1104    /// assert_eq!(commit_message.get_subject().to_string(), "Example commit message");
1105    /// ```
1106    ///
1107    /// # Errors
1108    ///
1109    /// Returns an Error if the file cannot be read or if the file contents cannot be parsed as UTF-8
1110    fn try_from(value: PathBuf) -> Result<Self, Self::Error> {
1111        let mut file = File::open(value)?;
1112        let mut buffer = String::new();
1113
1114        file.read_to_string(&mut buffer)
1115            .map_err(Error::from)
1116            .map(move |_| Self::from(buffer))
1117    }
1118}
1119
1120impl<'a> TryFrom<&'a Path> for CommitMessage<'a> {
1121    type Error = Error;
1122
1123    /// Creates a `CommitMessage` from a file path reference.
1124    ///
1125    /// # Arguments
1126    ///
1127    /// * `value` - The path reference to the file containing the commit message
1128    ///
1129    /// # Returns
1130    ///
1131    /// A `CommitMessage` parsed from the file contents
1132    ///
1133    /// # Examples
1134    ///
1135    /// ```
1136    /// use std::path::Path;
1137    /// use std::convert::TryFrom;
1138    /// use std::io::Write;
1139    /// use mit_commit::CommitMessage;
1140    ///
1141    /// // Create a temporary file for the example
1142    /// let mut temp_file = tempfile::NamedTempFile::new().unwrap();
1143    /// write!(temp_file.as_file(), "Example commit message").unwrap();
1144    ///
1145    /// // Use the temporary file path
1146    /// let path = temp_file.path();
1147    /// let commit_message = CommitMessage::try_from(path).expect("Failed to read commit message");
1148    /// assert_eq!(commit_message.get_subject().to_string(), "Example commit message");
1149    /// ```
1150    ///
1151    /// # Errors
1152    ///
1153    /// Returns an Error if the file cannot be read or if the file contents cannot be parsed as UTF-8
1154    fn try_from(value: &'a Path) -> Result<Self, Self::Error> {
1155        let mut file = File::open(value)?;
1156        let mut buffer = String::new();
1157
1158        file.read_to_string(&mut buffer)
1159            .map_err(Error::from)
1160            .map(move |_| Self::from(buffer))
1161    }
1162}
1163
1164impl<'a> From<&'a str> for CommitMessage<'a> {
1165    fn from(message: &'a str) -> Self {
1166        CommitMessage::from(Cow::from(message))
1167    }
1168}
1169
1170impl From<String> for CommitMessage<'_> {
1171    fn from(message: String) -> Self {
1172        Self::from(Cow::from(message))
1173    }
1174}
1175
1176/// Errors on reading commit messages
1177#[derive(Error, Debug, Diagnostic)]
1178pub enum Error {
1179    /// Failed to read a commit message
1180    #[error("failed to read commit file {0}")]
1181    #[diagnostic(
1182        url(docsrs),
1183        code(mit_commit::commit_message::error::io),
1184        help("check the file is readable")
1185    )]
1186    Io(#[from] io::Error),
1187}
1188
1189#[cfg(test)]
1190mod tests {
1191    use std::{convert::TryInto, io::Write};
1192
1193    use indoc::indoc;
1194    use quickcheck::TestResult;
1195    use regex::Regex;
1196    use tempfile::NamedTempFile;
1197
1198    use super::*;
1199    use crate::{
1200        Fragment, bodies::Bodies, body::Body, comment::Comment, scissors::Scissors,
1201        subject::Subject, trailer::Trailer,
1202    };
1203
1204    #[test]
1205    fn test_default_returns_empty_string() {
1206        let commit = CommitMessage::default();
1207        let actual: String = commit.into();
1208
1209        assert_eq!(
1210            actual,
1211            String::new(),
1212            "Default CommitMessage should convert to an empty string"
1213        );
1214    }
1215
1216    #[test]
1217    fn test_matches_pattern_returns_correct_results() {
1218        let commit = CommitMessage::from(indoc!(
1219                "
1220                Example Commit Message
1221
1222                This is an example commit message for linting
1223
1224                Relates-to: #153
1225                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1226                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1227                # bricht den Commit ab.
1228                #
1229                # Auf Branch main
1230                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1231                #
1232                # Zum Commit vorgemerkte \u{00E4}nderungen:
1233                #	neue Datei:     file
1234                #
1235                "
1236            ));
1237
1238        let re = Regex::new("[Bb]itte").unwrap();
1239        assert!(
1240            !commit.matches_pattern(&re),
1241            "Pattern should not match in comments"
1242        );
1243
1244        let re = Regex::new("f[o\u{00FC}]r linting").unwrap();
1245        assert!(
1246            commit.matches_pattern(&re),
1247            "Pattern should match in body text"
1248        );
1249
1250        let re = Regex::new("[Ee]xample Commit Message").unwrap();
1251        assert!(
1252            commit.matches_pattern(&re),
1253            "Pattern should match in subject"
1254        );
1255
1256        let re = Regex::new("Relates[- ]to").unwrap();
1257        assert!(
1258            commit.matches_pattern(&re),
1259            "Pattern should match in trailers"
1260        );
1261    }
1262
1263    #[test]
1264    fn test_parse_message_without_gutter_succeeds() {
1265        let commit = CommitMessage::from(indoc!(
1266                "
1267                Example Commit Message
1268                This is an example commit message for linting
1269
1270                This is another line
1271                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1272                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1273                # bricht den Commit ab.
1274                #
1275                # Auf Branch main
1276                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1277                #
1278                # Zum Commit vorgemerkte \u{00E4}nderungen:
1279                #	neue Datei:     file
1280                #
1281                "
1282            ));
1283
1284        assert_eq!(
1285            commit.get_subject(),
1286            Subject::from("Example Commit Message\nThis is an example commit message for linting"),
1287            "Subject should include both lines when there's no gutter"
1288        );
1289        assert_eq!(
1290            commit.get_body(),
1291            Bodies::from(vec![Body::default(), Body::from("This is another line")]),
1292            "Body should contain the line after the empty line"
1293        );
1294    }
1295
1296    #[test]
1297    fn test_add_trailer_to_normal_commit_appends_correctly() {
1298        let commit = CommitMessage::from(indoc!(
1299            "
1300            Example Commit Message
1301
1302            This is an example commit message for linting
1303
1304            Relates-to: #153
1305
1306            # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1307            # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1308            # bricht den Commit ab.
1309            #
1310            # Auf Branch main
1311            # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1312            #
1313            # Zum Commit vorgemerkte \u{00E4}nderungen:
1314            #	neue Datei:     file
1315            #
1316            "
1317        ));
1318
1319        let expected = CommitMessage::from(indoc!(
1320            "
1321            Example Commit Message
1322
1323            This is an example commit message for linting
1324
1325            Relates-to: #153
1326            Co-authored-by: Test Trailer <test@example.com>
1327
1328            # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1329            # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1330            # bricht den Commit ab.
1331            #
1332            # Auf Branch main
1333            # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1334            #
1335            # Zum Commit vorgemerkte \u{00E4}nderungen:
1336            #	neue Datei:     file
1337            #
1338            "
1339        ));
1340
1341        let actual = commit.add_trailer(Trailer::new(
1342            "Co-authored-by".into(),
1343            "Test Trailer <test@example.com>".into(),
1344        ));
1345
1346        assert_eq!(
1347            String::from(actual),
1348            String::from(expected),
1349            "Adding a trailer to a commit with existing trailers should append the new trailer after the last trailer"
1350        );
1351    }
1352
1353    #[test]
1354    fn test_add_trailer_to_conventional_commit_appends_correctly() {
1355        let commit = CommitMessage::from(indoc!(
1356            "
1357            feat: Example Commit Message
1358
1359            This is an example commit message for linting
1360
1361            # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1362            # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1363            # bricht den Commit ab.
1364            #
1365            # Auf Branch main
1366            # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1367            #
1368            # Zum Commit vorgemerkte \u{00E4}nderungen:
1369            #	neue Datei:     file
1370            #
1371            "
1372        ));
1373
1374        let expected = CommitMessage::from(indoc!(
1375            "
1376            feat: Example Commit Message
1377
1378            This is an example commit message for linting
1379
1380            Co-authored-by: Test Trailer <test@example.com>
1381
1382            # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1383            # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1384            # bricht den Commit ab.
1385            #
1386            # Auf Branch main
1387            # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1388            #
1389            # Zum Commit vorgemerkte \u{00E4}nderungen:
1390            #	neue Datei:     file
1391            #
1392            "
1393        ));
1394
1395        let actual = commit.add_trailer(Trailer::new(
1396            "Co-authored-by".into(),
1397            "Test Trailer <test@example.com>".into(),
1398        ));
1399
1400        assert_eq!(
1401            String::from(actual),
1402            String::from(expected),
1403            "Adding a trailer to a conventional commit should append the trailer after the body"
1404        );
1405    }
1406
1407    #[test]
1408    fn test_add_trailer_to_commit_without_trailers_creates_trailer_section() {
1409        let commit = CommitMessage::from(indoc!(
1410                "
1411                Example Commit Message
1412
1413                This is an example commit message for linting
1414
1415                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1416                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1417                # bricht den Commit ab.
1418                #
1419                # Auf Branch main
1420                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1421                #
1422                # Zum Commit vorgemerkte \u{00E4}nderungen:
1423                #	neue Datei:     file
1424                #
1425                "
1426            ));
1427
1428        let expected = CommitMessage::from(indoc!(
1429                "
1430                Example Commit Message
1431
1432                This is an example commit message for linting
1433
1434                Co-authored-by: Test Trailer <test@example.com>
1435
1436                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1437                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1438                # bricht den Commit ab.
1439                #
1440                # Auf Branch main
1441                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1442                #
1443                # Zum Commit vorgemerkte \u{00E4}nderungen:
1444                #	neue Datei:     file
1445                #
1446                "
1447            ));
1448        assert_eq!(
1449            String::from(commit.add_trailer(Trailer::new(
1450                "Co-authored-by".into(),
1451                "Test Trailer <test@example.com>".into(),
1452            ))),
1453            String::from(expected),
1454            "Adding a trailer to a commit without existing trailers should create a new trailer section after the body"
1455        );
1456    }
1457
1458    #[test]
1459    fn test_add_trailer_to_empty_commit_creates_trailer_section() {
1460        let commit = CommitMessage::from(indoc!(
1461                "
1462
1463                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1464                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1465                # bricht den Commit ab.
1466                #
1467                # Auf Branch main
1468                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1469                #
1470                # Zum Commit vorgemerkte \u{00E4}nderungen:
1471                #	neue Datei:     file
1472                #
1473                "
1474            ));
1475
1476        let expected = CommitMessage::from(indoc!(
1477                "
1478
1479
1480                Co-authored-by: Test Trailer <test@example.com>
1481
1482                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1483                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1484                # bricht den Commit ab.
1485                #
1486                # Auf Branch main
1487                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1488                #
1489                # Zum Commit vorgemerkte \u{00E4}nderungen:
1490                #	neue Datei:     file
1491                #
1492                "
1493            ));
1494        assert_eq!(
1495            String::from(commit.add_trailer(Trailer::new(
1496                "Co-authored-by".into(),
1497                "Test Trailer <test@example.com>".into(),
1498            ))),
1499            String::from(expected),
1500            "Adding a trailer to an empty commit should create a trailer section at the beginning"
1501        );
1502    }
1503
1504    #[test]
1505    fn test_add_trailer_to_empty_commit_with_trailer_appends_correctly() {
1506        let commit = CommitMessage::from(indoc!(
1507                "
1508
1509
1510                Co-authored-by: Test Trailer <test@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
1525        let expected = CommitMessage::from(indoc!(
1526                "
1527
1528
1529                Co-authored-by: Test Trailer <test@example.com>
1530                Co-authored-by: Someone Else <someone@example.com>
1531
1532                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1533                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1534                # bricht den Commit ab.
1535                #
1536                # Auf Branch main
1537                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1538                #
1539                # Zum Commit vorgemerkte \u{00E4}nderungen:
1540                #	neue Datei:     file
1541                #
1542                "
1543            ));
1544        assert_eq!(
1545            String::from(commit.add_trailer(Trailer::new(
1546                "Co-authored-by".into(),
1547                "Someone Else <someone@example.com>".into(),
1548            ))),
1549            String::from(expected),
1550            "Adding a trailer to an empty commit with an existing trailer should append the new trailer after the existing one"
1551        );
1552    }
1553
1554    #[test]
1555    fn test_from_fragments_generates_correct_commit() {
1556        let message = CommitMessage::from_fragments(
1557            vec![
1558                Fragment::Body(Body::from("Example Commit")),
1559                Fragment::Body(Body::default()),
1560                Fragment::Body(Body::from("Here is a body")),
1561                Fragment::Comment(Comment::from("# Example Commit")),
1562            ],
1563            Some(Scissors::from(indoc!(
1564                "
1565                # ------------------------ >8 ------------------------
1566                # \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
1567                # Alles unterhalb von ihr wird ignoriert.
1568                diff --git a/file b/file
1569                new file mode 100644
1570                index 0000000..e69de29
1571                "
1572            ))),
1573        );
1574
1575        assert_eq!(
1576            String::from(message),
1577            String::from(indoc!(
1578                "
1579                Example Commit
1580
1581                Here is a body
1582                # Example Commit
1583                # ------------------------ >8 ------------------------
1584                # \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
1585                # Alles unterhalb von ihr wird ignoriert.
1586                diff --git a/file b/file
1587                new file mode 100644
1588                index 0000000..e69de29
1589                "
1590            )),
1591            "Creating a CommitMessage from fragments should generate the correct string representation"
1592        );
1593    }
1594
1595    #[test]
1596    fn test_insert_after_last_body_appends_correctly() {
1597        let ast: Vec<Fragment<'_>> = vec![
1598            Fragment::Body(Body::from("Add file")),
1599            Fragment::Body(Body::default()),
1600            Fragment::Body(Body::from("Looks-like-a-trailer: But isn\'t")),
1601            Fragment::Body(Body::default()),
1602            Fragment::Body(Body::from(
1603                "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.",
1604            )),
1605            Fragment::Body(Body::default()),
1606            Fragment::Body(Body::from("Relates-to: #128")),
1607            Fragment::Body(Body::default()),
1608            Fragment::Comment(Comment::from(
1609                "# 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",
1610            )),
1611            Fragment::Body(Body::default()),
1612            Fragment::Comment(Comment::from(
1613                "# 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#",
1614            )),
1615        ];
1616        let commit = CommitMessage::from_fragments(ast, None);
1617
1618        assert_eq!(
1619            commit
1620                .insert_after_last_full_body(vec![Fragment::Body(Body::from("Relates-to: #656"))])
1621                .get_ast(),
1622            vec![
1623                Fragment::Body(Body::from("Add file")),
1624                Fragment::Body(Body::default()),
1625                Fragment::Body(Body::from("Looks-like-a-trailer: But isn\'t")),
1626                Fragment::Body(Body::default()),
1627                Fragment::Body(Body::from(
1628                    "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."
1629                )),
1630                Fragment::Body(Body::default()),
1631                Fragment::Body(Body::from("Relates-to: #128\nRelates-to: #656")),
1632                Fragment::Body(Body::default()),
1633                Fragment::Comment(Comment::from(
1634                    "# 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"
1635                )),
1636                Fragment::Body(Body::default()),
1637                Fragment::Comment(Comment::from(
1638                    "# 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#"
1639                )),
1640            ],
1641            "Inserting after the last body should append the new fragment after the last non-empty body fragment"
1642        );
1643    }
1644
1645    #[test]
1646    fn test_insert_after_last_body_with_no_body_inserts_at_beginning() {
1647        let ast: Vec<Fragment<'_>> = vec![
1648            Fragment::Comment(Comment::from(
1649                "# 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",
1650            )),
1651            Fragment::Body(Body::default()),
1652            Fragment::Comment(Comment::from(
1653                "# 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#",
1654            )),
1655        ];
1656        let commit = CommitMessage::from_fragments(ast, None);
1657
1658        assert_eq!(
1659            commit
1660                .insert_after_last_full_body(vec![Fragment::Body(Body::from("Relates-to: #656"))])
1661                .get_ast(),
1662            vec![
1663                Fragment::Body(Body::from("Relates-to: #656")),
1664                Fragment::Comment(Comment::from(
1665                    "# 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"
1666                )),
1667                Fragment::Body(Body::default()),
1668                Fragment::Comment(Comment::from(
1669                    "# 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#"
1670                )),
1671            ],
1672            "When there is no body, inserting after the last body should insert at the beginning of the AST"
1673        );
1674    }
1675
1676    #[allow(clippy::needless_pass_by_value)]
1677    #[quickcheck]
1678    fn test_with_subject_preserves_input_string(input: String) -> bool {
1679        let commit: CommitMessage<'_> = "Some Subject".into();
1680        let actual: String = commit
1681            .with_subject(input.clone().into())
1682            .get_subject()
1683            .into();
1684        // Property: The subject should be exactly the input string after setting it
1685        actual == input
1686    }
1687
1688    #[test]
1689    fn test_with_subject_on_default_commit_sets_subject_correctly() {
1690        let commit = CommitMessage::default().with_subject("Subject".into());
1691        assert_eq!(
1692            commit.get_subject(),
1693            Subject::from("Subject"),
1694            "Setting subject on default commit should update the subject correctly"
1695        );
1696    }
1697
1698    #[allow(clippy::needless_pass_by_value)]
1699    #[quickcheck]
1700    fn test_with_body_contents_replaces_body_correctly(input: String) -> TestResult {
1701        if input.contains('\r') {
1702            return TestResult::discard();
1703        }
1704
1705        let commit: CommitMessage<'_> = "Some Subject\n\nSome Body".into();
1706        let expected: String = format!("Some Subject\n\n{input}");
1707        let actual: String = commit.with_body_contents(&input).into();
1708        // Property: The body should be replaced with the input string while preserving the subject
1709        TestResult::from_bool(actual == expected)
1710    }
1711
1712    #[allow(clippy::needless_pass_by_value)]
1713    #[quickcheck]
1714    fn test_with_body_contents_preserves_multiline_subject(input: String) -> TestResult {
1715        if input.contains('\r') {
1716            return TestResult::discard();
1717        }
1718
1719        let commit: CommitMessage<'_> = "Some Subject\nSome More Subject\n\nBody".into();
1720        let expected: String = format!("Some Subject\nSome More Subject\n\n{input}");
1721        let actual: String = commit.with_body_contents(&input).into();
1722        // Property: The body should be replaced with the input string while preserving the multi-line subject
1723        TestResult::from_bool(actual == expected)
1724    }
1725
1726    #[test]
1727    fn test_get_comment_char_returns_none_when_no_comments() {
1728        let commit_character = CommitMessage::from("Example Commit Message");
1729        assert!(
1730            commit_character.get_comment_char().is_none(),
1731            "Comment character should be None when there are no comments in the message"
1732        );
1733    }
1734
1735    #[test]
1736    fn test_try_from_path_buf_reads_file_correctly() {
1737        let temp_file = NamedTempFile::new().expect("failed to create temp file");
1738        write!(temp_file.as_file(), "Some Subject").expect("Failed to write file");
1739
1740        let commit_character: CommitMessage<'_> = temp_file
1741            .path()
1742            .to_path_buf()
1743            .try_into()
1744            .expect("Could not read commit message");
1745        assert_eq!(
1746            commit_character.get_subject().to_string(),
1747            "Some Subject",
1748            "Reading from PathBuf should correctly parse the file contents into a CommitMessage"
1749        );
1750    }
1751
1752    #[test]
1753    fn test_try_from_path_reads_file_correctly() {
1754        let temp_file = NamedTempFile::new().expect("failed to create temp file");
1755        write!(temp_file.as_file(), "Some Subject").expect("Failed to write file");
1756
1757        let commit_character: CommitMessage<'_> = temp_file
1758            .path()
1759            .try_into()
1760            .expect("Could not read commit message");
1761        assert_eq!(
1762            commit_character.get_subject().to_string(),
1763            "Some Subject",
1764            "Reading from Path should correctly parse the file contents into a CommitMessage"
1765        );
1766    }
1767
1768    #[test]
1769    fn test_from_reference_produces_same_output_as_from_owned() {
1770        let commit = CommitMessage::from(indoc!(
1771            "
1772            Example Commit Message
1773
1774            This is an example commit message for linting
1775
1776            Relates-to: #153
1777
1778            # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1779            # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1780            # bricht den Commit ab.
1781            #
1782            # Auf Branch main
1783            # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1784            #
1785            # Zum Commit vorgemerkte \u{00E4}nderungen:
1786            #	neue Datei:     file
1787            #
1788            "
1789        ));
1790
1791        let from_ref = String::from(&commit);
1792        let from_owned = String::from(commit.clone());
1793
1794        assert_eq!(
1795            from_ref, from_owned,
1796            "String::from(&commit) should produce the same result as String::from(commit)"
1797        );
1798    }
1799
1800    #[test]
1801    fn test_from_reference_preserves_original() {
1802        let commit = CommitMessage::from(indoc!(
1803            "
1804            Example Commit Message
1805
1806            This is an example commit message for linting
1807            "
1808        ));
1809
1810        // Create a string from a reference to the commit
1811        let _string = String::from(&commit);
1812
1813        // Verify we can still use the commit after creating a string from it
1814        assert_eq!(
1815            commit.get_subject(),
1816            Subject::from("Example Commit Message"),
1817            "Original CommitMessage should still be usable after String::from(&commit)"
1818        );
1819    }
1820
1821    #[test]
1822    fn test_from_reference_with_scissors() {
1823        let commit = CommitMessage::from(indoc!(
1824            "
1825            Example Commit Message
1826
1827            This is an example commit message
1828
1829            # ------------------------ >8 ------------------------
1830            # Do not modify or remove the line above.
1831            # Everything below it will be ignored.
1832            diff --git a/file b/file
1833            new file mode 100644
1834            index 0000000..e69de29
1835            "
1836        ));
1837
1838        let from_ref = String::from(&commit);
1839
1840        assert!(
1841            from_ref.contains("# ------------------------ >8 ------------------------"),
1842            "String created from reference should include scissors section"
1843        );
1844        assert!(
1845            from_ref.contains("diff --git"),
1846            "String created from reference should include content after scissors"
1847        );
1848    }
1849}