Skip to main content

git_claw/core/
conventional.rs

1//! Conventional Commits parser and SemVer bump calculator.
2
3use crate::core::semver::BumpType;
4
5/// Standard Conventional Commit types recognized by the parser.
6pub const STANDARD_TYPES: &[&str] = &[
7    "feat", "fix", "perf", "refactor", "chore", "docs", "style", "test", "build", "ci",
8];
9
10/// Parsed Conventional Commit record.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct ConventionalCommit {
13    /// Commit type (e.g., "feat", "fix", "chore").
14    pub commit_type: String,
15    /// Optional scope qualifier (e.g., Some("auth") for `feat(auth): ...`).
16    pub scope: Option<String>,
17    /// Whether the commit introduces a breaking change.
18    pub is_breaking: bool,
19    /// Summary description from the subject line.
20    pub description: String,
21}
22
23/// Parses a commit message string into a [`ConventionalCommit`].
24///
25/// Recognizes standard types (`feat`, `fix`, `perf`, `refactor`, `chore`, `docs`,
26/// `style`, `test`, `build`, `ci`) and optional scopes. A breaking change is detected
27/// if the type/scope header ends with `!` or if `BREAKING CHANGE:` / `BREAKING-CHANGE:`
28/// appears in the commit body or footers.
29///
30/// Returns `None` if the commit does not adhere to the Conventional Commits specification.
31///
32/// # Arguments
33///
34/// * `message` - Full git commit message (subject and optional body/footers).
35///
36/// # Example
37///
38/// ```
39/// use git_claw::core::conventional::parse_conventional_commit;
40///
41/// let commit = parse_conventional_commit("feat(auth)!: add OAuth2 provider").unwrap();
42/// assert_eq!(commit.commit_type, "feat");
43/// assert_eq!(commit.scope, Some("auth".to_string()));
44/// assert!(commit.is_breaking);
45/// assert_eq!(commit.description, "add OAuth2 provider");
46/// ```
47pub fn parse_conventional_commit(message: &str) -> Option<ConventionalCommit> {
48    let first_line = message.lines().next()?.trim();
49    let (header_prefix, description) = first_line.split_once(": ")?;
50    let description = description.trim();
51    if description.is_empty() {
52        return None;
53    }
54
55    let (prefix_without_bang, bang) = if let Some(stripped) = header_prefix.strip_suffix('!') {
56        (stripped, true)
57    } else {
58        (header_prefix, false)
59    };
60
61    let (commit_type, scope) = if let Some((t, rest)) = prefix_without_bang.split_once('(') {
62        let scope_val = rest.strip_suffix(')')?;
63        (t.trim(), Some(scope_val.trim().to_string()))
64    } else {
65        (prefix_without_bang.trim(), None)
66    };
67
68    if !STANDARD_TYPES.contains(&commit_type) {
69        return None;
70    }
71
72    let is_breaking = bang
73        || message.contains("BREAKING CHANGE:")
74        || message.contains("BREAKING-CHANGE:")
75        || message.contains("BREAKING CHANGE ");
76
77    Some(ConventionalCommit {
78        commit_type: commit_type.to_string(),
79        scope,
80        is_breaking,
81        description: description.to_string(),
82    })
83}
84
85/// Determines the SemVer [`BumpType`] from a list of conventional commits.
86///
87/// Hierarchy:
88/// 1. If any commit is breaking -> [`BumpType::Major`]
89/// 2. Else if any commit has type "feat" -> [`BumpType::Minor`]
90/// 3. Otherwise -> [`BumpType::Patch`]
91///
92/// # Arguments
93///
94/// * `commits` - Slice of parsed conventional commits.
95///
96/// # Example
97///
98/// ```
99/// use git_claw::core::conventional::{determine_bump, ConventionalCommit};
100/// use git_claw::core::semver::BumpType;
101///
102/// let commits = vec![
103///     ConventionalCommit {
104///         commit_type: "fix".to_string(),
105///         scope: None,
106///         is_breaking: false,
107///         description: "fix bug".to_string(),
108///     },
109/// ];
110/// assert_eq!(determine_bump(&commits), BumpType::Patch);
111/// ```
112pub fn determine_bump(commits: &[ConventionalCommit]) -> BumpType {
113    if commits.iter().any(|c| c.is_breaking) {
114        BumpType::Major
115    } else if commits.iter().any(|c| c.commit_type == "feat") {
116        BumpType::Minor
117    } else {
118        BumpType::Patch
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn test_parse_simple_feat() {
128        let commit = parse_conventional_commit("feat: add search endpoint").unwrap();
129        assert_eq!(commit.commit_type, "feat");
130        assert_eq!(commit.scope, None);
131        assert!(!commit.is_breaking);
132        assert_eq!(commit.description, "add search endpoint");
133    }
134
135    #[test]
136    fn test_parse_scoped_fix() {
137        let commit = parse_conventional_commit("fix(router): resolve redirect loop").unwrap();
138        assert_eq!(commit.commit_type, "fix");
139        assert_eq!(commit.scope, Some("router".to_string()));
140        assert!(!commit.is_breaking);
141        assert_eq!(commit.description, "resolve redirect loop");
142    }
143
144    #[test]
145    fn test_parse_breaking_with_bang() {
146        let commit = parse_conventional_commit("feat!: drop v1 API support").unwrap();
147        assert_eq!(commit.commit_type, "feat");
148        assert_eq!(commit.scope, None);
149        assert!(commit.is_breaking);
150        assert_eq!(commit.description, "drop v1 API support");
151
152        let commit_scoped = parse_conventional_commit("refactor(db)!: change schema").unwrap();
153        assert_eq!(commit_scoped.commit_type, "refactor");
154        assert_eq!(commit_scoped.scope, Some("db".to_string()));
155        assert!(commit_scoped.is_breaking);
156    }
157
158    #[test]
159    fn test_parse_breaking_in_body() {
160        let msg = "fix: update auth flow\n\nBREAKING CHANGE: credentials format changed";
161        let commit = parse_conventional_commit(msg).unwrap();
162        assert_eq!(commit.commit_type, "fix");
163        assert!(commit.is_breaking);
164
165        let msg_hyphen = "chore: update deps\n\nBREAKING-CHANGE: requires node 20";
166        let commit_hyphen = parse_conventional_commit(msg_hyphen).unwrap();
167        assert!(commit_hyphen.is_breaking);
168    }
169
170    #[test]
171    fn test_parse_non_conventional() {
172        assert!(parse_conventional_commit("Initial commit").is_none());
173        assert!(parse_conventional_commit("Merge branch 'main'").is_none());
174        assert!(parse_conventional_commit("customtype: something").is_none());
175        assert!(parse_conventional_commit("feat: ").is_none());
176    }
177
178    #[test]
179    fn test_determine_bump() {
180        let fix = ConventionalCommit {
181            commit_type: "fix".to_string(),
182            scope: None,
183            is_breaking: false,
184            description: "bug fix".to_string(),
185        };
186        let feat = ConventionalCommit {
187            commit_type: "feat".to_string(),
188            scope: None,
189            is_breaking: false,
190            description: "new feature".to_string(),
191        };
192        let breaking = ConventionalCommit {
193            commit_type: "fix".to_string(),
194            scope: None,
195            is_breaking: true,
196            description: "breaking fix".to_string(),
197        };
198
199        assert_eq!(determine_bump(&[]), BumpType::Patch);
200        assert_eq!(determine_bump(std::slice::from_ref(&fix)), BumpType::Patch);
201        assert_eq!(
202            determine_bump(&[fix.clone(), feat.clone()]),
203            BumpType::Minor
204        );
205        assert_eq!(determine_bump(&[fix, feat, breaking]), BumpType::Major);
206    }
207}