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, thiserror::Error)]
29#[error("{message}")]
30pub struct LintError {
31 pub message: String,
33}
34
35pub 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}