1use super::DriftAnalyzer;
2use crate::context::ProjectContext;
3use crate::domain::{Divergence, Location, RuleId, Severity};
4use crate::error::SpecDriftError;
5use std::path::Path;
6
7#[derive(Default)]
16pub struct TestsAnalyzer;
17
18impl DriftAnalyzer for TestsAnalyzer {
19 fn analyze(&self, ctx: &ProjectContext) -> Vec<Divergence> {
20 let mut out = Vec::new();
21 for rs in &ctx.rust_files {
22 match scan_file(rs) {
23 Ok(mut divs) => out.append(&mut divs),
24 Err(e) => eprintln!("spec-drift: skipping {} in tests pillar: {e}", rs.display()),
25 }
26 }
27 out
28 }
29}
30
31fn scan_file(path: &Path) -> Result<Vec<Divergence>, SpecDriftError> {
32 let source = std::fs::read_to_string(path).map_err(|e| SpecDriftError::Io {
33 path: path.to_path_buf(),
34 source: e,
35 })?;
36 let file = syn::parse_file(&source).map_err(|e| SpecDriftError::RustParse {
37 path: path.to_path_buf(),
38 source: e,
39 })?;
40
41 let mut out = Vec::new();
42 visit_items(&file.items, path, &mut out);
43 Ok(out)
44}
45
46fn visit_items(items: &[syn::Item], path: &Path, out: &mut Vec<Divergence>) {
47 for item in items {
48 match item {
49 syn::Item::Fn(f) if is_test_fn(&f.attrs) => inspect_test(f, path, out),
50 syn::Item::Mod(m) => {
51 if let Some((_, inner)) = &m.content {
52 visit_items(inner, path, out);
53 }
54 }
55 _ => {}
56 }
57 }
58}
59
60fn is_test_fn(attrs: &[syn::Attribute]) -> bool {
61 attrs.iter().any(|a| {
62 let p = a.path();
63 p.is_ident("test") || p.segments.last().is_some_and(|s| s.ident == "test")
64 })
65}
66
67fn inspect_test(f: &syn::ItemFn, path: &Path, out: &mut Vec<Divergence>) {
68 let name = f.sig.ident.to_string();
69 if !intent_is_negative(&name) {
70 return;
71 }
72
73 let (count, has_negative) = scan_assertions(&f.block.stmts);
74
75 if count == 0 || !has_negative {
79 let line = f.sig.ident.span().start().line as u32;
80 let reason = if count == 0 {
81 "has no assertions"
82 } else {
83 "only contains positive assertions"
84 };
85 out.push(Divergence {
86 rule: RuleId::LyingTest,
87 severity: Severity::Critical,
88 location: Location::new(path, line),
89 stated: format!("`{name}` verifies a negative / failure path"),
90 reality: format!("`{name}` {reason}"),
91 risk: "The test is green but proves nothing about the stated contract.".to_string(),
92 attribution: None,
93 });
94 }
95}
96
97fn intent_is_negative(name: &str) -> bool {
98 let n = name.to_ascii_lowercase();
99
100 const DETECTION_PREFIXES: &[&str] = &[
105 "flags_",
106 "detects_",
107 "finds_",
108 "reports_",
109 "surfaces_",
110 "identifies_",
111 "catches_",
112 "warns_",
113 "does_not_",
114 "doesnt_",
115 ];
116 if DETECTION_PREFIXES.iter().any(|p| n.starts_with(p)) {
117 return false;
118 }
119
120 const NEGATIVE_HINTS: &[&str] = &[
124 "cannot",
125 "rejects",
126 "forbidden",
127 "unauthorized",
128 "denied",
129 "returns_error",
130 "returns_err",
131 "fails",
132 "invalid",
133 "not_allowed",
134 "panics",
135 ];
136 NEGATIVE_HINTS.iter().any(|h| n.contains(h))
137}
138
139fn scan_assertions(stmts: &[syn::Stmt]) -> (usize, bool) {
140 let mut count = 0usize;
141 let mut has_neg = false;
142 for stmt in stmts {
143 walk_stmt(stmt, &mut count, &mut has_neg);
144 }
145 (count, has_neg)
146}
147
148fn walk_stmt(stmt: &syn::Stmt, count: &mut usize, has_neg: &mut bool) {
149 match stmt {
150 syn::Stmt::Expr(e, _) => walk_expr(e, count, has_neg),
151 syn::Stmt::Local(local) => {
152 if let Some(init) = &local.init {
153 walk_expr(&init.expr, count, has_neg);
154 }
155 }
156 syn::Stmt::Macro(m) => {
157 handle_macro(&m.mac, count, has_neg);
158 }
159 _ => {}
160 }
161}
162
163fn walk_expr(expr: &syn::Expr, count: &mut usize, has_neg: &mut bool) {
164 match expr {
165 syn::Expr::Macro(m) => handle_macro(&m.mac, count, has_neg),
166 syn::Expr::Block(b) => {
167 for s in &b.block.stmts {
168 walk_stmt(s, count, has_neg);
169 }
170 }
171 syn::Expr::If(i) => {
172 for s in &i.then_branch.stmts {
173 walk_stmt(s, count, has_neg);
174 }
175 if let Some((_, else_expr)) = &i.else_branch {
176 walk_expr(else_expr, count, has_neg);
177 }
178 }
179 syn::Expr::Match(m) => {
180 for arm in &m.arms {
181 walk_expr(&arm.body, count, has_neg);
182 }
183 }
184 _ => {}
185 }
186}
187
188fn handle_macro(mac: &syn::Macro, count: &mut usize, has_neg: &mut bool) {
189 let name = mac.path.segments.last().map(|s| s.ident.to_string());
190 let Some(name) = name else { return };
191
192 let is_assertion = matches!(
193 name.as_str(),
194 "assert"
195 | "assert_eq"
196 | "assert_ne"
197 | "debug_assert"
198 | "debug_assert_eq"
199 | "debug_assert_ne"
200 | "panic"
201 );
202 if !is_assertion {
203 return;
204 }
205
206 *count += 1;
207 let tokens = mac.tokens.to_string();
208 if assertion_looks_negative(&name, &tokens) {
209 *has_neg = true;
210 }
211}
212
213fn assertion_looks_negative(name: &str, tokens: &str) -> bool {
214 if name == "assert_ne" {
215 return true;
216 }
217 if name == "panic" {
218 return true;
219 }
220 let t: String = tokens
225 .chars()
226 .filter(|c| !c.is_whitespace())
227 .collect::<String>()
228 .to_ascii_lowercase();
229 t.starts_with('!')
230 || t.contains(".is_err")
231 || t.contains(".is_none")
232 || t.contains("matches!(")
233 || t.contains("err(")
234 || t.contains(".is_not_")
235 || t.contains("403")
236 || t.contains("401")
237 || t.contains("404")
238}
239
240#[cfg(test)]
241mod tests_inner {
242 use super::*;
243
244 fn run_on_source(src: &str) -> Vec<Divergence> {
245 let tmp = tempfile::tempdir().unwrap();
246 let path = tmp.path().join("lib.rs");
247 std::fs::write(&path, src).unwrap();
248
249 let mut ctx = ProjectContext::new(tmp.path());
250 ctx.rust_files.push(path);
251 TestsAnalyzer.analyze(&ctx)
252 }
253
254 #[test]
255 fn flags_negative_name_with_only_positive_assertion() {
256 let src = r#"
257 #[test]
258 fn user_cannot_access_admin_panel() {
259 assert!(true);
260 }
261 "#;
262 let divs = run_on_source(src);
263 assert_eq!(divs.len(), 1);
264 assert_eq!(divs[0].rule, RuleId::LyingTest);
265 }
266
267 #[test]
268 fn flags_negative_name_with_no_assertions() {
269 let src = r#"
270 #[test]
271 fn rejects_invalid_payload() {
272 let _ = 1;
273 }
274 "#;
275 let divs = run_on_source(src);
276 assert_eq!(divs.len(), 1);
277 }
278
279 #[test]
280 fn passes_negative_name_with_is_err_assertion() {
281 let src = r#"
282 #[test]
283 fn rejects_invalid_payload() {
284 let res: Result<(), ()> = Err(());
285 assert!(res.is_err());
286 }
287 "#;
288 let divs = run_on_source(src);
289 assert!(divs.is_empty());
290 }
291
292 #[test]
293 fn ignores_positive_tests() {
294 let src = r#"
295 #[test]
296 fn builds_a_widget() {
297 assert!(true);
298 }
299 "#;
300 let divs = run_on_source(src);
301 assert!(divs.is_empty());
302 }
303
304 #[test]
305 fn exempts_detection_prefixed_test_names() {
306 let src = r#"
309 #[test]
310 fn flags_missing_symbol_in_markdown() {
311 let found = 1;
312 assert_eq!(found, 1);
313 }
314 "#;
315 let divs = run_on_source(src);
316 assert!(divs.is_empty());
317 }
318
319 #[test]
320 fn passes_negative_name_with_status_code_check() {
321 let src = r#"
322 #[test]
323 fn user_cannot_access_admin_panel() {
324 assert_eq!(status, 403);
325 }
326 "#;
327 let divs = run_on_source(src);
328 assert!(divs.is_empty());
329 }
330}