Skip to main content

standard_commit/
lint.rs

1use crate::parse::{self, ConventionalCommit};
2
3/// Configuration for linting conventional commit messages.
4#[derive(Debug, Clone)]
5pub struct LintConfig {
6    /// Allowed commit types. `None` means any lowercase type is accepted.
7    pub types: Option<Vec<String>>,
8    /// Allowed scopes. `None` means any scope is accepted.
9    pub scopes: Option<Vec<String>>,
10    /// Maximum header line length. Default: 100.
11    pub max_header_length: usize,
12    /// Whether a scope is required. Default: false.
13    pub require_scope: bool,
14}
15
16impl Default for LintConfig {
17    fn default() -> Self {
18        Self {
19            types: None,
20            scopes: None,
21            max_header_length: 100,
22            require_scope: false,
23        }
24    }
25}
26
27/// A lint error found in a commit message.
28#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
29#[error("{message}")]
30pub struct LintError {
31    /// Human-readable description of the error.
32    pub message: String,
33}
34
35/// Lint a commit message against the given configuration.
36///
37/// First parses the message, then applies additional rules from the config.
38/// Returns an empty vec if the message is valid.
39pub fn lint(message: &str, config: &LintConfig) -> Vec<LintError> {
40    let mut errors = Vec::new();
41
42    let commit = match parse::parse(message) {
43        Ok(c) => c,
44        Err(e) => {
45            errors.push(LintError {
46                message: e.to_string(),
47            });
48            return errors;
49        }
50    };
51
52    check_header_length(message, config.max_header_length, &mut errors);
53    check_type(&commit, &config.types, &mut errors);
54    check_scope(&commit, &config.scopes, config.require_scope, &mut errors);
55
56    errors
57}
58
59fn check_header_length(message: &str, max: usize, errors: &mut Vec<LintError>) {
60    if let Some(header) = message.lines().next()
61        && header.len() > max
62    {
63        errors.push(LintError {
64            message: format!(
65                "header is {} characters, exceeds maximum of {max}",
66                header.len()
67            ),
68        });
69    }
70}
71
72fn check_type(
73    commit: &ConventionalCommit,
74    types: &Option<Vec<String>>,
75    errors: &mut Vec<LintError>,
76) {
77    if let Some(allowed) = types
78        && !allowed.iter().any(|t| t == &commit.r#type)
79    {
80        errors.push(LintError {
81            message: format!(
82                "type '{}' is not in the allowed list: {}",
83                commit.r#type,
84                allowed.join(", ")
85            ),
86        });
87    }
88}
89
90fn check_scope(
91    commit: &ConventionalCommit,
92    scopes: &Option<Vec<String>>,
93    require_scope: bool,
94    errors: &mut Vec<LintError>,
95) {
96    if require_scope && commit.scope.is_none() {
97        errors.push(LintError {
98            message: "scope is required".to_string(),
99        });
100    }
101
102    if let (Some(allowed), Some(scope)) = (scopes, &commit.scope)
103        && !allowed.iter().any(|s| s == scope)
104    {
105        errors.push(LintError {
106            message: format!(
107                "scope '{scope}' is not in the allowed list: {}",
108                allowed.join(", ")
109            ),
110        });
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn valid_with_default_config() {
120        let errors = lint("feat: add login", &LintConfig::default());
121        assert!(errors.is_empty());
122    }
123
124    #[test]
125    fn invalid_message_returns_parse_error() {
126        let errors = lint("bad message", &LintConfig::default());
127        assert_eq!(errors.len(), 1);
128    }
129
130    #[test]
131    fn header_too_long() {
132        let long_desc = "x".repeat(100);
133        let msg = format!("feat: {long_desc}");
134        let errors = lint(&msg, &LintConfig::default());
135        assert!(errors.iter().any(|e| e.message.contains("exceeds maximum")));
136    }
137
138    #[test]
139    fn type_not_in_allowed_list() {
140        let config = LintConfig {
141            types: Some(vec!["feat".into(), "fix".into()]),
142            ..Default::default()
143        };
144        let errors = lint("docs: update readme", &config);
145        assert!(
146            errors
147                .iter()
148                .any(|e| e.message.contains("not in the allowed list"))
149        );
150    }
151
152    #[test]
153    fn type_in_allowed_list() {
154        let config = LintConfig {
155            types: Some(vec!["feat".into(), "fix".into()]),
156            ..Default::default()
157        };
158        let errors = lint("feat: add feature", &config);
159        assert!(errors.is_empty());
160    }
161
162    #[test]
163    fn scope_required_but_missing() {
164        let config = LintConfig {
165            require_scope: true,
166            ..Default::default()
167        };
168        let errors = lint("feat: add feature", &config);
169        assert!(
170            errors
171                .iter()
172                .any(|e| e.message.contains("scope is required"))
173        );
174    }
175
176    #[test]
177    fn scope_not_in_allowed_list() {
178        let config = LintConfig {
179            scopes: Some(vec!["auth".into(), "api".into()]),
180            ..Default::default()
181        };
182        let errors = lint("feat(unknown): add feature", &config);
183        assert!(
184            errors
185                .iter()
186                .any(|e| e.message.contains("not in the allowed list"))
187        );
188    }
189
190    #[test]
191    fn scope_in_allowed_list() {
192        let config = LintConfig {
193            scopes: Some(vec!["auth".into(), "api".into()]),
194            ..Default::default()
195        };
196        let errors = lint("feat(auth): add feature", &config);
197        assert!(errors.is_empty());
198    }
199}