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,
16    body::Body,
17    comment::Comment,
18    comments::Comments,
19    fragment::Fragment,
20    subject::Subject,
21    trailers::Trailers,
22};
23use crate::{scissors::Scissors, Trailer};
24
25/// A [`CommitMessage`], the primary entry point to the library
26#[derive(Debug, PartialEq, Eq, Clone, Default)]
27pub struct CommitMessage<'a> {
28    scissors: Option<Scissors<'a>>,
29    ast: Vec<Fragment<'a>>,
30    subject: Subject<'a>,
31    trailers: Trailers<'a>,
32    comments: Comments<'a>,
33    bodies: Bodies<'a>,
34}
35
36impl<'a> CommitMessage<'a> {
37    /// Convert from [`Fragment`] back into a full [`CommitMessage`]
38    ///
39    /// Get back to a [`CommitMessage`] from an ast, usually after you've been
40    /// editing the text.
41    ///
42    /// # Examples
43    ///
44    /// ```
45    /// use indoc::indoc;
46    /// use mit_commit::{Bodies, CommitMessage, Subject};
47    ///
48    /// let message = CommitMessage::from(indoc!(
49    ///     "
50    ///     Update bashrc to include kubernetes completions
51    ///
52    ///     This should make it easier to deploy things for the developers.
53    ///     Benchmarked with Hyperfine, no noticable performance decrease.
54    ///
55    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
56    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
57    ///     ; bricht den Commit ab.
58    ///     ;
59    ///     ; Datum:            Sat Jun 27 21:40:14 2020 +0200
60    ///     ;
61    ///     ; Auf Branch master
62    ///     ;
63    ///     ; Initialer Commit
64    ///     ;
65    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
66    ///     ;    neue Datei:     .bashrc
67    ///     ;"
68    /// ));
69    /// assert_eq!(
70    ///     CommitMessage::from_fragments(message.get_ast(), message.get_scissors()),
71    ///     message,
72    /// )
73    /// ```
74    #[must_use]
75    pub fn from_fragments(fragments: Vec<Fragment<'_>>, scissors: Option<Scissors<'_>>) -> Self {
76        let body = fragments
77            .into_iter()
78            .map(|x| match x {
79                Fragment::Body(contents) => String::from(contents),
80                Fragment::Comment(contents) => String::from(contents),
81            })
82            .collect::<Vec<String>>()
83            .join("\n");
84
85        let scissors: String = scissors
86            .map(|contents| format!("\n{}", String::from(contents)))
87            .unwrap_or_default();
88
89        Self::from(format!("{body}{scissors}"))
90    }
91
92    /// A helper method to let you insert [`Trailer`]
93    ///
94    /// # Examples
95    ///
96    /// ```
97    /// use indoc::indoc;
98    /// use mit_commit::{CommitMessage, Trailer};
99    /// let commit = CommitMessage::from(indoc!(
100    ///     "
101    ///     Example Commit Message
102    ///
103    ///     This is an example commit message for linting
104    ///
105    ///     Relates-to: #153
106    ///
107    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
108    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
109    ///     ; bricht den Commit ab.
110    ///     ;
111    ///     ; Auf Branch main
112    ///     ; Ihr Branch ist auf demselben Stand wie 'origin/main'.
113    ///     ;
114    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
115    ///     ;    neue Datei:     file
116    ///     ;
117    ///     "
118    /// ));
119    ///
120    /// assert_eq!(
121    ///     String::from(commit.add_trailer(Trailer::new(
122    ///         "Co-authored-by".into(),
123    ///         "Test Trailer <test@example.com>".into()
124    ///     ))),
125    ///     String::from(CommitMessage::from(indoc!(
126    ///         "
127    ///         Example Commit Message
128    ///
129    ///         This is an example commit message for linting
130    ///
131    ///         Relates-to: #153
132    ///         Co-authored-by: Test Trailer <test@example.com>
133    ///
134    ///         ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
135    ///         ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
136    ///         ; bricht den Commit ab.
137    ///         ;
138    ///         ; Auf Branch main
139    ///         ; Ihr Branch ist auf demselben Stand wie 'origin/main'.
140    ///         ;
141    ///         ; Zum Commit vorgemerkte \u{00E4}nderungen:
142    ///         ;    neue Datei:     file
143    ///         ;
144    ///         "
145    ///     )))
146    /// );
147    /// ```
148    #[must_use]
149    pub fn add_trailer(&self, trailer: Trailer<'_>) -> Self {
150        let mut fragments = Vec::new();
151
152        if self.bodies.iter().all(Body::is_empty) && self.trailers.is_empty() {
153            fragments.push(Body::default().into());
154        }
155
156        if self.trailers.is_empty() {
157            fragments.push(Body::default().into());
158        }
159
160        fragments.push(trailer.into());
161
162        self.insert_after_last_full_body(fragments)
163    }
164
165    /// Insert text in the place you're most likely to want it
166    ///
167    /// In the case you don't have any full [`Body`] in there, it inserts it at
168    /// the top of the commit, in the [`Subject`] line.
169    ///
170    /// # Examples
171    ///
172    /// ```
173    /// use mit_commit::{Fragment, Body, CommitMessage, Comment};
174    ///
175    ///         let ast: Vec<Fragment> = vec![
176    ///             Fragment::Body(Body::from("Add file")),
177    ///             Fragment::Body(Body::default()),
178    ///             Fragment::Body(Body::from("Looks-like-a-trailer: But isn\'t")),
179    ///             Fragment::Body(Body::default()),
180    ///             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.")),
181    ///             Fragment::Body(Body::default()),
182    ///             Fragment::Body(Body::from("Relates-to: #128")),
183    ///             Fragment::Body(Body::default()),
184    ///             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")),
185    ///             Fragment::Body(Body::default()),
186    ///             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#"))
187    ///         ];
188    ///         let commit = CommitMessage::from_fragments(ast, None);
189    ///
190    ///         assert_eq!(commit.insert_after_last_full_body(vec![Fragment::Body(Body::from("Relates-to: #656"))]).get_ast(), vec![
191    ///             Fragment::Body(Body::from("Add file")),
192    ///             Fragment::Body(Body::default()),
193    ///             Fragment::Body(Body::from("Looks-like-a-trailer: But isn\'t")),
194    ///             Fragment::Body(Body::default()),
195    ///             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.")),
196    ///             Fragment::Body(Body::default()),
197    ///             Fragment::Body(Body::from("Relates-to: #128\nRelates-to: #656")),
198    ///             Fragment::Body(Body::default()),
199    ///             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")),
200    ///             Fragment::Body(Body::default()),
201    ///             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#"))
202    ///         ])
203    /// ```
204    #[must_use]
205    pub fn insert_after_last_full_body(&self, fragment: Vec<Fragment<'_>>) -> Self {
206        let position = self.ast.iter().rposition(|fragment| match fragment {
207            Fragment::Body(body) => !body.is_empty(),
208            Fragment::Comment(_) => false,
209        });
210
211        let (before, after): (Vec<_>, Vec<_>) = position.map_or_else(
212            || (vec![], self.ast.clone().into_iter().enumerate().collect()),
213            |position| {
214                self.ast
215                    .clone()
216                    .into_iter()
217                    .enumerate()
218                    .partition(|(index, _)| index <= &position)
219            },
220        );
221
222        Self::from_fragments(
223            [
224                before.into_iter().map(|(_, x)| x).collect(),
225                fragment,
226                after.into_iter().map(|(_, x)| x).collect(),
227            ]
228            .concat(),
229            self.get_scissors(),
230        )
231    }
232
233    fn convert_to_per_line_ast(comment_character: Option<char>, rest: &str) -> Vec<Fragment<'a>> {
234        rest.lines()
235            .map(|line| {
236                comment_character.map_or_else(
237                    || Body::from(line.to_string()).into(),
238                    |comment_character| {
239                        if line.starts_with(comment_character) {
240                            Comment::from(line.to_string()).into()
241                        } else {
242                            Body::from(line.to_string()).into()
243                        }
244                    },
245                )
246            })
247            .collect()
248    }
249
250    fn group_ast(ungrouped_ast: Vec<Fragment<'a>>) -> Vec<Fragment<'a>> {
251        ungrouped_ast
252            .into_iter()
253            .fold(vec![], |acc: Vec<Fragment<'_>>, new_fragment| {
254                let mut previous_fragments = acc.clone();
255                match (acc.last(), &new_fragment) {
256                    (None, fragment) => {
257                        previous_fragments.push(fragment.clone());
258                        previous_fragments
259                    }
260                    (Some(Fragment::Comment(existing)), Fragment::Comment(new)) => {
261                        previous_fragments.truncate(acc.len() - 1);
262                        previous_fragments.push(existing.append(new).into());
263                        previous_fragments
264                    }
265                    (Some(Fragment::Body(existing)), Fragment::Body(new)) => {
266                        if new.is_empty() || existing.is_empty() {
267                            previous_fragments.push(Fragment::from(new.clone()));
268                        } else {
269                            previous_fragments.truncate(acc.len() - 1);
270                            previous_fragments.push(existing.append(new).into());
271                        }
272                        previous_fragments
273                    }
274                    (Some(Fragment::Body(_)), Fragment::Comment(new)) => {
275                        previous_fragments.push(Fragment::from(new.clone()));
276                        previous_fragments
277                    }
278                    (Some(Fragment::Comment(_)), Fragment::Body(new)) => {
279                        previous_fragments.push(Fragment::from(new.clone()));
280                        previous_fragments
281                    }
282                }
283            })
284    }
285
286    /// Get the [`Subject`] line from the [`CommitMessage`]
287    ///
288    /// It's possible to get this from the ast, but it's a bit of a faff, so
289    /// this is a convenience method
290    ///
291    /// # Examples
292    ///
293    /// ```
294    /// use indoc::indoc;
295    /// use mit_commit::{Bodies, CommitMessage, Subject};
296    ///
297    /// let message = CommitMessage::from(indoc!(
298    ///     "
299    ///     Update bashrc to include kubernetes completions
300    ///
301    ///     This should make it easier to deploy things for the developers.
302    ///     Benchmarked with Hyperfine, no noticable performance decrease.
303    ///
304    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
305    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
306    ///     ; bricht den Commit ab.
307    ///     ;
308    ///     ; Datum:            Sat Jun 27 21:40:14 2020 +0200
309    ///     ;
310    ///     ; Auf Branch master
311    ///     ;
312    ///     ; Initialer Commit
313    ///     ;
314    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
315    ///     ;    neue Datei:     .bashrc
316    ///     ;"
317    /// ));
318    /// assert_eq!(
319    ///     message.get_subject(),
320    ///     Subject::from("Update bashrc to include kubernetes completions")
321    /// )
322    /// ```
323    #[must_use]
324    pub fn get_subject(&self) -> Subject<'a> {
325        self.subject.clone()
326    }
327
328    /// Get the underlying data structure that represents the [`CommitMessage`]
329    ///
330    /// This is the underlying datastructure for the [`CommitMessage`]. You
331    /// might want this to create a complicated linter, or modify the
332    /// [`CommitMessage`] to your liking.
333    ///
334    /// Notice how it doesn't include the [`Scissors`] section.
335    ///
336    /// # Examples
337    ///
338    /// ```
339    /// use indoc::indoc;
340    /// use mit_commit::{Body, CommitMessage, Fragment, Trailer, Trailers, Comment};
341    ///
342    /// let message = CommitMessage::from(indoc!(
343    ///     "
344    ///     Add file
345    ///
346    ///     Looks-like-a-trailer: But isn't
347    ///
348    ///     This adds file primarily for demonstration purposes. It might not be
349    ///     useful as an actual commit, but it's very useful as a example to use in
350    ///     tests.
351    ///
352    ///     Relates-to: #128
353    ///     Relates-to: #129
354    ///
355    ///     ; Short (50 chars or less) summary of changes
356    ///     ;
357    ///     ; More detailed explanatory text, if necessary.  Wrap it to
358    ///     ; about 72 characters or so.  In some contexts, the first
359    ///     ; line is treated as the subject of an email and the rest of
360    ///     ; the text as the body.  The blank line separating the
361    ///     ; summary from the body is critical (unless you omit the body
362    ///     ; entirely); tools like rebase can get confused if you run
363    ///     ; the two together.
364    ///     ;
365    ///     ; Further paragraphs come after blank lines.
366    ///     ;
367    ///     ;   - Bullet points are okay, too
368    ///     ;
369    ///     ;   - Typically a hyphen or asterisk is used for the bullet,
370    ///     ;     preceded by a single space, with blank lines in
371    ///     ;     between, but conventions vary here
372    ///
373    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
374    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
375    ///     ; bricht den Commit ab.
376    ///     ;
377    ///     ; Auf Branch main
378    ///     ; Ihr Branch ist auf demselben Stand wie 'origin/main'.
379    ///     ;
380    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
381    ///     ;   neue Datei:     file
382    ///     ;
383    ///     ; ------------------------ >8 ------------------------
384    ///     ; \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
385    ///     ; Alles unterhalb von ihr wird ignoriert.
386    ///     diff --git a/file b/file
387    ///     new file mode 100644
388    ///     index 0000000..e69de29
389    ///     "
390    /// ));
391    /// let ast = vec![
392    ///     Fragment::Body(Body::from("Add file")),
393    ///     Fragment::Body(Body::default()),
394    ///     Fragment::Body(Body::from("Looks-like-a-trailer: But isn't")),
395    ///     Fragment::Body(Body::default()),
396    ///     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.")),
397    ///     Fragment::Body(Body::default()),
398    ///     Fragment::Body(Body::from("Relates-to: #128\nRelates-to: #129")),
399    ///     Fragment::Body(Body::default()),
400    ///     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")),
401    ///     Fragment::Body(Body::default()),
402    ///     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;"))
403    /// ];
404    /// assert_eq!(message.get_ast(), ast)
405    /// ```
406    #[must_use]
407    pub fn get_ast(&self) -> Vec<Fragment<'_>> {
408        self.ast.clone()
409    }
410
411    /// Get the `Bodies` from the [`CommitMessage`]
412    ///
413    /// This gets the [`Bodies`] from the [`CommitMessage`] in easy to use
414    /// paragraphs, we add in blank bodies because starting a new paragraph
415    /// is a visual delimiter so we want to make that easy to detect.
416    ///
417    /// It doesn't include the [`Subject`] line, but if there's a blank line
418    /// after it (as is recommended by the manual), the [`Bodies`] will
419    /// start with a new empty [`Body`].
420    ///
421    /// # Examples
422    ///
423    /// ```
424    /// use indoc::indoc;
425    /// use mit_commit::{Bodies, Body, CommitMessage, Subject};
426    ///
427    /// let message = CommitMessage::from(indoc!(
428    ///     "
429    ///     Update bashrc to include kubernetes completions
430    ///
431    ///     This should make it easier to deploy things for the developers.
432    ///     Benchmarked with Hyperfine, no noticable performance decrease.
433    ///
434    ///     I am unsure as to why this wasn't being automatically discovered from Brew.
435    ///     I've filed a bug report with them.
436    ///
437    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
438    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
439    ///     ; bricht den Commit ab.
440    ///     ;
441    ///     ; Datum:            Sat Jun 27 21:40:14 2020 +0200
442    ///     ;
443    ///     ; Auf Branch master
444    ///     ;
445    ///     ; Initialer Commit
446    ///     ;
447    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
448    ///     ;    neue Datei:     .bashrc
449    ///     ;"
450    /// ));
451    /// let bodies = vec![
452    ///     Body::default(),
453    ///     Body::from(indoc!(
454    ///         "
455    ///         This should make it easier to deploy things for the developers.
456    ///         Benchmarked with Hyperfine, no noticable performance decrease."
457    ///     )),
458    ///     Body::default(),
459    ///     Body::from(indoc!(
460    ///         "
461    ///         I am unsure as to why this wasn't being automatically discovered from Brew.
462    ///         I've filed a bug report with them."
463    ///     )),
464    /// ];
465    /// assert_eq!(message.get_body(), Bodies::from(bodies))
466    /// ```
467    #[must_use]
468    pub fn get_body(&self) -> Bodies<'_> {
469        self.bodies.clone()
470    }
471
472    /// Get the [`Comments`] from the [`CommitMessage`]
473    ///
474    /// We this will get you all the comments before the `Scissors` section. The
475    /// [`Scissors`] section is the bit that appears when you run `git commit
476    /// --verbose`, that contains the diffs.
477    ///
478    /// If there's [`Comment`] mixed in with the body, it'll return those too,
479    /// but not any of the [`Body`] around them.
480    ///
481    /// # Examples
482    ///
483    /// ```
484    /// use indoc::indoc;
485    /// use mit_commit::{Body, Comment, Comments, CommitMessage, Subject};
486    ///
487    /// let message = CommitMessage::from(indoc!(
488    ///     "
489    ///     Update bashrc to include kubernetes completions
490    ///
491    ///     This should make it easier to deploy things for the developers.
492    ///     Benchmarked with Hyperfine, no noticable performance decrease.
493    ///
494    ///     I am unsure as to why this wasn't being automatically discovered from Brew.
495    ///     I've filed a bug report with them.
496    ///
497    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
498    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
499    ///     ; bricht den Commit ab.
500    ///     ;
501    ///     ; Datum:            Sat Jun 27 21:40:14 2020 +0200
502    ///     ;
503    ///     ; Auf Branch master
504    ///     ;
505    ///     ; Initialer Commit
506    ///     ;
507    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
508    ///     ;    neue Datei:     .bashrc
509    ///     ;"
510    /// ));
511    /// let comments = vec![Comment::from(indoc!(
512    ///     "
513    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
514    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
515    ///     ; bricht den Commit ab.
516    ///     ;
517    ///     ; Datum:            Sat Jun 27 21:40:14 2020 +0200
518    ///     ;
519    ///     ; Auf Branch master
520    ///     ;
521    ///     ; Initialer Commit
522    ///     ;
523    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
524    ///     ;    neue Datei:     .bashrc
525    ///     ;"
526    /// ))];
527    /// assert_eq!(message.get_comments(), Comments::from(comments))
528    /// ```
529    #[must_use]
530    pub fn get_comments(&self) -> Comments<'_> {
531        self.comments.clone()
532    }
533
534    /// Get the [`Scissors`] from the [`CommitMessage`]
535    ///
536    /// We this will get you all the comments in the [`Scissors`] section. The
537    /// [`Scissors`] section is the bit that appears when you run `git commit
538    /// --verbose`, that contains the diffs, and is not preserved when you
539    /// save the commit.
540    ///
541    ///
542    /// # Examples
543    ///
544    /// ```
545    /// use indoc::indoc;
546    /// use mit_commit::{Body, CommitMessage, Scissors, Subject};
547    ///
548    /// let message = CommitMessage::from(indoc!(
549    ///     "
550    ///     Add file
551    ///
552    ///     This adds file primarily for demonstration purposes. It might not be
553    ///     useful as an actual commit, but it's very useful as a example to use in
554    ///     tests.
555    ///
556    ///     Relates-to: #128
557    ///
558    ///     ; Short (50 chars or less) summary of changes
559    ///     ;
560    ///     ; More detailed explanatory text, if necessary.  Wrap it to
561    ///     ; about 72 characters or so.  In some contexts, the first
562    ///     ; line is treated as the subject of an email and the rest of
563    ///     ; the text as the body.  The blank line separating the
564    ///     ; summary from the body is critical (unless you omit the body
565    ///     ; entirely); tools like rebase can get confused if you run
566    ///     ; the two together.
567    ///     ;
568    ///     ; Further paragraphs come after blank lines.
569    ///     ;
570    ///     ;   - Bullet points are okay, too
571    ///     ;
572    ///     ;   - Typically a hyphen or asterisk is used for the bullet,
573    ///     ;     preceded by a single space, with blank lines in
574    ///     ;     between, but conventions vary here
575    ///
576    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
577    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
578    ///     ; bricht den Commit ab.
579    ///     ;
580    ///     ; Auf Branch main
581    ///     ; Ihr Branch ist auf demselben Stand wie 'origin/main'.
582    ///     ;
583    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
584    ///     ;   neue Datei:     file
585    ///     ;
586    ///     ; ------------------------ >8 ------------------------
587    ///     ; \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
588    ///     ; Alles unterhalb von ihr wird ignoriert.
589    ///     diff --git a/file b/file
590    ///     new file mode 100644
591    ///     index 0000000..e69de29
592    ///     "
593    /// ));
594    /// let scissors = Scissors::from(indoc!(
595    ///     "
596    ///     ; ------------------------ >8 ------------------------
597    ///     ; \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
598    ///     ; Alles unterhalb von ihr wird ignoriert.
599    ///     diff --git a/file b/file
600    ///     new file mode 100644
601    ///     index 0000000..e69de29
602    ///     "
603    /// ));
604    /// assert_eq!(message.get_scissors(), Some(scissors))
605    /// ```
606    #[must_use]
607    pub fn get_scissors(&self) -> Option<Scissors<'_>> {
608        self.scissors.clone()
609    }
610
611    /// Get the [`Scissors`] from the [`CommitMessage`]
612    ///
613    /// We this will get you all the comments in the [`Scissors`] section. The
614    /// [`Scissors`] section is the bit that appears when you run `git commit
615    /// --verbose`, that contains the diffs, and is not preserved when you
616    /// save the commit.
617    ///
618    ///
619    /// # Examples
620    ///
621    /// ```
622    /// use indoc::indoc;
623    /// use mit_commit::{Body, CommitMessage, Trailer, Trailers};
624    ///
625    /// let message = CommitMessage::from(indoc!(
626    ///     "
627    ///     Add file
628    ///
629    ///     Looks-like-a-trailer: But isn't
630    ///
631    ///     This adds file primarily for demonstration purposes. It might not be
632    ///     useful as an actual commit, but it's very useful as a example to use in
633    ///     tests.
634    ///
635    ///     Relates-to: #128
636    ///     Relates-to: #129
637    ///
638    ///     ; Short (50 chars or less) summary of changes
639    ///     ;
640    ///     ; More detailed explanatory text, if necessary.  Wrap it to
641    ///     ; about 72 characters or so.  In some contexts, the first
642    ///     ; line is treated as the subject of an email and the rest of
643    ///     ; the text as the body.  The blank line separating the
644    ///     ; summary from the body is critical (unless you omit the body
645    ///     ; entirely); tools like rebase can get confused if you run
646    ///     ; the two together.
647    ///     ;
648    ///     ; Further paragraphs come after blank lines.
649    ///     ;
650    ///     ;   - Bullet points are okay, too
651    ///     ;
652    ///     ;   - Typically a hyphen or asterisk is used for the bullet,
653    ///     ;     preceded by a single space, with blank lines in
654    ///     ;     between, but conventions vary here
655    ///
656    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
657    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
658    ///     ; bricht den Commit ab.
659    ///     ;
660    ///     ; Auf Branch main
661    ///     ; Ihr Branch ist auf demselben Stand wie 'origin/main'.
662    ///     ;
663    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
664    ///     ;   neue Datei:     file
665    ///     ;
666    ///     ; ------------------------ >8 ------------------------
667    ///     ; \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
668    ///     ; Alles unterhalb von ihr wird ignoriert.
669    ///     diff --git a/file b/file
670    ///     new file mode 100644
671    ///     index 0000000..e69de29
672    ///     "
673    /// ));
674    /// let trailers = vec![
675    ///     Trailer::new("Relates-to".into(), "#128".into()),
676    ///     Trailer::new("Relates-to".into(), "#129".into()),
677    /// ];
678    /// assert_eq!(message.get_trailers(), Trailers::from(trailers))
679    /// ```
680    #[must_use]
681    pub fn get_trailers(&self) -> Trailers<'_> {
682        self.trailers.clone()
683    }
684
685    /// Does the [`CommitMessage`] the saved portions of the commit
686    ///
687    /// This takes a regex and matches it to the visible portions of the
688    /// commits, so it excludes comments, and everything after the scissors.
689    ///
690    /// # Examples
691    ///
692    /// ```
693    /// use indoc::indoc;
694    /// use mit_commit::CommitMessage;
695    /// use regex::Regex;
696    ///
697    /// let commit = CommitMessage::from(indoc!(
698    ///     "
699    ///     Example Commit Message
700    ///
701    ///     This is an example commit message for linting
702    ///
703    ///
704    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
705    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
706    ///     ; bricht den Commit ab.
707    ///     ;
708    ///     ; Auf Branch main
709    ///     ; Ihr Branch ist auf demselben Stand wie 'origin/main'.
710    ///     ;
711    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
712    ///     ;    neue Datei:     file
713    ///     ;
714    ///     "
715    /// ));
716    ///
717    /// let re = Regex::new("[Bb]itte").unwrap();
718    /// assert_eq!(commit.matches_pattern(&re), false);
719    ///
720    /// let re = Regex::new("f[o\u{00FC}]r linting").unwrap();
721    /// assert_eq!(commit.matches_pattern(&re), true);
722    ///
723    /// let re = Regex::new("[Ee]xample Commit Message").unwrap();
724    /// assert_eq!(commit.matches_pattern(&re), true);
725    /// ```
726    #[must_use]
727    pub fn matches_pattern(&self, re: &Regex) -> bool {
728        let text = self
729            .clone()
730            .get_ast()
731            .into_iter()
732            .filter_map(|fragment| match fragment {
733                Fragment::Body(body) => Some(String::from(body)),
734                Fragment::Comment(_) => None,
735            })
736            .collect::<Vec<_>>()
737            .join("\n");
738        re.is_match(&text)
739    }
740
741    fn guess_comment_character(message: &str) -> Option<char> {
742        Scissors::guess_comment_character(message)
743    }
744
745    /// Give you a new [`CommitMessage`] with the provided subject
746    ///
747    /// # Examples
748    ///
749    /// ```
750    /// use indoc::indoc;
751    /// use mit_commit::{CommitMessage, Subject};
752    /// use regex::Regex;
753    ///
754    /// let commit = CommitMessage::from(indoc!(
755    ///     "
756    ///     Example Commit Message
757    ///
758    ///     This is an example commit message
759    ///     "
760    /// ));
761    ///
762    /// assert_eq!(
763    ///     commit.with_subject("Subject".into()).get_subject(),
764    ///     Subject::from("Subject")
765    /// );
766    /// ```
767    #[must_use]
768    pub fn with_subject(self, subject: Subject<'a>) -> Self {
769        let mut ast: Vec<Fragment<'a>> = self.ast.clone();
770
771        if !ast.is_empty() {
772            ast.remove(0);
773        }
774        ast.insert(0, Body::from(subject.to_string()).into());
775
776        Self {
777            scissors: self.scissors,
778            ast,
779            subject,
780            trailers: self.trailers,
781            comments: self.comments,
782            bodies: self.bodies,
783        }
784    }
785
786    /// Give you a new [`CommitMessage`] with the provided body
787    ///
788    /// # Examples
789    ///
790    /// ```
791    /// use indoc::indoc;
792    /// use mit_commit::{CommitMessage, Subject};
793    /// use regex::Regex;
794    ///
795    /// let commit = CommitMessage::from(indoc!(
796    ///     "
797    ///     Example Commit Message
798    ///
799    ///     This is an example commit message
800    ///     "
801    /// ));
802    /// let expected = CommitMessage::from(indoc!(
803    ///     "
804    ///     Example Commit Message
805    ///
806    ///     New body"
807    /// ));
808    ///
809    /// assert_eq!(commit.with_body_contents("New body"), expected);
810    /// ```
811    ///
812    /// A note on what we consider the body. The body is what falls after the
813    /// gutter. This means the following behaviour might happen
814    ///
815    /// ```
816    /// use indoc::indoc;
817    /// use mit_commit::{CommitMessage, Subject};
818    /// use regex::Regex;
819    /// let commit = CommitMessage::from(indoc!(
820    ///     "
821    ///     Example Commit Message
822    ///     without gutter"
823    /// ));
824    /// let expected = CommitMessage::from(indoc!(
825    ///     "
826    ///     Example Commit Message
827    ///     without gutter
828    ///
829    ///     New body"
830    /// ));
831    ///
832    /// assert_eq!(commit.with_body_contents("New body"), expected);
833    /// ```
834    #[must_use]
835    pub fn with_body_contents(self, contents: &'a str) -> Self {
836        let existing_subject: Subject<'a> = self.get_subject();
837        let body = format!("Unused\n\n{contents}");
838        let commit = Self::from(body);
839
840        commit.with_subject(existing_subject)
841    }
842
843    /// Give you a new [`CommitMessage`] with the provided body
844    ///
845    /// # Examples
846    ///
847    /// ```
848    /// use mit_commit::{CommitMessage, Subject};
849    /// let commit = CommitMessage::from("No comment\n\n# Some Comment");
850    ///
851    /// assert_eq!(commit.get_comment_char().unwrap(), '#');
852    /// ```
853    ///
854    /// We return none is there is no comments
855    ///
856    /// ```
857    /// use mit_commit::{CommitMessage, Subject};
858    /// let commit = CommitMessage::from("No comment");
859    ///
860    /// assert!(commit.get_comment_char().is_none());
861    /// ```
862    #[must_use]
863    pub fn get_comment_char(&self) -> Option<char> {
864        self.comments
865            .iter()
866            .next()
867            .map(|comment| -> String { comment.clone().into() })
868            .and_then(|comment| comment.chars().next())
869    }
870}
871
872impl<'a> From<CommitMessage<'a>> for String {
873    fn from(commit_message: CommitMessage<'_>) -> Self {
874        let basic_commit = commit_message
875            .get_ast()
876            .iter()
877            .map(|item| match item {
878                Fragment::Body(contents) => Self::from(contents.clone()),
879                Fragment::Comment(contents) => Self::from(contents.clone()),
880            })
881            .collect::<Vec<_>>()
882            .join("\n");
883
884        if let Some(scissors) = commit_message.get_scissors() {
885            format!("{basic_commit}\n{}", Self::from(scissors))
886        } else {
887            basic_commit
888        }
889    }
890}
891
892impl<'a> From<Cow<'a, str>> for CommitMessage<'a> {
893    /// Create a new [`CommitMessage`]
894    ///
895    /// Create a commit message from a string. It's expected that you'll be
896    /// reading this during some sort of Git Hook
897    ///
898    /// # Examples
899    ///
900    /// ```
901    /// use indoc::indoc;
902    /// use mit_commit::{Bodies, CommitMessage, Subject};
903    ///
904    /// let message = CommitMessage::from(indoc!(
905    ///     "
906    ///     Update bashrc to include kubernetes completions
907    ///
908    ///     This should make it easier to deploy things for the developers.
909    ///     Benchmarked with Hyperfine, no noticable performance decrease.
910    ///
911    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
912    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
913    ///     ; bricht den Commit ab.
914    ///     ;
915    ///     ; Datum:            Sat Jun 27 21:40:14 2020 +0200
916    ///     ;
917    ///     ; Auf Branch master
918    ///     ;
919    ///     ; Initialer Commit
920    ///     ;
921    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
922    ///     ;    neue Datei:     .bashrc
923    ///     ;"
924    /// ));
925    /// assert_eq!(
926    ///     message.get_subject(),
927    ///     Subject::from("Update bashrc to include kubernetes completions")
928    /// )
929    /// ```
930    ///
931    ///  # Comment Character
932    ///
933    /// We load the comment character for the commit message
934    ///
935    /// Valid options are in [`LEGAL_CHARACTERS`], these are the 'auto" selection logic in the git codebase in the [`adjust_comment_line_char`](https://github.com/git/git/blob/master/builtin/commit.c#L667-L695) function.
936    ///
937    /// This does mean that we aren't making 100% of characters available, which
938    /// is technically possible, but given we don't have access to the users git
939    /// config this feels like a reasonable compromise, there are a lot of
940    /// non-whitespace characters as options otherwise, and we don't want to
941    /// confuse a genuine body with a comment
942    fn from(message: Cow<'a, str>) -> Self {
943        let (rest, scissors) = Scissors::parse_sections(&message);
944        let comment_character = Self::guess_comment_character(&message);
945        let per_line_ast = Self::convert_to_per_line_ast(comment_character, &rest);
946        let trailers = per_line_ast.clone().into();
947        let mut ast: Vec<Fragment<'_>> = Self::group_ast(per_line_ast);
948
949        if (scissors.clone(), message.chars().last()) == (None, Some('\n')) {
950            ast.push(Body::default().into());
951        }
952
953        let subject = Subject::from(ast.clone());
954        let comments = Comments::from(ast.clone());
955        let bodies = Bodies::from(ast.clone());
956
957        Self {
958            scissors,
959            ast,
960            subject,
961            trailers,
962            comments,
963            bodies,
964        }
965    }
966}
967
968impl<'a> TryFrom<PathBuf> for CommitMessage<'a> {
969    type Error = Error;
970
971    fn try_from(value: PathBuf) -> Result<Self, Self::Error> {
972        let mut file = File::open(value)?;
973        let mut buffer = String::new();
974
975        file.read_to_string(&mut buffer)
976            .map_err(Error::from)
977            .map(move |_| Self::from(buffer))
978    }
979}
980
981impl<'a> TryFrom<&'a Path> for CommitMessage<'a> {
982    type Error = Error;
983
984    fn try_from(value: &'a Path) -> Result<Self, Self::Error> {
985        let mut file = File::open(value)?;
986        let mut buffer = String::new();
987
988        file.read_to_string(&mut buffer)
989            .map_err(Error::from)
990            .map(move |_| Self::from(buffer))
991    }
992}
993
994impl<'a> From<&'a str> for CommitMessage<'a> {
995    fn from(message: &'a str) -> Self {
996        CommitMessage::from(Cow::from(message))
997    }
998}
999
1000impl<'a> From<String> for CommitMessage<'a> {
1001    fn from(message: String) -> Self {
1002        Self::from(Cow::from(message))
1003    }
1004}
1005
1006/// Errors on reading c commits
1007#[derive(Error, Debug, Diagnostic)]
1008pub enum Error {
1009    /// Failed to read a commit message
1010    #[error("failed to read commit file {0}")]
1011    #[diagnostic(
1012        url(docsrs),
1013        code(mit_commit::commit_message::error::io),
1014        help("check the file is readable")
1015    )]
1016    Io(#[from] io::Error),
1017}