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 /// 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 /// This will get you 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 .get_ast()
778 .into_iter()
779 .filter_map(|fragment| match fragment {
780 Fragment::Body(body) => Some(String::from(body)),
781 Fragment::Comment(_) => None,
782 })
783 .collect::<Vec<_>>()
784 .join("\n");
785 re.is_match(&text)
786 }
787
788 fn guess_comment_character(message: &str) -> Option<char> {
789 Scissors::guess_comment_character(message)
790 }
791
792 /// Give you a new [`CommitMessage`] with the provided subject
793 ///
794 /// # Arguments
795 ///
796 /// * `subject` - The new Subject to use for the commit message
797 ///
798 /// # Returns
799 ///
800 /// A new `CommitMessage` with the updated subject
801 ///
802 /// # Examples
803 ///
804 /// ```
805 /// use indoc::indoc;
806 /// use mit_commit::{CommitMessage, Subject};
807 /// use regex::Regex;
808 ///
809 /// let commit = CommitMessage::from(indoc!(
810 /// "
811 /// Example Commit Message
812 ///
813 /// This is an example commit message
814 /// "
815 /// ));
816 ///
817 /// assert_eq!(
818 /// commit.with_subject("Subject".into()).get_subject(),
819 /// Subject::from("Subject")
820 /// );
821 /// ```
822 #[must_use]
823 pub fn with_subject(self, subject: Subject<'a>) -> Self {
824 let mut ast = self.ast;
825
826 if !ast.is_empty() {
827 ast.remove(0);
828 }
829 ast.insert(0, Body::from(subject.to_string()).into());
830
831 let comments = Comments::from(ast.clone());
832 let bodies = Bodies::from(ast.clone());
833 // Changing the subject (first Body fragment) does not affect which of
834 // the remaining bodies are trailers, so we keep the correctly-extracted
835 // trailers rather than re-extracting from the grouped AST, which would
836 // merge consecutive trailer lines into a single multi-line Body and
837 // produce a single bogus trailer.
838 let trailers = self.trailers;
839
840 Self {
841 scissors: self.scissors,
842 ast,
843 subject,
844 trailers,
845 comments,
846 bodies,
847 }
848 }
849
850 /// Give you a new [`CommitMessage`] with the provided body
851 ///
852 /// # Arguments
853 ///
854 /// * `contents` - The new body content to use for the commit message
855 ///
856 /// # Returns
857 ///
858 /// A new `CommitMessage` with the updated body contents
859 ///
860 /// # Examples
861 ///
862 /// ```
863 /// use indoc::indoc;
864 /// use mit_commit::{CommitMessage, Subject};
865 /// use regex::Regex;
866 ///
867 /// let commit = CommitMessage::from(indoc!(
868 /// "
869 /// Example Commit Message
870 ///
871 /// This is an example commit message
872 /// "
873 /// ));
874 /// let expected = CommitMessage::from(indoc!(
875 /// "
876 /// Example Commit Message
877 ///
878 /// New body"
879 /// ));
880 ///
881 /// assert_eq!(commit.with_body_contents("New body"), expected);
882 /// ```
883 ///
884 /// A note on what we consider the body. The body is what falls after the
885 /// gutter. This means the following behaviour might happen
886 ///
887 /// ```
888 /// use indoc::indoc;
889 /// use mit_commit::{CommitMessage, Subject};
890 /// use regex::Regex;
891 /// let commit = CommitMessage::from(indoc!(
892 /// "
893 /// Example Commit Message
894 /// without gutter"
895 /// ));
896 /// let expected = CommitMessage::from(indoc!(
897 /// "
898 /// Example Commit Message
899 /// without gutter
900 ///
901 /// New body"
902 /// ));
903 ///
904 /// assert_eq!(commit.with_body_contents("New body"), expected);
905 /// ```
906 #[must_use]
907 pub fn with_body_contents(self, contents: &'a str) -> Self {
908 let existing_subject: Subject<'a> = self.get_subject();
909 let outer_scissors = self.scissors.map(|s| {
910 let string: String = s.into();
911 Scissors::from(string)
912 });
913 let body = format!("Unused\n\n{contents}");
914 let inner_commit = Self::from(body);
915
916 let inner_scissors = inner_commit.scissors.clone().map(|s| {
917 let string: String = s.into();
918 Scissors::from(string)
919 });
920
921 let mut result = inner_commit.with_subject(existing_subject);
922 // Preserve the outer scissors if present, otherwise keep any inner
923 // scissors so content containing the scissors marker is not silently
924 // dropped.
925 result.scissors = outer_scissors.or(inner_scissors);
926 result
927 }
928
929 /// Get the comment character used in the commit message
930 ///
931 /// # Returns
932 ///
933 /// The character used for comments in the commit message, or None if there are no comments
934 ///
935 /// # Examples
936 ///
937 /// ```
938 /// use mit_commit::{CommitMessage, Subject};
939 /// let commit = CommitMessage::from("No comment\n\n# Some Comment");
940 ///
941 /// assert_eq!(commit.get_comment_char().unwrap(), '#');
942 /// ```
943 ///
944 /// We return none is there is no comments
945 ///
946 /// ```
947 /// use mit_commit::{CommitMessage, Subject};
948 /// let commit = CommitMessage::from("No comment");
949 ///
950 /// assert!(commit.get_comment_char().is_none());
951 /// ```
952 #[must_use]
953 pub fn get_comment_char(&self) -> Option<char> {
954 self.comments
955 .iter()
956 .next()
957 .map(|comment| -> String { comment.clone().into() })
958 .and_then(|comment| comment.chars().next())
959 }
960}
961
962fn commit_message_to_string(commit_message: &CommitMessage<'_>) -> String {
963 let basic_commit = commit_message
964 .get_ast()
965 .iter()
966 .map(|item| match item {
967 Fragment::Body(contents) => String::from(contents.clone()),
968 Fragment::Comment(contents) => String::from(contents.clone()),
969 })
970 .collect::<Vec<_>>()
971 .join("\n");
972
973 if let Some(scissors) = commit_message.get_scissors() {
974 format!("{basic_commit}\n{}", String::from(scissors))
975 } else {
976 basic_commit
977 }
978}
979
980impl From<CommitMessage<'_>> for String {
981 fn from(commit_message: CommitMessage<'_>) -> Self {
982 commit_message_to_string(&commit_message)
983 }
984}
985
986impl From<&CommitMessage<'_>> for String {
987 fn from(commit_message: &CommitMessage<'_>) -> Self {
988 commit_message_to_string(commit_message)
989 }
990}
991
992/// Parse a commit message using parsers
993impl CommitMessage<'_> {
994 fn parse_commit_message(message: &str) -> Self {
995 // Step 1: Split the message into body and scissors sections
996 let (rest, scissors) = Scissors::parse_sections(message);
997
998 // Step 2: Guess the comment character
999 let comment_character = Self::guess_comment_character(message);
1000
1001 // Step 3: Convert the body to a per-line AST
1002 let per_line_ast = Self::convert_to_per_line_ast(comment_character, &rest);
1003
1004 // Step 4: Extract trailers before grouping to avoid cloning the entire AST
1005 let trailers = Trailers::from(per_line_ast.clone());
1006
1007 // Step 5: Group consecutive fragments of the same type
1008 let mut ast: Vec<Fragment<'_>> = Self::group_ast(per_line_ast);
1009
1010 // Step 6: Handle trailing newline case
1011 // Base this on the body portion (`rest`) rather than the whole `message`:
1012 // when a scissors section is present, `rest` still ends with the newline
1013 // that separates the body from the scissors marker (the blank line). Using
1014 // `rest` preserves that blank line through a round-trip.
1015 if rest.ends_with('\n') {
1016 ast.push(Body::default().into());
1017 }
1018
1019 // Step 7: Create subject, comments, and bodies from the AST
1020 // We need to clone here because the From implementations require owned vectors
1021 let subject = Subject::from(ast.clone());
1022 let comments = Comments::from(ast.clone());
1023 let bodies = Bodies::from(ast.clone());
1024
1025 // Step 8: Create and return the CommitMessage
1026 Self {
1027 scissors,
1028 ast,
1029 subject,
1030 trailers,
1031 comments,
1032 bodies,
1033 }
1034 }
1035}
1036
1037impl<'a> From<Cow<'a, str>> for CommitMessage<'a> {
1038 /// Create a new [`CommitMessage`]
1039 ///
1040 /// Create a commit message from a string. It's expected that you'll be
1041 /// reading this during some sort of Git Hook
1042 ///
1043 /// # Examples
1044 ///
1045 /// ```
1046 /// use indoc::indoc;
1047 /// use mit_commit::{Bodies, CommitMessage, Subject};
1048 ///
1049 /// let message = CommitMessage::from(indoc!(
1050 /// "
1051 /// Update bashrc to include kubernetes completions
1052 ///
1053 /// This should make it easier to deploy things for the developers.
1054 /// Benchmarked with Hyperfine, no noticable performance decrease.
1055 ///
1056 /// ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1057 /// ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
1058 /// ; bricht den Commit ab.
1059 /// ;
1060 /// ; Datum: Sat Jun 27 21:40:14 2020 +0200
1061 /// ;
1062 /// ; Auf Branch master
1063 /// ;
1064 /// ; Initialer Commit
1065 /// ;
1066 /// ; Zum Commit vorgemerkte \u{00E4}nderungen:
1067 /// ; neue Datei: .bashrc
1068 /// ;"
1069 /// ));
1070 /// assert_eq!(
1071 /// message.get_subject(),
1072 /// Subject::from("Update bashrc to include kubernetes completions")
1073 /// )
1074 /// ```
1075 ///
1076 /// # Comment Character
1077 ///
1078 /// We load the comment character for the commit message
1079 ///
1080 /// 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).
1081 ///
1082 /// This does mean that we aren't making 100% of characters available, which
1083 /// is technically possible, but given we don't have access to the users git
1084 /// config this feels like a reasonable compromise, there are a lot of
1085 /// non-whitespace characters as options otherwise, and we don't want to
1086 /// confuse a genuine body with a comment
1087 fn from(message: Cow<'a, str>) -> Self {
1088 Self::parse_commit_message(&message)
1089 }
1090}
1091
1092impl TryFrom<PathBuf> for CommitMessage<'_> {
1093 type Error = Error;
1094
1095 /// Creates a `CommitMessage` from a file path.
1096 ///
1097 /// # Arguments
1098 ///
1099 /// * `value` - The path to the file containing the commit message
1100 ///
1101 /// # Returns
1102 ///
1103 /// A `CommitMessage` parsed from the file contents
1104 ///
1105 /// # Examples
1106 ///
1107 /// ```
1108 /// use std::path::PathBuf;
1109 /// use std::convert::TryFrom;
1110 /// use std::io::Write;
1111 /// use mit_commit::CommitMessage;
1112 ///
1113 /// // Create a temporary file for the example
1114 /// let mut temp_file = tempfile::NamedTempFile::new().unwrap();
1115 /// write!(temp_file.as_file(), "Example commit message").unwrap();
1116 ///
1117 /// // Use the temporary file path
1118 /// let path = temp_file.path().to_path_buf();
1119 /// let commit_message = CommitMessage::try_from(path).expect("Failed to read commit message");
1120 /// assert_eq!(commit_message.get_subject().to_string(), "Example commit message");
1121 /// ```
1122 ///
1123 /// # Errors
1124 ///
1125 /// Returns an Error if the file cannot be read or if the file contents cannot be parsed as UTF-8
1126 fn try_from(value: PathBuf) -> Result<Self, Self::Error> {
1127 let mut file = File::open(value)?;
1128 let mut buffer = String::new();
1129
1130 file.read_to_string(&mut buffer)
1131 .map_err(Error::from)
1132 .map(move |_| Self::from(buffer))
1133 }
1134}
1135
1136impl<'a> TryFrom<&'a Path> for CommitMessage<'a> {
1137 type Error = Error;
1138
1139 /// Creates a `CommitMessage` from a file path reference.
1140 ///
1141 /// # Arguments
1142 ///
1143 /// * `value` - The path reference to the file containing the commit message
1144 ///
1145 /// # Returns
1146 ///
1147 /// A `CommitMessage` parsed from the file contents
1148 ///
1149 /// # Examples
1150 ///
1151 /// ```
1152 /// use std::path::Path;
1153 /// use std::convert::TryFrom;
1154 /// use std::io::Write;
1155 /// use mit_commit::CommitMessage;
1156 ///
1157 /// // Create a temporary file for the example
1158 /// let mut temp_file = tempfile::NamedTempFile::new().unwrap();
1159 /// write!(temp_file.as_file(), "Example commit message").unwrap();
1160 ///
1161 /// // Use the temporary file path
1162 /// let path = temp_file.path();
1163 /// let commit_message = CommitMessage::try_from(path).expect("Failed to read commit message");
1164 /// assert_eq!(commit_message.get_subject().to_string(), "Example commit message");
1165 /// ```
1166 ///
1167 /// # Errors
1168 ///
1169 /// Returns an Error if the file cannot be read or if the file contents cannot be parsed as UTF-8
1170 fn try_from(value: &'a Path) -> Result<Self, Self::Error> {
1171 let mut file = File::open(value)?;
1172 let mut buffer = String::new();
1173
1174 file.read_to_string(&mut buffer)
1175 .map_err(Error::from)
1176 .map(move |_| Self::from(buffer))
1177 }
1178}
1179
1180impl<'a> From<&'a str> for CommitMessage<'a> {
1181 fn from(message: &'a str) -> Self {
1182 CommitMessage::from(Cow::from(message))
1183 }
1184}
1185
1186impl From<String> for CommitMessage<'_> {
1187 fn from(message: String) -> Self {
1188 Self::from(Cow::from(message))
1189 }
1190}
1191
1192/// Errors on reading commit messages
1193#[derive(Error, Debug, Diagnostic)]
1194pub enum Error {
1195 /// Failed to read a commit message
1196 #[error("failed to read commit file {0}")]
1197 #[diagnostic(
1198 url(docsrs),
1199 code(mit_commit::commit_message::error::io),
1200 help("check the file is readable")
1201 )]
1202 Io(#[from] io::Error),
1203}
1204
1205#[cfg(test)]
1206mod tests {
1207 use std::{convert::TryInto, io::Write};
1208
1209 use indoc::indoc;
1210 use quickcheck::TestResult;
1211 use regex::Regex;
1212 use tempfile::NamedTempFile;
1213
1214 use super::*;
1215 use crate::{
1216 Fragment, bodies::Bodies, body::Body, comment::Comment, scissors::Scissors,
1217 subject::Subject, trailer::Trailer,
1218 };
1219
1220 #[test]
1221 fn test_default_returns_empty_string() {
1222 let commit = CommitMessage::default();
1223 let actual: String = commit.into();
1224
1225 assert_eq!(
1226 actual,
1227 String::new(),
1228 "Default CommitMessage should convert to an empty string"
1229 );
1230 }
1231
1232 #[test]
1233 fn test_matches_pattern_returns_correct_results() {
1234 let commit = CommitMessage::from(indoc!(
1235 "
1236 Example Commit Message
1237
1238 This is an example commit message for linting
1239
1240 Relates-to: #153
1241 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1242 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1243 # bricht den Commit ab.
1244 #
1245 # Auf Branch main
1246 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1247 #
1248 # Zum Commit vorgemerkte \u{00E4}nderungen:
1249 # neue Datei: file
1250 #
1251 "
1252 ));
1253
1254 let re = Regex::new("[Bb]itte").unwrap();
1255 assert!(
1256 !commit.matches_pattern(&re),
1257 "Pattern should not match in comments"
1258 );
1259
1260 let re = Regex::new("f[o\u{00FC}]r linting").unwrap();
1261 assert!(
1262 commit.matches_pattern(&re),
1263 "Pattern should match in body text"
1264 );
1265
1266 let re = Regex::new("[Ee]xample Commit Message").unwrap();
1267 assert!(
1268 commit.matches_pattern(&re),
1269 "Pattern should match in subject"
1270 );
1271
1272 let re = Regex::new("Relates[- ]to").unwrap();
1273 assert!(
1274 commit.matches_pattern(&re),
1275 "Pattern should match in trailers"
1276 );
1277 }
1278
1279 #[test]
1280 fn test_parse_message_without_gutter_succeeds() {
1281 let commit = CommitMessage::from(indoc!(
1282 "
1283 Example Commit Message
1284 This is an example commit message for linting
1285
1286 This is another line
1287 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1288 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1289 # bricht den Commit ab.
1290 #
1291 # Auf Branch main
1292 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1293 #
1294 # Zum Commit vorgemerkte \u{00E4}nderungen:
1295 # neue Datei: file
1296 #
1297 "
1298 ));
1299
1300 assert_eq!(
1301 commit.get_subject(),
1302 Subject::from("Example Commit Message\nThis is an example commit message for linting"),
1303 "Subject should include both lines when there's no gutter"
1304 );
1305 assert_eq!(
1306 commit.get_body(),
1307 Bodies::from(vec![Body::default(), Body::from("This is another line")]),
1308 "Body should contain the line after the empty line"
1309 );
1310 }
1311
1312 #[test]
1313 fn test_add_trailer_to_normal_commit_appends_correctly() {
1314 let commit = CommitMessage::from(indoc!(
1315 "
1316 Example Commit Message
1317
1318 This is an example commit message for linting
1319
1320 Relates-to: #153
1321
1322 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1323 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1324 # bricht den Commit ab.
1325 #
1326 # Auf Branch main
1327 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1328 #
1329 # Zum Commit vorgemerkte \u{00E4}nderungen:
1330 # neue Datei: file
1331 #
1332 "
1333 ));
1334
1335 let expected = CommitMessage::from(indoc!(
1336 "
1337 Example Commit Message
1338
1339 This is an example commit message for linting
1340
1341 Relates-to: #153
1342 Co-authored-by: Test Trailer <test@example.com>
1343
1344 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1345 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1346 # bricht den Commit ab.
1347 #
1348 # Auf Branch main
1349 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1350 #
1351 # Zum Commit vorgemerkte \u{00E4}nderungen:
1352 # neue Datei: file
1353 #
1354 "
1355 ));
1356
1357 let actual = commit.add_trailer(Trailer::new(
1358 "Co-authored-by".into(),
1359 "Test Trailer <test@example.com>".into(),
1360 ));
1361
1362 assert_eq!(
1363 String::from(actual),
1364 String::from(expected),
1365 "Adding a trailer to a commit with existing trailers should append the new trailer after the last trailer"
1366 );
1367 }
1368
1369 #[test]
1370 fn test_add_trailer_to_conventional_commit_appends_correctly() {
1371 let commit = CommitMessage::from(indoc!(
1372 "
1373 feat: Example Commit Message
1374
1375 This is an example commit message for linting
1376
1377 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1378 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1379 # bricht den Commit ab.
1380 #
1381 # Auf Branch main
1382 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1383 #
1384 # Zum Commit vorgemerkte \u{00E4}nderungen:
1385 # neue Datei: file
1386 #
1387 "
1388 ));
1389
1390 let expected = CommitMessage::from(indoc!(
1391 "
1392 feat: Example Commit Message
1393
1394 This is an example commit message for linting
1395
1396 Co-authored-by: Test Trailer <test@example.com>
1397
1398 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1399 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1400 # bricht den Commit ab.
1401 #
1402 # Auf Branch main
1403 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1404 #
1405 # Zum Commit vorgemerkte \u{00E4}nderungen:
1406 # neue Datei: file
1407 #
1408 "
1409 ));
1410
1411 let actual = commit.add_trailer(Trailer::new(
1412 "Co-authored-by".into(),
1413 "Test Trailer <test@example.com>".into(),
1414 ));
1415
1416 assert_eq!(
1417 String::from(actual),
1418 String::from(expected),
1419 "Adding a trailer to a conventional commit should append the trailer after the body"
1420 );
1421 }
1422
1423 #[test]
1424 fn test_add_trailer_to_commit_without_trailers_creates_trailer_section() {
1425 let commit = CommitMessage::from(indoc!(
1426 "
1427 Example Commit Message
1428
1429 This is an example commit message for linting
1430
1431 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1432 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1433 # bricht den Commit ab.
1434 #
1435 # Auf Branch main
1436 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1437 #
1438 # Zum Commit vorgemerkte \u{00E4}nderungen:
1439 # neue Datei: file
1440 #
1441 "
1442 ));
1443
1444 let expected = CommitMessage::from(indoc!(
1445 "
1446 Example Commit Message
1447
1448 This is an example commit message for linting
1449
1450 Co-authored-by: Test Trailer <test@example.com>
1451
1452 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1453 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1454 # bricht den Commit ab.
1455 #
1456 # Auf Branch main
1457 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1458 #
1459 # Zum Commit vorgemerkte \u{00E4}nderungen:
1460 # neue Datei: file
1461 #
1462 "
1463 ));
1464 assert_eq!(
1465 String::from(commit.add_trailer(Trailer::new(
1466 "Co-authored-by".into(),
1467 "Test Trailer <test@example.com>".into(),
1468 ))),
1469 String::from(expected),
1470 "Adding a trailer to a commit without existing trailers should create a new trailer section after the body"
1471 );
1472 }
1473
1474 #[test]
1475 fn test_add_trailer_to_empty_commit_creates_trailer_section() {
1476 let commit = CommitMessage::from(indoc!(
1477 "
1478
1479 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1480 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1481 # bricht den Commit ab.
1482 #
1483 # Auf Branch main
1484 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1485 #
1486 # Zum Commit vorgemerkte \u{00E4}nderungen:
1487 # neue Datei: file
1488 #
1489 "
1490 ));
1491
1492 let expected = CommitMessage::from(indoc!(
1493 "
1494
1495
1496 Co-authored-by: Test Trailer <test@example.com>
1497
1498 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1499 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1500 # bricht den Commit ab.
1501 #
1502 # Auf Branch main
1503 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1504 #
1505 # Zum Commit vorgemerkte \u{00E4}nderungen:
1506 # neue Datei: file
1507 #
1508 "
1509 ));
1510 assert_eq!(
1511 String::from(commit.add_trailer(Trailer::new(
1512 "Co-authored-by".into(),
1513 "Test Trailer <test@example.com>".into(),
1514 ))),
1515 String::from(expected),
1516 "Adding a trailer to an empty commit should create a trailer section at the beginning"
1517 );
1518 }
1519
1520 #[test]
1521 fn test_add_trailer_to_empty_commit_with_trailer_appends_correctly() {
1522 let commit = CommitMessage::from(indoc!(
1523 "
1524
1525
1526 Co-authored-by: Test Trailer <test@example.com>
1527
1528 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1529 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1530 # bricht den Commit ab.
1531 #
1532 # Auf Branch main
1533 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1534 #
1535 # Zum Commit vorgemerkte \u{00E4}nderungen:
1536 # neue Datei: file
1537 #
1538 "
1539 ));
1540
1541 let expected = CommitMessage::from(indoc!(
1542 "
1543
1544
1545 Co-authored-by: Test Trailer <test@example.com>
1546 Co-authored-by: Someone Else <someone@example.com>
1547
1548 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1549 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1550 # bricht den Commit ab.
1551 #
1552 # Auf Branch main
1553 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1554 #
1555 # Zum Commit vorgemerkte \u{00E4}nderungen:
1556 # neue Datei: file
1557 #
1558 "
1559 ));
1560 assert_eq!(
1561 String::from(commit.add_trailer(Trailer::new(
1562 "Co-authored-by".into(),
1563 "Someone Else <someone@example.com>".into(),
1564 ))),
1565 String::from(expected),
1566 "Adding a trailer to an empty commit with an existing trailer should append the new trailer after the existing one"
1567 );
1568 }
1569
1570 #[test]
1571 fn test_from_fragments_generates_correct_commit() {
1572 let message = CommitMessage::from_fragments(
1573 vec![
1574 Fragment::Body(Body::from("Example Commit")),
1575 Fragment::Body(Body::default()),
1576 Fragment::Body(Body::from("Here is a body")),
1577 Fragment::Comment(Comment::from("# Example Commit")),
1578 ],
1579 Some(Scissors::from(indoc!(
1580 "
1581 # ------------------------ >8 ------------------------
1582 # \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
1583 # Alles unterhalb von ihr wird ignoriert.
1584 diff --git a/file b/file
1585 new file mode 100644
1586 index 0000000..e69de29
1587 "
1588 ))),
1589 );
1590
1591 assert_eq!(
1592 String::from(message),
1593 String::from(indoc!(
1594 "
1595 Example Commit
1596
1597 Here is a body
1598 # Example Commit
1599 # ------------------------ >8 ------------------------
1600 # \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
1601 # Alles unterhalb von ihr wird ignoriert.
1602 diff --git a/file b/file
1603 new file mode 100644
1604 index 0000000..e69de29
1605 "
1606 )),
1607 "Creating a CommitMessage from fragments should generate the correct string representation"
1608 );
1609 }
1610
1611 #[test]
1612 fn test_insert_after_last_body_appends_correctly() {
1613 let ast: Vec<Fragment<'_>> = vec![
1614 Fragment::Body(Body::from("Add file")),
1615 Fragment::Body(Body::default()),
1616 Fragment::Body(Body::from("Looks-like-a-trailer: But isn\'t")),
1617 Fragment::Body(Body::default()),
1618 Fragment::Body(Body::from(
1619 "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.",
1620 )),
1621 Fragment::Body(Body::default()),
1622 Fragment::Body(Body::from("Relates-to: #128")),
1623 Fragment::Body(Body::default()),
1624 Fragment::Comment(Comment::from(
1625 "# 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",
1626 )),
1627 Fragment::Body(Body::default()),
1628 Fragment::Comment(Comment::from(
1629 "# 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#",
1630 )),
1631 ];
1632 let commit = CommitMessage::from_fragments(ast, None);
1633
1634 assert_eq!(
1635 commit
1636 .insert_after_last_full_body(vec![Fragment::Body(Body::from("Relates-to: #656"))])
1637 .get_ast(),
1638 vec![
1639 Fragment::Body(Body::from("Add file")),
1640 Fragment::Body(Body::default()),
1641 Fragment::Body(Body::from("Looks-like-a-trailer: But isn\'t")),
1642 Fragment::Body(Body::default()),
1643 Fragment::Body(Body::from(
1644 "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."
1645 )),
1646 Fragment::Body(Body::default()),
1647 Fragment::Body(Body::from("Relates-to: #128\nRelates-to: #656")),
1648 Fragment::Body(Body::default()),
1649 Fragment::Comment(Comment::from(
1650 "# 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"
1651 )),
1652 Fragment::Body(Body::default()),
1653 Fragment::Comment(Comment::from(
1654 "# 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#"
1655 )),
1656 ],
1657 "Inserting after the last body should append the new fragment after the last non-empty body fragment"
1658 );
1659 }
1660
1661 #[test]
1662 fn test_insert_after_last_body_with_no_body_inserts_at_beginning() {
1663 let ast: Vec<Fragment<'_>> = vec![
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 let commit = CommitMessage::from_fragments(ast, None);
1673
1674 assert_eq!(
1675 commit
1676 .insert_after_last_full_body(vec![Fragment::Body(Body::from("Relates-to: #656"))])
1677 .get_ast(),
1678 vec![
1679 Fragment::Body(Body::from("Relates-to: #656")),
1680 Fragment::Comment(Comment::from(
1681 "# 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"
1682 )),
1683 Fragment::Body(Body::default()),
1684 Fragment::Comment(Comment::from(
1685 "# 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#"
1686 )),
1687 ],
1688 "When there is no body, inserting after the last body should insert at the beginning of the AST"
1689 );
1690 }
1691
1692 #[allow(clippy::needless_pass_by_value)]
1693 #[quickcheck]
1694 fn test_with_subject_preserves_input_string(input: String) -> bool {
1695 let commit: CommitMessage<'_> = "Some Subject".into();
1696 let actual: String = commit
1697 .with_subject(input.clone().into())
1698 .get_subject()
1699 .into();
1700 // Property: The subject should be exactly the input string after setting it
1701 actual == input
1702 }
1703
1704 #[test]
1705 fn test_with_subject_preserves_multiple_trailers() {
1706 // When multiple consecutive trailers exist, the grouped AST merges them
1707 // into a single Body (e.g. "Relates-to: #1\nRelates-to: #2"). Calling
1708 // with_subject must NOT collapse those into a single trailer.
1709 let commit = CommitMessage::from(indoc!(
1710 "
1711 feat: add login
1712
1713 Body text
1714
1715 Relates-to: #1
1716 Relates-to: #2
1717 "
1718 ));
1719
1720 assert_eq!(
1721 commit.get_trailers().len(),
1722 2,
1723 "Initial commit should have two trailers"
1724 );
1725
1726 let updated = commit.with_subject("fix: add signup".into());
1727 let trailers: Vec<_> = updated.get_trailers().into_iter().collect();
1728
1729 assert_eq!(
1730 trailers.len(),
1731 2,
1732 "with_subject should preserve all trailers, got {}: {:?}",
1733 trailers.len(),
1734 trailers
1735 );
1736 assert_eq!(trailers[0], Trailer::new("Relates-to".into(), "#1".into()),);
1737 assert_eq!(trailers[1], Trailer::new("Relates-to".into(), "#2".into()),);
1738 }
1739
1740 #[test]
1741 fn test_with_subject_on_default_commit_sets_subject_correctly() {
1742 let commit = CommitMessage::default().with_subject("Subject".into());
1743 assert_eq!(
1744 commit.get_subject(),
1745 Subject::from("Subject"),
1746 "Setting subject on default commit should update the subject correctly"
1747 );
1748 }
1749
1750 #[test]
1751 fn test_with_subject_recomputes_derived_fields_from_new_ast() {
1752 // A message where the first fragment is a comment, not a body
1753 let commit = CommitMessage::from("# Comment\nOriginal Subject");
1754
1755 // The original string contains the comment
1756 let original_string: String = commit.clone().into();
1757 assert!(original_string.contains("# Comment"));
1758
1759 let updated = commit.with_subject("New Subject".into());
1760
1761 // The new subject should be correct
1762 assert_eq!(updated.get_subject(), Subject::from("New Subject"));
1763
1764 // The string representation should not contain the old comment
1765 let as_string: String = updated.clone().into();
1766 assert!(
1767 !as_string.contains("# Comment"),
1768 "Old comment should not appear in string representation: {as_string}"
1769 );
1770
1771 // Verify that get_comments() returns fresh data recomputed from the new AST,
1772 // not stale data carried over from the old commit message
1773 assert!(
1774 updated.get_comments().iter().next().is_none(),
1775 "Comments should be recomputed from new AST, but got stale data: {:?}",
1776 updated.get_comments().iter().collect::<Vec<_>>()
1777 );
1778
1779 // Verify that get_bodies() returns fresh data recomputed from the new AST.
1780 // After replacing the comment with a subject body, the old subject body
1781 // should now appear in bodies.
1782 assert!(
1783 updated
1784 .get_body()
1785 .into_iter()
1786 .any(|b| b.to_string() == "Original Subject"),
1787 "Bodies should be recomputed from new AST, but got stale data"
1788 );
1789 }
1790
1791 #[allow(clippy::needless_pass_by_value)]
1792 #[quickcheck]
1793 fn test_with_body_contents_replaces_body_correctly(input: String) -> TestResult {
1794 if input.contains('\r') {
1795 return TestResult::discard();
1796 }
1797
1798 let commit: CommitMessage<'_> = "Some Subject\n\nSome Body".into();
1799 let expected: String = format!("Some Subject\n\n{input}");
1800 let actual: String = commit.with_body_contents(&input).into();
1801 // Property: The body should be replaced with the input string while preserving the subject
1802 TestResult::from_bool(actual == expected)
1803 }
1804
1805 #[allow(clippy::needless_pass_by_value)]
1806 #[quickcheck]
1807 fn test_with_body_contents_preserves_multiline_subject(input: String) -> TestResult {
1808 if input.contains('\r') {
1809 return TestResult::discard();
1810 }
1811
1812 let commit: CommitMessage<'_> = "Some Subject\nSome More Subject\n\nBody".into();
1813 let expected: String = format!("Some Subject\nSome More Subject\n\n{input}");
1814 let actual: String = commit.with_body_contents(&input).into();
1815 // Property: The body should be replaced with the input string while preserving the multi-line subject
1816 TestResult::from_bool(actual == expected)
1817 }
1818
1819 #[test]
1820 fn test_with_body_contents_preserves_content_containing_scissors_marker() {
1821 let input = "some line\n# ------------------------ >8 ------------------------\nmore";
1822 let commit: CommitMessage<'_> = "Some Subject\n\nSome Body".into();
1823 let expected: String = format!("Some Subject\n\n{input}");
1824 let actual: String = commit.with_body_contents(input).into();
1825 assert_eq!(
1826 actual, expected,
1827 "with_body_contents should preserve all content including scissors markers, got: {actual}"
1828 );
1829 }
1830
1831 #[test]
1832 fn test_get_comment_char_returns_none_when_no_comments() {
1833 let commit_character = CommitMessage::from("Example Commit Message");
1834 assert!(
1835 commit_character.get_comment_char().is_none(),
1836 "Comment character should be None when there are no comments in the message"
1837 );
1838 }
1839
1840 #[test]
1841 fn test_try_from_path_buf_reads_file_correctly() {
1842 let temp_file = NamedTempFile::new().expect("failed to create temp file");
1843 write!(temp_file.as_file(), "Some Subject").expect("Failed to write file");
1844
1845 let commit_character: CommitMessage<'_> = temp_file
1846 .path()
1847 .to_path_buf()
1848 .try_into()
1849 .expect("Could not read commit message");
1850 assert_eq!(
1851 commit_character.get_subject().to_string(),
1852 "Some Subject",
1853 "Reading from PathBuf should correctly parse the file contents into a CommitMessage"
1854 );
1855 }
1856
1857 #[test]
1858 fn test_try_from_path_reads_file_correctly() {
1859 let temp_file = NamedTempFile::new().expect("failed to create temp file");
1860 write!(temp_file.as_file(), "Some Subject").expect("Failed to write file");
1861
1862 let commit_character: CommitMessage<'_> = temp_file
1863 .path()
1864 .try_into()
1865 .expect("Could not read commit message");
1866 assert_eq!(
1867 commit_character.get_subject().to_string(),
1868 "Some Subject",
1869 "Reading from Path should correctly parse the file contents into a CommitMessage"
1870 );
1871 }
1872
1873 #[test]
1874 fn test_from_reference_produces_same_output_as_from_owned() {
1875 let commit = CommitMessage::from(indoc!(
1876 "
1877 Example Commit Message
1878
1879 This is an example commit message for linting
1880
1881 Relates-to: #153
1882
1883 # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
1884 # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
1885 # bricht den Commit ab.
1886 #
1887 # Auf Branch main
1888 # Ihr Branch ist auf demselben Stand wie 'origin/main'.
1889 #
1890 # Zum Commit vorgemerkte \u{00E4}nderungen:
1891 # neue Datei: file
1892 #
1893 "
1894 ));
1895
1896 let from_ref = String::from(&commit);
1897 let from_owned = String::from(commit.clone());
1898
1899 assert_eq!(
1900 from_ref, from_owned,
1901 "String::from(&commit) should produce the same result as String::from(commit)"
1902 );
1903 }
1904
1905 #[test]
1906 fn test_from_reference_preserves_original() {
1907 let commit = CommitMessage::from(indoc!(
1908 "
1909 Example Commit Message
1910
1911 This is an example commit message for linting
1912 "
1913 ));
1914
1915 // Create a string from a reference to the commit
1916 let _string = String::from(&commit);
1917
1918 // Verify we can still use the commit after creating a string from it
1919 assert_eq!(
1920 commit.get_subject(),
1921 Subject::from("Example Commit Message"),
1922 "Original CommitMessage should still be usable after String::from(&commit)"
1923 );
1924 }
1925
1926 #[test]
1927 fn test_with_body_contents_preserves_scissors() {
1928 let commit = CommitMessage::from(indoc!(
1929 "
1930 Example Commit Message
1931
1932 This is an example commit message
1933
1934 # ------------------------ >8 ------------------------
1935 # Do not modify or remove the line above.
1936 # Everything below it will be ignored.
1937 diff --git a/file b/file
1938 new file mode 100644
1939 index 0000000..e69de29
1940 "
1941 ));
1942
1943 let updated = commit.with_body_contents("New body content");
1944 let result: String = updated.into();
1945
1946 assert!(
1947 result.contains("# ------------------------ >8 ------------------------"),
1948 "with_body_contents should preserve scissors section, got: {result}"
1949 );
1950 assert!(
1951 result.contains("New body content"),
1952 "with_body_contents should contain the new body, got: {result}"
1953 );
1954 assert!(
1955 result.contains("Example Commit Message"),
1956 "with_body_contents should preserve the subject, got: {result}"
1957 );
1958 }
1959
1960 #[test]
1961 fn test_from_reference_with_scissors() {
1962 let commit = CommitMessage::from(indoc!(
1963 "
1964 Example Commit Message
1965
1966 This is an example commit message
1967
1968 # ------------------------ >8 ------------------------
1969 # Do not modify or remove the line above.
1970 # Everything below it will be ignored.
1971 diff --git a/file b/file
1972 new file mode 100644
1973 index 0000000..e69de29
1974 "
1975 ));
1976
1977 let from_ref = String::from(&commit);
1978
1979 assert!(
1980 from_ref.contains("# ------------------------ >8 ------------------------"),
1981 "String created from reference should include scissors section"
1982 );
1983 assert!(
1984 from_ref.contains("diff --git"),
1985 "String created from reference should include content after scissors"
1986 );
1987 }
1988
1989 #[test]
1990 fn test_roundtrip_preserves_blank_line_before_scissors() {
1991 let input = "Subject\n\nBody text\n\n# ------------------------ >8 ------------------------\n# Everything below is ignored.\ndiff --git a/file b/file\n";
1992 let output: String = CommitMessage::from(input).into();
1993
1994 assert_eq!(
1995 output, input,
1996 "Round-tripping a commit message with a body and scissors should preserve the blank line between the body and the scissors marker"
1997 );
1998 }
1999}