1use regex::{Captures, Regex, RegexBuilder};
31
32use once_cell::sync::Lazy;
33macro_rules! regex {
34 ($name:ident, $re:expr $(,)?) => {
35 static $name: Lazy<Regex> = Lazy::new(|| Regex::new($re).expect("Valid Regex"));
36 };
37}
38
39regex!(
40 CONVENTIONAL_COMMIT_REGEX,
41 r"(?i)^(SECURITY FIX!?|BREAKING CHANGE!?|\w+!?)(\(.+\)!?)?[/:\s]*(.+)"
42);
43
44regex!(ADD_REGEX, r"(?i)^add:?\s*");
45regex!(FIX_REGEX, r"(?i)^(bug)?fix(ing|ed)?(\(.+\))?[/:\s]+");
46
47regex!(UPDATE_REGEX, r#"^Update :?(.+) to (.+)"#);
48regex!(SPLIT_REGEX, r#"^Split '(.+)/' into commit '(.+)'"#);
49regex!(IMPORT_REGEX, r#"^:?(.+) Import .+⸪(.+)"#);
50
51regex!(
52 PR_REGEX,
53 r"^Merge (?:remote-tracking branch '.+/pr/(\d+)'|pull request #(\d+) from .+)$"
54);
55regex!(PR_REGEX_BORS, r"^Merge #(\d+)");
57regex!(PR_REGEX_BB, r"^Merge pull request #(\d+) in .+ from .+$");
58regex!(PR_REGEX_AZURE, r"^Merged PR (\d+): (.*)$");
59
60static RELEASE_REGEX1: Lazy<Regex> = Lazy::new(|| {
61 RegexBuilder::new(r#"^(?:Release|Bump) :?(.+)@v?([0-9.]+)\b.*"#)
62 .case_insensitive(true)
63 .build()
64 .expect("Valid Regex")
65});
66
67static RELEASE_REGEX2: Lazy<Regex> = Lazy::new(|| {
68 RegexBuilder::new(r#"^(?:Release|Bump)\s.*?v?([0-9.]+).*"#)
69 .case_insensitive(true)
70 .build()
71 .expect("Valid Regex")
72});
73
74#[allow(missing_docs)]
76#[derive(Clone, Debug, Eq, PartialEq)]
77pub enum SubtreeOperation {
78 Import { subtree: String, git_ref: String },
79 Split { subtree: String, git_ref: String },
80 Update { subtree: String, git_ref: String },
81}
82
83#[allow(missing_docs)]
85#[derive(Clone, Debug, Eq, PartialEq)]
86pub enum Type {
87 Archive,
88 Build,
89 Change,
90 Chore,
91 Ci,
92 Dev,
93 Deps,
94 Docs,
95 Deprecate,
96 Feat,
97 Fix,
98 I18n,
99 Issue,
100 Improvement,
101 Other,
102 Perf,
103 Refactor,
104 Repo,
105 Security,
106 Style,
107 Test,
108}
109#[derive(Debug, Eq, PartialEq, Clone)]
117pub enum Subject {
118 #[allow(missing_docs)]
120 ConventionalCommit {
121 breaking_change: bool,
122 category: Type,
123 scope: Option<String>,
124 description: String,
125 },
126 Fixup(String),
128 #[allow(missing_docs)]
130 PullRequest { id: String, description: String },
131 #[allow(missing_docs)]
133 Release {
134 version: String,
135 scope: Option<String>,
136 description: String,
137 },
138 Remove(String),
140 Rename(String),
142 Revert(String),
144
145 #[allow(missing_docs)]
147 SubtreeCommit {
148 operation: SubtreeOperation,
149 description: String,
150 },
151 Simple(String),
153}
154impl From<&str> for Subject {
156 #[inline]
157 fn from(subject: &str) -> Self {
158 #[allow(clippy::option_if_let_else)]
159 if let Some(caps) = RELEASE_REGEX1.captures(subject) {
160 Self::Release {
161 version: caps[2].to_owned(),
162 scope: Some(caps[1].to_owned()),
163 description: subject.to_owned(),
164 }
165 } else if let Some(caps) = RELEASE_REGEX2.captures(subject) {
166 Self::Release {
167 version: caps[1].to_owned(),
168 scope: None,
169 description: subject.to_owned(),
170 }
171 } else if let Some(caps) = PR_REGEX_AZURE.captures(subject) {
172 let id = caps[1].to_owned();
173 let description = format!("{} (#{})", &caps[2], id);
174 Self::PullRequest { id, description }
175 } else if let Some(caps) = PR_REGEX
176 .captures(subject)
177 .or_else(|| PR_REGEX_AZURE.captures(subject))
178 .or_else(|| PR_REGEX_BB.captures(subject))
179 .or_else(|| PR_REGEX_BORS.captures(subject))
180 {
181 Self::parse_pr(&caps, subject)
182 } else if subject.starts_with("fixup!") {
183 Self::Fixup(subject.to_owned())
184 } else if let Some(caps) = UPDATE_REGEX.captures(subject) {
185 let operation = SubtreeOperation::Update {
186 subtree: caps[1].to_owned(),
187 git_ref: caps[2].to_owned(),
188 };
189 Self::SubtreeCommit {
190 operation,
191 description: subject.to_owned(),
192 }
193 } else if let Some(caps) = IMPORT_REGEX.captures(subject) {
194 let operation = SubtreeOperation::Import {
195 subtree: caps[1].to_owned(),
196 git_ref: caps[2].to_owned(),
197 };
198 Self::SubtreeCommit {
199 operation,
200 description: subject.to_owned(),
201 }
202 } else if let Some(caps) = SPLIT_REGEX.captures(subject) {
203 let operation = SubtreeOperation::Split {
204 subtree: caps[1].to_owned(),
205 git_ref: caps[2].to_owned(),
206 };
207 Self::SubtreeCommit {
208 operation,
209 description: subject.to_owned(),
210 }
211 } else if subject.to_lowercase().starts_with("remove ") {
212 Self::Remove(subject.to_owned())
213 } else if subject.to_lowercase().starts_with("rename ")
214 || subject.to_lowercase().starts_with("move ")
215 {
216 Self::Rename(subject.to_owned())
217 } else if subject.to_lowercase().starts_with("revert ") {
218 Self::Revert(subject.to_owned())
219 } else if ADD_REGEX.is_match(subject) {
220 Self::ConventionalCommit {
221 breaking_change: false,
222 category: Type::Feat,
223 scope: None,
224 description: subject.to_owned(),
225 }
226 } else if FIX_REGEX.is_match(subject) {
227 Self::ConventionalCommit {
228 breaking_change: false,
229 category: Type::Fix,
230 scope: None,
231 description: subject.to_owned(),
232 }
233 } else if subject.to_lowercase().starts_with("deprecate ") {
234 Self::ConventionalCommit {
235 breaking_change: false,
236 category: Type::Deprecate,
237 scope: None,
238 description: subject.to_owned(),
239 }
240 } else if let Some(caps) = CONVENTIONAL_COMMIT_REGEX.captures(subject) {
241 Self::parse_conventional_commit(&caps)
242 } else {
243 Self::Simple(subject.to_owned())
244 }
245 }
246}
247
248impl Subject {
249 #[must_use]
251 #[inline]
252 pub const fn icon(&self) -> &str {
253 match self {
254 Self::Fixup(_) => "\u{f0e3} ",
255 Self::ConventionalCommit {
256 breaking_change,
257 category,
258 ..
259 } => {
260 if *breaking_change {
261 "⚠ "
262 } else {
263 match category {
264 Type::Archive => "\u{f53b} ",
265 Type::Build => "🔨",
266 Type::Change | Type::Improvement => "\u{e370} ",
267 Type::Chore => "\u{1F6A7}", Type::Ci => "\u{f085} ",
269 Type::Deprecate => "\u{f48e} ",
270 Type::Dev => "\u{1f6a9}",
271 Type::Deps => "\u{f487} ",
272 Type::Docs => "✎ ",
273 Type::Feat => "\u{1f381}", Type::Issue => " ",
275 Type::Fix => "\u{f188} ",
276 Type::I18n => "\u{fac9}",
277 Type::Other => " ",
278 Type::Perf => "\u{f9c4} ",
279 Type::Refactor => "\u{f021} ",
280 Type::Repo => " ",
281 Type::Security => " ",
282 Type::Style => "♥ ",
283 Type::Test => "\u{f45e} ",
284 }
285 }
286 }
287 Self::SubtreeCommit { operation, .. } => match operation {
288 SubtreeOperation::Import { .. } => "⮈ ",
289 SubtreeOperation::Split { .. } => "\u{f403} ",
290 SubtreeOperation::Update { .. } => "\u{f419} ",
291 },
292 Self::Simple(_) => " ",
293 Self::Release { .. } => "\u{f412} ",
294 Self::Remove(_) => "\u{f48e} ",
295 Self::Rename(_) => "\u{f044} ",
296 Self::Revert(_) => " ",
297 Self::PullRequest { .. } => " ",
298 }
299 }
300
301 fn parse_pr(caps: &Captures<'_>, subject: &str) -> Self {
302 let id = if let Some(n) = caps.get(1) {
303 n.as_str().to_owned()
304 } else if let Some(n) = caps.get(2) {
305 n.as_str().to_owned()
306 } else {
307 return Self::Simple(subject.to_owned());
310 };
311 Self::PullRequest {
312 id,
313 description: subject.to_owned(),
314 }
315 }
316
317 fn parse_conventional_commit(caps: &Captures<'_>) -> Self {
318 let mut cat_text = caps[1].to_owned();
319 let mut scope_text = caps
320 .get(2)
321 .map_or_else(|| "".to_owned(), |_| caps[2].to_owned());
322 let mut rest_text = caps[3].to_owned();
323 let breaking_change = cat_text.ends_with('!')
324 || scope_text.ends_with('!')
325 || cat_text.to_lowercase().as_str() == "breaking change";
326
327 #[allow(clippy::arithmetic)]
328 {
329 if cat_text.ends_with('!') {
331 cat_text.truncate(cat_text.len() - 1);
332 }
333 if scope_text.ends_with('!') {
334 scope_text.truncate(scope_text.len() - 1);
335 }
336
337 if scope_text.len() >= 3 {
338 scope_text = scope_text[1..scope_text.len() - 1].to_owned();
339 }
340 }
341
342 let scope = if scope_text.is_empty() {
343 None
344 } else {
345 Some(scope_text)
346 };
347
348 let category = match cat_text.to_lowercase().as_str() {
349 "archive" => Type::Archive,
350 "build" => Type::Build,
351 "breaking change" | "change" => Type::Change,
352 "chore" => Type::Chore,
353 "ci" => Type::Ci,
354 "deprecate" => Type::Deprecate,
355 "deps" => Type::Deps,
356 "dev" => Type::Dev,
357 "docs" => Type::Docs,
358 "add" | "feat" | "feature" => Type::Feat,
359 "bugfix" | "fix" | "hotfix" => Type::Fix,
360 "security" | "security fix" => Type::Security,
361 "i18n" => Type::I18n,
362 "gi" | "issue" | "done" => Type::Issue,
363 "improvement" => Type::Improvement,
364 "perf" => Type::Perf,
365 "internal" | "refactor" => Type::Refactor,
366 "repo" => Type::Repo,
367 "style" => Type::Style,
368 "test" | "tests" => Type::Test,
369 _ => Type::Other,
370 };
371
372 if category == Type::Other {
373 rest_text = caps[0].to_owned();
374 }
375 if breaking_change {
376 let mut tmp = "! ".to_owned();
377 tmp.push_str(&rest_text);
378 rest_text = tmp;
379 }
380
381 Self::ConventionalCommit {
382 breaking_change,
383 category,
384 scope,
385 description: rest_text,
386 }
387 }
388
389 #[must_use]
391 #[inline]
392 pub fn description(&self) -> &str {
393 match self {
394 Self::ConventionalCommit { description, .. }
395 | Self::Fixup(description)
396 | Self::PullRequest { description, .. }
397 | Self::Release { description, .. }
398 | Self::SubtreeCommit { description, .. }
399 | Self::Remove(description)
400 | Self::Rename(description)
401 | Self::Revert(description)
402 | Self::Simple(description) => description,
403 }
404 }
405
406 #[must_use]
408 #[inline]
409 pub const fn scope(&self) -> Option<&String> {
410 match self {
411 Self::ConventionalCommit { scope, .. } | Self::Release { scope, .. } => scope.as_ref(),
412 Self::SubtreeCommit { operation, .. } => match operation {
413 SubtreeOperation::Import { subtree, .. }
414 | SubtreeOperation::Split { subtree, .. }
415 | SubtreeOperation::Update { subtree, .. } => Some(subtree),
416 },
417 _ => None,
418 }
419 }
420}
421
422#[cfg(test)]
423mod tests {
424 use crate::{Subject, SubtreeOperation, Type};
425
426 #[test]
427 fn archive() {
428 let result = Subject::from("archive: windowmanager");
429 let description = String::from("windowmanager");
430 assert_eq!(
431 result,
432 Subject::ConventionalCommit {
433 breaking_change: false,
434 category: Type::Archive,
435 scope: None,
436 description,
437 },
438 );
439 }
440
441 #[test]
442 fn build() {
443 let result = Subject::from("build(repo): Always use local file-expert");
444 let description = String::from("Always use local file-expert");
445 assert_eq!(
446 result,
447 Subject::ConventionalCommit {
448 breaking_change: false,
449 category: Type::Build,
450 scope: Some("repo".to_owned()),
451 description,
452 },
453 );
454 }
455
456 #[test]
457 fn change() {
458 {
459 let result = Subject::from("change!: Replace strncpy with memcpy");
460 let description = "! Replace strncpy with memcpy".to_owned();
461 assert_eq!(
462 result,
463 Subject::ConventionalCommit {
464 breaking_change: true,
465 category: Type::Change,
466 scope: None,
467 description,
468 },
469 );
470 assert_eq!(result.icon(), "⚠ ");
471 }
472 {
473 let result = Subject::from("change: Replace strncpy with memcpy");
474 let description = "Replace strncpy with memcpy".to_owned();
475 assert_eq!(
476 result,
477 Subject::ConventionalCommit {
478 breaking_change: false,
479 category: Type::Change,
480 scope: None,
481 description: description.clone(),
482 },
483 );
484 assert_eq!(result.description(), description);
485 assert_ne!(result.icon(), "⚠ ");
486 }
487
488 {
489 let result = Subject::from("CHANGE Replace strncpy with memcpy");
490 let description = "Replace strncpy with memcpy".to_owned();
491 assert_eq!(
492 result,
493 Subject::ConventionalCommit {
494 breaking_change: false,
495 category: Type::Change,
496 scope: None,
497 description: description.clone(),
498 },
499 );
500 assert_eq!(result.description(), description);
501 assert_ne!(result.icon(), "⚠ ");
502 }
503 }
504
505 #[test]
506 fn breaking_change() {
507 let result = Subject::from("breaking change: Commits are now namedtupples");
508 let description = "! Commits are now namedtupples".to_owned();
509 assert_eq!(
510 result,
511 Subject::ConventionalCommit {
512 breaking_change: true,
513 category: Type::Change,
514 scope: None,
515 description: description.clone(),
516 },
517 );
518 assert_eq!(result.description(), description);
519 assert_eq!(result.icon(), "⚠ ");
520 }
521
522 #[test]
523 fn ci() {
524 let result = Subject::from("ci(srht): Fedora Rawhide run dist-rpm && qubes-builder");
525 let description = "Fedora Rawhide run dist-rpm && qubes-builder".to_owned();
526 assert_eq!(
527 result,
528 Subject::ConventionalCommit {
529 breaking_change: false,
530 category: Type::Ci,
531 scope: Some("srht".to_owned()),
532 description,
533 },
534 );
535 }
536 #[test]
537 fn deps() {
538 let result = Subject::from("deps: Use thick Xlib bindings");
539 let description = "Use thick Xlib bindings".to_owned();
540 assert_eq!(
541 result,
542 Subject::ConventionalCommit {
543 breaking_change: false,
544 category: Type::Deps,
545 scope: None,
546 description,
547 },
548 );
549 }
550 #[test]
551 fn docs() {
552 let result = Subject::from("docs(readme): add xcb-util-xrm to dependencies' list");
553 let description = "add xcb-util-xrm to dependencies' list".to_owned();
554 assert_eq!(
555 result,
556 Subject::ConventionalCommit {
557 breaking_change: false,
558 category: Type::Docs,
559 scope: Some("readme".to_owned()),
560 description,
561 },
562 );
563 }
564
565 #[test]
566 fn refactor() {
567 let result = Subject::from("internal: Move mismatched arg count diagnostic to inference");
568 let description = String::from("Move mismatched arg count diagnostic to inference");
569 assert_eq!(
570 result,
571 Subject::ConventionalCommit {
572 breaking_change: false,
573 category: Type::Refactor,
574 scope: None,
575 description,
576 },
577 );
578 }
579
580 #[test]
581 fn scope_breaking_change() {
582 let result = Subject::from("fix(search)!: This breaks the api");
583 let description = "! This breaks the api".to_owned();
584 assert_eq!(
585 result,
586 Subject::ConventionalCommit {
587 breaking_change: true,
588 category: Type::Fix,
589 scope: Some("search".to_owned()),
590 description,
591 },
592 );
593 assert_eq!(result.icon(), "⚠ ");
594 }
595
596 #[test]
597 fn update_subtree() {
598 let text = "Update :qubes-builder to 5e5301b8eac";
599 let result = Subject::from(text);
600 assert_eq!(
601 result,
602 Subject::SubtreeCommit {
603 operation: SubtreeOperation::Update {
604 subtree: "qubes-builder".to_owned(),
605 git_ref: "5e5301b8eac".to_owned()
606 },
607 description: text.to_owned()
608 }
609 );
610 }
611
612 #[test]
613 fn split_subtree() {
614 let text = "Split 'rust/' into commit 'baa77665cab9b8b25c7887e021280d8b55e2c9cb'";
615 let result = Subject::from(text);
616 assert_eq!(
617 result,
618 Subject::SubtreeCommit {
619 operation: SubtreeOperation::Split {
620 subtree: "rust".to_owned(),
621 git_ref: "baa77665cab9b8b25c7887e021280d8b55e2c9cb".to_owned()
622 },
623 description: text.to_owned()
624 }
625 );
626 }
627
628 #[test]
629 fn import_subtree() {
630 let text = ":php/composer-monorepo-plugin Import GH:github.com/beberlei/composer-monorepo-plugin⸪master";
631 let result = Subject::from(text);
632 assert_eq!(
633 result,
634 Subject::SubtreeCommit {
635 operation: SubtreeOperation::Import {
636 subtree: "php/composer-monorepo-plugin".to_owned(),
637 git_ref: "master".to_owned()
638 },
639 description: text.to_owned()
640 }
641 );
642 }
643
644 #[test]
645 fn release1() {
646 let text = "Release foo@v2.11.0";
647 let result = Subject::from(text);
648 assert_eq!(
649 result,
650 Subject::Release {
651 version: "2.11.0".to_owned(),
652 scope: Some("foo".to_owned()),
653 description: text.to_owned()
654 }
655 );
656 }
657
658 #[test]
659 fn release2() {
660 {
661 let text = "Release v2.11.0";
662 let result = Subject::from(text);
663 assert_eq!(
664 result,
665 Subject::Release {
666 version: "2.11.0".to_owned(),
667 scope: None,
668 description: text.to_owned()
669 }
670 );
671 }
672
673 {
674 let text = "Release 2.11.0";
675 let result = Subject::from(text);
676 assert_eq!(
677 result,
678 Subject::Release {
679 version: "2.11.0".to_owned(),
680 scope: None,
681 description: text.to_owned()
682 }
683 );
684 }
685 }
686
687 #[test]
688 fn revert() {
689 let text = "Revert two commits breaking watching hotplug-status xenstore node";
690 let result = Subject::from(text);
691 assert_eq!(result, Subject::Revert(text.to_owned()));
692 }
693
694 #[test]
695 fn rename() {
696 let text = "Rename ForkPointCalculation::Needed → InProgress";
697 let result = Subject::from(text);
698 assert_eq!(result, Subject::Rename(text.to_owned()));
699 }
700
701 #[test]
702 fn pr() {
703 let text = "Merge remote-tracking branch 'origin/pr/126'";
704 let result = Subject::from(text);
705 assert_eq!(
706 result,
707 Subject::PullRequest {
708 id: "126".to_owned(),
709 description: text.to_owned()
710 }
711 );
712 }
713
714 #[test]
715 fn pr_bitbucket() {
716 let text = "Merge pull request #7771 in FOO/bar from feature/asdqwert to development";
717 let result = Subject::from(text);
718 assert_eq!(
719 result,
720 Subject::PullRequest {
721 id: "7771".to_owned(),
722 description: text.to_owned()
723 }
724 );
725 }
726
727 #[test]
728 fn pr_azure() {
729 let text = "Merged PR 36587: Add Foo calibration to item type";
730 let result = Subject::from(text);
731 assert_eq!(
732 result,
733 Subject::PullRequest {
734 id: "36587".to_owned(),
735 description: "Add Foo calibration to item type (#36587)".to_owned()
736 }
737 );
738 }
739
740 #[test]
741 fn security() {
742 {
743 let text = "security: Fix CSV-FOO-1234";
744 let result = Subject::from(text);
745 let description = "Fix CSV-FOO-1234".to_owned();
746 assert_eq!(
747 result,
748 Subject::ConventionalCommit {
749 breaking_change: false,
750 category: Type::Security,
751 scope: None,
752 description
753 }
754 );
755 }
756
757 {
758 let text = "security fix: Fix CSV-FOO-1234";
759 let result = Subject::from(text);
760 let description = "Fix CSV-FOO-1234".to_owned();
761 assert_eq!(
762 result,
763 Subject::ConventionalCommit {
764 breaking_change: false,
765 category: Type::Security,
766 scope: None,
767 description
768 }
769 );
770 }
771 }
772
773 #[test]
774 fn other() {
775 let text = "Makefile: replace '-' in plugins_var";
776 let result = Subject::from(text);
777 assert_eq!(
778 result,
779 Subject::ConventionalCommit {
780 breaking_change: false,
781 category: Type::Other,
782 scope: None,
783 description: "Makefile: replace '-' in plugins_var".to_owned()
784 }
785 );
786 }
787
788 #[test]
789 fn deprecate() {
790 {
791 let text = "deprecate: Mark Foo() as deprecated";
792 let result = Subject::from(text);
793 let description = "Mark Foo() as deprecated".to_owned();
794 assert_eq!(
795 result,
796 Subject::ConventionalCommit {
797 breaking_change: false,
798 category: Type::Deprecate,
799 scope: None,
800 description
801 }
802 );
803 }
804 {
805 let text = "Deprecate Foo() use Bar() instead";
806 let result = Subject::from(text);
807 let description = "Deprecate Foo() use Bar() instead".to_owned();
808 assert_eq!(
809 result,
810 Subject::ConventionalCommit {
811 breaking_change: false,
812 category: Type::Deprecate,
813 scope: None,
814 description
815 }
816 );
817 }
818 }
819}