1use super::{Body, BreakingChange, CommitType, Description, Footer, References, Scope};
2use thiserror::Error;
3
4#[derive(Debug, Clone, PartialEq, Eq, Error)]
6pub enum CommitMessageError {
7 #[error("first line too long: {actual} characters (max {max})")]
9 FirstLineTooLong { actual: usize, max: usize },
10
11 #[error("output failed git-conventional validation: {reason}")]
16 InvalidConventionalFormat { reason: String },
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ConventionalCommit {
21 commit_type: CommitType,
22 scope: Scope,
23 description: Description,
24 breaking_change: BreakingChange,
25 body: Body,
26 references: References,
27}
28
29impl ConventionalCommit {
30 pub const FIRST_LINE_MAX_LENGTH: usize = 72;
32
33 pub fn new(
43 commit_type: CommitType,
44 scope: Scope,
45 description: Description,
46 breaking_change: BreakingChange,
47 body: Body,
48 references: References,
49 ) -> Result<Self, CommitMessageError> {
50 let commit = Self {
51 commit_type,
52 scope,
53 description,
54 breaking_change,
55 body,
56 references,
57 };
58 let len = commit.first_line_len();
59 if len > Self::FIRST_LINE_MAX_LENGTH {
60 return Err(CommitMessageError::FirstLineTooLong {
61 actual: len,
62 max: Self::FIRST_LINE_MAX_LENGTH,
63 });
64 }
65 let formatted = commit.format();
66 git_conventional::Commit::parse(&formatted).map_err(|e| {
67 CommitMessageError::InvalidConventionalFormat {
68 reason: e.to_string(),
69 }
70 })?;
71 Ok(commit)
72 }
73
74 pub fn first_line_len(&self) -> usize {
80 self.commit_type.len()
81 + self.scope.header_segment_len()
82 + if self.breaking_change.is_absent() { 0 } else { 1 }
83 + 2 + self.description.len()
85 }
86
87 pub fn format(&self) -> String {
92 Self::format_preview(
93 self.commit_type,
94 &self.scope,
95 &self.description,
96 &self.breaking_change,
97 &self.body,
98 &self.references,
99 )
100 }
101
102 pub fn format_preview(
108 commit_type: CommitType,
109 scope: &Scope,
110 description: &Description,
111 breaking_change: &BreakingChange,
112 body: &Body,
113 references: &References,
114 ) -> String {
115 let scope = scope.header_segment();
116 let breaking_change_header = breaking_change.header_segment();
117 let breaking_change_footer = breaking_change.as_footer();
118 let refs_footer = references.as_footer();
119 format!(
120 r#"{commit_type}{scope}{breaking_change_header}: {description}
121{}
122{breaking_change_footer}{refs_footer}"#,
123 body.format()
124 )
125 .trim()
126 .to_string()
127 }
128}
129
130impl std::fmt::Display for ConventionalCommit {
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 write!(f, "{}", self.format())
133 }
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 fn test_scope(value: &str) -> Scope {
142 Scope::parse(value).expect("test scope should be valid")
143 }
144
145 fn test_description(value: &str) -> Description {
147 Description::parse(value).expect("test description should be valid")
148 }
149
150 fn test_commit(
152 commit_type: CommitType,
153 scope: Scope,
154 description: Description,
155 breaking_change: BreakingChange,
156 ) -> ConventionalCommit {
157 ConventionalCommit::new(
158 commit_type,
159 scope,
160 description,
161 breaking_change,
162 Body::default(),
163 References::default(),
164 )
165 .expect("test commit should have valid line length")
166 }
167
168 #[test]
170 fn new_creates_commit_with_all_fields() {
171 let commit = test_commit(
172 CommitType::Feat,
173 test_scope("cli"),
174 test_description("add new feature"),
175 BreakingChange::No,
176 );
177 assert_eq!(commit.commit_type, CommitType::Feat);
178 assert_eq!(commit.scope.as_str(), "cli");
179 assert_eq!(commit.description.as_str(), "add new feature");
180 }
181
182 #[test]
184 fn new_creates_commit_with_empty_scope() {
185 let commit = test_commit(
186 CommitType::Fix,
187 Scope::empty(),
188 test_description("fix critical bug"),
189 BreakingChange::No,
190 );
191 assert_eq!(commit.commit_type, CommitType::Fix);
192 assert!(commit.scope.is_empty());
193 assert_eq!(commit.description.as_str(), "fix critical bug");
194 }
195
196 #[test]
198 fn format_with_scope_produces_correct_output() {
199 let commit = test_commit(
200 CommitType::Feat,
201 test_scope("auth"),
202 test_description("add login"),
203 BreakingChange::No,
204 );
205 assert_eq!(commit.format(), "feat(auth): add login");
206 }
207
208 #[test]
210 fn format_with_various_scopes() {
211 let commit1 = test_commit(
213 CommitType::Fix,
214 test_scope("user-auth"),
215 test_description("fix token refresh"),
216 BreakingChange::No,
217 );
218 assert_eq!(commit1.format(), "fix(user-auth): fix token refresh");
219
220 let commit2 = test_commit(
222 CommitType::Docs,
223 test_scope("api_docs"),
224 test_description("update README"),
225 BreakingChange::No,
226 );
227 assert_eq!(commit2.format(), "docs(api_docs): update README");
228
229 let commit3 = test_commit(
231 CommitType::Chore,
232 test_scope("PROJ-123/cleanup"),
233 test_description("remove unused code"),
234 BreakingChange::No,
235 );
236 assert_eq!(
237 commit3.format(),
238 "chore(PROJ-123/cleanup): remove unused code"
239 );
240 }
241
242 #[test]
244 fn format_without_scope_produces_correct_output() {
245 let commit = test_commit(
246 CommitType::Feat,
247 Scope::empty(),
248 test_description("add login"),
249 BreakingChange::No,
250 );
251 assert_eq!(commit.format(), "feat: add login");
252 }
253
254 #[test]
256 fn format_without_scope_various_descriptions() {
257 let commit1 = test_commit(
258 CommitType::Fix,
259 Scope::empty(),
260 test_description("fix critical bug"),
261 BreakingChange::No,
262 );
263 assert_eq!(commit1.format(), "fix: fix critical bug");
264
265 let commit2 = test_commit(
266 CommitType::Docs,
267 Scope::empty(),
268 test_description("update installation guide"),
269 BreakingChange::No,
270 );
271 assert_eq!(commit2.format(), "docs: update installation guide");
272 }
273
274 #[test]
276 fn all_commit_types_format_correctly_with_scope() {
277 let scope = test_scope("cli");
278 let desc = test_description("test change");
279
280 let expected_formats = [
281 (CommitType::Feat, "feat(cli): test change"),
282 (CommitType::Fix, "fix(cli): test change"),
283 (CommitType::Docs, "docs(cli): test change"),
284 (CommitType::Style, "style(cli): test change"),
285 (CommitType::Refactor, "refactor(cli): test change"),
286 (CommitType::Perf, "perf(cli): test change"),
287 (CommitType::Test, "test(cli): test change"),
288 (CommitType::Build, "build(cli): test change"),
289 (CommitType::Ci, "ci(cli): test change"),
290 (CommitType::Chore, "chore(cli): test change"),
291 (CommitType::Revert, "revert(cli): test change"),
292 ];
293
294 for (commit_type, expected) in expected_formats {
295 let commit = test_commit(commit_type, scope.clone(), desc.clone(), BreakingChange::No);
296 assert_eq!(
297 commit.format(),
298 expected,
299 "Format should be correct for {:?}",
300 commit_type
301 );
302 }
303 }
304
305 #[test]
307 fn all_commit_types_format_correctly_without_scope() {
308 let desc = test_description("test change");
309
310 let expected_formats = [
311 (CommitType::Feat, "feat: test change"),
312 (CommitType::Fix, "fix: test change"),
313 (CommitType::Docs, "docs: test change"),
314 (CommitType::Style, "style: test change"),
315 (CommitType::Refactor, "refactor: test change"),
316 (CommitType::Perf, "perf: test change"),
317 (CommitType::Test, "test: test change"),
318 (CommitType::Build, "build: test change"),
319 (CommitType::Ci, "ci: test change"),
320 (CommitType::Chore, "chore: test change"),
321 (CommitType::Revert, "revert: test change"),
322 ];
323
324 for (commit_type, expected) in expected_formats {
325 let commit = test_commit(
326 commit_type,
327 Scope::empty(),
328 desc.clone(),
329 BreakingChange::No,
330 );
331 assert_eq!(
332 commit.format(),
333 expected,
334 "Format should be correct for {:?}",
335 commit_type
336 );
337 }
338 }
339
340 #[test]
342 fn display_delegates_to_format() {
343 let commit = test_commit(
344 CommitType::Feat,
345 test_scope("auth"),
346 test_description("add login"),
347 BreakingChange::No,
348 );
349 let display_output = format!("{}", commit);
350 let format_output = commit.format();
351 assert_eq!(display_output, format_output);
352 }
353
354 #[test]
356 fn display_with_scope() {
357 let commit = test_commit(
358 CommitType::Fix,
359 test_scope("api"),
360 test_description("handle null response"),
361 BreakingChange::No,
362 );
363 assert_eq!(format!("{}", commit), "fix(api): handle null response");
364 }
365
366 #[test]
368 fn display_without_scope() {
369 let commit = test_commit(
370 CommitType::Docs,
371 Scope::empty(),
372 test_description("improve README"),
373 BreakingChange::No,
374 );
375 assert_eq!(format!("{}", commit), "docs: improve README");
376 }
377
378 #[test]
380 fn display_equals_format_for_all_types() {
381 for commit_type in CommitType::all() {
382 let commit_with_scope = test_commit(
384 *commit_type,
385 test_scope("test"),
386 test_description("change"),
387 BreakingChange::No,
388 );
389 assert_eq!(
390 format!("{}", commit_with_scope),
391 commit_with_scope.format(),
392 "Display should equal format() for {:?} with scope",
393 commit_type
394 );
395
396 let commit_without_scope = test_commit(
398 *commit_type,
399 Scope::empty(),
400 test_description("change"),
401 BreakingChange::No,
402 );
403 assert_eq!(
404 format!("{}", commit_without_scope),
405 commit_without_scope.format(),
406 "Display should equal format() for {:?} without scope",
407 commit_type
408 );
409 }
410 }
411
412 #[test]
414 fn conventional_commit_is_cloneable() {
415 let original = test_commit(
416 CommitType::Feat,
417 test_scope("cli"),
418 test_description("add feature"),
419 BreakingChange::No,
420 );
421 let cloned = original.clone();
422 assert_eq!(original, cloned);
423 }
424
425 #[test]
427 fn conventional_commit_equality() {
428 let commit1 = test_commit(
429 CommitType::Feat,
430 test_scope("cli"),
431 test_description("add feature"),
432 BreakingChange::No,
433 );
434 let commit2 = test_commit(
435 CommitType::Feat,
436 test_scope("cli"),
437 test_description("add feature"),
438 BreakingChange::No,
439 );
440 assert_eq!(commit1, commit2);
441 }
442
443 #[test]
445 fn conventional_commit_inequality_different_type() {
446 let commit1 = test_commit(
447 CommitType::Feat,
448 test_scope("cli"),
449 test_description("change"),
450 BreakingChange::No,
451 );
452 let commit2 = test_commit(
453 CommitType::Fix,
454 test_scope("cli"),
455 test_description("change"),
456 BreakingChange::No,
457 );
458 assert_ne!(commit1, commit2);
459 }
460
461 #[test]
463 fn conventional_commit_inequality_different_scope() {
464 let commit1 = test_commit(
465 CommitType::Feat,
466 test_scope("cli"),
467 test_description("change"),
468 BreakingChange::No,
469 );
470 let commit2 = test_commit(
471 CommitType::Feat,
472 test_scope("api"),
473 test_description("change"),
474 BreakingChange::No,
475 );
476 assert_ne!(commit1, commit2);
477 }
478
479 #[test]
481 fn conventional_commit_inequality_different_description() {
482 let commit1 = test_commit(
483 CommitType::Feat,
484 test_scope("cli"),
485 test_description("add feature"),
486 BreakingChange::No,
487 );
488 let commit2 = test_commit(
489 CommitType::Feat,
490 test_scope("cli"),
491 test_description("fix bug"),
492 BreakingChange::No,
493 );
494 assert_ne!(commit1, commit2);
495 }
496
497 #[test]
499 fn conventional_commit_has_debug() {
500 let commit = test_commit(
501 CommitType::Feat,
502 test_scope("cli"),
503 test_description("add feature"),
504 BreakingChange::No,
505 );
506 let debug_output = format!("{:?}", commit);
507 assert!(debug_output.contains("ConventionalCommit"));
508 assert!(debug_output.contains("Feat"));
509 }
510
511 #[test]
513 fn real_world_feature_with_scope() {
514 let commit = test_commit(
515 CommitType::Feat,
516 test_scope("auth"),
517 test_description("implement OAuth2 login flow"),
518 BreakingChange::No,
519 );
520 assert_eq!(commit.format(), "feat(auth): implement OAuth2 login flow");
521 }
522
523 #[test]
525 fn real_world_bugfix_without_scope() {
526 let commit = test_commit(
527 CommitType::Fix,
528 Scope::empty(),
529 test_description("prevent crash on empty input"),
530 BreakingChange::No,
531 );
532 assert_eq!(commit.format(), "fix: prevent crash on empty input");
533 }
534
535 #[test]
537 fn real_world_docs() {
538 let commit = test_commit(
539 CommitType::Docs,
540 test_scope("README"),
541 test_description("add installation instructions"),
542 BreakingChange::No,
543 );
544 assert_eq!(
545 commit.format(),
546 "docs(README): add installation instructions"
547 );
548 }
549
550 #[test]
552 fn real_world_refactor() {
553 let commit = test_commit(
554 CommitType::Refactor,
555 test_scope("core"),
556 test_description("extract validation logic"),
557 BreakingChange::No,
558 );
559 assert_eq!(commit.format(), "refactor(core): extract validation logic");
560 }
561
562 #[test]
564 fn real_world_ci() {
565 let commit = test_commit(
566 CommitType::Ci,
567 test_scope("github"),
568 test_description("add release workflow"),
569 BreakingChange::No,
570 );
571 assert_eq!(commit.format(), "ci(github): add release workflow");
572 }
573
574 #[test]
576 fn format_with_max_length_description() {
577 let long_desc = "a".repeat(50);
578 let commit = test_commit(
579 CommitType::Feat,
580 Scope::empty(),
581 Description::parse(&long_desc).unwrap(),
582 BreakingChange::No,
583 );
584 let formatted = commit.format();
586 assert!(formatted.starts_with("feat: "));
587 assert_eq!(formatted.len(), 56); }
589
590 #[test]
592 fn format_with_complex_scope() {
593 let commit = test_commit(
594 CommitType::Feat,
595 test_scope("my-scope_v2/feature"),
596 test_description("add support"),
597 BreakingChange::No,
598 );
599 assert_eq!(commit.format(), "feat(my-scope_v2/feature): add support");
600 }
601
602 #[test]
604 fn first_line_max_length_constant_is_72() {
605 assert_eq!(ConventionalCommit::FIRST_LINE_MAX_LENGTH, 72);
606 }
607
608 #[test]
610 fn first_line_len_without_scope() {
611 let commit = test_commit(
612 CommitType::Feat,
613 Scope::empty(),
614 test_description("add login"),
615 BreakingChange::No,
616 );
617 assert_eq!(commit.first_line_len(), 15);
619 }
620
621 #[test]
623 fn first_line_len_with_scope() {
624 let commit = test_commit(
625 CommitType::Feat,
626 test_scope("auth"),
627 test_description("add login"),
628 BreakingChange::No,
629 );
630 assert_eq!(commit.first_line_len(), 21);
632 }
633
634 #[test]
636 fn exactly_72_characters_accepted() {
637 let scope_20 = "a".repeat(20);
640 let desc_44 = "b".repeat(44);
641 let result = ConventionalCommit::new(
642 CommitType::Feat,
643 Scope::parse(&scope_20).unwrap(),
644 Description::parse(&desc_44).unwrap(),
645 BreakingChange::No,
646 Body::default(),
647 References::default(),
648 );
649 assert!(result.is_ok());
650 let commit = result.unwrap();
651 assert_eq!(commit.first_line_len(), 72);
652 }
653
654 #[test]
656 fn seventy_three_characters_rejected() {
657 let scope_30 = "a".repeat(30);
671 let desc_31 = "b".repeat(31);
672 let result = ConventionalCommit::new(
673 CommitType::Refactor,
674 Scope::parse(&scope_30).unwrap(),
675 Description::parse(&desc_31).unwrap(),
676 BreakingChange::No,
677 Body::default(),
678 References::default(),
679 );
680 assert!(result.is_err());
681 assert_eq!(
682 result.unwrap_err(),
683 CommitMessageError::FirstLineTooLong {
684 actual: 73,
685 max: 72
686 }
687 );
688 }
689
690 #[test]
692 fn valid_components_can_exceed_limit() {
693 let scope_30 = "a".repeat(30);
696 let desc_40 = "b".repeat(40);
697 let result = ConventionalCommit::new(
698 CommitType::Refactor,
699 Scope::parse(&scope_30).unwrap(),
700 Description::parse(&desc_40).unwrap(),
701 BreakingChange::No,
702 Body::default(),
703 References::default(),
704 );
705 assert!(result.is_err());
706 assert_eq!(
707 result.unwrap_err(),
708 CommitMessageError::FirstLineTooLong {
709 actual: 82,
710 max: 72
711 }
712 );
713 }
714
715 #[test]
717 fn short_commit_without_scope_accepted() {
718 let result = ConventionalCommit::new(
719 CommitType::Fix,
720 Scope::empty(),
721 test_description("quick fix"),
722 BreakingChange::No,
723 Body::default(),
724 References::default(),
725 );
726 assert!(result.is_ok());
727 }
728
729 #[test]
731 fn short_commit_with_scope_accepted() {
732 let result = ConventionalCommit::new(
733 CommitType::Feat,
734 test_scope("cli"),
735 test_description("add feature"),
736 BreakingChange::No,
737 Body::default(),
738 References::default(),
739 );
740 assert!(result.is_ok());
741 }
742
743 #[test]
745 fn first_line_too_long_error_display() {
746 let err = CommitMessageError::FirstLineTooLong {
747 actual: 80,
748 max: 72,
749 };
750 let msg = format!("{}", err);
751 assert!(msg.contains("too long"));
752 assert!(msg.contains("80"));
753 assert!(msg.contains("72"));
754 }
755
756 #[test]
758 fn new_returns_result() {
759 let result = ConventionalCommit::new(
760 CommitType::Feat,
761 Scope::empty(),
762 test_description("test"),
763 BreakingChange::No,
764 Body::default(),
765 References::default(),
766 );
767 assert!(result.is_ok());
769 }
770
771 #[test]
776 fn all_valid_commits_parse_with_git_conventional() {
777 let cases: &[(&str, Option<&str>)] = &[
778 ("add new feature", None),
779 ("fix critical bug", Some("api")),
780 ("update README", Some("docs")),
781 ("remove unused code", Some("core")),
782 ];
783
784 for commit_type in CommitType::all() {
785 for (desc_str, scope_str) in cases {
786 let scope = match scope_str {
787 Some(s) => Scope::parse(*s).unwrap(),
788 None => Scope::empty(),
789 };
790 let desc = Description::parse(*desc_str).unwrap();
791 let commit = ConventionalCommit::new(
792 *commit_type,
793 scope,
794 desc,
795 BreakingChange::No,
796 Body::default(),
797 References::default(),
798 );
799 assert!(
802 commit.is_ok(),
803 "git-conventional rejected {:?}/{:?}/{:?}",
804 commit_type,
805 scope_str,
806 desc_str
807 );
808 }
809 }
810 }
811
812 #[test]
814 fn invalid_conventional_format_error_display() {
815 let err = CommitMessageError::InvalidConventionalFormat {
816 reason: "missing type".to_string(),
817 };
818 let msg = format!("{}", err);
819 assert!(msg.contains("git-conventional"));
820 assert!(msg.contains("missing type"));
821 }
822
823 #[test]
825 fn format_breaking_change_no_note_no_scope() {
826 let commit = test_commit(
827 CommitType::Feat,
828 Scope::empty(),
829 test_description("add login"),
830 BreakingChange::Yes,
831 );
832 assert_eq!(commit.format(), "feat!: add login");
833 }
834
835 #[test]
837 fn format_breaking_change_no_note_with_scope() {
838 let commit = test_commit(
839 CommitType::Feat,
840 test_scope("auth"),
841 test_description("add login"),
842 BreakingChange::Yes,
843 );
844 assert_eq!(commit.format(), "feat(auth)!: add login");
845 }
846
847 #[test]
849 fn format_breaking_change_with_note_no_scope() {
850 let commit = test_commit(
851 CommitType::Feat,
852 Scope::empty(),
853 test_description("drop Node 6"),
854 "Node 6 is no longer supported".into(),
855 );
856 assert_eq!(
857 commit.format(),
858 "feat!: drop Node 6\n\nBREAKING CHANGE: Node 6 is no longer supported",
859 );
860 }
861
862 #[test]
864 fn format_breaking_change_with_note_and_scope() {
865 let commit = test_commit(
866 CommitType::Fix,
867 test_scope("api"),
868 test_description("drop Node 6"),
869 "Node 6 is no longer supported".into(),
870 );
871 assert_eq!(
872 commit.format(),
873 "fix(api)!: drop Node 6\n\nBREAKING CHANGE: Node 6 is no longer supported",
874 );
875 }
876
877 #[test]
879 fn display_breaking_change_with_note() {
880 let commit = test_commit(
881 CommitType::Feat,
882 Scope::empty(),
883 test_description("drop Node 6"),
884 "Node 6 is no longer supported".into(),
885 );
886 assert_eq!(
887 format!("{}", commit),
888 "feat!: drop Node 6\n\nBREAKING CHANGE: Node 6 is no longer supported",
889 );
890 }
891
892 #[test]
896 fn first_line_len_breaking_change_no_scope() {
897 let commit = test_commit(
898 CommitType::Feat,
899 Scope::empty(),
900 test_description("add login"),
901 BreakingChange::Yes,
902 );
903 assert_eq!(commit.first_line_len(), 16);
904 }
905
906 #[test]
910 fn first_line_len_breaking_change_with_scope() {
911 let commit = test_commit(
912 CommitType::Feat,
913 test_scope("auth"),
914 test_description("add login"),
915 BreakingChange::Yes,
916 );
917 assert_eq!(commit.first_line_len(), 22);
918 }
919
920 #[test]
926 fn breaking_change_exclamation_counts_toward_line_limit() {
927 let scope_20 = "a".repeat(20);
928 let desc_44 = "b".repeat(44);
929 let result = ConventionalCommit::new(
930 CommitType::Feat,
931 Scope::parse(&scope_20).unwrap(),
932 Description::parse(&desc_44).unwrap(),
933 BreakingChange::Yes,
934 Body::default(),
935 References::default(),
936 );
937 assert!(result.is_err());
938 assert_eq!(
939 result.unwrap_err(),
940 CommitMessageError::FirstLineTooLong {
941 actual: 73,
942 max: 72
943 },
944 );
945 }
946
947 #[test]
949 fn breaking_change_footer_does_not_count_toward_line_limit() {
950 let long_note = "x".repeat(200);
952 let result = ConventionalCommit::new(
953 CommitType::Fix,
954 Scope::empty(),
955 test_description("quick fix"),
956 long_note.into(),
957 Body::default(),
958 References::default(),
959 );
960 assert!(result.is_ok());
961 }
962
963 #[test]
965 fn format_preview_matches_format() {
966 let commit = test_commit(
967 CommitType::Feat,
968 test_scope("auth"),
969 test_description("add login"),
970 BreakingChange::No,
971 );
972 let preview = ConventionalCommit::format_preview(
973 commit.commit_type,
974 &commit.scope,
975 &commit.description,
976 &BreakingChange::No,
977 &Body::default(),
978 &References::default(),
979 );
980 assert_eq!(preview, commit.format());
981 }
982
983 #[test]
985 fn format_preview_breaking_change_with_note() {
986 let preview = ConventionalCommit::format_preview(
987 CommitType::Feat,
988 &Scope::empty(),
989 &test_description("drop legacy API"),
990 &"removes legacy endpoint".into(),
991 &Body::default(),
992 &References::default(),
993 );
994 assert_eq!(
995 preview,
996 "feat!: drop legacy API\n\nBREAKING CHANGE: removes legacy endpoint"
997 );
998 }
999
1000 #[test]
1002 fn format_preview_breaking_change_with_scope_and_note() {
1003 let preview = ConventionalCommit::format_preview(
1004 CommitType::Fix,
1005 &test_scope("api"),
1006 &test_description("drop Node 6"),
1007 &"Node 6 is no longer supported".into(),
1008 &Body::default(),
1009 &References::default(),
1010 );
1011 assert_eq!(
1012 preview,
1013 "fix(api)!: drop Node 6\n\nBREAKING CHANGE: Node 6 is no longer supported"
1014 );
1015 }
1016
1017 #[test]
1019 fn format_breaking_change_footer_separator() {
1020 let commit = test_commit(
1021 CommitType::Fix,
1022 Scope::empty(),
1023 test_description("drop old API"),
1024 "old API removed".into(),
1025 );
1026 let formatted = commit.format();
1027 let parts: Vec<&str> = formatted.splitn(2, "\n\n").collect();
1028 assert_eq!(
1029 parts.len(),
1030 2,
1031 "expected header and footer separated by \\n\\n"
1032 );
1033 assert_eq!(parts[0], "fix!: drop old API");
1034 assert_eq!(parts[1], "BREAKING CHANGE: old API removed");
1035 }
1036
1037 #[test]
1039 fn format_has_no_surrounding_whitespace() {
1040 let no_bc = test_commit(
1041 CommitType::Feat,
1042 Scope::empty(),
1043 test_description("add feature"),
1044 BreakingChange::No,
1045 );
1046 let f = no_bc.format();
1047 assert_eq!(
1048 f,
1049 f.trim(),
1050 "format() must not have surrounding whitespace (no breaking change)"
1051 );
1052
1053 let with_note = test_commit(
1054 CommitType::Fix,
1055 Scope::empty(),
1056 test_description("fix bug"),
1057 "important migration required".into(),
1058 );
1059 let f2 = with_note.format();
1060 assert_eq!(
1061 f2,
1062 f2.trim(),
1063 "format() must not have surrounding whitespace (with note)"
1064 );
1065 }
1066
1067 #[test]
1069 fn all_commit_types_format_with_breaking_change_no_note() {
1070 let desc = test_description("test change");
1071
1072 let expected_formats = [
1073 (CommitType::Feat, "feat!: test change"),
1074 (CommitType::Fix, "fix!: test change"),
1075 (CommitType::Docs, "docs!: test change"),
1076 (CommitType::Style, "style!: test change"),
1077 (CommitType::Refactor, "refactor!: test change"),
1078 (CommitType::Perf, "perf!: test change"),
1079 (CommitType::Test, "test!: test change"),
1080 (CommitType::Build, "build!: test change"),
1081 (CommitType::Ci, "ci!: test change"),
1082 (CommitType::Chore, "chore!: test change"),
1083 (CommitType::Revert, "revert!: test change"),
1084 ];
1085
1086 for (commit_type, expected) in expected_formats {
1087 let commit = test_commit(
1088 commit_type,
1089 Scope::empty(),
1090 desc.clone(),
1091 BreakingChange::Yes,
1092 );
1093 assert_eq!(
1094 commit.format(),
1095 expected,
1096 "Format should be correct for {:?} with breaking change",
1097 commit_type
1098 );
1099 }
1100 }
1101
1102 #[test]
1108 fn format_with_body_no_breaking_change() {
1109 let commit = ConventionalCommit::new(
1110 CommitType::Feat,
1111 Scope::empty(),
1112 test_description("add feature"),
1113 BreakingChange::No,
1114 Body::from("This explains the change."),
1115 References::default(),
1116 )
1117 .unwrap();
1118 assert_eq!(
1119 commit.format(),
1120 "feat: add feature\n\nThis explains the change."
1121 );
1122 }
1123
1124 #[test]
1126 fn format_with_body_and_scope() {
1127 let commit = ConventionalCommit::new(
1128 CommitType::Fix,
1129 test_scope("api"),
1130 test_description("handle null response"),
1131 BreakingChange::No,
1132 Body::from("Null responses were previously unhandled."),
1133 References::default(),
1134 )
1135 .unwrap();
1136 assert_eq!(
1137 commit.format(),
1138 "fix(api): handle null response\n\nNull responses were previously unhandled."
1139 );
1140 }
1141
1142 #[test]
1144 fn format_with_multiline_body() {
1145 let commit = ConventionalCommit::new(
1146 CommitType::Docs,
1147 Scope::empty(),
1148 test_description("update README"),
1149 BreakingChange::No,
1150 Body::from("First paragraph.\n\nSecond paragraph."),
1151 References::default(),
1152 )
1153 .unwrap();
1154 assert_eq!(
1155 commit.format(),
1156 "docs: update README\n\nFirst paragraph.\n\nSecond paragraph."
1157 );
1158 }
1159
1160 #[test]
1164 fn format_with_body_and_breaking_change_note() {
1165 let commit = ConventionalCommit::new(
1166 CommitType::Feat,
1167 Scope::empty(),
1168 test_description("drop legacy API"),
1169 "removes legacy endpoint".into(),
1170 Body::from("The endpoint was deprecated in v2."),
1171 References::default(),
1172 )
1173 .unwrap();
1174 assert_eq!(
1175 commit.format(),
1176 "feat!: drop legacy API\n\nThe endpoint was deprecated in v2.\n\nBREAKING CHANGE: removes legacy endpoint"
1177 );
1178 }
1179
1180 #[test]
1182 fn format_preview_with_body() {
1183 let preview = ConventionalCommit::format_preview(
1184 CommitType::Feat,
1185 &Scope::empty(),
1186 &test_description("add feature"),
1187 &BreakingChange::No,
1188 &Body::from("This explains the change."),
1189 &References::default(),
1190 );
1191 assert_eq!(preview, "feat: add feature\n\nThis explains the change.");
1192 }
1193
1194 #[test]
1198 fn format_preview_with_body_and_breaking_change() {
1199 let preview = ConventionalCommit::format_preview(
1200 CommitType::Fix,
1201 &Scope::empty(),
1202 &test_description("drop old API"),
1203 &"old API removed".into(),
1204 &Body::from("Migration guide: see CHANGELOG."),
1205 &References::default(),
1206 );
1207 assert_eq!(
1208 preview,
1209 "fix!: drop old API\n\nMigration guide: see CHANGELOG.\n\nBREAKING CHANGE: old API removed"
1210 );
1211 }
1212}