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