Skip to main content

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    /// 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    /// This will get you 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            .get_ast()
778            .into_iter()
779            .filter_map(|fragment| match fragment {
780                Fragment::Body(body) => Some(String::from(body)),
781                Fragment::Comment(_) => None,
782            })
783            .collect::<Vec<_>>()
784            .join("\n");
785        re.is_match(&text)
786    }
787
788    fn guess_comment_character(message: &str) -> Option<char> {
789        Scissors::guess_comment_character(message)
790    }
791
792    /// Give you a new [`CommitMessage`] with the provided subject
793    ///
794    /// # Arguments
795    ///
796    /// * `subject` - The new Subject to use for the commit message
797    ///
798    /// # Returns
799    ///
800    /// A new `CommitMessage` with the updated subject
801    ///
802    /// # Examples
803    ///
804    /// ```
805    /// use indoc::indoc;
806    /// use mit_commit::{CommitMessage, Subject};
807    /// use regex::Regex;
808    ///
809    /// let commit = CommitMessage::from(indoc!(
810    ///     "
811    ///     Example Commit Message
812    ///
813    ///     This is an example commit message
814    ///     "
815    /// ));
816    ///
817    /// assert_eq!(
818    ///     commit.with_subject("Subject".into()).get_subject(),
819    ///     Subject::from("Subject")
820    /// );
821    /// ```
822    #[must_use]
823    pub fn with_subject(self, subject: Subject<'a>) -> Self {
824        let mut ast = self.ast;
825
826        if !ast.is_empty() {
827            ast.remove(0);
828        }
829        ast.insert(0, Body::from(subject.to_string()).into());
830
831        let comments = Comments::from(ast.clone());
832        let bodies = Bodies::from(ast.clone());
833        // Changing the subject (first Body fragment) does not affect which of
834        // the remaining bodies are trailers, so we keep the correctly-extracted
835        // trailers rather than re-extracting from the grouped AST, which would
836        // merge consecutive trailer lines into a single multi-line Body and
837        // produce a single bogus trailer.
838        let trailers = self.trailers;
839
840        Self {
841            scissors: self.scissors,
842            ast,
843            subject,
844            trailers,
845            comments,
846            bodies,
847        }
848    }
849
850    /// Give you a new [`CommitMessage`] with the provided body
851    ///
852    /// # Arguments
853    ///
854    /// * `contents` - The new body content to use for the commit message
855    ///
856    /// # Returns
857    ///
858    /// A new `CommitMessage` with the updated body contents
859    ///
860    /// # Examples
861    ///
862    /// ```
863    /// use indoc::indoc;
864    /// use mit_commit::{CommitMessage, Subject};
865    /// use regex::Regex;
866    ///
867    /// let commit = CommitMessage::from(indoc!(
868    ///     "
869    ///     Example Commit Message
870    ///
871    ///     This is an example commit message
872    ///     "
873    /// ));
874    /// let expected = CommitMessage::from(indoc!(
875    ///     "
876    ///     Example Commit Message
877    ///
878    ///     New body"
879    /// ));
880    ///
881    /// assert_eq!(commit.with_body_contents("New body"), expected);
882    /// ```
883    ///
884    /// A note on what we consider the body. The body is what falls after the
885    /// gutter. This means the following behaviour might happen
886    ///
887    /// ```
888    /// use indoc::indoc;
889    /// use mit_commit::{CommitMessage, Subject};
890    /// use regex::Regex;
891    /// let commit = CommitMessage::from(indoc!(
892    ///     "
893    ///     Example Commit Message
894    ///     without gutter"
895    /// ));
896    /// let expected = CommitMessage::from(indoc!(
897    ///     "
898    ///     Example Commit Message
899    ///     without gutter
900    ///
901    ///     New body"
902    /// ));
903    ///
904    /// assert_eq!(commit.with_body_contents("New body"), expected);
905    /// ```
906    #[must_use]
907    pub fn with_body_contents(self, contents: &'a str) -> Self {
908        let existing_subject: Subject<'a> = self.get_subject();
909        let outer_scissors = self.scissors.map(|s| {
910            let string: String = s.into();
911            Scissors::from(string)
912        });
913        let body = format!("Unused\n\n{contents}");
914        let inner_commit = Self::from(body);
915
916        let inner_scissors = inner_commit.scissors.clone().map(|s| {
917            let string: String = s.into();
918            Scissors::from(string)
919        });
920
921        let mut result = inner_commit.with_subject(existing_subject);
922        // Preserve the outer scissors if present, otherwise keep any inner
923        // scissors so content containing the scissors marker is not silently
924        // dropped.
925        result.scissors = outer_scissors.or(inner_scissors);
926        result
927    }
928
929    /// Get the comment character used in the commit message
930    ///
931    /// # Returns
932    ///
933    /// The character used for comments in the commit message, or None if there are no comments
934    ///
935    /// # Examples
936    ///
937    /// ```
938    /// use mit_commit::{CommitMessage, Subject};
939    /// let commit = CommitMessage::from("No comment\n\n# Some Comment");
940    ///
941    /// assert_eq!(commit.get_comment_char().unwrap(), '#');
942    /// ```
943    ///
944    /// We return none is there is no comments
945    ///
946    /// ```
947    /// use mit_commit::{CommitMessage, Subject};
948    /// let commit = CommitMessage::from("No comment");
949    ///
950    /// assert!(commit.get_comment_char().is_none());
951    /// ```
952    #[must_use]
953    pub fn get_comment_char(&self) -> Option<char> {
954        self.comments
955            .iter()
956            .next()
957            .map(|comment| -> String { comment.clone().into() })
958            .and_then(|comment| comment.chars().next())
959    }
960}
961
962fn commit_message_to_string(commit_message: &CommitMessage<'_>) -> String {
963    let basic_commit = commit_message
964        .get_ast()
965        .iter()
966        .map(|item| match item {
967            Fragment::Body(contents) => String::from(contents.clone()),
968            Fragment::Comment(contents) => String::from(contents.clone()),
969        })
970        .collect::<Vec<_>>()
971        .join("\n");
972
973    if let Some(scissors) = commit_message.get_scissors() {
974        format!("{basic_commit}\n{}", String::from(scissors))
975    } else {
976        basic_commit
977    }
978}
979
980impl From<CommitMessage<'_>> for String {
981    fn from(commit_message: CommitMessage<'_>) -> Self {
982        commit_message_to_string(&commit_message)
983    }
984}
985
986impl From<&CommitMessage<'_>> for String {
987    fn from(commit_message: &CommitMessage<'_>) -> Self {
988        commit_message_to_string(commit_message)
989    }
990}
991
992/// Parse a commit message using parsers
993impl CommitMessage<'_> {
994    fn parse_commit_message(message: &str) -> Self {
995        // Step 1: Split the message into body and scissors sections
996        let (rest, scissors) = Scissors::parse_sections(message);
997
998        // Step 2: Guess the comment character
999        let comment_character = Self::guess_comment_character(message);
1000
1001        // Step 3: Convert the body to a per-line AST
1002        let per_line_ast = Self::convert_to_per_line_ast(comment_character, &rest);
1003
1004        // Step 4: Extract trailers before grouping to avoid cloning the entire AST
1005        let trailers = Trailers::from(per_line_ast.clone());
1006
1007        // Step 5: Group consecutive fragments of the same type
1008        let mut ast: Vec<Fragment<'_>> = Self::group_ast(per_line_ast);
1009
1010        // Step 6: Handle trailing newline case
1011        // Base this on the body portion (`rest`) rather than the whole `message`:
1012        // when a scissors section is present, `rest` still ends with the newline
1013        // that separates the body from the scissors marker (the blank line). Using
1014        // `rest` preserves that blank line through a round-trip.
1015        if rest.ends_with('\n') {
1016            ast.push(Body::default().into());
1017        }
1018
1019        // Step 7: Create subject, comments, and bodies from the AST
1020        // We need to clone here because the From implementations require owned vectors
1021        let subject = Subject::from(ast.clone());
1022        let comments = Comments::from(ast.clone());
1023        let bodies = Bodies::from(ast.clone());
1024
1025        // Step 8: Create and return the CommitMessage
1026        Self {
1027            scissors,
1028            ast,
1029            subject,
1030            trailers,
1031            comments,
1032            bodies,
1033        }
1034    }
1035}
1036
1037impl<'a> From<Cow<'a, str>> for CommitMessage<'a> {
1038    /// Create a new [`CommitMessage`]
1039    ///
1040    /// Create a commit message from a string. It's expected that you'll be
1041    /// reading this during some sort of Git Hook
1042    ///
1043    /// # Examples
1044    ///
1045    /// ```
1046    /// use indoc::indoc;
1047    /// use mit_commit::{Bodies, CommitMessage, Subject};
1048    ///
1049    /// let message = CommitMessage::from(indoc!(
1050    ///     "
1051    ///     Update bashrc to include kubernetes completions
1052    ///
1053    ///     This should make it easier to deploy things for the developers.
1054    ///     Benchmarked with Hyperfine, no noticable performance decrease.
1055    ///
1056    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1057    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
1058    ///     ; bricht den Commit ab.
1059    ///     ;
1060    ///     ; Datum:            Sat Jun 27 21:40:14 2020 +0200
1061    ///     ;
1062    ///     ; Auf Branch master
1063    ///     ;
1064    ///     ; Initialer Commit
1065    ///     ;
1066    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
1067    ///     ;    neue Datei:     .bashrc
1068    ///     ;"
1069    /// ));
1070    /// assert_eq!(
1071    ///     message.get_subject(),
1072    ///     Subject::from("Update bashrc to include kubernetes completions")
1073    /// )
1074    /// ```
1075    ///
1076    ///  # Comment Character
1077    ///
1078    /// We load the comment character for the commit message
1079    ///
1080    /// 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).
1081    ///
1082    /// This does mean that we aren't making 100% of characters available, which
1083    /// is technically possible, but given we don't have access to the users git
1084    /// config this feels like a reasonable compromise, there are a lot of
1085    /// non-whitespace characters as options otherwise, and we don't want to
1086    /// confuse a genuine body with a comment
1087    fn from(message: Cow<'a, str>) -> Self {
1088        Self::parse_commit_message(&message)
1089    }
1090}
1091
1092impl TryFrom<PathBuf> for CommitMessage<'_> {
1093    type Error = Error;
1094
1095    /// Creates a `CommitMessage` from a file path.
1096    ///
1097    /// # Arguments
1098    ///
1099    /// * `value` - The path to the file containing the commit message
1100    ///
1101    /// # Returns
1102    ///
1103    /// A `CommitMessage` parsed from the file contents
1104    ///
1105    /// # Examples
1106    ///
1107    /// ```
1108    /// use std::path::PathBuf;
1109    /// use std::convert::TryFrom;
1110    /// use std::io::Write;
1111    /// use mit_commit::CommitMessage;
1112    ///
1113    /// // Create a temporary file for the example
1114    /// let mut temp_file = tempfile::NamedTempFile::new().unwrap();
1115    /// write!(temp_file.as_file(), "Example commit message").unwrap();
1116    ///
1117    /// // Use the temporary file path
1118    /// let path = temp_file.path().to_path_buf();
1119    /// let commit_message = CommitMessage::try_from(path).expect("Failed to read commit message");
1120    /// assert_eq!(commit_message.get_subject().to_string(), "Example commit message");
1121    /// ```
1122    ///
1123    /// # Errors
1124    ///
1125    /// Returns an Error if the file cannot be read or if the file contents cannot be parsed as UTF-8
1126    fn try_from(value: PathBuf) -> Result<Self, Self::Error> {
1127        let mut file = File::open(value)?;
1128        let mut buffer = String::new();
1129
1130        file.read_to_string(&mut buffer)
1131            .map_err(Error::from)
1132            .map(move |_| Self::from(buffer))
1133    }
1134}
1135
1136impl<'a> TryFrom<&'a Path> for CommitMessage<'a> {
1137    type Error = Error;
1138
1139    /// Creates a `CommitMessage` from a file path reference.
1140    ///
1141    /// # Arguments
1142    ///
1143    /// * `value` - The path reference to the file containing the commit message
1144    ///
1145    /// # Returns
1146    ///
1147    /// A `CommitMessage` parsed from the file contents
1148    ///
1149    /// # Examples
1150    ///
1151    /// ```
1152    /// use std::path::Path;
1153    /// use std::convert::TryFrom;
1154    /// use std::io::Write;
1155    /// use mit_commit::CommitMessage;
1156    ///
1157    /// // Create a temporary file for the example
1158    /// let mut temp_file = tempfile::NamedTempFile::new().unwrap();
1159    /// write!(temp_file.as_file(), "Example commit message").unwrap();
1160    ///
1161    /// // Use the temporary file path
1162    /// let path = temp_file.path();
1163    /// let commit_message = CommitMessage::try_from(path).expect("Failed to read commit message");
1164    /// assert_eq!(commit_message.get_subject().to_string(), "Example commit message");
1165    /// ```
1166    ///
1167    /// # Errors
1168    ///
1169    /// Returns an Error if the file cannot be read or if the file contents cannot be parsed as UTF-8
1170    fn try_from(value: &'a Path) -> Result<Self, Self::Error> {
1171        let mut file = File::open(value)?;
1172        let mut buffer = String::new();
1173
1174        file.read_to_string(&mut buffer)
1175            .map_err(Error::from)
1176            .map(move |_| Self::from(buffer))
1177    }
1178}
1179
1180impl<'a> From<&'a str> for CommitMessage<'a> {
1181    fn from(message: &'a str) -> Self {
1182        CommitMessage::from(Cow::from(message))
1183    }
1184}
1185
1186impl From<String> for CommitMessage<'_> {
1187    fn from(message: String) -> Self {
1188        Self::from(Cow::from(message))
1189    }
1190}
1191
1192/// Errors on reading commit messages
1193#[derive(Error, Debug, Diagnostic)]
1194pub enum Error {
1195    /// Failed to read a commit message
1196    #[error("failed to read commit file {0}")]
1197    #[diagnostic(
1198        url(docsrs),
1199        code(mit_commit::commit_message::error::io),
1200        help("check the file is readable")
1201    )]
1202    Io(#[from] io::Error),
1203}
1204
1205#[cfg(test)]
1206mod tests {
1207    use std::{convert::TryInto, io::Write};
1208
1209    use indoc::indoc;
1210    use quickcheck::TestResult;
1211    use regex::Regex;
1212    use tempfile::NamedTempFile;
1213
1214    use super::*;
1215    use crate::{
1216        Fragment, bodies::Bodies, body::Body, comment::Comment, scissors::Scissors,
1217        subject::Subject, trailer::Trailer,
1218    };
1219
1220    #[test]
1221    fn test_default_returns_empty_string() {
1222        let commit = CommitMessage::default();
1223        let actual: String = commit.into();
1224
1225        assert_eq!(
1226            actual,
1227            String::new(),
1228            "Default CommitMessage should convert to an empty string"
1229        );
1230    }
1231
1232    #[test]
1233    fn test_matches_pattern_returns_correct_results() {
1234        let commit = CommitMessage::from(indoc!(
1235                "
1236                Example Commit Message
1237
1238                This is an example commit message for linting
1239
1240                Relates-to: #153
1241                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1242                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1243                # bricht den Commit ab.
1244                #
1245                # Auf Branch main
1246                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1247                #
1248                # Zum Commit vorgemerkte \u{00E4}nderungen:
1249                #	neue Datei:     file
1250                #
1251                "
1252            ));
1253
1254        let re = Regex::new("[Bb]itte").unwrap();
1255        assert!(
1256            !commit.matches_pattern(&re),
1257            "Pattern should not match in comments"
1258        );
1259
1260        let re = Regex::new("f[o\u{00FC}]r linting").unwrap();
1261        assert!(
1262            commit.matches_pattern(&re),
1263            "Pattern should match in body text"
1264        );
1265
1266        let re = Regex::new("[Ee]xample Commit Message").unwrap();
1267        assert!(
1268            commit.matches_pattern(&re),
1269            "Pattern should match in subject"
1270        );
1271
1272        let re = Regex::new("Relates[- ]to").unwrap();
1273        assert!(
1274            commit.matches_pattern(&re),
1275            "Pattern should match in trailers"
1276        );
1277    }
1278
1279    #[test]
1280    fn test_parse_message_without_gutter_succeeds() {
1281        let commit = CommitMessage::from(indoc!(
1282                "
1283                Example Commit Message
1284                This is an example commit message for linting
1285
1286                This is another line
1287                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1288                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1289                # bricht den Commit ab.
1290                #
1291                # Auf Branch main
1292                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1293                #
1294                # Zum Commit vorgemerkte \u{00E4}nderungen:
1295                #	neue Datei:     file
1296                #
1297                "
1298            ));
1299
1300        assert_eq!(
1301            commit.get_subject(),
1302            Subject::from("Example Commit Message\nThis is an example commit message for linting"),
1303            "Subject should include both lines when there's no gutter"
1304        );
1305        assert_eq!(
1306            commit.get_body(),
1307            Bodies::from(vec![Body::default(), Body::from("This is another line")]),
1308            "Body should contain the line after the empty line"
1309        );
1310    }
1311
1312    #[test]
1313    fn test_add_trailer_to_normal_commit_appends_correctly() {
1314        let commit = CommitMessage::from(indoc!(
1315            "
1316            Example Commit Message
1317
1318            This is an example commit message for linting
1319
1320            Relates-to: #153
1321
1322            # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1323            # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1324            # bricht den Commit ab.
1325            #
1326            # Auf Branch main
1327            # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1328            #
1329            # Zum Commit vorgemerkte \u{00E4}nderungen:
1330            #	neue Datei:     file
1331            #
1332            "
1333        ));
1334
1335        let expected = CommitMessage::from(indoc!(
1336            "
1337            Example Commit Message
1338
1339            This is an example commit message for linting
1340
1341            Relates-to: #153
1342            Co-authored-by: Test Trailer <test@example.com>
1343
1344            # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1345            # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1346            # bricht den Commit ab.
1347            #
1348            # Auf Branch main
1349            # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1350            #
1351            # Zum Commit vorgemerkte \u{00E4}nderungen:
1352            #	neue Datei:     file
1353            #
1354            "
1355        ));
1356
1357        let actual = commit.add_trailer(Trailer::new(
1358            "Co-authored-by".into(),
1359            "Test Trailer <test@example.com>".into(),
1360        ));
1361
1362        assert_eq!(
1363            String::from(actual),
1364            String::from(expected),
1365            "Adding a trailer to a commit with existing trailers should append the new trailer after the last trailer"
1366        );
1367    }
1368
1369    #[test]
1370    fn test_add_trailer_to_conventional_commit_appends_correctly() {
1371        let commit = CommitMessage::from(indoc!(
1372            "
1373            feat: Example Commit Message
1374
1375            This is an example commit message for linting
1376
1377            # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1378            # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1379            # bricht den Commit ab.
1380            #
1381            # Auf Branch main
1382            # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1383            #
1384            # Zum Commit vorgemerkte \u{00E4}nderungen:
1385            #	neue Datei:     file
1386            #
1387            "
1388        ));
1389
1390        let expected = CommitMessage::from(indoc!(
1391            "
1392            feat: Example Commit Message
1393
1394            This is an example commit message for linting
1395
1396            Co-authored-by: Test Trailer <test@example.com>
1397
1398            # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1399            # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1400            # bricht den Commit ab.
1401            #
1402            # Auf Branch main
1403            # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1404            #
1405            # Zum Commit vorgemerkte \u{00E4}nderungen:
1406            #	neue Datei:     file
1407            #
1408            "
1409        ));
1410
1411        let actual = commit.add_trailer(Trailer::new(
1412            "Co-authored-by".into(),
1413            "Test Trailer <test@example.com>".into(),
1414        ));
1415
1416        assert_eq!(
1417            String::from(actual),
1418            String::from(expected),
1419            "Adding a trailer to a conventional commit should append the trailer after the body"
1420        );
1421    }
1422
1423    #[test]
1424    fn test_add_trailer_to_commit_without_trailers_creates_trailer_section() {
1425        let commit = CommitMessage::from(indoc!(
1426                "
1427                Example Commit Message
1428
1429                This is an example commit message for linting
1430
1431                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1432                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1433                # bricht den Commit ab.
1434                #
1435                # Auf Branch main
1436                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1437                #
1438                # Zum Commit vorgemerkte \u{00E4}nderungen:
1439                #	neue Datei:     file
1440                #
1441                "
1442            ));
1443
1444        let expected = CommitMessage::from(indoc!(
1445                "
1446                Example Commit Message
1447
1448                This is an example commit message for linting
1449
1450                Co-authored-by: Test Trailer <test@example.com>
1451
1452                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1453                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1454                # bricht den Commit ab.
1455                #
1456                # Auf Branch main
1457                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1458                #
1459                # Zum Commit vorgemerkte \u{00E4}nderungen:
1460                #	neue Datei:     file
1461                #
1462                "
1463            ));
1464        assert_eq!(
1465            String::from(commit.add_trailer(Trailer::new(
1466                "Co-authored-by".into(),
1467                "Test Trailer <test@example.com>".into(),
1468            ))),
1469            String::from(expected),
1470            "Adding a trailer to a commit without existing trailers should create a new trailer section after the body"
1471        );
1472    }
1473
1474    #[test]
1475    fn test_add_trailer_to_empty_commit_creates_trailer_section() {
1476        let commit = CommitMessage::from(indoc!(
1477                "
1478
1479                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1480                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1481                # bricht den Commit ab.
1482                #
1483                # Auf Branch main
1484                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1485                #
1486                # Zum Commit vorgemerkte \u{00E4}nderungen:
1487                #	neue Datei:     file
1488                #
1489                "
1490            ));
1491
1492        let expected = CommitMessage::from(indoc!(
1493                "
1494
1495
1496                Co-authored-by: Test Trailer <test@example.com>
1497
1498                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1499                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1500                # bricht den Commit ab.
1501                #
1502                # Auf Branch main
1503                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1504                #
1505                # Zum Commit vorgemerkte \u{00E4}nderungen:
1506                #	neue Datei:     file
1507                #
1508                "
1509            ));
1510        assert_eq!(
1511            String::from(commit.add_trailer(Trailer::new(
1512                "Co-authored-by".into(),
1513                "Test Trailer <test@example.com>".into(),
1514            ))),
1515            String::from(expected),
1516            "Adding a trailer to an empty commit should create a trailer section at the beginning"
1517        );
1518    }
1519
1520    #[test]
1521    fn test_add_trailer_to_empty_commit_with_trailer_appends_correctly() {
1522        let commit = CommitMessage::from(indoc!(
1523                "
1524
1525
1526                Co-authored-by: Test Trailer <test@example.com>
1527
1528                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1529                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1530                # bricht den Commit ab.
1531                #
1532                # Auf Branch main
1533                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1534                #
1535                # Zum Commit vorgemerkte \u{00E4}nderungen:
1536                #	neue Datei:     file
1537                #
1538                "
1539            ));
1540
1541        let expected = CommitMessage::from(indoc!(
1542                "
1543
1544
1545                Co-authored-by: Test Trailer <test@example.com>
1546                Co-authored-by: Someone Else <someone@example.com>
1547
1548                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1549                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1550                # bricht den Commit ab.
1551                #
1552                # Auf Branch main
1553                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1554                #
1555                # Zum Commit vorgemerkte \u{00E4}nderungen:
1556                #	neue Datei:     file
1557                #
1558                "
1559            ));
1560        assert_eq!(
1561            String::from(commit.add_trailer(Trailer::new(
1562                "Co-authored-by".into(),
1563                "Someone Else <someone@example.com>".into(),
1564            ))),
1565            String::from(expected),
1566            "Adding a trailer to an empty commit with an existing trailer should append the new trailer after the existing one"
1567        );
1568    }
1569
1570    #[test]
1571    fn test_from_fragments_generates_correct_commit() {
1572        let message = CommitMessage::from_fragments(
1573            vec![
1574                Fragment::Body(Body::from("Example Commit")),
1575                Fragment::Body(Body::default()),
1576                Fragment::Body(Body::from("Here is a body")),
1577                Fragment::Comment(Comment::from("# Example Commit")),
1578            ],
1579            Some(Scissors::from(indoc!(
1580                "
1581                # ------------------------ >8 ------------------------
1582                # \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
1583                # Alles unterhalb von ihr wird ignoriert.
1584                diff --git a/file b/file
1585                new file mode 100644
1586                index 0000000..e69de29
1587                "
1588            ))),
1589        );
1590
1591        assert_eq!(
1592            String::from(message),
1593            String::from(indoc!(
1594                "
1595                Example Commit
1596
1597                Here is a body
1598                # Example Commit
1599                # ------------------------ >8 ------------------------
1600                # \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
1601                # Alles unterhalb von ihr wird ignoriert.
1602                diff --git a/file b/file
1603                new file mode 100644
1604                index 0000000..e69de29
1605                "
1606            )),
1607            "Creating a CommitMessage from fragments should generate the correct string representation"
1608        );
1609    }
1610
1611    #[test]
1612    fn test_insert_after_last_body_appends_correctly() {
1613        let ast: Vec<Fragment<'_>> = vec![
1614            Fragment::Body(Body::from("Add file")),
1615            Fragment::Body(Body::default()),
1616            Fragment::Body(Body::from("Looks-like-a-trailer: But isn\'t")),
1617            Fragment::Body(Body::default()),
1618            Fragment::Body(Body::from(
1619                "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.",
1620            )),
1621            Fragment::Body(Body::default()),
1622            Fragment::Body(Body::from("Relates-to: #128")),
1623            Fragment::Body(Body::default()),
1624            Fragment::Comment(Comment::from(
1625                "# 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",
1626            )),
1627            Fragment::Body(Body::default()),
1628            Fragment::Comment(Comment::from(
1629                "# 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#",
1630            )),
1631        ];
1632        let commit = CommitMessage::from_fragments(ast, None);
1633
1634        assert_eq!(
1635            commit
1636                .insert_after_last_full_body(vec![Fragment::Body(Body::from("Relates-to: #656"))])
1637                .get_ast(),
1638            vec![
1639                Fragment::Body(Body::from("Add file")),
1640                Fragment::Body(Body::default()),
1641                Fragment::Body(Body::from("Looks-like-a-trailer: But isn\'t")),
1642                Fragment::Body(Body::default()),
1643                Fragment::Body(Body::from(
1644                    "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."
1645                )),
1646                Fragment::Body(Body::default()),
1647                Fragment::Body(Body::from("Relates-to: #128\nRelates-to: #656")),
1648                Fragment::Body(Body::default()),
1649                Fragment::Comment(Comment::from(
1650                    "# 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"
1651                )),
1652                Fragment::Body(Body::default()),
1653                Fragment::Comment(Comment::from(
1654                    "# 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#"
1655                )),
1656            ],
1657            "Inserting after the last body should append the new fragment after the last non-empty body fragment"
1658        );
1659    }
1660
1661    #[test]
1662    fn test_insert_after_last_body_with_no_body_inserts_at_beginning() {
1663        let ast: Vec<Fragment<'_>> = vec![
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        let commit = CommitMessage::from_fragments(ast, None);
1673
1674        assert_eq!(
1675            commit
1676                .insert_after_last_full_body(vec![Fragment::Body(Body::from("Relates-to: #656"))])
1677                .get_ast(),
1678            vec![
1679                Fragment::Body(Body::from("Relates-to: #656")),
1680                Fragment::Comment(Comment::from(
1681                    "# 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"
1682                )),
1683                Fragment::Body(Body::default()),
1684                Fragment::Comment(Comment::from(
1685                    "# 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#"
1686                )),
1687            ],
1688            "When there is no body, inserting after the last body should insert at the beginning of the AST"
1689        );
1690    }
1691
1692    #[allow(clippy::needless_pass_by_value)]
1693    #[quickcheck]
1694    fn test_with_subject_preserves_input_string(input: String) -> bool {
1695        let commit: CommitMessage<'_> = "Some Subject".into();
1696        let actual: String = commit
1697            .with_subject(input.clone().into())
1698            .get_subject()
1699            .into();
1700        // Property: The subject should be exactly the input string after setting it
1701        actual == input
1702    }
1703
1704    #[test]
1705    fn test_with_subject_preserves_multiple_trailers() {
1706        // When multiple consecutive trailers exist, the grouped AST merges them
1707        // into a single Body (e.g. "Relates-to: #1\nRelates-to: #2"). Calling
1708        // with_subject must NOT collapse those into a single trailer.
1709        let commit = CommitMessage::from(indoc!(
1710            "
1711            feat: add login
1712
1713            Body text
1714
1715            Relates-to: #1
1716            Relates-to: #2
1717            "
1718        ));
1719
1720        assert_eq!(
1721            commit.get_trailers().len(),
1722            2,
1723            "Initial commit should have two trailers"
1724        );
1725
1726        let updated = commit.with_subject("fix: add signup".into());
1727        let trailers: Vec<_> = updated.get_trailers().into_iter().collect();
1728
1729        assert_eq!(
1730            trailers.len(),
1731            2,
1732            "with_subject should preserve all trailers, got {}: {:?}",
1733            trailers.len(),
1734            trailers
1735        );
1736        assert_eq!(trailers[0], Trailer::new("Relates-to".into(), "#1".into()),);
1737        assert_eq!(trailers[1], Trailer::new("Relates-to".into(), "#2".into()),);
1738    }
1739
1740    #[test]
1741    fn test_with_subject_on_default_commit_sets_subject_correctly() {
1742        let commit = CommitMessage::default().with_subject("Subject".into());
1743        assert_eq!(
1744            commit.get_subject(),
1745            Subject::from("Subject"),
1746            "Setting subject on default commit should update the subject correctly"
1747        );
1748    }
1749
1750    #[test]
1751    fn test_with_subject_recomputes_derived_fields_from_new_ast() {
1752        // A message where the first fragment is a comment, not a body
1753        let commit = CommitMessage::from("# Comment\nOriginal Subject");
1754
1755        // The original string contains the comment
1756        let original_string: String = commit.clone().into();
1757        assert!(original_string.contains("# Comment"));
1758
1759        let updated = commit.with_subject("New Subject".into());
1760
1761        // The new subject should be correct
1762        assert_eq!(updated.get_subject(), Subject::from("New Subject"));
1763
1764        // The string representation should not contain the old comment
1765        let as_string: String = updated.clone().into();
1766        assert!(
1767            !as_string.contains("# Comment"),
1768            "Old comment should not appear in string representation: {as_string}"
1769        );
1770
1771        // Verify that get_comments() returns fresh data recomputed from the new AST,
1772        // not stale data carried over from the old commit message
1773        assert!(
1774            updated.get_comments().iter().next().is_none(),
1775            "Comments should be recomputed from new AST, but got stale data: {:?}",
1776            updated.get_comments().iter().collect::<Vec<_>>()
1777        );
1778
1779        // Verify that get_bodies() returns fresh data recomputed from the new AST.
1780        // After replacing the comment with a subject body, the old subject body
1781        // should now appear in bodies.
1782        assert!(
1783            updated
1784                .get_body()
1785                .into_iter()
1786                .any(|b| b.to_string() == "Original Subject"),
1787            "Bodies should be recomputed from new AST, but got stale data"
1788        );
1789    }
1790
1791    #[allow(clippy::needless_pass_by_value)]
1792    #[quickcheck]
1793    fn test_with_body_contents_replaces_body_correctly(input: String) -> TestResult {
1794        if input.contains('\r') {
1795            return TestResult::discard();
1796        }
1797
1798        let commit: CommitMessage<'_> = "Some Subject\n\nSome Body".into();
1799        let expected: String = format!("Some Subject\n\n{input}");
1800        let actual: String = commit.with_body_contents(&input).into();
1801        // Property: The body should be replaced with the input string while preserving the subject
1802        TestResult::from_bool(actual == expected)
1803    }
1804
1805    #[allow(clippy::needless_pass_by_value)]
1806    #[quickcheck]
1807    fn test_with_body_contents_preserves_multiline_subject(input: String) -> TestResult {
1808        if input.contains('\r') {
1809            return TestResult::discard();
1810        }
1811
1812        let commit: CommitMessage<'_> = "Some Subject\nSome More Subject\n\nBody".into();
1813        let expected: String = format!("Some Subject\nSome More Subject\n\n{input}");
1814        let actual: String = commit.with_body_contents(&input).into();
1815        // Property: The body should be replaced with the input string while preserving the multi-line subject
1816        TestResult::from_bool(actual == expected)
1817    }
1818
1819    #[test]
1820    fn test_with_body_contents_preserves_content_containing_scissors_marker() {
1821        let input = "some line\n# ------------------------ >8 ------------------------\nmore";
1822        let commit: CommitMessage<'_> = "Some Subject\n\nSome Body".into();
1823        let expected: String = format!("Some Subject\n\n{input}");
1824        let actual: String = commit.with_body_contents(input).into();
1825        assert_eq!(
1826            actual, expected,
1827            "with_body_contents should preserve all content including scissors markers, got: {actual}"
1828        );
1829    }
1830
1831    #[test]
1832    fn test_get_comment_char_returns_none_when_no_comments() {
1833        let commit_character = CommitMessage::from("Example Commit Message");
1834        assert!(
1835            commit_character.get_comment_char().is_none(),
1836            "Comment character should be None when there are no comments in the message"
1837        );
1838    }
1839
1840    #[test]
1841    fn test_try_from_path_buf_reads_file_correctly() {
1842        let temp_file = NamedTempFile::new().expect("failed to create temp file");
1843        write!(temp_file.as_file(), "Some Subject").expect("Failed to write file");
1844
1845        let commit_character: CommitMessage<'_> = temp_file
1846            .path()
1847            .to_path_buf()
1848            .try_into()
1849            .expect("Could not read commit message");
1850        assert_eq!(
1851            commit_character.get_subject().to_string(),
1852            "Some Subject",
1853            "Reading from PathBuf should correctly parse the file contents into a CommitMessage"
1854        );
1855    }
1856
1857    #[test]
1858    fn test_try_from_path_reads_file_correctly() {
1859        let temp_file = NamedTempFile::new().expect("failed to create temp file");
1860        write!(temp_file.as_file(), "Some Subject").expect("Failed to write file");
1861
1862        let commit_character: CommitMessage<'_> = temp_file
1863            .path()
1864            .try_into()
1865            .expect("Could not read commit message");
1866        assert_eq!(
1867            commit_character.get_subject().to_string(),
1868            "Some Subject",
1869            "Reading from Path should correctly parse the file contents into a CommitMessage"
1870        );
1871    }
1872
1873    #[test]
1874    fn test_from_reference_produces_same_output_as_from_owned() {
1875        let commit = CommitMessage::from(indoc!(
1876            "
1877            Example Commit Message
1878
1879            This is an example commit message for linting
1880
1881            Relates-to: #153
1882
1883            # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1884            # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1885            # bricht den Commit ab.
1886            #
1887            # Auf Branch main
1888            # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1889            #
1890            # Zum Commit vorgemerkte \u{00E4}nderungen:
1891            #	neue Datei:     file
1892            #
1893            "
1894        ));
1895
1896        let from_ref = String::from(&commit);
1897        let from_owned = String::from(commit.clone());
1898
1899        assert_eq!(
1900            from_ref, from_owned,
1901            "String::from(&commit) should produce the same result as String::from(commit)"
1902        );
1903    }
1904
1905    #[test]
1906    fn test_from_reference_preserves_original() {
1907        let commit = CommitMessage::from(indoc!(
1908            "
1909            Example Commit Message
1910
1911            This is an example commit message for linting
1912            "
1913        ));
1914
1915        // Create a string from a reference to the commit
1916        let _string = String::from(&commit);
1917
1918        // Verify we can still use the commit after creating a string from it
1919        assert_eq!(
1920            commit.get_subject(),
1921            Subject::from("Example Commit Message"),
1922            "Original CommitMessage should still be usable after String::from(&commit)"
1923        );
1924    }
1925
1926    #[test]
1927    fn test_with_body_contents_preserves_scissors() {
1928        let commit = CommitMessage::from(indoc!(
1929            "
1930            Example Commit Message
1931
1932            This is an example commit message
1933
1934            # ------------------------ >8 ------------------------
1935            # Do not modify or remove the line above.
1936            # Everything below it will be ignored.
1937            diff --git a/file b/file
1938            new file mode 100644
1939            index 0000000..e69de29
1940            "
1941        ));
1942
1943        let updated = commit.with_body_contents("New body content");
1944        let result: String = updated.into();
1945
1946        assert!(
1947            result.contains("# ------------------------ >8 ------------------------"),
1948            "with_body_contents should preserve scissors section, got: {result}"
1949        );
1950        assert!(
1951            result.contains("New body content"),
1952            "with_body_contents should contain the new body, got: {result}"
1953        );
1954        assert!(
1955            result.contains("Example Commit Message"),
1956            "with_body_contents should preserve the subject, got: {result}"
1957        );
1958    }
1959
1960    #[test]
1961    fn test_from_reference_with_scissors() {
1962        let commit = CommitMessage::from(indoc!(
1963            "
1964            Example Commit Message
1965
1966            This is an example commit message
1967
1968            # ------------------------ >8 ------------------------
1969            # Do not modify or remove the line above.
1970            # Everything below it will be ignored.
1971            diff --git a/file b/file
1972            new file mode 100644
1973            index 0000000..e69de29
1974            "
1975        ));
1976
1977        let from_ref = String::from(&commit);
1978
1979        assert!(
1980            from_ref.contains("# ------------------------ >8 ------------------------"),
1981            "String created from reference should include scissors section"
1982        );
1983        assert!(
1984            from_ref.contains("diff --git"),
1985            "String created from reference should include content after scissors"
1986        );
1987    }
1988
1989    #[test]
1990    fn test_roundtrip_preserves_blank_line_before_scissors() {
1991        let input = "Subject\n\nBody text\n\n# ------------------------ >8 ------------------------\n# Everything below is ignored.\ndiff --git a/file b/file\n";
1992        let output: String = CommitMessage::from(input).into();
1993
1994        assert_eq!(
1995            output, input,
1996            "Round-tripping a commit message with a body and scissors should preserve the blank line between the body and the scissors marker"
1997        );
1998    }
1999}