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