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}