1use crate::parse::{self, ConventionalCommit};
2
3#[derive(Debug, Clone)]
5pub struct LintConfig {
6 pub types: Option<Vec<String>>,
8 pub scopes: Option<Vec<String>>,
10 pub max_header_length: usize,
12 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#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct LintError {
30 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
40pub 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}