1use regex::Regex;
2use std::sync::OnceLock;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum VerifyLintLevel {
7 Error,
8 Warning,
9}
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct VerifyLintResult {
14 pub level: VerifyLintLevel,
15 pub message: String,
16}
17
18impl VerifyLintResult {
19 fn error(message: impl Into<String>) -> Self {
20 Self {
21 level: VerifyLintLevel::Error,
22 message: message.into(),
23 }
24 }
25
26 fn warning(message: impl Into<String>) -> Self {
27 Self {
28 level: VerifyLintLevel::Warning,
29 message: message.into(),
30 }
31 }
32}
33
34#[must_use]
36pub fn lint_verify(cmd: &str) -> Vec<VerifyLintResult> {
37 let trimmed = cmd.trim();
38 if trimmed.is_empty() {
39 return vec![VerifyLintResult::error(
40 "Verify command is empty. Use a command that can fail when the unit is incomplete.",
41 )];
42 }
43
44 let segments: Vec<&str> = shell_segment_splitter()
45 .split(trimmed)
46 .map(str::trim)
47 .filter(|segment| !segment.is_empty())
48 .collect();
49 let meaningful_segments: Vec<&str> = segments
50 .iter()
51 .copied()
52 .filter(|segment| !is_setup_segment(segment))
53 .collect();
54 let target_segment = meaningful_segments
55 .last()
56 .copied()
57 .or_else(|| segments.last().copied())
58 .unwrap_or(trimmed);
59
60 let mut findings = Vec::new();
61
62 if meaningful_segments.len() <= 1 && is_always_pass_command(target_segment) {
63 findings.push(VerifyLintResult::error(
64 "Verify command always exits successfully (`true`, `echo ...`, or `exit 0`). Replace it with a focused check that can fail.",
65 ));
66 }
67
68 if is_bare_cargo_test(target_segment) {
69 findings.push(VerifyLintResult::warning(
70 "Bare `cargo test` is too broad and may pass without proving this unit. Prefer a focused filter like `cargo test auth::login`.",
71 ));
72 }
73
74 if is_npm_test_without_filter(target_segment) {
75 findings.push(VerifyLintResult::warning(
76 "Bare `npm test` is too broad. Prefer `npm test -- --grep login` or `npm test -- -t login`.",
77 ));
78 }
79
80 if is_pytest_without_filter(target_segment) {
81 findings.push(VerifyLintResult::warning(
82 "Bare `pytest` is too broad. Prefer `pytest -k login` and pair it with a grep existence check.",
83 ));
84 }
85
86 if is_go_test_without_run(target_segment) {
87 findings.push(VerifyLintResult::warning(
88 "Bare `go test` without `-run` is too broad. Prefer `go test ./... -run TestLogin`.",
89 ));
90 }
91
92 if cargo_test_filter_arg(target_segment).is_some() && !contains_grep(trimmed) {
93 findings.push(VerifyLintResult::warning(
94 "`cargo test <filter>` exits 0 when the filter matches no tests. Prepend an existence check like `grep -rq '<filter>' tests && cargo test <filter>`.",
95 ));
96 }
97
98 if has_pytest_k_filter(target_segment) && !contains_grep(trimmed) {
99 findings.push(VerifyLintResult::warning(
100 "`pytest -k <filter>` exits 0 when the filter matches no tests. Prepend an existence check like `grep -rq 'test_login' tests && pytest -k login`.",
101 ));
102 }
103
104 if is_existence_only_check(trimmed) {
105 findings.push(VerifyLintResult::warning(
106 "A file existence check does not verify correctness. Chain it with a real assertion, for example `test -f file && grep -q 'expected text' file`.",
107 ));
108 }
109
110 findings
111}
112
113fn shell_segment_splitter() -> &'static Regex {
114 static SPLITTER: OnceLock<Regex> = OnceLock::new();
115 SPLITTER
116 .get_or_init(|| Regex::new(r"\s*(?:&&|\|\||;|\n)\s*").expect("valid shell splitter regex"))
117}
118
119fn is_setup_segment(segment: &str) -> bool {
120 let trimmed = segment.trim();
121 trimmed == "cd" || trimmed.starts_with("cd ")
122}
123
124fn is_always_pass_command(segment: &str) -> bool {
125 let trimmed = segment.trim();
126 trimmed == "true" || trimmed == "exit 0" || trimmed == "echo" || trimmed.starts_with("echo ")
127}
128
129fn tokens(segment: &str) -> Vec<&str> {
130 segment.split_whitespace().collect()
131}
132
133fn is_bare_cargo_test(segment: &str) -> bool {
134 matches!(tokens(segment).as_slice(), ["cargo", "test"])
135}
136
137fn is_npm_test_without_filter(segment: &str) -> bool {
138 let tokens = tokens(segment);
139 tokens.len() >= 2
140 && tokens[0] == "npm"
141 && tokens[1] == "test"
142 && !segment.contains("-- --grep")
143 && !segment.contains("-- -t")
144}
145
146fn is_pytest_without_filter(segment: &str) -> bool {
147 let tokens = tokens(segment);
148 tokens.first() == Some(&"pytest") && !has_pytest_k_filter(segment)
149}
150
151fn is_go_test_without_run(segment: &str) -> bool {
152 let tokens = tokens(segment);
153 tokens.len() >= 2
154 && tokens[0] == "go"
155 && tokens[1] == "test"
156 && !tokens
157 .iter()
158 .any(|token| *token == "-run" || token.starts_with("-run="))
159}
160
161fn cargo_test_filter_arg(segment: &str) -> Option<&str> {
162 let tokens = tokens(segment);
163 if tokens.first() != Some(&"cargo") || tokens.get(1) != Some(&"test") {
164 return None;
165 }
166
167 let mut skip_next = false;
168 for token in tokens.iter().skip(2) {
169 if skip_next {
170 skip_next = false;
171 continue;
172 }
173
174 if *token == "--" {
175 break;
176 }
177
178 if takes_cargo_test_value(token) {
179 skip_next = true;
180 continue;
181 }
182
183 if has_inline_cargo_test_value(token) || token.starts_with('-') {
184 continue;
185 }
186
187 return Some(token);
188 }
189
190 None
191}
192
193fn takes_cargo_test_value(token: &str) -> bool {
194 matches!(
195 token,
196 "-p" | "--package"
197 | "--manifest-path"
198 | "--message-format"
199 | "--target"
200 | "--target-dir"
201 | "--color"
202 | "-j"
203 | "--jobs"
204 | "--profile"
205 | "--config"
206 | "--test"
207 | "--bench"
208 | "--example"
209 | "--bin"
210 | "--features"
211 )
212}
213
214fn has_inline_cargo_test_value(token: &str) -> bool {
215 [
216 "--package=",
217 "--manifest-path=",
218 "--message-format=",
219 "--target=",
220 "--target-dir=",
221 "--color=",
222 "--jobs=",
223 "--profile=",
224 "--config=",
225 "--test=",
226 "--bench=",
227 "--example=",
228 "--bin=",
229 "--features=",
230 ]
231 .iter()
232 .any(|prefix| token.starts_with(prefix))
233}
234
235fn has_pytest_k_filter(segment: &str) -> bool {
236 let tokens = tokens(segment);
237 tokens.windows(2).any(|window| matches!(window, ["-k", _]))
238 || tokens.iter().any(|token| token.starts_with("-k="))
239}
240
241fn contains_grep(command: &str) -> bool {
242 command.split_whitespace().any(|token| token == "grep")
243}
244
245fn is_existence_only_check(command: &str) -> bool {
246 let tokens = tokens(command);
247 matches!(tokens.as_slice(), ["test", "-f", _] | ["[", "-f", _, "]"])
248}
249
250#[cfg(test)]
251mod tests {
252 use super::{lint_verify, VerifyLintLevel};
253
254 #[test]
255 fn verify_lint_rejects_empty_commands() {
256 let findings = lint_verify(" ");
257 assert_eq!(findings.len(), 1);
258 assert_eq!(findings[0].level, VerifyLintLevel::Error);
259 }
260
261 #[test]
262 fn verify_lint_rejects_always_pass_commands() {
263 let findings = lint_verify("cd mana && exit 0");
264 assert!(findings.iter().any(|finding| {
265 finding.level == VerifyLintLevel::Error && finding.message.contains("always exits")
266 }));
267 }
268
269 #[test]
270 fn verify_lint_warns_on_bare_test_runners() {
271 let cargo = lint_verify("cargo test");
272 assert!(cargo.iter().any(|finding| {
273 finding.level == VerifyLintLevel::Warning && finding.message.contains("cargo test")
274 }));
275
276 let pytest = lint_verify("pytest");
277 assert!(pytest.iter().any(|finding| {
278 finding.level == VerifyLintLevel::Warning && finding.message.contains("pytest")
279 }));
280
281 let go = lint_verify("go test ./...");
282 assert!(go.iter().any(|finding| {
283 finding.level == VerifyLintLevel::Warning && finding.message.contains("go test")
284 }));
285 }
286
287 #[test]
288 fn verify_lint_warns_on_filtered_commands_without_grep() {
289 let cargo = lint_verify("cargo test create::tests::lint");
290 assert!(cargo.iter().any(|finding| {
291 finding.level == VerifyLintLevel::Warning
292 && finding.message.contains("matches no tests")
293 }));
294
295 let pytest = lint_verify("pytest -k login");
296 assert!(pytest.iter().any(|finding| {
297 finding.level == VerifyLintLevel::Warning
298 && finding.message.contains("matches no tests")
299 }));
300 }
301
302 #[test]
303 fn verify_lint_accepts_filtered_commands_with_grep_guard() {
304 let findings = lint_verify("grep -rq 'test_login' tests && pytest -k login");
305 assert!(findings.is_empty());
306 }
307
308 #[test]
309 fn verify_lint_warns_on_existence_only_checks() {
310 let findings = lint_verify("test -f README.md");
311 assert!(findings.iter().any(|finding| {
312 finding.level == VerifyLintLevel::Warning && finding.message.contains("existence check")
313 }));
314 }
315}