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
960/// Parse a commit message using parsers
961impl CommitMessage<'_> {
962 fn parse_commit_message(message: &str) -> Self {
963 // Step 1: Split the message into body and scissors sections
964 let (rest, scissors) = Scissors::parse_sections(message);
965
966 // Step 2: Guess the comment character
967 let comment_character = Self::guess_comment_character(message);
968
969 // Step 3: Convert the body to a per-line AST
970 let per_line_ast = Self::convert_to_per_line_ast(comment_character, &rest);
971
972 // Step 4: Extract trailers before grouping to avoid cloning the entire AST
973 let trailers = Trailers::from(per_line_ast.clone());
974
975 // Step 5: Group consecutive fragments of the same type
976 let mut ast: Vec<Fragment<'_>> = Self::group_ast(per_line_ast);
977
978 // Step 6: Handle trailing newline case
979 if message.ends_with('\n') && scissors.is_none() {
980 ast.push(Body::default().into());
981 }
982
983 // Step 7: Create subject, comments, and bodies from the AST
984 // We need to clone here because the From implementations require owned vectors
985 let subject = Subject::from(ast.clone());
986 let comments = Comments::from(ast.clone());
987 let bodies = Bodies::from(ast.clone());
988
989 // Step 8: Create and return the CommitMessage
990 Self {
991 scissors,
992 ast,
993 subject,
994 trailers,
995 comments,
996 bodies,
997 }
998 }
999}
1000
1001impl<'a> From<Cow<'a, str>> for CommitMessage<'a> {
1002 /// Create a new [`CommitMessage`]
1003 ///
1004 /// Create a commit message from a string. It's expected that you'll be
1005 /// reading this during some sort of Git Hook
1006 ///
1007 /// # Examples
1008 ///
1009 /// ```
1010 /// use indoc::indoc;
1011 /// use mit_commit::{Bodies, CommitMessage, Subject};
1012 ///
1013 /// let message = CommitMessage::from(indoc!(
1014 /// "
1015 /// Update bashrc to include kubernetes completions
1016 ///
1017 /// This should make it easier to deploy things for the developers.
1018 /// Benchmarked with Hyperfine, no noticable performance decrease.
1019 ///
1020 /// ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1021 /// ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
1022 /// ; bricht den Commit ab.
1023 /// ;
1024 /// ; Datum: Sat Jun 27 21:40:14 2020 +0200
1025 /// ;
1026 /// ; Auf Branch master
1027 /// ;
1028 /// ; Initialer Commit
1029 /// ;
1030 /// ; Zum Commit vorgemerkte \u{00E4}nderungen:
1031 /// ; neue Datei: .bashrc
1032 /// ;"
1033 /// ));
1034 /// assert_eq!(
1035 /// message.get_subject(),
1036 /// Subject::from("Update bashrc to include kubernetes completions")
1037 /// )
1038 /// ```
1039 ///
1040 /// # Comment Character
1041 ///
1042 /// We load the comment character for the commit message
1043 ///
1044 /// 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).
1045 ///
1046 /// This does mean that we aren't making 100% of characters available, which
1047 /// is technically possible, but given we don't have access to the users git
1048 /// config this feels like a reasonable compromise, there are a lot of
1049 /// non-whitespace characters as options otherwise, and we don't want to
1050 /// confuse a genuine body with a comment
1051 fn from(message: Cow<'a, str>) -> Self {
1052 Self::parse_commit_message(&message)
1053 }
1054}
1055
1056impl TryFrom<PathBuf> for CommitMessage<'_> {
1057 type Error = Error;
1058
1059 /// Creates a `CommitMessage` from a file path.
1060 ///
1061 /// # Arguments
1062 ///
1063 /// * `value` - The path to the file containing the commit message
1064 ///
1065 /// # Returns
1066 ///
1067 /// A `CommitMessage` parsed from the file contents
1068 ///
1069 /// # Examples
1070 ///
1071 /// ```
1072 /// use std::path::PathBuf;
1073 /// use std::convert::TryFrom;
1074 /// use std::io::Write;
1075 /// use mit_commit::CommitMessage;
1076 ///
1077 /// // Create a temporary file for the example
1078 /// let mut temp_file = tempfile::NamedTempFile::new().unwrap();
1079 /// write!(temp_file.as_file(), "Example commit message").unwrap();
1080 ///
1081 /// // Use the temporary file path
1082 /// let path = temp_file.path().to_path_buf();
1083 /// let commit_message = CommitMessage::try_from(path).expect("Failed to read commit message");
1084 /// assert_eq!(commit_message.get_subject().to_string(), "Example commit message");
1085 /// ```
1086 ///
1087 /// # Errors
1088 ///
1089 /// Returns an Error if the file cannot be read or if the file contents cannot be parsed as UTF-8
1090 fn try_from(value: PathBuf) -> Result<Self, Self::Error> {
1091 let mut file = File::open(value)?;
1092 let mut buffer = String::new();
1093
1094 file.read_to_string(&mut buffer)
1095 .map_err(Error::from)
1096 .map(move |_| Self::from(buffer))
1097 }
1098}
1099
1100impl<'a> TryFrom<&'a Path> for CommitMessage<'a> {
1101 type Error = Error;
1102
1103 /// Creates a `CommitMessage` from a file path reference.
1104 ///
1105 /// # Arguments
1106 ///
1107 /// * `value` - The path reference to the file containing the commit message
1108 ///
1109 /// # Returns
1110 ///
1111 /// A `CommitMessage` parsed from the file contents
1112 ///
1113 /// # Examples
1114 ///
1115 /// ```
1116 /// use std::path::Path;
1117 /// use std::convert::TryFrom;
1118 /// use std::io::Write;
1119 /// use mit_commit::CommitMessage;
1120 ///
1121 /// // Create a temporary file for the example
1122 /// let mut temp_file = tempfile::NamedTempFile::new().unwrap();
1123 /// write!(temp_file.as_file(), "Example commit message").unwrap();
1124 ///
1125 /// // Use the temporary file path
1126 /// let path = temp_file.path();
1127 /// let commit_message = CommitMessage::try_from(path).expect("Failed to read commit message");
1128 /// assert_eq!(commit_message.get_subject().to_string(), "Example commit message");
1129 /// ```
1130 ///
1131 /// # Errors
1132 ///
1133 /// Returns an Error if the file cannot be read or if the file contents cannot be parsed as UTF-8
1134 fn try_from(value: &'a Path) -> Result<Self, Self::Error> {
1135 let mut file = File::open(value)?;
1136 let mut buffer = String::new();
1137
1138 file.read_to_string(&mut buffer)
1139 .map_err(Error::from)
1140 .map(move |_| Self::from(buffer))
1141 }
1142}
1143
1144impl<'a> From<&'a str> for CommitMessage<'a> {
1145 fn from(message: &'a str) -> Self {
1146 CommitMessage::from(Cow::from(message))
1147 }
1148}
1149
1150impl From<String> for CommitMessage<'_> {
1151 fn from(message: String) -> Self {
1152 Self::from(Cow::from(message))
1153 }
1154}
1155
1156/// Errors on reading commit messages
1157#[derive(Error, Debug, Diagnostic)]
1158pub enum Error {
1159 /// Failed to read a commit message
1160 #[error("failed to read commit file {0}")]
1161 #[diagnostic(
1162 url(docsrs),
1163 code(mit_commit::commit_message::error::io),
1164 help("check the file is readable")
1165 )]
1166 Io(#[from] io::Error),
1167}
1168
1169#[cfg(test)]
1170mod tests {
1171 use std::{convert::TryInto, io::Write};
1172
1173 use indoc::indoc;
1174 use quickcheck::TestResult;
1175 use regex::Regex;
1176 use tempfile::NamedTempFile;
1177
1178 use super::*;
1179 use crate::{
1180 Fragment, bodies::Bodies, body::Body, comment::Comment, scissors::Scissors,
1181 subject::Subject, trailer::Trailer,
1182 };
1183
1184 #[test]
1185 fn test_default_returns_empty_string() {
1186 let commit = CommitMessage::default();
1187 let actual: String = commit.into();
1188
1189 assert_eq!(
1190 actual,
1191 String::new(),
1192 "Default CommitMessage should convert to an empty string"
1193 );
1194 }
1195
1196 #[test]
1197 fn test_matches_pattern_returns_correct_results() {
1198 let commit = CommitMessage::from(indoc!(
1199 "
1200 Example Commit Message
1201
1202 This is an example commit message for linting
1203
1204 Relates-to: #153
1205 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1206 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1207 # bricht den Commit ab.
1208 #
1209 # Auf Branch main
1210 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1211 #
1212 # Zum Commit vorgemerkte \u{00E4}nderungen:
1213 # neue Datei: file
1214 #
1215 "
1216 ));
1217
1218 let re = Regex::new("[Bb]itte").unwrap();
1219 assert!(
1220 !commit.matches_pattern(&re),
1221 "Pattern should not match in comments"
1222 );
1223
1224 let re = Regex::new("f[o\u{00FC}]r linting").unwrap();
1225 assert!(
1226 commit.matches_pattern(&re),
1227 "Pattern should match in body text"
1228 );
1229
1230 let re = Regex::new("[Ee]xample Commit Message").unwrap();
1231 assert!(
1232 commit.matches_pattern(&re),
1233 "Pattern should match in subject"
1234 );
1235
1236 let re = Regex::new("Relates[- ]to").unwrap();
1237 assert!(
1238 commit.matches_pattern(&re),
1239 "Pattern should match in trailers"
1240 );
1241 }
1242
1243 #[test]
1244 fn test_parse_message_without_gutter_succeeds() {
1245 let commit = CommitMessage::from(indoc!(
1246 "
1247 Example Commit Message
1248 This is an example commit message for linting
1249
1250 This is another line
1251 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1252 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1253 # bricht den Commit ab.
1254 #
1255 # Auf Branch main
1256 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1257 #
1258 # Zum Commit vorgemerkte \u{00E4}nderungen:
1259 # neue Datei: file
1260 #
1261 "
1262 ));
1263
1264 assert_eq!(
1265 commit.get_subject(),
1266 Subject::from("Example Commit Message\nThis is an example commit message for linting"),
1267 "Subject should include both lines when there's no gutter"
1268 );
1269 assert_eq!(
1270 commit.get_body(),
1271 Bodies::from(vec![Body::default(), Body::from("This is another line")]),
1272 "Body should contain the line after the empty line"
1273 );
1274 }
1275
1276 #[test]
1277 fn test_add_trailer_to_normal_commit_appends_correctly() {
1278 let commit = CommitMessage::from(indoc!(
1279 "
1280 Example Commit Message
1281
1282 This is an example commit message for linting
1283
1284 Relates-to: #153
1285
1286 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1287 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1288 # bricht den Commit ab.
1289 #
1290 # Auf Branch main
1291 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1292 #
1293 # Zum Commit vorgemerkte \u{00E4}nderungen:
1294 # neue Datei: file
1295 #
1296 "
1297 ));
1298
1299 let expected = CommitMessage::from(indoc!(
1300 "
1301 Example Commit Message
1302
1303 This is an example commit message for linting
1304
1305 Relates-to: #153
1306 Co-authored-by: Test Trailer <test@example.com>
1307
1308 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1309 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1310 # bricht den Commit ab.
1311 #
1312 # Auf Branch main
1313 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1314 #
1315 # Zum Commit vorgemerkte \u{00E4}nderungen:
1316 # neue Datei: file
1317 #
1318 "
1319 ));
1320
1321 let actual = commit.add_trailer(Trailer::new(
1322 "Co-authored-by".into(),
1323 "Test Trailer <test@example.com>".into(),
1324 ));
1325
1326 assert_eq!(
1327 String::from(actual),
1328 String::from(expected),
1329 "Adding a trailer to a commit with existing trailers should append the new trailer after the last trailer"
1330 );
1331 }
1332
1333 #[test]
1334 fn test_add_trailer_to_conventional_commit_appends_correctly() {
1335 let commit = CommitMessage::from(indoc!(
1336 "
1337 feat: Example Commit Message
1338
1339 This is an example commit message for linting
1340
1341 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1342 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1343 # bricht den Commit ab.
1344 #
1345 # Auf Branch main
1346 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1347 #
1348 # Zum Commit vorgemerkte \u{00E4}nderungen:
1349 # neue Datei: file
1350 #
1351 "
1352 ));
1353
1354 let expected = CommitMessage::from(indoc!(
1355 "
1356 feat: Example Commit Message
1357
1358 This is an example commit message for linting
1359
1360 Co-authored-by: Test Trailer <test@example.com>
1361
1362 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1363 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1364 # bricht den Commit ab.
1365 #
1366 # Auf Branch main
1367 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1368 #
1369 # Zum Commit vorgemerkte \u{00E4}nderungen:
1370 # neue Datei: file
1371 #
1372 "
1373 ));
1374
1375 let actual = commit.add_trailer(Trailer::new(
1376 "Co-authored-by".into(),
1377 "Test Trailer <test@example.com>".into(),
1378 ));
1379
1380 assert_eq!(
1381 String::from(actual),
1382 String::from(expected),
1383 "Adding a trailer to a conventional commit should append the trailer after the body"
1384 );
1385 }
1386
1387 #[test]
1388 fn test_add_trailer_to_commit_without_trailers_creates_trailer_section() {
1389 let commit = CommitMessage::from(indoc!(
1390 "
1391 Example Commit Message
1392
1393 This is an example commit message for linting
1394
1395 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1396 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1397 # bricht den Commit ab.
1398 #
1399 # Auf Branch main
1400 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1401 #
1402 # Zum Commit vorgemerkte \u{00E4}nderungen:
1403 # neue Datei: file
1404 #
1405 "
1406 ));
1407
1408 let expected = CommitMessage::from(indoc!(
1409 "
1410 Example Commit Message
1411
1412 This is an example commit message for linting
1413
1414 Co-authored-by: Test Trailer <test@example.com>
1415
1416 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1417 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1418 # bricht den Commit ab.
1419 #
1420 # Auf Branch main
1421 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1422 #
1423 # Zum Commit vorgemerkte \u{00E4}nderungen:
1424 # neue Datei: file
1425 #
1426 "
1427 ));
1428 assert_eq!(
1429 String::from(commit.add_trailer(Trailer::new(
1430 "Co-authored-by".into(),
1431 "Test Trailer <test@example.com>".into(),
1432 ))),
1433 String::from(expected),
1434 "Adding a trailer to a commit without existing trailers should create a new trailer section after the body"
1435 );
1436 }
1437
1438 #[test]
1439 fn test_add_trailer_to_empty_commit_creates_trailer_section() {
1440 let commit = CommitMessage::from(indoc!(
1441 "
1442
1443 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1444 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1445 # bricht den Commit ab.
1446 #
1447 # Auf Branch main
1448 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1449 #
1450 # Zum Commit vorgemerkte \u{00E4}nderungen:
1451 # neue Datei: file
1452 #
1453 "
1454 ));
1455
1456 let expected = CommitMessage::from(indoc!(
1457 "
1458
1459
1460 Co-authored-by: Test Trailer <test@example.com>
1461
1462 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1463 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1464 # bricht den Commit ab.
1465 #
1466 # Auf Branch main
1467 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1468 #
1469 # Zum Commit vorgemerkte \u{00E4}nderungen:
1470 # neue Datei: file
1471 #
1472 "
1473 ));
1474 assert_eq!(
1475 String::from(commit.add_trailer(Trailer::new(
1476 "Co-authored-by".into(),
1477 "Test Trailer <test@example.com>".into(),
1478 ))),
1479 String::from(expected),
1480 "Adding a trailer to an empty commit should create a trailer section at the beginning"
1481 );
1482 }
1483
1484 #[test]
1485 fn test_add_trailer_to_empty_commit_with_trailer_appends_correctly() {
1486 let commit = CommitMessage::from(indoc!(
1487 "
1488
1489
1490 Co-authored-by: Test Trailer <test@example.com>
1491
1492 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1493 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1494 # bricht den Commit ab.
1495 #
1496 # Auf Branch main
1497 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1498 #
1499 # Zum Commit vorgemerkte \u{00E4}nderungen:
1500 # neue Datei: file
1501 #
1502 "
1503 ));
1504
1505 let expected = CommitMessage::from(indoc!(
1506 "
1507
1508
1509 Co-authored-by: Test Trailer <test@example.com>
1510 Co-authored-by: Someone Else <someone@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 assert_eq!(
1525 String::from(commit.add_trailer(Trailer::new(
1526 "Co-authored-by".into(),
1527 "Someone Else <someone@example.com>".into(),
1528 ))),
1529 String::from(expected),
1530 "Adding a trailer to an empty commit with an existing trailer should append the new trailer after the existing one"
1531 );
1532 }
1533
1534 #[test]
1535 fn test_from_fragments_generates_correct_commit() {
1536 let message = CommitMessage::from_fragments(
1537 vec![
1538 Fragment::Body(Body::from("Example Commit")),
1539 Fragment::Body(Body::default()),
1540 Fragment::Body(Body::from("Here is a body")),
1541 Fragment::Comment(Comment::from("# Example Commit")),
1542 ],
1543 Some(Scissors::from(indoc!(
1544 "
1545 # ------------------------ >8 ------------------------
1546 # \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
1547 # Alles unterhalb von ihr wird ignoriert.
1548 diff --git a/file b/file
1549 new file mode 100644
1550 index 0000000..e69de29
1551 "
1552 ))),
1553 );
1554
1555 assert_eq!(
1556 String::from(message),
1557 String::from(indoc!(
1558 "
1559 Example Commit
1560
1561 Here is a body
1562 # Example Commit
1563 # ------------------------ >8 ------------------------
1564 # \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
1565 # Alles unterhalb von ihr wird ignoriert.
1566 diff --git a/file b/file
1567 new file mode 100644
1568 index 0000000..e69de29
1569 "
1570 )),
1571 "Creating a CommitMessage from fragments should generate the correct string representation"
1572 );
1573 }
1574
1575 #[test]
1576 fn test_insert_after_last_body_appends_correctly() {
1577 let ast: Vec<Fragment<'_>> = vec![
1578 Fragment::Body(Body::from("Add file")),
1579 Fragment::Body(Body::default()),
1580 Fragment::Body(Body::from("Looks-like-a-trailer: But isn\'t")),
1581 Fragment::Body(Body::default()),
1582 Fragment::Body(Body::from(
1583 "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.",
1584 )),
1585 Fragment::Body(Body::default()),
1586 Fragment::Body(Body::from("Relates-to: #128")),
1587 Fragment::Body(Body::default()),
1588 Fragment::Comment(Comment::from(
1589 "# 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",
1590 )),
1591 Fragment::Body(Body::default()),
1592 Fragment::Comment(Comment::from(
1593 "# 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#",
1594 )),
1595 ];
1596 let commit = CommitMessage::from_fragments(ast, None);
1597
1598 assert_eq!(
1599 commit
1600 .insert_after_last_full_body(vec![Fragment::Body(Body::from("Relates-to: #656"))])
1601 .get_ast(),
1602 vec![
1603 Fragment::Body(Body::from("Add file")),
1604 Fragment::Body(Body::default()),
1605 Fragment::Body(Body::from("Looks-like-a-trailer: But isn\'t")),
1606 Fragment::Body(Body::default()),
1607 Fragment::Body(Body::from(
1608 "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."
1609 )),
1610 Fragment::Body(Body::default()),
1611 Fragment::Body(Body::from("Relates-to: #128\nRelates-to: #656")),
1612 Fragment::Body(Body::default()),
1613 Fragment::Comment(Comment::from(
1614 "# 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"
1615 )),
1616 Fragment::Body(Body::default()),
1617 Fragment::Comment(Comment::from(
1618 "# 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#"
1619 )),
1620 ],
1621 "Inserting after the last body should append the new fragment after the last non-empty body fragment"
1622 );
1623 }
1624
1625 #[test]
1626 fn test_insert_after_last_body_with_no_body_inserts_at_beginning() {
1627 let ast: Vec<Fragment<'_>> = vec![
1628 Fragment::Comment(Comment::from(
1629 "# 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",
1630 )),
1631 Fragment::Body(Body::default()),
1632 Fragment::Comment(Comment::from(
1633 "# 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#",
1634 )),
1635 ];
1636 let commit = CommitMessage::from_fragments(ast, None);
1637
1638 assert_eq!(
1639 commit
1640 .insert_after_last_full_body(vec![Fragment::Body(Body::from("Relates-to: #656"))])
1641 .get_ast(),
1642 vec![
1643 Fragment::Body(Body::from("Relates-to: #656")),
1644 Fragment::Comment(Comment::from(
1645 "# 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"
1646 )),
1647 Fragment::Body(Body::default()),
1648 Fragment::Comment(Comment::from(
1649 "# 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#"
1650 )),
1651 ],
1652 "When there is no body, inserting after the last body should insert at the beginning of the AST"
1653 );
1654 }
1655
1656 #[allow(clippy::needless_pass_by_value)]
1657 #[quickcheck]
1658 fn test_with_subject_preserves_input_string(input: String) -> bool {
1659 let commit: CommitMessage<'_> = "Some Subject".into();
1660 let actual: String = commit
1661 .with_subject(input.clone().into())
1662 .get_subject()
1663 .into();
1664 // Property: The subject should be exactly the input string after setting it
1665 actual == input
1666 }
1667
1668 #[test]
1669 fn test_with_subject_on_default_commit_sets_subject_correctly() {
1670 let commit = CommitMessage::default().with_subject("Subject".into());
1671 assert_eq!(
1672 commit.get_subject(),
1673 Subject::from("Subject"),
1674 "Setting subject on default commit should update the subject correctly"
1675 );
1676 }
1677
1678 #[allow(clippy::needless_pass_by_value)]
1679 #[quickcheck]
1680 fn test_with_body_contents_replaces_body_correctly(input: String) -> TestResult {
1681 if input.contains('\r') {
1682 return TestResult::discard();
1683 }
1684
1685 let commit: CommitMessage<'_> = "Some Subject\n\nSome Body".into();
1686 let expected: String = format!("Some Subject\n\n{input}");
1687 let actual: String = commit.with_body_contents(&input).into();
1688 // Property: The body should be replaced with the input string while preserving the subject
1689 TestResult::from_bool(actual == expected)
1690 }
1691
1692 #[allow(clippy::needless_pass_by_value)]
1693 #[quickcheck]
1694 fn test_with_body_contents_preserves_multiline_subject(input: String) -> TestResult {
1695 if input.contains('\r') {
1696 return TestResult::discard();
1697 }
1698
1699 let commit: CommitMessage<'_> = "Some Subject\nSome More Subject\n\nBody".into();
1700 let expected: String = format!("Some Subject\nSome More Subject\n\n{input}");
1701 let actual: String = commit.with_body_contents(&input).into();
1702 // Property: The body should be replaced with the input string while preserving the multi-line subject
1703 TestResult::from_bool(actual == expected)
1704 }
1705
1706 #[test]
1707 fn test_get_comment_char_returns_none_when_no_comments() {
1708 let commit_character = CommitMessage::from("Example Commit Message");
1709 assert!(
1710 commit_character.get_comment_char().is_none(),
1711 "Comment character should be None when there are no comments in the message"
1712 );
1713 }
1714
1715 #[test]
1716 fn test_try_from_path_buf_reads_file_correctly() {
1717 let temp_file = NamedTempFile::new().expect("failed to create temp file");
1718 write!(temp_file.as_file(), "Some Subject").expect("Failed to write file");
1719
1720 let commit_character: CommitMessage<'_> = temp_file
1721 .path()
1722 .to_path_buf()
1723 .try_into()
1724 .expect("Could not read commit message");
1725 assert_eq!(
1726 commit_character.get_subject().to_string(),
1727 "Some Subject",
1728 "Reading from PathBuf should correctly parse the file contents into a CommitMessage"
1729 );
1730 }
1731
1732 #[test]
1733 fn test_try_from_path_reads_file_correctly() {
1734 let temp_file = NamedTempFile::new().expect("failed to create temp file");
1735 write!(temp_file.as_file(), "Some Subject").expect("Failed to write file");
1736
1737 let commit_character: CommitMessage<'_> = temp_file
1738 .path()
1739 .try_into()
1740 .expect("Could not read commit message");
1741 assert_eq!(
1742 commit_character.get_subject().to_string(),
1743 "Some Subject",
1744 "Reading from Path should correctly parse the file contents into a CommitMessage"
1745 );
1746 }
1747}