subject_classifier/
lib.rs

1// Copyright (c) 2022 Bahtiar `kalkin` Gadimov <bahtiar@gadimov.de>
2//
3// This file is part of subject-classifier.
4//
5// This program is free software: you can redistribute it and/or modify
6// it under the terms of the GNU Lesser General Public License as published by
7// the Free Software Foundation, either version 3 of the License, or
8// (at your option) any later version.
9//
10// This program is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13// GNU Lesser General Public License for more details.
14//
15// You should have received a copy of the GNU Lesser General Public License
16// along with this program. If not, see <http://www.gnu.org/licenses/>.
17
18//! Library for classifying a commit by it's subject. Tries hard to recognize the subject type
19//! according to the commit message. Supports [Conventional Commits Standard v1.0.0](https://www.conventionalcommits.org/en/v1.0.0)
20//!
21//! ```rust
22//! use subject_classifier::Subject;
23//!
24//! let subject = subject_classifier::Subject::from("feat(Stuff): Add a new feature XYZ");
25//! println!("Icon: {}, scope {:?}, msg: {}",
26//!         subject.icon(),
27//!         subject.scope(),
28//!         subject.description());
29//! ```
30use 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);
55// https://github.com/apps/bors
56regex!(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/// Represents different subtree operations encoded in the commit message.
75#[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/// The type of the commit
84#[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/// Classified subject
110///
111/// ```rust
112/// use subject_classifier::Subject;
113///
114/// let subject = Subject::from("feat: Some new feature");
115/// ```
116#[derive(Debug, Eq, PartialEq, Clone)]
117pub enum Subject {
118    /// Conventaion Commit following the specification
119    #[allow(missing_docs)]
120    ConventionalCommit {
121        breaking_change: bool,
122        category: Type,
123        scope: Option<String>,
124        description: String,
125    },
126    /// Git fixup commit
127    Fixup(String),
128    /// A merged pull request
129    #[allow(missing_docs)]
130    PullRequest { id: String, description: String },
131    /// Commit releasing something
132    #[allow(missing_docs)]
133    Release {
134        version: String,
135        scope: Option<String>,
136        description: String,
137    },
138    /// Something removed
139    Remove(String),
140    /// Something renamed
141    Rename(String),
142    /// Commit created by `git-revert`
143    Revert(String),
144
145    /// A commit modifying a subtree tracked by`git-stree`.
146    #[allow(missing_docs)]
147    SubtreeCommit {
148        operation: SubtreeOperation,
149        description: String,
150    },
151    /// Just some commit
152    Simple(String),
153}
154//
155impl 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    /// Return a unicode character representing the subject
250    #[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}", // unicode construction sign
268                        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}", // unicode wrapped present
274                        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            // If we are here then something went completly wrong.
308            // to minimize the damage just return a `Subject::Simple`
309            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            // arithmetic: if conditions guard the arithmetic
330            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    /// Manipulated commit subject
390    #[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    /// Returns the scope defined by e.g. Conventional Commit
407    #[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}