Skip to main content

jj_cz/commit/types/
message.rs

1use super::{Body, BreakingChange, CommitType, Description, Footer, References, Scope};
2use thiserror::Error;
3
4/// Errors that can occur when creating a ConventionalCommit
5#[derive(Debug, Clone, PartialEq, Eq, Error)]
6pub enum CommitMessageError {
7    /// The complete first line exceeds the maximum allowed length
8    #[error("first line too long: {actual} characters (max {max})")]
9    FirstLineTooLong { actual: usize, max: usize },
10
11    /// The formatted message is not parseable as a conventional commit
12    ///
13    /// This should never occur in normal use - it indicates a bug in the
14    /// formatting logic.
15    #[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    /// Maximum allowed length for the complete first line (type + scope + description)
31    pub const FIRST_LINE_MAX_LENGTH: usize = 72;
32
33    /// Create a new conventional commit message
34    ///
35    /// # Arguments
36    /// All arguments are pre-validated types, but the combined first line
37    /// length is validated here (max 72 characters).
38    ///
39    /// # Errors
40    /// Returns `CommitMessageError::FirstLineTooLong` if the formatted first
41    /// line exceeds 72 characters.
42    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    /// Calculate the length of the formatted first line
75    ///
76    /// Formula:
77    /// - `len(type)` + `len(scope)` + `len(breaking_change)` + 2 + `len(description)`
78    ///   (the 2 accounts for colon and space: ": ")
79    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 // ": "
84            + self.description.len()
85    }
86
87    /// Format the complete commit message
88    ///
89    /// Returns `type(scope): description` if scope is non-empty, or
90    /// `type: description` if scope is empty
91    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    /// Format a preview of the commit message without creating a validated instance
103    ///
104    /// This is useful for showing what the message would look like before validation
105    /// Returns `type(scope): description` if scope is non-empty, or
106    /// `type: description` if scope is empty
107    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    /// Helper to create a valid Scope for testing
141    fn test_scope(value: &str) -> Scope {
142        Scope::parse(value).expect("test scope should be valid")
143    }
144
145    /// Helper to create a valid Description for testing
146    fn test_description(value: &str) -> Description {
147        Description::parse(value).expect("test description should be valid")
148    }
149
150    /// Helper to create a valid ConventionalCommit for testing
151    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 that ConventionalCommit::new() creates a valid commit with all fields
169    #[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 that ConventionalCommit::new() works with empty scope
183    #[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 that format() produces "type(scope): description" when scope is non-empty
197    #[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 format with different scope values
209    #[test]
210    fn format_with_various_scopes() {
211        // Hyphenated scope
212        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        // Underscored scope
221        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        // Scope with slash (Jira-style)
230        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 that format() produces "type: description" when scope is empty
243    #[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 format without scope for various descriptions
255    #[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 that all 11 commit types format correctly with scope
275    #[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 that all 11 commit types format correctly without scope
306    #[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 that Display implementation delegates to format()
341    #[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 Display with scope
355    #[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 Display without scope
367    #[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 Display delegates to format for all commit types
379    #[test]
380    fn display_equals_format_for_all_types() {
381        for commit_type in CommitType::all() {
382            // With scope
383            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            // Without scope
397            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 Clone trait
413    #[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 PartialEq trait - equal commits
426    #[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 PartialEq trait - different commit types
444    #[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 PartialEq trait - different scopes
462    #[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 PartialEq trait - different descriptions
480    #[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 Debug trait
498    #[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 real-world commit message example: feature with scope
512    #[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 real-world commit message example: bug fix without scope
524    #[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 real-world commit message example: documentation
536    #[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 real-world commit message example: refactoring
551    #[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 real-world commit message example: CI change
563    #[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 commit message with maximum description length (50 chars)
575    #[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        // Format should be "feat: " + 50 chars = 56 total chars
585        let formatted = commit.format();
586        assert!(formatted.starts_with("feat: "));
587        assert_eq!(formatted.len(), 56); // "feat: " (6) + 50 = 56
588    }
589
590    /// Test commit message with scope containing all valid special chars
591    #[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 FIRST_LINE_MAX_LENGTH constant is 72
603    #[test]
604    fn first_line_max_length_constant_is_72() {
605        assert_eq!(ConventionalCommit::FIRST_LINE_MAX_LENGTH, 72);
606    }
607
608    /// Test first_line_len() calculates correctly without scope
609    #[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        // "feat: add login" = 4 + 2 + 9 = 15
618        assert_eq!(commit.first_line_len(), 15);
619    }
620
621    /// Test first_line_len() calculates correctly with scope
622    #[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        // "feat(auth): add login" = 4 + 4 + 4 + 9 = 21
631        assert_eq!(commit.first_line_len(), 21);
632    }
633
634    /// Test exactly 72 characters is accepted (boundary)
635    #[test]
636    fn exactly_72_characters_accepted() {
637        // Build a commit that's exactly 72 chars:
638        // feat(20chars): 44chars = 4 + 20 + 4 + 44 = 72
639        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 73 characters is rejected (boundary)
655    #[test]
656    fn seventy_three_characters_rejected() {
657        // Build a commit that's 73 chars:
658        // "refactor: " = 10 chars, so we need 63 chars of description for 73 total
659        // But wait, we need to account for commit type and potentially scope
660        // Let's use "feat" (4) + ": " (2) + 67 chars = 73
661        // However Description MAX_LENGTH is 50, so we need a different approach
662        // Use scope to pad: "refactor(scope): desc"
663        // refactor = 8, (scope) = 7, : = 1, space = 1, desc needed for 73
664        // 8 + 7 + 2 + desc = 73, so desc = 56, but max is 50
665        // Let's use a longer type: "refactor" (8) + longer scope
666        // Actually, let me use max scope (30) and appropriate description:
667        // type(scope): desc
668        // refactor(30chars): X = 8 + 30 + 4 + X = 73
669        // X = 73 - 42 = 31
670        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 that valid components can still exceed 72 chars when combined
691    #[test]
692    fn valid_components_can_exceed_limit() {
693        // Use maximum valid scope (30 chars) and a 40-char description
694        // refactor(30): 40 = 8 + 30 + 4 + 40 = 82 chars (exceeds 72)
695        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 short commit without scope is accepted
716    #[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 short commit with scope is accepted
730    #[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 CommitMessageError::FirstLineTooLong displays correctly
744    #[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 new() returns Result type
757    #[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        // Just verify it's a Result by using is_ok()
768        assert!(result.is_ok());
769    }
770
771    /// Test that all valid commits produce messages parseable by git-conventional (SC-002)
772    ///
773    /// This verifies that 100% of commit messages produced by this tool conform to
774    /// the conventional commit specification.
775    #[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                // new() itself calls git_conventional::Commit::parse internally, so
800                // if this is Ok, SC-002 is satisfied for this case.
801                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 InvalidConventionalFormat error displays correctly
813    #[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    /// Breaking change without note and without scope: header gets '!', no footer
824    #[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    /// Breaking change without note and with scope: '!' goes after closing paren
836    #[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    /// Breaking change with note and without scope: footer is appended after a blank line
848    #[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    /// Breaking change with note and with scope: both '!' and footer are present
863    #[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    /// Display with breaking change delegates to format() (no scope, with note)
878    #[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    /// first_line_len() counts the '!' for a breaking change without scope
893    ///
894    /// "feat!: add login" = 4 + 1 + 2 + 9 = 16
895    #[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    /// first_line_len() counts the '!' for a breaking change with scope
907    ///
908    /// "feat(auth)!: add login" = 4 + 6 + 1 + 2 + 9 = 22
909    #[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    /// The `!` counts toward the 72-character first-line limit
921    ///
922    /// The inputs below produce exactly 72 chars without a breaking change
923    /// (covered by `exactly_72_characters_accepted`). With `!` they reach
924    /// 73 and must be rejected.
925    #[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    /// Breaking change footer does not count toward the 72-character first-line limit
948    #[test]
949    fn breaking_change_footer_does_not_count_toward_line_limit() {
950        // First line is short; the note itself is long - should still be accepted.
951        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    /// format_preview() static method produces the same result as format() for identical inputs
964    #[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    /// format_preview() with a breaking-change note produces the full multi-line message
984    #[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    /// format_preview() with scope and breaking-change note
1001    #[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    /// Breaking-change footer is separated from the header by exactly one blank line
1018    #[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    /// format() output has no leading or trailing whitespace for any variant
1038    #[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    /// All commit types format correctly with breaking change and no note
1068    #[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    // --- Body tests (these will fail until format_preview() is updated to use body) ---
1103
1104    /// format() includes the body between the header and an empty footer
1105    ///
1106    /// Case: body = Some, footer = "" → "type: desc\n\ncontent"
1107    #[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    /// format() includes the body when a scope is also present
1125    #[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    /// format() preserves internal newlines in a multi-paragraph body
1143    #[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    /// format() places the body between the header and the breaking-change footer
1161    ///
1162    /// Case: body = Some, footer = Some → "type: desc\n\nbody\n\nBREAKING CHANGE: note"
1163    #[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    /// format_preview() includes the body in the output
1181    #[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    /// format_preview() with body and breaking-change note produces the full message
1195    ///
1196    /// Case: body = Some, footer = Some → "type: desc\n\nbody\n\nBREAKING CHANGE: note"
1197    #[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}