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}