@@ -37,7 +37,10 @@ pub fn parse_conventional_commit(subject: &str, body: &str) -> Option<Convention
3737 let ( kind, scope) = match kind_with_scope. split_once ( '(' ) {
3838 Some ( ( kind, rest) ) => {
3939 let scope = rest. strip_suffix ( ')' ) ?. trim ( ) ;
40- ( kind. trim ( ) , ( !scope. is_empty ( ) ) . then_some ( scope) )
40+ if scope. is_empty ( ) {
41+ return None ;
42+ }
43+ ( kind. trim ( ) , Some ( scope) )
4144 }
4245 None => ( kind_with_scope. trim ( ) , None ) ,
4346 } ;
@@ -59,22 +62,155 @@ pub fn parse_conventional_commit(subject: &str, body: &str) -> Option<Convention
5962mod tests {
6063 use super :: * ;
6164
65+ fn assert_commit < ' a > (
66+ subject : & ' a str ,
67+ body : & ' a str ,
68+ kind : & ' a str ,
69+ scope : Option < & ' a str > ,
70+ description : & ' a str ,
71+ breaking : bool ,
72+ ) {
73+ let commit = parse_conventional_commit ( subject, body) . expect ( "commit should parse" ) ;
74+ assert_eq ! ( commit. kind, kind) ;
75+ assert_eq ! ( commit. scope, scope) ;
76+ assert_eq ! ( commit. description, description) ;
77+ assert_eq ! ( commit. breaking, breaking) ;
78+ }
79+
6280 #[ test]
6381 fn parses_scope_and_breaking_marker ( ) {
64- let commit =
65- parse_conventional_commit ( "feat(cli)!: add release" , "" ) . expect ( "commit should parse" ) ;
66- assert_eq ! ( commit. kind, "feat" ) ;
67- assert_eq ! ( commit. scope, Some ( "cli" ) ) ;
68- assert_eq ! ( commit. description, "add release" ) ;
69- assert ! ( commit. breaking) ;
82+ assert_commit ( "feat(cli)!: add release" , "" , "feat" , Some ( "cli" ) , "add release" , true ) ;
83+ }
84+
85+ #[ test]
86+ fn parses_commits_without_scope ( ) {
87+ assert_commit ( "fix: handle cache miss" , "" , "fix" , None , "handle cache miss" , false ) ;
88+ }
89+
90+ #[ test]
91+ fn trims_subject_parts ( ) {
92+ assert_commit (
93+ " feat(parser): add support for colons: here " ,
94+ "" ,
95+ "feat" ,
96+ Some ( "parser" ) ,
97+ "add support for colons: here" ,
98+ false ,
99+ ) ;
100+ }
101+
102+ #[ test]
103+ fn parses_breaking_header_without_scope ( ) {
104+ assert_commit ( "refactor!: split module" , "" , "refactor" , None , "split module" , true ) ;
105+ }
106+
107+ #[ test]
108+ fn parses_scopes_with_symbols_used_in_package_names ( ) {
109+ assert_commit (
110+ "build(pkg-utils/core): ship binary" ,
111+ "" ,
112+ "build" ,
113+ Some ( "pkg-utils/core" ) ,
114+ "ship binary" ,
115+ false ,
116+ ) ;
70117 }
71118
72119 #[ test]
73120 fn parses_breaking_change_footer ( ) {
74- let commit = parse_conventional_commit ( "chore: cleanup" , "BREAKING CHANGE: changed API" )
75- . expect ( "commit should parse" ) ;
76- assert_eq ! ( commit. kind, "chore" ) ;
77- assert ! ( commit. breaking) ;
121+ assert_commit (
122+ "chore: cleanup" ,
123+ "BREAKING CHANGE: changed API" ,
124+ "chore" ,
125+ None ,
126+ "cleanup" ,
127+ true ,
128+ ) ;
129+ }
130+
131+ #[ test]
132+ fn parses_breaking_change_hyphenated_footer ( ) {
133+ assert_commit (
134+ "feat: ship release" ,
135+ "BREAKING-CHANGE: config file layout changed" ,
136+ "feat" ,
137+ None ,
138+ "ship release" ,
139+ true ,
140+ ) ;
141+ }
142+
143+ #[ test]
144+ fn detects_breaking_footer_after_blank_line_and_indentation ( ) {
145+ assert_commit (
146+ "feat(ui): refresh" ,
147+ "\n BREAKING CHANGE: theme tokens moved" ,
148+ "feat" ,
149+ Some ( "ui" ) ,
150+ "refresh" ,
151+ true ,
152+ ) ;
153+ }
154+
155+ #[ test]
156+ fn does_not_mark_non_breaking_body_text_as_breaking ( ) {
157+ assert_commit (
158+ "docs: explain migration" ,
159+ "This mentions BREAKING CHANGE but not as a footer.\n Also BREAKING CHANGE without colon" ,
160+ "docs" ,
161+ None ,
162+ "explain migration" ,
163+ false ,
164+ ) ;
165+ }
166+
167+ #[ test]
168+ fn breaking_header_wins_even_without_footer ( ) {
169+ assert_commit ( "feat!: ship api v2" , "some body" , "feat" , None , "ship api v2" , true ) ;
170+ }
171+
172+ #[ test]
173+ fn returns_none_for_empty_scope ( ) {
174+ assert ! ( parse_conventional_commit( "feat(): release" , "" ) . is_none( ) ) ;
175+ assert ! ( parse_conventional_commit( "feat( ): release" , "" ) . is_none( ) ) ;
176+ }
177+
178+ #[ test]
179+ fn returns_none_for_missing_separator_or_description ( ) {
180+ assert ! ( parse_conventional_commit( "release prep" , "" ) . is_none( ) ) ;
181+ assert ! ( parse_conventional_commit( "feat" , "" ) . is_none( ) ) ;
182+ assert ! ( parse_conventional_commit( "feat:" , "" ) . is_none( ) ) ;
183+ assert ! ( parse_conventional_commit( "feat: " , "" ) . is_none( ) ) ;
184+ }
185+
186+ #[ test]
187+ fn returns_none_for_missing_type ( ) {
188+ assert ! ( parse_conventional_commit( ": description" , "" ) . is_none( ) ) ;
189+ assert ! ( parse_conventional_commit( " : description" , "" ) . is_none( ) ) ;
190+ }
191+
192+ #[ test]
193+ fn returns_none_for_malformed_scope_syntax ( ) {
194+ assert ! ( parse_conventional_commit( "feat(parser: release" , "" ) . is_none( ) ) ;
195+ assert ! ( parse_conventional_commit( "feat)parser(: release" , "" ) . is_none( ) ) ;
196+ assert ! ( parse_conventional_commit( "feat(parser) extra: release" , "" ) . is_none( ) ) ;
197+ }
198+
199+ #[ test]
200+ fn only_uses_first_colon_as_header_separator ( ) {
201+ assert_commit (
202+ "feat: add support: parser mode" ,
203+ "" ,
204+ "feat" ,
205+ None ,
206+ "add support: parser mode" ,
207+ false ,
208+ ) ;
209+ }
210+
211+ #[ test]
212+ fn preserves_case_of_kind_and_scope ( ) {
213+ assert_commit ( "Feat(API): allow preview" , "" , "Feat" , Some ( "API" ) , "allow preview" , false ) ;
78214 }
79215
80216 #[ test]
0 commit comments