mit_commit/commit_message.rs
1use std::{
2 borrow::Cow,
3 convert::TryFrom,
4 fs::File,
5 io,
6 io::Read,
7 path::{Path, PathBuf},
8};
9
10use miette::Diagnostic;
11use regex::Regex;
12use thiserror::Error;
13
14use super::{
15 bodies::Bodies, body::Body, comment::Comment, comments::Comments, fragment::Fragment,
16 subject::Subject, trailers::Trailers,
17};
18use crate::{Trailer, scissors::Scissors};
19
20/// A [`Self`], the primary entry point to the library
21#[derive(Debug, PartialEq, Eq, Clone, Default)]
22pub struct CommitMessage<'a> {
23 scissors: Option<Scissors<'a>>,
24 ast: Vec<Fragment<'a>>,
25 subject: Subject<'a>,
26 trailers: Trailers<'a>,
27 comments: Comments<'a>,
28 bodies: Bodies<'a>,
29}
30
31impl<'a> CommitMessage<'a> {
32 /// Convert from [`Fragment`] back into a full [`CommitMessage`]
33 ///
34 /// Get back to a [`CommitMessage`] from an ast, usually after you've been
35 /// editing the text.
36 ///
37 /// # Arguments
38 ///
39 /// * `fragments` - Vector of Fragment objects representing the abstract syntax tree
40 /// * `scissors` - Optional Scissors section to include in the commit message
41 ///
42 /// # Returns
43 ///
44 /// A new `CommitMessage` instance constructed from the provided fragments and scissors
45 ///
46 /// # Examples
47 ///
48 /// ```
49 /// use indoc::indoc;
50 /// use mit_commit::{Bodies, CommitMessage, Subject};
51 ///
52 /// let message = CommitMessage::from(indoc!(
53 /// "
54 /// Update bashrc to include kubernetes completions
55 ///
56 /// This should make it easier to deploy things for the developers.
57 /// Benchmarked with Hyperfine, no noticable performance decrease.
58 ///
59 /// ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
60 /// ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
61 /// ; bricht den Commit ab.
62 /// ;
63 /// ; Datum: Sat Jun 27 21:40:14 2020 +0200
64 /// ;
65 /// ; Auf Branch master
66 /// ;
67 /// ; Initialer Commit
68 /// ;
69 /// ; Zum Commit vorgemerkte \u{00E4}nderungen:
70 /// ; neue Datei: .bashrc
71 /// ;"
72 /// ));
73 /// assert_eq!(
74 /// CommitMessage::from_fragments(message.get_ast(), message.get_scissors()),
75 /// message,
76 /// )
77 /// ```
78 #[must_use]
79 pub fn from_fragments(fragments: Vec<Fragment<'_>>, scissors: Option<Scissors<'_>>) -> Self {
80 let body = fragments
81 .into_iter()
82 .map(|x| match x {
83 Fragment::Body(contents) => String::from(contents),
84 Fragment::Comment(contents) => String::from(contents),
85 })
86 .collect::<Vec<String>>()
87 .join("\n");
88
89 let scissors: String = scissors
90 .map(|contents| format!("\n{}", String::from(contents)))
91 .unwrap_or_default();
92
93 Self::from(format!("{body}{scissors}"))
94 }
95
96 /// A helper method to let you insert [`Trailer`]
97 ///
98 /// # Arguments
99 ///
100 /// * `trailer` - The trailer to add to the commit message
101 ///
102 /// # Returns
103 ///
104 /// A new `CommitMessage` with the trailer added in the appropriate location
105 ///
106 /// # Examples
107 ///
108 /// ```
109 /// use indoc::indoc;
110 /// use mit_commit::{CommitMessage, Trailer};
111 /// let commit = CommitMessage::from(indoc!(
112 /// "
113 /// Example Commit Message
114 ///
115 /// This is an example commit message for linting
116 ///
117 /// Relates-to: #153
118 ///
119 /// ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
120 /// ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
121 /// ; bricht den Commit ab.
122 /// ;
123 /// ; Auf Branch main
124 /// ; Ihr Branch ist auf demselben Stand wie 'origin/main'.
125 /// ;
126 /// ; Zum Commit vorgemerkte \u{00E4}nderungen:
127 /// ; neue Datei: file
128 /// ;
129 /// "
130 /// ));
131 ///
132 /// assert_eq!(
133 /// String::from(commit.add_trailer(Trailer::new(
134 /// "Co-authored-by".into(),
135 /// "Test Trailer <test@example.com>".into()
136 /// ))),
137 /// String::from(CommitMessage::from(indoc!(
138 /// "
139 /// Example Commit Message
140 ///
141 /// This is an example commit message for linting
142 ///
143 /// Relates-to: #153
144 /// Co-authored-by: Test Trailer <test@example.com>
145 ///
146 /// ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
147 /// ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
148 /// ; bricht den Commit ab.
149 /// ;
150 /// ; Auf Branch main
151 /// ; Ihr Branch ist auf demselben Stand wie 'origin/main'.
152 /// ;
153 /// ; Zum Commit vorgemerkte \u{00E4}nderungen:
154 /// ; neue Datei: file
155 /// ;
156 /// "
157 /// )))
158 /// );
159 /// ```
160 #[must_use]
161 pub fn add_trailer(&self, trailer: Trailer<'_>) -> Self {
162 // Preallocate with capacity to avoid reallocations
163 let mut fragments = Vec::with_capacity(2);
164
165 // Only add an empty body if we have no bodies or all bodies are empty,
166 // and we have no trailers
167 let needs_empty_body = self.bodies.iter().all(Body::is_empty) && self.trailers.is_empty();
168
169 if needs_empty_body {
170 fragments.push(Body::default().into());
171 fragments.push(Body::default().into());
172 } else if self.trailers.is_empty() {
173 // Only add a separator if we have non-empty bodies but no trailers
174 fragments.push(Body::default().into());
175 }
176
177 fragments.push(trailer.into());
178
179 self.insert_after_last_full_body(fragments)
180 }
181
182 /// Insert text in the place you're most likely to want it
183 ///
184 /// In the case you don't have any full [`Body`] in there, it inserts it at
185 /// the top of the commit, in the [`Subject`] line.
186 ///
187 /// # Arguments
188 ///
189 /// * `fragment` - Vector of Fragment objects to insert after the last non-empty body
190 ///
191 /// # Returns
192 ///
193 /// A new `CommitMessage` with the fragments inserted after the last non-empty body
194 ///
195 /// # Examples
196 ///
197 /// ```
198 /// use mit_commit::{Fragment, Body, CommitMessage, Comment};
199 ///
200 /// let ast: Vec<Fragment> = vec![
201 /// Fragment::Body(Body::from("Add file")),
202 /// Fragment::Body(Body::default()),
203 /// Fragment::Body(Body::from("Looks-like-a-trailer: But isn\'t")),
204 /// Fragment::Body(Body::default()),
205 /// Fragment::Body(Body::from("This adds file primarily for demonstration purposes. It might not be\nuseful as an actual commit, but it\'s very useful as a example to use in\ntests.")),
206 /// Fragment::Body(Body::default()),
207 /// Fragment::Body(Body::from("Relates-to: #128")),
208 /// Fragment::Body(Body::default()),
209 /// Fragment::Comment(Comment::from("# Short (50 chars or less) summary of changes\n#\n# More detailed explanatory text, if necessary. Wrap it to\n# about 72 characters or so. In some contexts, the first\n# line is treated as the subject of an email and the rest of\n# the text as the body. The blank line separating the\n# summary from the body is critical (unless you omit the body\n# entirely); tools like rebase can get confused if you run\n# the two together.\n#\n# Further paragraphs come after blank lines.\n#\n# - Bullet points are okay, too\n#\n# - Typically a hyphen or asterisk is used for the bullet,\n# preceded by a single space, with blank lines in\n# between, but conventions vary here")),
210 /// Fragment::Body(Body::default()),
211 /// Fragment::Comment(Comment::from("# Bitte geben Sie eine Commit-Beschreibung f\u{fc}r Ihre \u{e4}nderungen ein. Zeilen,\n# die mit \'#\' beginnen, werden ignoriert, und eine leere Beschreibung\n# bricht den Commit ab.\n#\n# Auf Branch main\n# Ihr Branch ist auf demselben Stand wie \'origin/main\'.\n#\n# Zum Commit vorgemerkte \u{e4}nderungen:\n#\tneue Datei: file\n#"))
212 /// ];
213 /// let commit = CommitMessage::from_fragments(ast, None);
214 ///
215 /// assert_eq!(commit.insert_after_last_full_body(vec![Fragment::Body(Body::from("Relates-to: #656"))]).get_ast(), vec![
216 /// Fragment::Body(Body::from("Add file")),
217 /// Fragment::Body(Body::default()),
218 /// Fragment::Body(Body::from("Looks-like-a-trailer: But isn\'t")),
219 /// Fragment::Body(Body::default()),
220 /// Fragment::Body(Body::from("This adds file primarily for demonstration purposes. It might not be\nuseful as an actual commit, but it\'s very useful as a example to use in\ntests.")),
221 /// Fragment::Body(Body::default()),
222 /// Fragment::Body(Body::from("Relates-to: #128\nRelates-to: #656")),
223 /// Fragment::Body(Body::default()),
224 /// Fragment::Comment(Comment::from("# Short (50 chars or less) summary of changes\n#\n# More detailed explanatory text, if necessary. Wrap it to\n# about 72 characters or so. In some contexts, the first\n# line is treated as the subject of an email and the rest of\n# the text as the body. The blank line separating the\n# summary from the body is critical (unless you omit the body\n# entirely); tools like rebase can get confused if you run\n# the two together.\n#\n# Further paragraphs come after blank lines.\n#\n# - Bullet points are okay, too\n#\n# - Typically a hyphen or asterisk is used for the bullet,\n# preceded by a single space, with blank lines in\n# between, but conventions vary here")),
225 /// Fragment::Body(Body::default()),
226 /// Fragment::Comment(Comment::from("# Bitte geben Sie eine Commit-Beschreibung f\u{fc}r Ihre \u{e4}nderungen ein. Zeilen,\n# die mit \'#\' beginnen, werden ignoriert, und eine leere Beschreibung\n# bricht den Commit ab.\n#\n# Auf Branch main\n# Ihr Branch ist auf demselben Stand wie \'origin/main\'.\n#\n# Zum Commit vorgemerkte \u{e4}nderungen:\n#\tneue Datei: file\n#"))
227 /// ])
228 /// ```
229 #[must_use]
230 pub fn insert_after_last_full_body(&self, fragment: Vec<Fragment<'_>>) -> Self {
231 let position = self.ast.iter().rposition(|fragment| match fragment {
232 Fragment::Body(body) => !body.is_empty(),
233 Fragment::Comment(_) => false,
234 });
235
236 // Preallocate with capacity to avoid reallocations
237 let mut new_ast = Vec::with_capacity(self.ast.len() + fragment.len());
238
239 if let Some(position) = position {
240 // Copy elements up to and including the position
241 new_ast.extend_from_slice(&self.ast[..=position]);
242 // Add the new fragments
243 new_ast.extend(fragment);
244 // Add the remaining elements
245 if position + 1 < self.ast.len() {
246 new_ast.extend_from_slice(&self.ast[position + 1..]);
247 }
248 } else {
249 // If no non-empty body found, add fragments at the beginning
250 new_ast.extend(fragment);
251 new_ast.extend_from_slice(&self.ast);
252 }
253
254 Self::from_fragments(new_ast, self.get_scissors())
255 }
256
257 fn convert_to_per_line_ast(comment_character: Option<char>, rest: &str) -> Vec<Fragment<'a>> {
258 rest.lines()
259 .map(|line| {
260 comment_character.map_or_else(
261 || Body::from(line.to_string()).into(),
262 |comment_character| {
263 if line.starts_with(comment_character) {
264 Comment::from(line.to_string()).into()
265 } else {
266 Body::from(line.to_string()).into()
267 }
268 },
269 )
270 })
271 .collect()
272 }
273
274 /// Group consecutive fragments of the same type
275 fn group_ast(ungrouped_ast: Vec<Fragment<'a>>) -> Vec<Fragment<'a>> {
276 // Using a more functional approach with fold
277 ungrouped_ast
278 .into_iter()
279 .fold(Vec::new(), |mut acc, fragment| {
280 match (acc.last_mut(), fragment) {
281 // Consecutive Comment fragments
282 (Some(Fragment::Comment(existing)), Fragment::Comment(new)) => {
283 *existing = existing.append(&new);
284 }
285
286 // Consecutive Body fragments
287 (Some(Fragment::Body(existing)), Fragment::Body(new)) => {
288 if new.is_empty() || existing.is_empty() {
289 acc.push(Fragment::from(new));
290 } else {
291 *existing = existing.append(&new);
292 }
293 }
294
295 // First fragment or different fragment types
296 (_, fragment) => {
297 acc.push(fragment);
298 }
299 }
300
301 acc
302 })
303 }
304
305 /// Get the [`Subject`] line from the [`CommitMessage`]
306 ///
307 /// It's possible to get this from the ast, but it's a bit of a faff, so
308 /// this is a convenience method
309 ///
310 /// # Returns
311 ///
312 /// The Subject of the commit message
313 ///
314 /// # Examples
315 ///
316 /// ```
317 /// use indoc::indoc;
318 /// use mit_commit::{Bodies, CommitMessage, Subject};
319 ///
320 /// let message = CommitMessage::from(indoc!(
321 /// "
322 /// Update bashrc to include kubernetes completions
323 ///
324 /// This should make it easier to deploy things for the developers.
325 /// Benchmarked with Hyperfine, no noticable performance decrease.
326 ///
327 /// ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
328 /// ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
329 /// ; bricht den Commit ab.
330 /// ;
331 /// ; Datum: Sat Jun 27 21:40:14 2020 +0200
332 /// ;
333 /// ; Auf Branch master
334 /// ;
335 /// ; Initialer Commit
336 /// ;
337 /// ; Zum Commit vorgemerkte \u{00E4}nderungen:
338 /// ; neue Datei: .bashrc
339 /// ;"
340 /// ));
341 /// assert_eq!(
342 /// message.get_subject(),
343 /// Subject::from("Update bashrc to include kubernetes completions")
344 /// )
345 /// ```
346 #[must_use]
347 pub fn get_subject(&self) -> Subject<'a> {
348 self.subject.clone()
349 }
350
351 /// Get the underlying data structure that represents the [`CommitMessage`]
352 ///
353 /// This is the underlying datastructure for the [`CommitMessage`]. You
354 /// might want this to create a complicated linter, or modify the
355 /// [`CommitMessage`] to your liking.
356 ///
357 /// Notice how it doesn't include the [`Scissors`] section.
358 ///
359 /// # Returns
360 ///
361 /// A vector of Fragment objects representing the abstract syntax tree of the commit message
362 ///
363 /// # Examples
364 ///
365 /// ```
366 /// use indoc::indoc;
367 /// use mit_commit::{Body, CommitMessage, Fragment, Trailer, Trailers, Comment};
368 ///
369 /// let message = CommitMessage::from(indoc!(
370 /// "
371 /// Add file
372 ///
373 /// Looks-like-a-trailer: But isn't
374 ///
375 /// This adds file primarily for demonstration purposes. It might not be
376 /// useful as an actual commit, but it's very useful as a example to use in
377 /// tests.
378 ///
379 /// Relates-to: #128
380 /// Relates-to: #129
381 ///
382 /// ; Short (50 chars or less) summary of changes
383 /// ;
384 /// ; More detailed explanatory text, if necessary. Wrap it to
385 /// ; about 72 characters or so. In some contexts, the first
386 /// ; line is treated as the subject of an email and the rest of
387 /// ; the text as the body. The blank line separating the
388 /// ; summary from the body is critical (unless you omit the body
389 /// ; entirely); tools like rebase can get confused if you run
390 /// ; the two together.
391 /// ;
392 /// ; Further paragraphs come after blank lines.
393 /// ;
394 /// ; - Bullet points are okay, too
395 /// ;
396 /// ; - Typically a hyphen or asterisk is used for the bullet,
397 /// ; preceded by a single space, with blank lines in
398 /// ; between, but conventions vary here
399 ///
400 /// ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
401 /// ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
402 /// ; bricht den Commit ab.
403 /// ;
404 /// ; Auf Branch main
405 /// ; Ihr Branch ist auf demselben Stand wie 'origin/main'.
406 /// ;
407 /// ; Zum Commit vorgemerkte \u{00E4}nderungen:
408 /// ; neue Datei: file
409 /// ;
410 /// ; ------------------------ >8 ------------------------
411 /// ; \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
412 /// ; Alles unterhalb von ihr wird ignoriert.
413 /// diff --git a/file b/file
414 /// new file mode 100644
415 /// index 0000000..e69de29
416 /// "
417 /// ));
418 /// let ast = vec![
419 /// Fragment::Body(Body::from("Add file")),
420 /// Fragment::Body(Body::default()),
421 /// Fragment::Body(Body::from("Looks-like-a-trailer: But isn't")),
422 /// Fragment::Body(Body::default()),
423 /// Fragment::Body(Body::from("This adds file primarily for demonstration purposes. It might not be\nuseful as an actual commit, but it\'s very useful as a example to use in\ntests.")),
424 /// Fragment::Body(Body::default()),
425 /// Fragment::Body(Body::from("Relates-to: #128\nRelates-to: #129")),
426 /// Fragment::Body(Body::default()),
427 /// Fragment::Comment(Comment::from("; Short (50 chars or less) summary of changes\n;\n; More detailed explanatory text, if necessary. Wrap it to\n; about 72 characters or so. In some contexts, the first\n; line is treated as the subject of an email and the rest of\n; the text as the body. The blank line separating the\n; summary from the body is critical (unless you omit the body\n; entirely); tools like rebase can get confused if you run\n; the two together.\n;\n; Further paragraphs come after blank lines.\n;\n; - Bullet points are okay, too\n;\n; - Typically a hyphen or asterisk is used for the bullet,\n; preceded by a single space, with blank lines in\n; between, but conventions vary here")),
428 /// Fragment::Body(Body::default()),
429 /// Fragment::Comment(Comment::from("; Bitte geben Sie eine Commit-Beschreibung für Ihre änderungen ein. Zeilen,\n; die mit \';\' beginnen, werden ignoriert, und eine leere Beschreibung\n; bricht den Commit ab.\n;\n; Auf Branch main\n; Ihr Branch ist auf demselben Stand wie \'origin/main\'.\n;\n; Zum Commit vorgemerkte änderungen:\n; neue Datei: file\n;"))
430 /// ];
431 /// assert_eq!(message.get_ast(), ast)
432 /// ```
433 #[must_use]
434 pub fn get_ast(&self) -> Vec<Fragment<'_>> {
435 self.ast.clone()
436 }
437
438 /// Get the `Bodies` from the [`CommitMessage`]
439 ///
440 /// This gets the [`Bodies`] from the [`CommitMessage`] in easy to use
441 /// paragraphs, we add in blank bodies because starting a new paragraph
442 /// is a visual delimiter so we want to make that easy to detect.
443 ///
444 /// It doesn't include the [`Subject`] line, but if there's a blank line
445 /// after it (as is recommended by the manual), the [`Bodies`] will
446 /// start with a new empty [`Body`].
447 ///
448 /// # Returns
449 ///
450 /// The Bodies of the commit message, containing all body paragraphs
451 ///
452 /// # Examples
453 ///
454 /// ```
455 /// use indoc::indoc;
456 /// use mit_commit::{Bodies, Body, CommitMessage, Subject};
457 ///
458 /// let message = CommitMessage::from(indoc!(
459 /// "
460 /// Update bashrc to include kubernetes completions
461 ///
462 /// This should make it easier to deploy things for the developers.
463 /// Benchmarked with Hyperfine, no noticable performance decrease.
464 ///
465 /// I am unsure as to why this wasn't being automatically discovered from Brew.
466 /// I've filed a bug report with them.
467 ///
468 /// ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
469 /// ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
470 /// ; bricht den Commit ab.
471 /// ;
472 /// ; Datum: Sat Jun 27 21:40:14 2020 +0200
473 /// ;
474 /// ; Auf Branch master
475 /// ;
476 /// ; Initialer Commit
477 /// ;
478 /// ; Zum Commit vorgemerkte \u{00E4}nderungen:
479 /// ; neue Datei: .bashrc
480 /// ;"
481 /// ));
482 /// let bodies = vec![
483 /// Body::default(),
484 /// Body::from(indoc!(
485 /// "
486 /// This should make it easier to deploy things for the developers.
487 /// Benchmarked with Hyperfine, no noticable performance decrease."
488 /// )),
489 /// Body::default(),
490 /// Body::from(indoc!(
491 /// "
492 /// I am unsure as to why this wasn't being automatically discovered from Brew.
493 /// I've filed a bug report with them."
494 /// )),
495 /// ];
496 /// assert_eq!(message.get_body(), Bodies::from(bodies))
497 /// ```
498 #[must_use]
499 pub fn get_body(&self) -> Bodies<'_> {
500 self.bodies.clone()
501 }
502
503 /// Get the [`Comments`] from the [`CommitMessage`]
504 ///
505 /// We this will get you all the comments before the `Scissors` section. The
506 /// [`Scissors`] section is the bit that appears when you run `git commit
507 /// --verbose`, that contains the diffs.
508 ///
509 /// If there's [`Comment`] mixed in with the body, it'll return those too,
510 /// but not any of the [`Body`] around them.
511 ///
512 /// # Returns
513 ///
514 /// The Comments from the commit message, excluding those in the Scissors section
515 ///
516 /// # Examples
517 ///
518 /// ```
519 /// use indoc::indoc;
520 /// use mit_commit::{Body, Comment, Comments, CommitMessage, Subject};
521 ///
522 /// let message = CommitMessage::from(indoc!(
523 /// "
524 /// Update bashrc to include kubernetes completions
525 ///
526 /// This should make it easier to deploy things for the developers.
527 /// Benchmarked with Hyperfine, no noticable performance decrease.
528 ///
529 /// I am unsure as to why this wasn't being automatically discovered from Brew.
530 /// I've filed a bug report with them.
531 ///
532 /// ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
533 /// ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
534 /// ; bricht den Commit ab.
535 /// ;
536 /// ; Datum: Sat Jun 27 21:40:14 2020 +0200
537 /// ;
538 /// ; Auf Branch master
539 /// ;
540 /// ; Initialer Commit
541 /// ;
542 /// ; Zum Commit vorgemerkte \u{00E4}nderungen:
543 /// ; neue Datei: .bashrc
544 /// ;"
545 /// ));
546 /// let comments = vec![Comment::from(indoc!(
547 /// "
548 /// ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
549 /// ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
550 /// ; bricht den Commit ab.
551 /// ;
552 /// ; Datum: Sat Jun 27 21:40:14 2020 +0200
553 /// ;
554 /// ; Auf Branch master
555 /// ;
556 /// ; Initialer Commit
557 /// ;
558 /// ; Zum Commit vorgemerkte \u{00E4}nderungen:
559 /// ; neue Datei: .bashrc
560 /// ;"
561 /// ))];
562 /// assert_eq!(message.get_comments(), Comments::from(comments))
563 /// ```
564 #[must_use]
565 pub fn get_comments(&self) -> Comments<'_> {
566 self.comments.clone()
567 }
568
569 /// Get the [`Scissors`] from the [`CommitMessage`]
570 ///
571 /// We this will get you all the comments in the [`Scissors`] section. The
572 /// [`Scissors`] section is the bit that appears when you run `git commit
573 /// --verbose`, that contains the diffs, and is not preserved when you
574 /// save the commit.
575 ///
576 /// # Returns
577 ///
578 /// An optional Scissors section if present in the commit message, or None if not present
579 ///
580 /// # Examples
581 ///
582 /// ```
583 /// use indoc::indoc;
584 /// use mit_commit::{Body, CommitMessage, Scissors, Subject};
585 ///
586 /// let message = CommitMessage::from(indoc!(
587 /// "
588 /// Add file
589 ///
590 /// This adds file primarily for demonstration purposes. It might not be
591 /// useful as an actual commit, but it's very useful as a example to use in
592 /// tests.
593 ///
594 /// Relates-to: #128
595 ///
596 /// ; Short (50 chars or less) summary of changes
597 /// ;
598 /// ; More detailed explanatory text, if necessary. Wrap it to
599 /// ; about 72 characters or so. In some contexts, the first
600 /// ; line is treated as the subject of an email and the rest of
601 /// ; the text as the body. The blank line separating the
602 /// ; summary from the body is critical (unless you omit the body
603 /// ; entirely); tools like rebase can get confused if you run
604 /// ; the two together.
605 /// ;
606 /// ; Further paragraphs come after blank lines.
607 /// ;
608 /// ; - Bullet points are okay, too
609 /// ;
610 /// ; - Typically a hyphen or asterisk is used for the bullet,
611 /// ; preceded by a single space, with blank lines in
612 /// ; between, but conventions vary here
613 ///
614 /// ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
615 /// ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
616 /// ; bricht den Commit ab.
617 /// ;
618 /// ; Auf Branch main
619 /// ; Ihr Branch ist auf demselben Stand wie 'origin/main'.
620 /// ;
621 /// ; Zum Commit vorgemerkte \u{00E4}nderungen:
622 /// ; neue Datei: file
623 /// ;
624 /// ; ------------------------ >8 ------------------------
625 /// ; \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
626 /// ; Alles unterhalb von ihr wird ignoriert.
627 /// diff --git a/file b/file
628 /// new file mode 100644
629 /// index 0000000..e69de29
630 /// "
631 /// ));
632 /// let scissors = Scissors::from(indoc!(
633 /// "
634 /// ; ------------------------ >8 ------------------------
635 /// ; \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
636 /// ; Alles unterhalb von ihr wird ignoriert.
637 /// diff --git a/file b/file
638 /// new file mode 100644
639 /// index 0000000..e69de29
640 /// "
641 /// ));
642 /// assert_eq!(message.get_scissors(), Some(scissors))
643 /// ```
644 #[must_use]
645 pub fn get_scissors(&self) -> Option<Scissors<'_>> {
646 self.scissors.clone()
647 }
648
649 /// Get the [`Trailers`] from the [`CommitMessage`]
650 ///
651 /// This will get you all the trailers in the commit message. Trailers are
652 /// special metadata lines at the end of the commit message, like "Signed-off-by:"
653 /// or "Relates-to:".
654 ///
655 /// # Returns
656 ///
657 /// The Trailers found in the commit message
658 ///
659 /// # Examples
660 ///
661 /// ```
662 /// use indoc::indoc;
663 /// use mit_commit::{Body, CommitMessage, Trailer, Trailers};
664 ///
665 /// let message = CommitMessage::from(indoc!(
666 /// "
667 /// Add file
668 ///
669 /// Looks-like-a-trailer: But isn't
670 ///
671 /// This adds file primarily for demonstration purposes. It might not be
672 /// useful as an actual commit, but it's very useful as a example to use in
673 /// tests.
674 ///
675 /// Relates-to: #128
676 /// Relates-to: #129
677 ///
678 /// ; Short (50 chars or less) summary of changes
679 /// ;
680 /// ; More detailed explanatory text, if necessary. Wrap it to
681 /// ; about 72 characters or so. In some contexts, the first
682 /// ; line is treated as the subject of an email and the rest of
683 /// ; the text as the body. The blank line separating the
684 /// ; summary from the body is critical (unless you omit the body
685 /// ; entirely); tools like rebase can get confused if you run
686 /// ; the two together.
687 /// ;
688 /// ; Further paragraphs come after blank lines.
689 /// ;
690 /// ; - Bullet points are okay, too
691 /// ;
692 /// ; - Typically a hyphen or asterisk is used for the bullet,
693 /// ; preceded by a single space, with blank lines in
694 /// ; between, but conventions vary here
695 ///
696 /// ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
697 /// ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
698 /// ; bricht den Commit ab.
699 /// ;
700 /// ; Auf Branch main
701 /// ; Ihr Branch ist auf demselben Stand wie 'origin/main'.
702 /// ;
703 /// ; Zum Commit vorgemerkte \u{00E4}nderungen:
704 /// ; neue Datei: file
705 /// ;
706 /// ; ------------------------ >8 ------------------------
707 /// ; \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
708 /// ; Alles unterhalb von ihr wird ignoriert.
709 /// diff --git a/file b/file
710 /// new file mode 100644
711 /// index 0000000..e69de29
712 /// "
713 /// ));
714 /// let trailers = vec![
715 /// Trailer::new("Relates-to".into(), "#128".into()),
716 /// Trailer::new("Relates-to".into(), "#129".into()),
717 /// ];
718 /// assert_eq!(message.get_trailers(), Trailers::from(trailers))
719 /// ```
720 #[must_use]
721 pub fn get_trailers(&self) -> Trailers<'_> {
722 self.trailers.clone()
723 }
724
725 /// Checks if the [`CommitMessage`] matches a given pattern in the saved portions
726 ///
727 /// This takes a regex and matches it to the visible portions of the
728 /// commits, so it excludes comments, and everything after the scissors.
729 ///
730 /// # Arguments
731 ///
732 /// * `re` - The regex pattern to match against the commit message
733 ///
734 /// # Returns
735 ///
736 /// `true` if the pattern matches any part of the visible commit message, `false` otherwise
737 ///
738 /// # Examples
739 ///
740 /// ```
741 /// use indoc::indoc;
742 /// use mit_commit::CommitMessage;
743 /// use regex::Regex;
744 ///
745 /// let commit = CommitMessage::from(indoc!(
746 /// "
747 /// Example Commit Message
748 ///
749 /// This is an example commit message for linting
750 ///
751 ///
752 /// ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
753 /// ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
754 /// ; bricht den Commit ab.
755 /// ;
756 /// ; Auf Branch main
757 /// ; Ihr Branch ist auf demselben Stand wie 'origin/main'.
758 /// ;
759 /// ; Zum Commit vorgemerkte \u{00E4}nderungen:
760 /// ; neue Datei: file
761 /// ;
762 /// "
763 /// ));
764 ///
765 /// let re = Regex::new("[Bb]itte").unwrap();
766 /// assert_eq!(commit.matches_pattern(&re), false);
767 ///
768 /// let re = Regex::new("f[o\u{00FC}]r linting").unwrap();
769 /// assert_eq!(commit.matches_pattern(&re), true);
770 ///
771 /// let re = Regex::new("[Ee]xample Commit Message").unwrap();
772 /// assert_eq!(commit.matches_pattern(&re), true);
773 /// ```
774 #[must_use]
775 pub fn matches_pattern(&self, re: &Regex) -> bool {
776 let text = self
777 .clone()
778 .get_ast()
779 .into_iter()
780 .filter_map(|fragment| match fragment {
781 Fragment::Body(body) => Some(String::from(body)),
782 Fragment::Comment(_) => None,
783 })
784 .collect::<Vec<_>>()
785 .join("\n");
786 re.is_match(&text)
787 }
788
789 fn guess_comment_character(message: &str) -> Option<char> {
790 Scissors::guess_comment_character(message)
791 }
792
793 /// Give you a new [`CommitMessage`] with the provided subject
794 ///
795 /// # Arguments
796 ///
797 /// * `subject` - The new Subject to use for the commit message
798 ///
799 /// # Returns
800 ///
801 /// A new `CommitMessage` with the updated subject
802 ///
803 /// # Examples
804 ///
805 /// ```
806 /// use indoc::indoc;
807 /// use mit_commit::{CommitMessage, Subject};
808 /// use regex::Regex;
809 ///
810 /// let commit = CommitMessage::from(indoc!(
811 /// "
812 /// Example Commit Message
813 ///
814 /// This is an example commit message
815 /// "
816 /// ));
817 ///
818 /// assert_eq!(
819 /// commit.with_subject("Subject".into()).get_subject(),
820 /// Subject::from("Subject")
821 /// );
822 /// ```
823 #[must_use]
824 pub fn with_subject(self, subject: Subject<'a>) -> Self {
825 let mut ast: Vec<Fragment<'a>> = self.ast.clone();
826
827 if !ast.is_empty() {
828 ast.remove(0);
829 }
830 ast.insert(0, Body::from(subject.to_string()).into());
831
832 Self {
833 scissors: self.scissors,
834 ast,
835 subject,
836 trailers: self.trailers,
837 comments: self.comments,
838 bodies: self.bodies,
839 }
840 }
841
842 /// Give you a new [`CommitMessage`] with the provided body
843 ///
844 /// # Arguments
845 ///
846 /// * `contents` - The new body content to use for the commit message
847 ///
848 /// # Returns
849 ///
850 /// A new `CommitMessage` with the updated body contents
851 ///
852 /// # Examples
853 ///
854 /// ```
855 /// use indoc::indoc;
856 /// use mit_commit::{CommitMessage, Subject};
857 /// use regex::Regex;
858 ///
859 /// let commit = CommitMessage::from(indoc!(
860 /// "
861 /// Example Commit Message
862 ///
863 /// This is an example commit message
864 /// "
865 /// ));
866 /// let expected = CommitMessage::from(indoc!(
867 /// "
868 /// Example Commit Message
869 ///
870 /// New body"
871 /// ));
872 ///
873 /// assert_eq!(commit.with_body_contents("New body"), expected);
874 /// ```
875 ///
876 /// A note on what we consider the body. The body is what falls after the
877 /// gutter. This means the following behaviour might happen
878 ///
879 /// ```
880 /// use indoc::indoc;
881 /// use mit_commit::{CommitMessage, Subject};
882 /// use regex::Regex;
883 /// let commit = CommitMessage::from(indoc!(
884 /// "
885 /// Example Commit Message
886 /// without gutter"
887 /// ));
888 /// let expected = CommitMessage::from(indoc!(
889 /// "
890 /// Example Commit Message
891 /// without gutter
892 ///
893 /// New body"
894 /// ));
895 ///
896 /// assert_eq!(commit.with_body_contents("New body"), expected);
897 /// ```
898 #[must_use]
899 pub fn with_body_contents(self, contents: &'a str) -> Self {
900 let existing_subject: Subject<'a> = self.get_subject();
901 let body = format!("Unused\n\n{contents}");
902 let commit = Self::from(body);
903
904 commit.with_subject(existing_subject)
905 }
906
907 /// Get the comment character used in the commit message
908 ///
909 /// # Returns
910 ///
911 /// The character used for comments in the commit message, or None if there are no comments
912 ///
913 /// # Examples
914 ///
915 /// ```
916 /// use mit_commit::{CommitMessage, Subject};
917 /// let commit = CommitMessage::from("No comment\n\n# Some Comment");
918 ///
919 /// assert_eq!(commit.get_comment_char().unwrap(), '#');
920 /// ```
921 ///
922 /// We return none is there is no comments
923 ///
924 /// ```
925 /// use mit_commit::{CommitMessage, Subject};
926 /// let commit = CommitMessage::from("No comment");
927 ///
928 /// assert!(commit.get_comment_char().is_none());
929 /// ```
930 #[must_use]
931 pub fn get_comment_char(&self) -> Option<char> {
932 self.comments
933 .iter()
934 .next()
935 .map(|comment| -> String { comment.clone().into() })
936 .and_then(|comment| comment.chars().next())
937 }
938}
939
940impl From<CommitMessage<'_>> for String {
941 fn from(commit_message: CommitMessage<'_>) -> Self {
942 let basic_commit = commit_message
943 .get_ast()
944 .iter()
945 .map(|item| match item {
946 Fragment::Body(contents) => Self::from(contents.clone()),
947 Fragment::Comment(contents) => Self::from(contents.clone()),
948 })
949 .collect::<Vec<_>>()
950 .join("\n");
951
952 if let Some(scissors) = commit_message.get_scissors() {
953 format!("{basic_commit}\n{}", Self::from(scissors))
954 } else {
955 basic_commit
956 }
957 }
958}
959
960impl From<&CommitMessage<'_>> for String {
961 fn from(commit_message: &CommitMessage<'_>) -> Self {
962 let basic_commit = commit_message
963 .get_ast()
964 .iter()
965 .map(|item| match item {
966 Fragment::Body(contents) => Self::from(contents.clone()),
967 Fragment::Comment(contents) => Self::from(contents.clone()),
968 })
969 .collect::<Vec<_>>()
970 .join("\n");
971
972 if let Some(scissors) = commit_message.get_scissors() {
973 format!("{basic_commit}\n{}", Self::from(scissors))
974 } else {
975 basic_commit
976 }
977 }
978}
979
980/// Parse a commit message using parsers
981impl CommitMessage<'_> {
982 fn parse_commit_message(message: &str) -> Self {
983 // Step 1: Split the message into body and scissors sections
984 let (rest, scissors) = Scissors::parse_sections(message);
985
986 // Step 2: Guess the comment character
987 let comment_character = Self::guess_comment_character(message);
988
989 // Step 3: Convert the body to a per-line AST
990 let per_line_ast = Self::convert_to_per_line_ast(comment_character, &rest);
991
992 // Step 4: Extract trailers before grouping to avoid cloning the entire AST
993 let trailers = Trailers::from(per_line_ast.clone());
994
995 // Step 5: Group consecutive fragments of the same type
996 let mut ast: Vec<Fragment<'_>> = Self::group_ast(per_line_ast);
997
998 // Step 6: Handle trailing newline case
999 if message.ends_with('\n') && scissors.is_none() {
1000 ast.push(Body::default().into());
1001 }
1002
1003 // Step 7: Create subject, comments, and bodies from the AST
1004 // We need to clone here because the From implementations require owned vectors
1005 let subject = Subject::from(ast.clone());
1006 let comments = Comments::from(ast.clone());
1007 let bodies = Bodies::from(ast.clone());
1008
1009 // Step 8: Create and return the CommitMessage
1010 Self {
1011 scissors,
1012 ast,
1013 subject,
1014 trailers,
1015 comments,
1016 bodies,
1017 }
1018 }
1019}
1020
1021impl<'a> From<Cow<'a, str>> for CommitMessage<'a> {
1022 /// Create a new [`CommitMessage`]
1023 ///
1024 /// Create a commit message from a string. It's expected that you'll be
1025 /// reading this during some sort of Git Hook
1026 ///
1027 /// # Examples
1028 ///
1029 /// ```
1030 /// use indoc::indoc;
1031 /// use mit_commit::{Bodies, CommitMessage, Subject};
1032 ///
1033 /// let message = CommitMessage::from(indoc!(
1034 /// "
1035 /// Update bashrc to include kubernetes completions
1036 ///
1037 /// This should make it easier to deploy things for the developers.
1038 /// Benchmarked with Hyperfine, no noticable performance decrease.
1039 ///
1040 /// ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1041 /// ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
1042 /// ; bricht den Commit ab.
1043 /// ;
1044 /// ; Datum: Sat Jun 27 21:40:14 2020 +0200
1045 /// ;
1046 /// ; Auf Branch master
1047 /// ;
1048 /// ; Initialer Commit
1049 /// ;
1050 /// ; Zum Commit vorgemerkte \u{00E4}nderungen:
1051 /// ; neue Datei: .bashrc
1052 /// ;"
1053 /// ));
1054 /// assert_eq!(
1055 /// message.get_subject(),
1056 /// Subject::from("Update bashrc to include kubernetes completions")
1057 /// )
1058 /// ```
1059 ///
1060 /// # Comment Character
1061 ///
1062 /// We load the comment character for the commit message
1063 ///
1064 /// Valid options are in [`crate::comment::LEGAL_CHARACTERS`], these are based on the auto-selection logic in the git codebase's [`adjust_comment_line_char` function](https://github.com/git/git/blob/master/builtin/commit.c#L667-L695).
1065 ///
1066 /// This does mean that we aren't making 100% of characters available, which
1067 /// is technically possible, but given we don't have access to the users git
1068 /// config this feels like a reasonable compromise, there are a lot of
1069 /// non-whitespace characters as options otherwise, and we don't want to
1070 /// confuse a genuine body with a comment
1071 fn from(message: Cow<'a, str>) -> Self {
1072 Self::parse_commit_message(&message)
1073 }
1074}
1075
1076impl TryFrom<PathBuf> for CommitMessage<'_> {
1077 type Error = Error;
1078
1079 /// Creates a `CommitMessage` from a file path.
1080 ///
1081 /// # Arguments
1082 ///
1083 /// * `value` - The path to the file containing the commit message
1084 ///
1085 /// # Returns
1086 ///
1087 /// A `CommitMessage` parsed from the file contents
1088 ///
1089 /// # Examples
1090 ///
1091 /// ```
1092 /// use std::path::PathBuf;
1093 /// use std::convert::TryFrom;
1094 /// use std::io::Write;
1095 /// use mit_commit::CommitMessage;
1096 ///
1097 /// // Create a temporary file for the example
1098 /// let mut temp_file = tempfile::NamedTempFile::new().unwrap();
1099 /// write!(temp_file.as_file(), "Example commit message").unwrap();
1100 ///
1101 /// // Use the temporary file path
1102 /// let path = temp_file.path().to_path_buf();
1103 /// let commit_message = CommitMessage::try_from(path).expect("Failed to read commit message");
1104 /// assert_eq!(commit_message.get_subject().to_string(), "Example commit message");
1105 /// ```
1106 ///
1107 /// # Errors
1108 ///
1109 /// Returns an Error if the file cannot be read or if the file contents cannot be parsed as UTF-8
1110 fn try_from(value: PathBuf) -> Result<Self, Self::Error> {
1111 let mut file = File::open(value)?;
1112 let mut buffer = String::new();
1113
1114 file.read_to_string(&mut buffer)
1115 .map_err(Error::from)
1116 .map(move |_| Self::from(buffer))
1117 }
1118}
1119
1120impl<'a> TryFrom<&'a Path> for CommitMessage<'a> {
1121 type Error = Error;
1122
1123 /// Creates a `CommitMessage` from a file path reference.
1124 ///
1125 /// # Arguments
1126 ///
1127 /// * `value` - The path reference to the file containing the commit message
1128 ///
1129 /// # Returns
1130 ///
1131 /// A `CommitMessage` parsed from the file contents
1132 ///
1133 /// # Examples
1134 ///
1135 /// ```
1136 /// use std::path::Path;
1137 /// use std::convert::TryFrom;
1138 /// use std::io::Write;
1139 /// use mit_commit::CommitMessage;
1140 ///
1141 /// // Create a temporary file for the example
1142 /// let mut temp_file = tempfile::NamedTempFile::new().unwrap();
1143 /// write!(temp_file.as_file(), "Example commit message").unwrap();
1144 ///
1145 /// // Use the temporary file path
1146 /// let path = temp_file.path();
1147 /// let commit_message = CommitMessage::try_from(path).expect("Failed to read commit message");
1148 /// assert_eq!(commit_message.get_subject().to_string(), "Example commit message");
1149 /// ```
1150 ///
1151 /// # Errors
1152 ///
1153 /// Returns an Error if the file cannot be read or if the file contents cannot be parsed as UTF-8
1154 fn try_from(value: &'a Path) -> Result<Self, Self::Error> {
1155 let mut file = File::open(value)?;
1156 let mut buffer = String::new();
1157
1158 file.read_to_string(&mut buffer)
1159 .map_err(Error::from)
1160 .map(move |_| Self::from(buffer))
1161 }
1162}
1163
1164impl<'a> From<&'a str> for CommitMessage<'a> {
1165 fn from(message: &'a str) -> Self {
1166 CommitMessage::from(Cow::from(message))
1167 }
1168}
1169
1170impl From<String> for CommitMessage<'_> {
1171 fn from(message: String) -> Self {
1172 Self::from(Cow::from(message))
1173 }
1174}
1175
1176/// Errors on reading commit messages
1177#[derive(Error, Debug, Diagnostic)]
1178pub enum Error {
1179 /// Failed to read a commit message
1180 #[error("failed to read commit file {0}")]
1181 #[diagnostic(
1182 url(docsrs),
1183 code(mit_commit::commit_message::error::io),
1184 help("check the file is readable")
1185 )]
1186 Io(#[from] io::Error),
1187}
1188
1189#[cfg(test)]
1190mod tests {
1191 use std::{convert::TryInto, io::Write};
1192
1193 use indoc::indoc;
1194 use quickcheck::TestResult;
1195 use regex::Regex;
1196 use tempfile::NamedTempFile;
1197
1198 use super::*;
1199 use crate::{
1200 Fragment, bodies::Bodies, body::Body, comment::Comment, scissors::Scissors,
1201 subject::Subject, trailer::Trailer,
1202 };
1203
1204 #[test]
1205 fn test_default_returns_empty_string() {
1206 let commit = CommitMessage::default();
1207 let actual: String = commit.into();
1208
1209 assert_eq!(
1210 actual,
1211 String::new(),
1212 "Default CommitMessage should convert to an empty string"
1213 );
1214 }
1215
1216 #[test]
1217 fn test_matches_pattern_returns_correct_results() {
1218 let commit = CommitMessage::from(indoc!(
1219 "
1220 Example Commit Message
1221
1222 This is an example commit message for linting
1223
1224 Relates-to: #153
1225 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1226 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1227 # bricht den Commit ab.
1228 #
1229 # Auf Branch main
1230 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1231 #
1232 # Zum Commit vorgemerkte \u{00E4}nderungen:
1233 # neue Datei: file
1234 #
1235 "
1236 ));
1237
1238 let re = Regex::new("[Bb]itte").unwrap();
1239 assert!(
1240 !commit.matches_pattern(&re),
1241 "Pattern should not match in comments"
1242 );
1243
1244 let re = Regex::new("f[o\u{00FC}]r linting").unwrap();
1245 assert!(
1246 commit.matches_pattern(&re),
1247 "Pattern should match in body text"
1248 );
1249
1250 let re = Regex::new("[Ee]xample Commit Message").unwrap();
1251 assert!(
1252 commit.matches_pattern(&re),
1253 "Pattern should match in subject"
1254 );
1255
1256 let re = Regex::new("Relates[- ]to").unwrap();
1257 assert!(
1258 commit.matches_pattern(&re),
1259 "Pattern should match in trailers"
1260 );
1261 }
1262
1263 #[test]
1264 fn test_parse_message_without_gutter_succeeds() {
1265 let commit = CommitMessage::from(indoc!(
1266 "
1267 Example Commit Message
1268 This is an example commit message for linting
1269
1270 This is another line
1271 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1272 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1273 # bricht den Commit ab.
1274 #
1275 # Auf Branch main
1276 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1277 #
1278 # Zum Commit vorgemerkte \u{00E4}nderungen:
1279 # neue Datei: file
1280 #
1281 "
1282 ));
1283
1284 assert_eq!(
1285 commit.get_subject(),
1286 Subject::from("Example Commit Message\nThis is an example commit message for linting"),
1287 "Subject should include both lines when there's no gutter"
1288 );
1289 assert_eq!(
1290 commit.get_body(),
1291 Bodies::from(vec![Body::default(), Body::from("This is another line")]),
1292 "Body should contain the line after the empty line"
1293 );
1294 }
1295
1296 #[test]
1297 fn test_add_trailer_to_normal_commit_appends_correctly() {
1298 let commit = CommitMessage::from(indoc!(
1299 "
1300 Example Commit Message
1301
1302 This is an example commit message for linting
1303
1304 Relates-to: #153
1305
1306 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1307 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1308 # bricht den Commit ab.
1309 #
1310 # Auf Branch main
1311 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1312 #
1313 # Zum Commit vorgemerkte \u{00E4}nderungen:
1314 # neue Datei: file
1315 #
1316 "
1317 ));
1318
1319 let expected = CommitMessage::from(indoc!(
1320 "
1321 Example Commit Message
1322
1323 This is an example commit message for linting
1324
1325 Relates-to: #153
1326 Co-authored-by: Test Trailer <test@example.com>
1327
1328 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1329 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1330 # bricht den Commit ab.
1331 #
1332 # Auf Branch main
1333 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1334 #
1335 # Zum Commit vorgemerkte \u{00E4}nderungen:
1336 # neue Datei: file
1337 #
1338 "
1339 ));
1340
1341 let actual = commit.add_trailer(Trailer::new(
1342 "Co-authored-by".into(),
1343 "Test Trailer <test@example.com>".into(),
1344 ));
1345
1346 assert_eq!(
1347 String::from(actual),
1348 String::from(expected),
1349 "Adding a trailer to a commit with existing trailers should append the new trailer after the last trailer"
1350 );
1351 }
1352
1353 #[test]
1354 fn test_add_trailer_to_conventional_commit_appends_correctly() {
1355 let commit = CommitMessage::from(indoc!(
1356 "
1357 feat: Example Commit Message
1358
1359 This is an example commit message for linting
1360
1361 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1362 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1363 # bricht den Commit ab.
1364 #
1365 # Auf Branch main
1366 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1367 #
1368 # Zum Commit vorgemerkte \u{00E4}nderungen:
1369 # neue Datei: file
1370 #
1371 "
1372 ));
1373
1374 let expected = CommitMessage::from(indoc!(
1375 "
1376 feat: Example Commit Message
1377
1378 This is an example commit message for linting
1379
1380 Co-authored-by: Test Trailer <test@example.com>
1381
1382 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1383 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1384 # bricht den Commit ab.
1385 #
1386 # Auf Branch main
1387 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1388 #
1389 # Zum Commit vorgemerkte \u{00E4}nderungen:
1390 # neue Datei: file
1391 #
1392 "
1393 ));
1394
1395 let actual = commit.add_trailer(Trailer::new(
1396 "Co-authored-by".into(),
1397 "Test Trailer <test@example.com>".into(),
1398 ));
1399
1400 assert_eq!(
1401 String::from(actual),
1402 String::from(expected),
1403 "Adding a trailer to a conventional commit should append the trailer after the body"
1404 );
1405 }
1406
1407 #[test]
1408 fn test_add_trailer_to_commit_without_trailers_creates_trailer_section() {
1409 let commit = CommitMessage::from(indoc!(
1410 "
1411 Example Commit Message
1412
1413 This is an example commit message for linting
1414
1415 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1416 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1417 # bricht den Commit ab.
1418 #
1419 # Auf Branch main
1420 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1421 #
1422 # Zum Commit vorgemerkte \u{00E4}nderungen:
1423 # neue Datei: file
1424 #
1425 "
1426 ));
1427
1428 let expected = CommitMessage::from(indoc!(
1429 "
1430 Example Commit Message
1431
1432 This is an example commit message for linting
1433
1434 Co-authored-by: Test Trailer <test@example.com>
1435
1436 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1437 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1438 # bricht den Commit ab.
1439 #
1440 # Auf Branch main
1441 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1442 #
1443 # Zum Commit vorgemerkte \u{00E4}nderungen:
1444 # neue Datei: file
1445 #
1446 "
1447 ));
1448 assert_eq!(
1449 String::from(commit.add_trailer(Trailer::new(
1450 "Co-authored-by".into(),
1451 "Test Trailer <test@example.com>".into(),
1452 ))),
1453 String::from(expected),
1454 "Adding a trailer to a commit without existing trailers should create a new trailer section after the body"
1455 );
1456 }
1457
1458 #[test]
1459 fn test_add_trailer_to_empty_commit_creates_trailer_section() {
1460 let commit = CommitMessage::from(indoc!(
1461 "
1462
1463 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1464 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1465 # bricht den Commit ab.
1466 #
1467 # Auf Branch main
1468 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1469 #
1470 # Zum Commit vorgemerkte \u{00E4}nderungen:
1471 # neue Datei: file
1472 #
1473 "
1474 ));
1475
1476 let expected = CommitMessage::from(indoc!(
1477 "
1478
1479
1480 Co-authored-by: Test Trailer <test@example.com>
1481
1482 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1483 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1484 # bricht den Commit ab.
1485 #
1486 # Auf Branch main
1487 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1488 #
1489 # Zum Commit vorgemerkte \u{00E4}nderungen:
1490 # neue Datei: file
1491 #
1492 "
1493 ));
1494 assert_eq!(
1495 String::from(commit.add_trailer(Trailer::new(
1496 "Co-authored-by".into(),
1497 "Test Trailer <test@example.com>".into(),
1498 ))),
1499 String::from(expected),
1500 "Adding a trailer to an empty commit should create a trailer section at the beginning"
1501 );
1502 }
1503
1504 #[test]
1505 fn test_add_trailer_to_empty_commit_with_trailer_appends_correctly() {
1506 let commit = CommitMessage::from(indoc!(
1507 "
1508
1509
1510 Co-authored-by: Test Trailer <test@example.com>
1511
1512 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1513 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1514 # bricht den Commit ab.
1515 #
1516 # Auf Branch main
1517 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1518 #
1519 # Zum Commit vorgemerkte \u{00E4}nderungen:
1520 # neue Datei: file
1521 #
1522 "
1523 ));
1524
1525 let expected = CommitMessage::from(indoc!(
1526 "
1527
1528
1529 Co-authored-by: Test Trailer <test@example.com>
1530 Co-authored-by: Someone Else <someone@example.com>
1531
1532 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1533 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1534 # bricht den Commit ab.
1535 #
1536 # Auf Branch main
1537 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1538 #
1539 # Zum Commit vorgemerkte \u{00E4}nderungen:
1540 # neue Datei: file
1541 #
1542 "
1543 ));
1544 assert_eq!(
1545 String::from(commit.add_trailer(Trailer::new(
1546 "Co-authored-by".into(),
1547 "Someone Else <someone@example.com>".into(),
1548 ))),
1549 String::from(expected),
1550 "Adding a trailer to an empty commit with an existing trailer should append the new trailer after the existing one"
1551 );
1552 }
1553
1554 #[test]
1555 fn test_from_fragments_generates_correct_commit() {
1556 let message = CommitMessage::from_fragments(
1557 vec![
1558 Fragment::Body(Body::from("Example Commit")),
1559 Fragment::Body(Body::default()),
1560 Fragment::Body(Body::from("Here is a body")),
1561 Fragment::Comment(Comment::from("# Example Commit")),
1562 ],
1563 Some(Scissors::from(indoc!(
1564 "
1565 # ------------------------ >8 ------------------------
1566 # \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
1567 # Alles unterhalb von ihr wird ignoriert.
1568 diff --git a/file b/file
1569 new file mode 100644
1570 index 0000000..e69de29
1571 "
1572 ))),
1573 );
1574
1575 assert_eq!(
1576 String::from(message),
1577 String::from(indoc!(
1578 "
1579 Example Commit
1580
1581 Here is a body
1582 # Example Commit
1583 # ------------------------ >8 ------------------------
1584 # \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
1585 # Alles unterhalb von ihr wird ignoriert.
1586 diff --git a/file b/file
1587 new file mode 100644
1588 index 0000000..e69de29
1589 "
1590 )),
1591 "Creating a CommitMessage from fragments should generate the correct string representation"
1592 );
1593 }
1594
1595 #[test]
1596 fn test_insert_after_last_body_appends_correctly() {
1597 let ast: Vec<Fragment<'_>> = vec![
1598 Fragment::Body(Body::from("Add file")),
1599 Fragment::Body(Body::default()),
1600 Fragment::Body(Body::from("Looks-like-a-trailer: But isn\'t")),
1601 Fragment::Body(Body::default()),
1602 Fragment::Body(Body::from(
1603 "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.",
1604 )),
1605 Fragment::Body(Body::default()),
1606 Fragment::Body(Body::from("Relates-to: #128")),
1607 Fragment::Body(Body::default()),
1608 Fragment::Comment(Comment::from(
1609 "# 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",
1610 )),
1611 Fragment::Body(Body::default()),
1612 Fragment::Comment(Comment::from(
1613 "# 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#",
1614 )),
1615 ];
1616 let commit = CommitMessage::from_fragments(ast, None);
1617
1618 assert_eq!(
1619 commit
1620 .insert_after_last_full_body(vec![Fragment::Body(Body::from("Relates-to: #656"))])
1621 .get_ast(),
1622 vec![
1623 Fragment::Body(Body::from("Add file")),
1624 Fragment::Body(Body::default()),
1625 Fragment::Body(Body::from("Looks-like-a-trailer: But isn\'t")),
1626 Fragment::Body(Body::default()),
1627 Fragment::Body(Body::from(
1628 "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."
1629 )),
1630 Fragment::Body(Body::default()),
1631 Fragment::Body(Body::from("Relates-to: #128\nRelates-to: #656")),
1632 Fragment::Body(Body::default()),
1633 Fragment::Comment(Comment::from(
1634 "# 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"
1635 )),
1636 Fragment::Body(Body::default()),
1637 Fragment::Comment(Comment::from(
1638 "# 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#"
1639 )),
1640 ],
1641 "Inserting after the last body should append the new fragment after the last non-empty body fragment"
1642 );
1643 }
1644
1645 #[test]
1646 fn test_insert_after_last_body_with_no_body_inserts_at_beginning() {
1647 let ast: Vec<Fragment<'_>> = vec![
1648 Fragment::Comment(Comment::from(
1649 "# 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",
1650 )),
1651 Fragment::Body(Body::default()),
1652 Fragment::Comment(Comment::from(
1653 "# 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#",
1654 )),
1655 ];
1656 let commit = CommitMessage::from_fragments(ast, None);
1657
1658 assert_eq!(
1659 commit
1660 .insert_after_last_full_body(vec![Fragment::Body(Body::from("Relates-to: #656"))])
1661 .get_ast(),
1662 vec![
1663 Fragment::Body(Body::from("Relates-to: #656")),
1664 Fragment::Comment(Comment::from(
1665 "# Short (50 chars or less) summary of changes\n#\n# More detailed explanatory text, if necessary. Wrap it to\n# about 72 characters or so. In some contexts, the first\n# line is treated as the subject of an email and the rest of\n# the text as the body. The blank line separating the\n# summary from the body is critical (unless you omit the body\n# entirely); tools like rebase can get confused if you run\n# the two together.\n#\n# Further paragraphs come after blank lines.\n#\n# - Bullet points are okay, too\n#\n# - Typically a hyphen or asterisk is used for the bullet,\n# preceded by a single space, with blank lines in\n# between, but conventions vary here"
1666 )),
1667 Fragment::Body(Body::default()),
1668 Fragment::Comment(Comment::from(
1669 "# Bitte geben Sie eine Commit-Beschreibung f\u{fc}r Ihre \u{e4}nderungen ein. Zeilen,\n# die mit \'#\' beginnen, werden ignoriert, und eine leere Beschreibung\n# bricht den Commit ab.\n#\n# Auf Branch main\n# Ihr Branch ist auf demselben Stand wie \'origin/main\'.\n#\n# Zum Commit vorgemerkte \u{e4}nderungen:\n#\tneue Datei: file\n#"
1670 )),
1671 ],
1672 "When there is no body, inserting after the last body should insert at the beginning of the AST"
1673 );
1674 }
1675
1676 #[allow(clippy::needless_pass_by_value)]
1677 #[quickcheck]
1678 fn test_with_subject_preserves_input_string(input: String) -> bool {
1679 let commit: CommitMessage<'_> = "Some Subject".into();
1680 let actual: String = commit
1681 .with_subject(input.clone().into())
1682 .get_subject()
1683 .into();
1684 // Property: The subject should be exactly the input string after setting it
1685 actual == input
1686 }
1687
1688 #[test]
1689 fn test_with_subject_on_default_commit_sets_subject_correctly() {
1690 let commit = CommitMessage::default().with_subject("Subject".into());
1691 assert_eq!(
1692 commit.get_subject(),
1693 Subject::from("Subject"),
1694 "Setting subject on default commit should update the subject correctly"
1695 );
1696 }
1697
1698 #[allow(clippy::needless_pass_by_value)]
1699 #[quickcheck]
1700 fn test_with_body_contents_replaces_body_correctly(input: String) -> TestResult {
1701 if input.contains('\r') {
1702 return TestResult::discard();
1703 }
1704
1705 let commit: CommitMessage<'_> = "Some Subject\n\nSome Body".into();
1706 let expected: String = format!("Some Subject\n\n{input}");
1707 let actual: String = commit.with_body_contents(&input).into();
1708 // Property: The body should be replaced with the input string while preserving the subject
1709 TestResult::from_bool(actual == expected)
1710 }
1711
1712 #[allow(clippy::needless_pass_by_value)]
1713 #[quickcheck]
1714 fn test_with_body_contents_preserves_multiline_subject(input: String) -> TestResult {
1715 if input.contains('\r') {
1716 return TestResult::discard();
1717 }
1718
1719 let commit: CommitMessage<'_> = "Some Subject\nSome More Subject\n\nBody".into();
1720 let expected: String = format!("Some Subject\nSome More Subject\n\n{input}");
1721 let actual: String = commit.with_body_contents(&input).into();
1722 // Property: The body should be replaced with the input string while preserving the multi-line subject
1723 TestResult::from_bool(actual == expected)
1724 }
1725
1726 #[test]
1727 fn test_get_comment_char_returns_none_when_no_comments() {
1728 let commit_character = CommitMessage::from("Example Commit Message");
1729 assert!(
1730 commit_character.get_comment_char().is_none(),
1731 "Comment character should be None when there are no comments in the message"
1732 );
1733 }
1734
1735 #[test]
1736 fn test_try_from_path_buf_reads_file_correctly() {
1737 let temp_file = NamedTempFile::new().expect("failed to create temp file");
1738 write!(temp_file.as_file(), "Some Subject").expect("Failed to write file");
1739
1740 let commit_character: CommitMessage<'_> = temp_file
1741 .path()
1742 .to_path_buf()
1743 .try_into()
1744 .expect("Could not read commit message");
1745 assert_eq!(
1746 commit_character.get_subject().to_string(),
1747 "Some Subject",
1748 "Reading from PathBuf should correctly parse the file contents into a CommitMessage"
1749 );
1750 }
1751
1752 #[test]
1753 fn test_try_from_path_reads_file_correctly() {
1754 let temp_file = NamedTempFile::new().expect("failed to create temp file");
1755 write!(temp_file.as_file(), "Some Subject").expect("Failed to write file");
1756
1757 let commit_character: CommitMessage<'_> = temp_file
1758 .path()
1759 .try_into()
1760 .expect("Could not read commit message");
1761 assert_eq!(
1762 commit_character.get_subject().to_string(),
1763 "Some Subject",
1764 "Reading from Path should correctly parse the file contents into a CommitMessage"
1765 );
1766 }
1767
1768 #[test]
1769 fn test_from_reference_produces_same_output_as_from_owned() {
1770 let commit = CommitMessage::from(indoc!(
1771 "
1772 Example Commit Message
1773
1774 This is an example commit message for linting
1775
1776 Relates-to: #153
1777
1778 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1779 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1780 # bricht den Commit ab.
1781 #
1782 # Auf Branch main
1783 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1784 #
1785 # Zum Commit vorgemerkte \u{00E4}nderungen:
1786 # neue Datei: file
1787 #
1788 "
1789 ));
1790
1791 let from_ref = String::from(&commit);
1792 let from_owned = String::from(commit.clone());
1793
1794 assert_eq!(
1795 from_ref, from_owned,
1796 "String::from(&commit) should produce the same result as String::from(commit)"
1797 );
1798 }
1799
1800 #[test]
1801 fn test_from_reference_preserves_original() {
1802 let commit = CommitMessage::from(indoc!(
1803 "
1804 Example Commit Message
1805
1806 This is an example commit message for linting
1807 "
1808 ));
1809
1810 // Create a string from a reference to the commit
1811 let _string = String::from(&commit);
1812
1813 // Verify we can still use the commit after creating a string from it
1814 assert_eq!(
1815 commit.get_subject(),
1816 Subject::from("Example Commit Message"),
1817 "Original CommitMessage should still be usable after String::from(&commit)"
1818 );
1819 }
1820
1821 #[test]
1822 fn test_from_reference_with_scissors() {
1823 let commit = CommitMessage::from(indoc!(
1824 "
1825 Example Commit Message
1826
1827 This is an example commit message
1828
1829 # ------------------------ >8 ------------------------
1830 # Do not modify or remove the line above.
1831 # Everything below it will be ignored.
1832 diff --git a/file b/file
1833 new file mode 100644
1834 index 0000000..e69de29
1835 "
1836 ));
1837
1838 let from_ref = String::from(&commit);
1839
1840 assert!(
1841 from_ref.contains("# ------------------------ >8 ------------------------"),
1842 "String created from reference should include scissors section"
1843 );
1844 assert!(
1845 from_ref.contains("diff --git"),
1846 "String created from reference should include content after scissors"
1847 );
1848 }
1849}