1use std::sync::OnceLock;
2
3use exspec_core::extractor::{FileAnalysis, LanguageExtractor, TestAnalysis, TestFunction};
4use exspec_core::query_utils::{
5 collect_mock_class_names, count_captures, count_captures_within_context,
6 count_duplicate_literals, extract_suppression_from_previous_line, has_any_match,
7};
8use streaming_iterator::StreamingIterator;
9use tree_sitter::{Node, Parser, Query, QueryCursor};
10
11const TEST_FUNCTION_QUERY: &str = include_str!("../queries/test_function.scm");
12const ASSERTION_QUERY: &str = include_str!("../queries/assertion.scm");
13const MOCK_USAGE_QUERY: &str = include_str!("../queries/mock_usage.scm");
14const MOCK_ASSIGNMENT_QUERY: &str = include_str!("../queries/mock_assignment.scm");
15const PARAMETERIZED_QUERY: &str = include_str!("../queries/parameterized.scm");
16const IMPORT_PBT_QUERY: &str = include_str!("../queries/import_pbt.scm");
17const IMPORT_CONTRACT_QUERY: &str = include_str!("../queries/import_contract.scm");
18const HOW_NOT_WHAT_QUERY: &str = include_str!("../queries/how_not_what.scm");
19const PRIVATE_IN_ASSERTION_QUERY: &str = include_str!("../queries/private_in_assertion.scm");
20const ERROR_TEST_QUERY: &str = include_str!("../queries/error_test.scm");
21const RELATIONAL_ASSERTION_QUERY: &str = include_str!("../queries/relational_assertion.scm");
22const WAIT_AND_SEE_QUERY: &str = include_str!("../queries/wait_and_see.scm");
23
24fn rust_language() -> tree_sitter::Language {
25 tree_sitter_rust::LANGUAGE.into()
26}
27
28fn cached_query<'a>(lock: &'a OnceLock<Query>, source: &str) -> &'a Query {
29 lock.get_or_init(|| Query::new(&rust_language(), source).expect("invalid query"))
30}
31
32static TEST_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
33static ASSERTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
34static MOCK_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
35static MOCK_ASSIGN_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
36static PARAMETERIZED_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
37static IMPORT_PBT_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
38static IMPORT_CONTRACT_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
39static HOW_NOT_WHAT_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
40static PRIVATE_IN_ASSERTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
41static ERROR_TEST_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
42static RELATIONAL_ASSERTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
43static WAIT_AND_SEE_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
44
45pub struct RustExtractor;
46
47impl RustExtractor {
48 pub fn new() -> Self {
49 Self
50 }
51
52 pub fn parser() -> Parser {
53 let mut parser = Parser::new();
54 let language = tree_sitter_rust::LANGUAGE;
55 parser
56 .set_language(&language.into())
57 .expect("failed to load Rust grammar");
58 parser
59 }
60}
61
62impl Default for RustExtractor {
63 fn default() -> Self {
64 Self::new()
65 }
66}
67
68fn extract_mock_class_name(var_name: &str) -> String {
69 if let Some(stripped) = var_name.strip_prefix("mock_") {
71 if !stripped.is_empty() {
72 return stripped.to_string();
73 }
74 }
75 if let Some(stripped) = var_name.strip_prefix("mock") {
77 if !stripped.is_empty() && stripped.starts_with(|c: char| c.is_uppercase()) {
78 return stripped.to_string();
79 }
80 }
81 var_name.to_string()
82}
83
84struct TestMatch {
85 name: String,
86 fn_start_byte: usize,
87 fn_end_byte: usize,
88 fn_start_row: usize,
89 fn_end_row: usize,
90 attr_start_row: usize,
92 has_should_panic: bool,
94}
95
96fn is_constructor_call(node: Node) -> bool {
104 let func = match node.child_by_field_name("function") {
105 Some(f) => f,
106 None => return true, };
108 match func.kind() {
109 "scoped_identifier" => true,
111 "identifier" => true,
113 "field_expression" => {
115 let value = match func.child_by_field_name("value") {
116 Some(v) => v,
117 None => return true,
118 };
119 if value.kind() == "call_expression" {
120 is_constructor_call(value)
122 } else {
123 false
125 }
126 }
127 _ => true,
128 }
129}
130
131fn is_fixture_value(node: Node) -> bool {
136 match node.kind() {
137 "call_expression" => is_constructor_call(node),
138 "struct_expression" | "macro_invocation" => true,
139 _ => true, }
141}
142
143fn count_assertion_messages_rust(assertion_query: &Query, fn_node: Node, source: &[u8]) -> usize {
147 let assertion_idx = match assertion_query.capture_index_for_name("assertion") {
148 Some(idx) => idx,
149 None => return 0,
150 };
151 let mut cursor = QueryCursor::new();
152 let mut matches = cursor.matches(assertion_query, fn_node, source);
153 let mut count = 0;
154 while let Some(m) = matches.next() {
155 for cap in m.captures.iter().filter(|c| c.index == assertion_idx) {
156 let node = cap.node;
157 let macro_name = node
158 .child_by_field_name("macro")
159 .and_then(|n| n.utf8_text(source).ok())
160 .unwrap_or("");
161
162 let token_tree = (0..node.child_count()).find_map(|i| {
164 let child = node.child(i)?;
165 if child.kind() == "token_tree" {
166 Some(child)
167 } else {
168 None
169 }
170 });
171
172 if let Some(tt) = token_tree {
173 let mut comma_count = 0;
178 for i in 0..tt.child_count() {
179 if let Some(child) = tt.child(i) {
180 if child.kind() == "," {
181 comma_count += 1;
182 }
183 }
184 }
185
186 let min_commas = if macro_name.contains("_eq") || macro_name.contains("_ne") {
189 2
190 } else {
191 1
192 };
193 if comma_count >= min_commas {
194 count += 1;
195 }
196 }
197 }
198 }
199 count
200}
201
202fn count_fixture_lets(fn_node: Node) -> usize {
205 let body = match fn_node.child_by_field_name("body") {
206 Some(n) => n,
207 None => return 0,
208 };
209
210 let mut count = 0;
211 let mut cursor = body.walk();
212 if cursor.goto_first_child() {
213 loop {
214 let node = cursor.node();
215 if node.kind() == "let_declaration" {
216 match node.child_by_field_name("value") {
217 Some(value) => {
218 if is_fixture_value(value) {
219 count += 1;
220 }
221 }
222 None => count += 1, }
224 }
225 if !cursor.goto_next_sibling() {
226 break;
227 }
228 }
229 }
230 count
231}
232
233fn extract_functions_from_tree(source: &str, file_path: &str, root: Node) -> Vec<TestFunction> {
234 let test_query = cached_query(&TEST_QUERY_CACHE, TEST_FUNCTION_QUERY);
235 let assertion_query = cached_query(&ASSERTION_QUERY_CACHE, ASSERTION_QUERY);
236 let mock_query = cached_query(&MOCK_QUERY_CACHE, MOCK_USAGE_QUERY);
237 let mock_assign_query = cached_query(&MOCK_ASSIGN_QUERY_CACHE, MOCK_ASSIGNMENT_QUERY);
238 let how_not_what_query = cached_query(&HOW_NOT_WHAT_QUERY_CACHE, HOW_NOT_WHAT_QUERY);
239 let private_query = cached_query(
240 &PRIVATE_IN_ASSERTION_QUERY_CACHE,
241 PRIVATE_IN_ASSERTION_QUERY,
242 );
243 let wait_query = cached_query(&WAIT_AND_SEE_QUERY_CACHE, WAIT_AND_SEE_QUERY);
244
245 let source_bytes = source.as_bytes();
246
247 let attr_idx = test_query
250 .capture_index_for_name("test_attr")
251 .expect("no @test_attr capture");
252
253 let mut test_matches: Vec<TestMatch> = Vec::new();
254 let mut seen_fn_bytes: std::collections::HashSet<usize> = std::collections::HashSet::new();
255
256 {
257 let mut cursor = QueryCursor::new();
258 let mut matches = cursor.matches(test_query, root, source_bytes);
259 while let Some(m) = matches.next() {
260 let attr_capture = match m.captures.iter().find(|c| c.index == attr_idx) {
261 Some(c) => c,
262 None => continue,
263 };
264 let attr_node = attr_capture.node;
265 let attr_start_row = attr_node.start_position().row;
266
267 let mut has_should_panic = false;
270 let mut attr_start_row = attr_start_row;
271 {
272 let mut prev = attr_node.prev_sibling();
273 while let Some(p) = prev {
274 if p.kind() == "attribute_item" {
275 attr_start_row = p.start_position().row;
276 if let Ok(text) = p.utf8_text(source_bytes) {
277 if text.contains("should_panic") {
278 has_should_panic = true;
279 }
280 }
281 } else if p.kind() != "line_comment" && p.kind() != "block_comment" {
282 break;
283 }
284 prev = p.prev_sibling();
285 }
286 }
287
288 let mut sibling = attr_node.next_sibling();
291 while let Some(s) = sibling {
292 if s.kind() == "function_item" {
293 let fn_start_byte = s.start_byte();
294 if seen_fn_bytes.insert(fn_start_byte) {
295 let name = s
296 .child_by_field_name("name")
297 .and_then(|n| n.utf8_text(source_bytes).ok())
298 .unwrap_or("")
299 .to_string();
300 if !name.is_empty() {
301 test_matches.push(TestMatch {
302 name,
303 fn_start_byte,
304 fn_end_byte: s.end_byte(),
305 fn_start_row: s.start_position().row,
306 fn_end_row: s.end_position().row,
307 attr_start_row,
308 has_should_panic,
309 });
310 }
311 }
312 break;
313 }
314 if s.kind() == "attribute_item" {
317 if let Ok(text) = s.utf8_text(source_bytes) {
318 if text.contains("should_panic") {
319 has_should_panic = true;
320 }
321 }
322 } else if s.kind() != "line_comment" && s.kind() != "block_comment" {
323 break;
324 }
325 sibling = s.next_sibling();
326 }
327 }
328 }
329
330 let mut functions = Vec::new();
331 for tm in &test_matches {
332 let fn_node = match root.descendant_for_byte_range(tm.fn_start_byte, tm.fn_end_byte) {
333 Some(n) => n,
334 None => continue,
335 };
336
337 let line = tm.fn_start_row + 1;
338 let end_line = tm.fn_end_row + 1;
339 let line_count = end_line - line + 1;
340
341 let mut assertion_count =
342 count_captures(assertion_query, "assertion", fn_node, source_bytes);
343
344 if tm.has_should_panic {
346 assertion_count += 1;
347 }
348 let mock_count = count_captures(mock_query, "mock", fn_node, source_bytes);
349 let mock_classes = collect_mock_class_names(
350 mock_assign_query,
351 fn_node,
352 source_bytes,
353 extract_mock_class_name,
354 );
355 let how_not_what_count =
356 count_captures(how_not_what_query, "how_pattern", fn_node, source_bytes);
357
358 let private_in_assertion_count = count_captures_within_context(
359 assertion_query,
360 "assertion",
361 private_query,
362 "private_access",
363 fn_node,
364 source_bytes,
365 );
366
367 let fixture_count = count_fixture_lets(fn_node);
368
369 let has_wait = has_any_match(wait_query, "wait", fn_node, source_bytes);
371
372 let assertion_message_count =
374 count_assertion_messages_rust(assertion_query, fn_node, source_bytes);
375
376 let duplicate_literal_count = count_duplicate_literals(
378 assertion_query,
379 fn_node,
380 source_bytes,
381 &["integer_literal", "float_literal", "string_literal"],
382 );
383
384 let suppressed_rules = extract_suppression_from_previous_line(source, tm.attr_start_row);
386
387 functions.push(TestFunction {
388 name: tm.name.clone(),
389 file: file_path.to_string(),
390 line,
391 end_line,
392 analysis: TestAnalysis {
393 assertion_count,
394 mock_count,
395 mock_classes,
396 line_count,
397 how_not_what_count: how_not_what_count + private_in_assertion_count,
398 fixture_count,
399 has_wait,
400 has_skip_call: false,
401 assertion_message_count,
402 duplicate_literal_count,
403 suppressed_rules,
404 },
405 });
406 }
407
408 functions
409}
410
411impl LanguageExtractor for RustExtractor {
412 fn extract_test_functions(&self, source: &str, file_path: &str) -> Vec<TestFunction> {
413 let mut parser = Self::parser();
414 let tree = match parser.parse(source, None) {
415 Some(t) => t,
416 None => return Vec::new(),
417 };
418 extract_functions_from_tree(source, file_path, tree.root_node())
419 }
420
421 fn extract_file_analysis(&self, source: &str, file_path: &str) -> FileAnalysis {
422 let mut parser = Self::parser();
423 let tree = match parser.parse(source, None) {
424 Some(t) => t,
425 None => {
426 return FileAnalysis {
427 file: file_path.to_string(),
428 functions: Vec::new(),
429 has_pbt_import: false,
430 has_contract_import: false,
431 has_error_test: false,
432 has_relational_assertion: false,
433 parameterized_count: 0,
434 };
435 }
436 };
437
438 let root = tree.root_node();
439 let source_bytes = source.as_bytes();
440
441 let functions = extract_functions_from_tree(source, file_path, root);
442
443 let param_query = cached_query(&PARAMETERIZED_QUERY_CACHE, PARAMETERIZED_QUERY);
444 let parameterized_count = count_captures(param_query, "parameterized", root, source_bytes);
445
446 let pbt_query = cached_query(&IMPORT_PBT_QUERY_CACHE, IMPORT_PBT_QUERY);
447 let has_pbt_import = has_any_match(pbt_query, "pbt_import", root, source_bytes);
448
449 let contract_query = cached_query(&IMPORT_CONTRACT_QUERY_CACHE, IMPORT_CONTRACT_QUERY);
450 let has_contract_import =
451 has_any_match(contract_query, "contract_import", root, source_bytes);
452
453 let error_test_query = cached_query(&ERROR_TEST_QUERY_CACHE, ERROR_TEST_QUERY);
454 let has_error_test = has_any_match(error_test_query, "error_test", root, source_bytes);
455
456 let relational_query = cached_query(
457 &RELATIONAL_ASSERTION_QUERY_CACHE,
458 RELATIONAL_ASSERTION_QUERY,
459 );
460 let has_relational_assertion =
461 has_any_match(relational_query, "relational", root, source_bytes);
462
463 FileAnalysis {
464 file: file_path.to_string(),
465 functions,
466 has_pbt_import,
467 has_contract_import,
468 has_error_test,
469 has_relational_assertion,
470 parameterized_count,
471 }
472 }
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478
479 fn fixture(name: &str) -> String {
480 let path = format!(
481 "{}/tests/fixtures/rust/{}",
482 env!("CARGO_MANIFEST_DIR").replace("/crates/lang-rust", ""),
483 name
484 );
485 std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}"))
486 }
487
488 #[test]
491 fn parse_rust_source() {
492 let source = "#[test]\nfn test_example() {\n assert_eq!(1, 1);\n}\n";
493 let mut parser = RustExtractor::parser();
494 let tree = parser.parse(source, None).unwrap();
495 assert_eq!(tree.root_node().kind(), "source_file");
496 }
497
498 #[test]
499 fn rust_extractor_implements_language_extractor() {
500 let extractor = RustExtractor::new();
501 let _: &dyn exspec_core::extractor::LanguageExtractor = &extractor;
502 }
503
504 #[test]
507 fn extract_single_test() {
508 let source = fixture("t001_pass.rs");
510 let extractor = RustExtractor::new();
511 let funcs = extractor.extract_test_functions(&source, "t001_pass.rs");
512 assert_eq!(funcs.len(), 1, "should extract exactly 1 test function");
513 assert_eq!(funcs[0].name, "test_create_user");
514 }
515
516 #[test]
517 fn non_test_function_not_extracted() {
518 let source = "fn helper() -> i32 { 42 }\n";
520 let extractor = RustExtractor::new();
521 let funcs = extractor.extract_test_functions(&source, "helper.rs");
522 assert_eq!(funcs.len(), 0, "non-test fn should not be extracted");
523 }
524
525 #[test]
526 fn extract_tokio_test() {
527 let source =
529 "#[tokio::test]\nasync fn test_async_operation() {\n assert_eq!(1, 1);\n}\n";
530 let extractor = RustExtractor::new();
531 let funcs = extractor.extract_test_functions(&source, "tokio_test.rs");
532 assert_eq!(funcs.len(), 1, "should extract #[tokio::test] function");
533 assert_eq!(funcs[0].name, "test_async_operation");
534 }
535
536 #[test]
539 fn assertion_count_zero_for_violation() {
540 let source = fixture("t001_violation.rs");
542 let extractor = RustExtractor::new();
543 let funcs = extractor.extract_test_functions(&source, "t001_violation.rs");
544 assert_eq!(funcs.len(), 1);
545 assert_eq!(
546 funcs[0].analysis.assertion_count, 0,
547 "violation file should have 0 assertions"
548 );
549 }
550
551 #[test]
552 fn assertion_count_positive_for_pass() {
553 let source = fixture("t001_pass.rs");
555 let extractor = RustExtractor::new();
556 let funcs = extractor.extract_test_functions(&source, "t001_pass.rs");
557 assert_eq!(funcs.len(), 1);
558 assert!(
559 funcs[0].analysis.assertion_count >= 1,
560 "pass file should have >= 1 assertion"
561 );
562 }
563
564 #[test]
565 fn all_assert_macros_counted() {
566 let source = "#[test]\nfn test_all_asserts() {\n assert!(true);\n assert_eq!(1, 1);\n assert_ne!(1, 2);\n}\n";
568 let extractor = RustExtractor::new();
569 let funcs = extractor.extract_test_functions(&source, "test_asserts.rs");
570 assert_eq!(funcs.len(), 1);
571 assert_eq!(
572 funcs[0].analysis.assertion_count, 3,
573 "should count assert!, assert_eq!, assert_ne!"
574 );
575 }
576
577 #[test]
578 fn debug_assert_counted() {
579 let source = "#[test]\nfn test_debug_assert() {\n debug_assert!(true);\n}\n";
581 let extractor = RustExtractor::new();
582 let funcs = extractor.extract_test_functions(&source, "test_debug.rs");
583 assert_eq!(funcs.len(), 1);
584 assert_eq!(
585 funcs[0].analysis.assertion_count, 1,
586 "debug_assert! should be counted"
587 );
588 }
589
590 #[test]
593 fn simple_assert_fn_call_detected() {
594 let source = fixture("t001_pass_helper_delegation.rs");
596 let extractor = RustExtractor::new();
597 let funcs = extractor.extract_test_functions(&source, "t001_pass_helper_delegation.rs");
598 let simple = funcs
599 .iter()
600 .find(|f| f.name == "test_simple_helper")
601 .unwrap();
602 assert!(
603 simple.analysis.assertion_count >= 1,
604 "assert_matches() fn call should be counted as assertion, got {}",
605 simple.analysis.assertion_count
606 );
607 }
608
609 #[test]
610 fn scoped_assert_fn_call_detected() {
611 let source = fixture("t001_pass_helper_delegation.rs");
613 let extractor = RustExtractor::new();
614 let funcs = extractor.extract_test_functions(&source, "t001_pass_helper_delegation.rs");
615 let scoped = funcs
616 .iter()
617 .find(|f| f.name == "test_scoped_helper")
618 .unwrap();
619 assert!(
620 scoped.analysis.assertion_count >= 1,
621 "common::assert_matches() should be counted as assertion, got {}",
622 scoped.analysis.assertion_count
623 );
624 }
625
626 #[test]
627 fn mixed_macro_and_fn_call_counted() {
628 let source = fixture("t001_pass_helper_delegation.rs");
630 let extractor = RustExtractor::new();
631 let funcs = extractor.extract_test_functions(&source, "t001_pass_helper_delegation.rs");
632 let mixed = funcs
633 .iter()
634 .find(|f| f.name == "test_mixed_macro_and_fn")
635 .unwrap();
636 assert_eq!(
637 mixed.analysis.assertion_count, 2,
638 "assert_eq! macro + assert_matches() fn call should total 2, got {}",
639 mixed.analysis.assertion_count
640 );
641 }
642
643 #[test]
644 fn assertion_prefix_not_counted() {
645 let source = "#[test]\nfn test_foo() {\n assertion_helper(expected, actual);\n}\n";
647 let extractor = RustExtractor::new();
648 let funcs = extractor.extract_test_functions(&source, "test_negative.rs");
649 assert_eq!(funcs.len(), 1);
650 assert_eq!(
651 funcs[0].analysis.assertion_count, 0,
652 "assertion_helper() should NOT be counted as assertion"
653 );
654 }
655
656 #[test]
657 fn ordinary_helper_not_counted() {
658 let source = "#[test]\nfn test_foo() {\n helper_check(expected, actual);\n}\n";
660 let extractor = RustExtractor::new();
661 let funcs = extractor.extract_test_functions(&source, "test_negative2.rs");
662 assert_eq!(funcs.len(), 1);
663 assert_eq!(
664 funcs[0].analysis.assertion_count, 0,
665 "helper_check() should NOT be counted as assertion"
666 );
667 }
668
669 #[test]
672 fn mock_pattern_detected() {
673 let source = "#[test]\nfn test_with_mock() {\n let mock_svc = MockService::new();\n assert_eq!(mock_svc.len(), 0);\n}\n";
675 let extractor = RustExtractor::new();
676 let funcs = extractor.extract_test_functions(&source, "test_mock.rs");
677 assert_eq!(funcs.len(), 1);
678 assert!(
679 funcs[0].analysis.mock_count >= 1,
680 "MockService::new() should be detected"
681 );
682 }
683
684 #[test]
685 fn mock_count_for_violation() {
686 let source = fixture("t002_violation.rs");
688 let extractor = RustExtractor::new();
689 let funcs = extractor.extract_test_functions(&source, "t002_violation.rs");
690 assert_eq!(funcs.len(), 1);
691 assert!(
692 funcs[0].analysis.mock_count > 5,
693 "violation file should have > 5 mocks, got {}",
694 funcs[0].analysis.mock_count
695 );
696 }
697
698 #[test]
699 fn mock_count_for_pass() {
700 let source = fixture("t002_pass.rs");
702 let extractor = RustExtractor::new();
703 let funcs = extractor.extract_test_functions(&source, "t002_pass.rs");
704 assert_eq!(funcs.len(), 1);
705 assert_eq!(
706 funcs[0].analysis.mock_count, 1,
707 "pass file should have 1 mock"
708 );
709 assert_eq!(funcs[0].analysis.mock_classes, vec!["repo"]);
710 }
711
712 #[test]
713 fn mock_class_name_extraction() {
714 assert_eq!(extract_mock_class_name("mock_service"), "service");
716 assert_eq!(extract_mock_class_name("mock_db"), "db");
717 assert_eq!(extract_mock_class_name("service"), "service");
718 assert_eq!(extract_mock_class_name("mockService"), "Service");
719 }
720
721 #[test]
724 fn giant_test_line_count() {
725 let source = fixture("t003_violation.rs");
727 let extractor = RustExtractor::new();
728 let funcs = extractor.extract_test_functions(&source, "t003_violation.rs");
729 assert_eq!(funcs.len(), 1);
730 assert!(
731 funcs[0].analysis.line_count > 50,
732 "violation file line_count should > 50, got {}",
733 funcs[0].analysis.line_count
734 );
735 }
736
737 #[test]
738 fn short_test_line_count() {
739 let source = fixture("t003_pass.rs");
741 let extractor = RustExtractor::new();
742 let funcs = extractor.extract_test_functions(&source, "t003_pass.rs");
743 assert_eq!(funcs.len(), 1);
744 assert!(
745 funcs[0].analysis.line_count <= 50,
746 "pass file line_count should <= 50, got {}",
747 funcs[0].analysis.line_count
748 );
749 }
750
751 #[test]
754 fn file_analysis_detects_parameterized() {
755 let source = fixture("t004_pass.rs");
757 let extractor = RustExtractor::new();
758 let fa = extractor.extract_file_analysis(&source, "t004_pass.rs");
759 assert!(
760 fa.parameterized_count >= 1,
761 "should detect #[rstest], got {}",
762 fa.parameterized_count
763 );
764 }
765
766 #[test]
767 fn file_analysis_no_parameterized() {
768 let source = fixture("t004_violation.rs");
770 let extractor = RustExtractor::new();
771 let fa = extractor.extract_file_analysis(&source, "t004_violation.rs");
772 assert_eq!(
773 fa.parameterized_count, 0,
774 "violation file should have 0 parameterized"
775 );
776 }
777
778 #[test]
779 fn file_analysis_pbt_import() {
780 let source = fixture("t005_pass.rs");
782 let extractor = RustExtractor::new();
783 let fa = extractor.extract_file_analysis(&source, "t005_pass.rs");
784 assert!(fa.has_pbt_import, "should detect proptest import");
785 }
786
787 #[test]
788 fn file_analysis_no_pbt_import() {
789 let source = fixture("t005_violation.rs");
791 let extractor = RustExtractor::new();
792 let fa = extractor.extract_file_analysis(&source, "t005_violation.rs");
793 assert!(!fa.has_pbt_import, "should not detect PBT import");
794 }
795
796 #[test]
797 fn file_analysis_no_contract() {
798 let source = fixture("t008_violation.rs");
800 let extractor = RustExtractor::new();
801 let fa = extractor.extract_file_analysis(&source, "t008_violation.rs");
802 assert!(!fa.has_contract_import, "Rust has no contract library");
803 }
804
805 #[test]
808 fn prop_assert_counts_as_assertion() {
809 let source = fixture("t001_proptest_pass.rs");
811 let extractor = RustExtractor::new();
812 let funcs = extractor.extract_test_functions(&source, "t001_proptest_pass.rs");
813 assert_eq!(funcs.len(), 1, "should extract test from proptest! macro");
814 assert!(
815 funcs[0].analysis.assertion_count >= 1,
816 "prop_assert_eq! should be counted, got {}",
817 funcs[0].analysis.assertion_count
818 );
819 }
820
821 #[test]
824 fn suppressed_test_has_suppressed_rules() {
825 let source = fixture("suppressed.rs");
827 let extractor = RustExtractor::new();
828 let funcs = extractor.extract_test_functions(&source, "suppressed.rs");
829 assert_eq!(funcs.len(), 1);
830 assert!(
831 funcs[0]
832 .analysis
833 .suppressed_rules
834 .iter()
835 .any(|r| r.0 == "T001"),
836 "T001 should be suppressed, got: {:?}",
837 funcs[0].analysis.suppressed_rules
838 );
839 }
840
841 fn make_query(scm: &str) -> Query {
844 Query::new(&rust_language(), scm).unwrap()
845 }
846
847 #[test]
848 fn query_capture_names_test_function() {
849 let q = make_query(include_str!("../queries/test_function.scm"));
850 assert!(
851 q.capture_index_for_name("test_attr").is_some(),
852 "test_function.scm must define @test_attr capture"
853 );
854 }
855
856 #[test]
857 fn query_capture_names_assertion() {
858 let q = make_query(include_str!("../queries/assertion.scm"));
859 assert!(
860 q.capture_index_for_name("assertion").is_some(),
861 "assertion.scm must define @assertion capture"
862 );
863 }
864
865 #[test]
866 fn query_capture_names_mock_usage() {
867 let q = make_query(include_str!("../queries/mock_usage.scm"));
868 assert!(
869 q.capture_index_for_name("mock").is_some(),
870 "mock_usage.scm must define @mock capture"
871 );
872 }
873
874 #[test]
875 fn query_capture_names_mock_assignment() {
876 let q = make_query(include_str!("../queries/mock_assignment.scm"));
877 assert!(
878 q.capture_index_for_name("var_name").is_some(),
879 "mock_assignment.scm must define @var_name (required by collect_mock_class_names .expect())"
880 );
881 }
882
883 #[test]
884 fn query_capture_names_parameterized() {
885 let q = make_query(include_str!("../queries/parameterized.scm"));
886 assert!(
887 q.capture_index_for_name("parameterized").is_some(),
888 "parameterized.scm must define @parameterized capture"
889 );
890 }
891
892 #[test]
893 fn query_capture_names_import_pbt() {
894 let q = make_query(include_str!("../queries/import_pbt.scm"));
895 assert!(
896 q.capture_index_for_name("pbt_import").is_some(),
897 "import_pbt.scm must define @pbt_import capture"
898 );
899 }
900
901 #[test]
905 fn query_capture_names_import_contract_comment_only() {
906 let q = make_query(include_str!("../queries/import_contract.scm"));
907 assert!(
908 q.capture_index_for_name("contract_import").is_none(),
909 "Rust import_contract.scm is intentionally comment-only"
910 );
911 }
912
913 #[test]
916 fn error_test_should_panic() {
917 let source = fixture("t103_pass.rs");
918 let extractor = RustExtractor::new();
919 let fa = extractor.extract_file_analysis(&source, "t103_pass.rs");
920 assert!(
921 fa.has_error_test,
922 "#[should_panic] should set has_error_test"
923 );
924 }
925
926 #[test]
927 fn error_test_unwrap_err() {
928 let source = fixture("t103_pass_unwrap_err.rs");
929 let extractor = RustExtractor::new();
930 let fa = extractor.extract_file_analysis(&source, "t103_pass_unwrap_err.rs");
931 assert!(fa.has_error_test, ".unwrap_err() should set has_error_test");
932 }
933
934 #[test]
935 fn error_test_no_patterns() {
936 let source = fixture("t103_violation.rs");
937 let extractor = RustExtractor::new();
938 let fa = extractor.extract_file_analysis(&source, "t103_violation.rs");
939 assert!(
940 !fa.has_error_test,
941 "no error patterns should set has_error_test=false"
942 );
943 }
944
945 #[test]
946 fn error_test_is_err_only_not_sufficient() {
947 let source = fixture("t103_is_err_only.rs");
948 let extractor = RustExtractor::new();
949 let fa = extractor.extract_file_analysis(&source, "t103_is_err_only.rs");
950 assert!(
951 !fa.has_error_test,
952 ".is_err() alone should not count as error test (weak proxy)"
953 );
954 }
955
956 #[test]
957 fn query_capture_names_error_test() {
958 let q = make_query(include_str!("../queries/error_test.scm"));
959 assert!(
960 q.capture_index_for_name("error_test").is_some(),
961 "error_test.scm must define @error_test capture"
962 );
963 }
964
965 #[test]
968 fn relational_assertion_pass_contains() {
969 let source = fixture("t105_pass.rs");
970 let extractor = RustExtractor::new();
971 let fa = extractor.extract_file_analysis(&source, "t105_pass.rs");
972 assert!(
973 fa.has_relational_assertion,
974 ".contains() should set has_relational_assertion"
975 );
976 }
977
978 #[test]
979 fn relational_assertion_violation() {
980 let source = fixture("t105_violation.rs");
981 let extractor = RustExtractor::new();
982 let fa = extractor.extract_file_analysis(&source, "t105_violation.rs");
983 assert!(
984 !fa.has_relational_assertion,
985 "only assert_eq! should not set has_relational_assertion"
986 );
987 }
988
989 #[test]
990 fn query_capture_names_relational_assertion() {
991 let q = make_query(include_str!("../queries/relational_assertion.scm"));
992 assert!(
993 q.capture_index_for_name("relational").is_some(),
994 "relational_assertion.scm must define @relational capture"
995 );
996 }
997
998 #[test]
1001 fn how_not_what_expect_method() {
1002 let source = fixture("t101_violation.rs");
1003 let extractor = RustExtractor::new();
1004 let funcs = extractor.extract_test_functions(&source, "t101_violation.rs");
1005 assert!(
1006 funcs[0].analysis.how_not_what_count > 0,
1007 "mock.expect_save() should trigger how_not_what, got {}",
1008 funcs[0].analysis.how_not_what_count
1009 );
1010 }
1011
1012 #[test]
1013 fn how_not_what_pass() {
1014 let source = fixture("t101_pass.rs");
1015 let extractor = RustExtractor::new();
1016 let funcs = extractor.extract_test_functions(&source, "t101_pass.rs");
1017 assert_eq!(
1018 funcs[0].analysis.how_not_what_count, 0,
1019 "no mock patterns should have how_not_what_count=0"
1020 );
1021 }
1022
1023 #[test]
1024 fn how_not_what_private_field_limited_by_token_tree() {
1025 let source = fixture("t101_private_violation.rs");
1031 let extractor = RustExtractor::new();
1032 let funcs = extractor.extract_test_functions(&source, "t101_private_violation.rs");
1033 assert_eq!(
1034 funcs[0].analysis.how_not_what_count, 0,
1035 "Rust token_tree limitation: private field access in test is not detected"
1036 );
1037 }
1038
1039 #[test]
1040 fn query_capture_names_how_not_what() {
1041 let q = make_query(include_str!("../queries/how_not_what.scm"));
1042 assert!(
1043 q.capture_index_for_name("how_pattern").is_some(),
1044 "how_not_what.scm must define @how_pattern capture"
1045 );
1046 }
1047
1048 #[test]
1049 fn query_capture_names_private_in_assertion() {
1050 let q = make_query(include_str!("../queries/private_in_assertion.scm"));
1051 assert!(
1052 q.capture_index_for_name("private_access").is_some(),
1053 "private_in_assertion.scm must define @private_access capture"
1054 );
1055 }
1056
1057 #[test]
1060 fn fixture_count_for_violation() {
1061 let source = fixture("t102_violation.rs");
1062 let extractor = RustExtractor::new();
1063 let funcs = extractor.extract_test_functions(&source, "t102_violation.rs");
1064 assert_eq!(
1065 funcs[0].analysis.fixture_count, 7,
1066 "expected 7 let bindings as fixture_count"
1067 );
1068 }
1069
1070 #[test]
1071 fn fixture_count_for_pass() {
1072 let source = fixture("t102_pass.rs");
1073 let extractor = RustExtractor::new();
1074 let funcs = extractor.extract_test_functions(&source, "t102_pass.rs");
1075 assert_eq!(
1076 funcs[0].analysis.fixture_count, 1,
1077 "expected 1 let binding as fixture_count"
1078 );
1079 }
1080
1081 #[test]
1082 fn fixture_count_excludes_method_calls_on_locals() {
1083 let source = fixture("t102_method_chain.rs");
1084 let extractor = RustExtractor::new();
1085 let funcs = extractor.extract_test_functions(&source, "t102_method_chain.rs");
1086 assert_eq!(
1087 funcs[0].analysis.fixture_count, 6,
1088 "scoped calls (3) + struct (1) + macro (1) + builder chain (1) = 6, method calls on locals excluded"
1089 );
1090 }
1091
1092 #[test]
1095 fn wait_and_see_violation_sleep() {
1096 let source = fixture("t108_violation_sleep.rs");
1097 let extractor = RustExtractor::new();
1098 let funcs = extractor.extract_test_functions(&source, "t108_violation_sleep.rs");
1099 assert!(!funcs.is_empty());
1100 for func in &funcs {
1101 assert!(
1102 func.analysis.has_wait,
1103 "test '{}' should have has_wait=true",
1104 func.name
1105 );
1106 }
1107 }
1108
1109 #[test]
1110 fn wait_and_see_pass_no_sleep() {
1111 let source = fixture("t108_pass_no_sleep.rs");
1112 let extractor = RustExtractor::new();
1113 let funcs = extractor.extract_test_functions(&source, "t108_pass_no_sleep.rs");
1114 assert_eq!(funcs.len(), 1);
1115 assert!(
1116 !funcs[0].analysis.has_wait,
1117 "test without sleep should have has_wait=false"
1118 );
1119 }
1120
1121 #[test]
1122 fn query_capture_names_wait_and_see() {
1123 let q = make_query(include_str!("../queries/wait_and_see.scm"));
1124 assert!(
1125 q.capture_index_for_name("wait").is_some(),
1126 "wait_and_see.scm must define @wait capture"
1127 );
1128 }
1129
1130 #[test]
1133 fn t107_violation_no_messages() {
1134 let source = fixture("t107_violation.rs");
1135 let extractor = RustExtractor::new();
1136 let funcs = extractor.extract_test_functions(&source, "t107_violation.rs");
1137 assert_eq!(funcs.len(), 1);
1138 assert!(
1139 funcs[0].analysis.assertion_count >= 2,
1140 "should have multiple assertions"
1141 );
1142 assert_eq!(
1143 funcs[0].analysis.assertion_message_count, 0,
1144 "no assertion should have a message"
1145 );
1146 }
1147
1148 #[test]
1149 fn t107_pass_with_messages() {
1150 let source = fixture("t107_pass_with_messages.rs");
1151 let extractor = RustExtractor::new();
1152 let funcs = extractor.extract_test_functions(&source, "t107_pass_with_messages.rs");
1153 assert_eq!(funcs.len(), 1);
1154 assert!(
1155 funcs[0].analysis.assertion_message_count >= 1,
1156 "assertions with messages should be counted"
1157 );
1158 }
1159
1160 #[test]
1163 fn t109_violation_names_detected() {
1164 let source = fixture("t109_violation.rs");
1165 let extractor = RustExtractor::new();
1166 let funcs = extractor.extract_test_functions(&source, "t109_violation.rs");
1167 assert!(!funcs.is_empty());
1168 for func in &funcs {
1169 assert!(
1170 exspec_core::rules::is_undescriptive_test_name(&func.name),
1171 "test '{}' should be undescriptive",
1172 func.name
1173 );
1174 }
1175 }
1176
1177 #[test]
1178 fn t109_pass_descriptive_names() {
1179 let source = fixture("t109_pass.rs");
1180 let extractor = RustExtractor::new();
1181 let funcs = extractor.extract_test_functions(&source, "t109_pass.rs");
1182 assert!(!funcs.is_empty());
1183 for func in &funcs {
1184 assert!(
1185 !exspec_core::rules::is_undescriptive_test_name(&func.name),
1186 "test '{}' should be descriptive",
1187 func.name
1188 );
1189 }
1190 }
1191
1192 #[test]
1195 fn t106_violation_duplicate_literal() {
1196 let source = fixture("t106_violation.rs");
1197 let extractor = RustExtractor::new();
1198 let funcs = extractor.extract_test_functions(&source, "t106_violation.rs");
1199 assert_eq!(funcs.len(), 1);
1200 assert!(
1201 funcs[0].analysis.duplicate_literal_count >= 3,
1202 "42 appears 3 times, should be >= 3: got {}",
1203 funcs[0].analysis.duplicate_literal_count
1204 );
1205 }
1206
1207 #[test]
1208 fn t106_pass_no_duplicates() {
1209 let source = fixture("t106_pass_no_duplicates.rs");
1210 let extractor = RustExtractor::new();
1211 let funcs = extractor.extract_test_functions(&source, "t106_pass_no_duplicates.rs");
1212 assert_eq!(funcs.len(), 1);
1213 assert!(
1214 funcs[0].analysis.duplicate_literal_count < 3,
1215 "each literal appears once: got {}",
1216 funcs[0].analysis.duplicate_literal_count
1217 );
1218 }
1219
1220 #[test]
1223 fn t001_should_panic_counts_as_assertion() {
1224 let source = fixture("t001_should_panic.rs");
1226 let extractor = RustExtractor::new();
1227 let funcs = extractor.extract_test_functions(&source, "t001_should_panic.rs");
1228 assert_eq!(funcs.len(), 1);
1229 assert!(
1230 funcs[0].analysis.assertion_count >= 1,
1231 "#[should_panic] should count as assertion, got {}",
1232 funcs[0].analysis.assertion_count
1233 );
1234 }
1235
1236 #[test]
1237 fn t001_should_panic_before_test_counts_as_assertion() {
1238 let source = fixture("t001_should_panic_before_test.rs");
1240 let extractor = RustExtractor::new();
1241 let funcs = extractor.extract_test_functions(&source, "t001_should_panic_before_test.rs");
1242 assert_eq!(funcs.len(), 1);
1243 assert!(
1244 funcs[0].analysis.assertion_count >= 1,
1245 "#[should_panic] before #[test] should count as assertion, got {}",
1246 funcs[0].analysis.assertion_count
1247 );
1248 }
1249
1250 #[test]
1251 fn t001_should_panic_in_mod_counts_as_assertion() {
1252 let source = fixture("t001_should_panic_in_mod.rs");
1254 let extractor = RustExtractor::new();
1255 let funcs = extractor.extract_test_functions(&source, "t001_should_panic_in_mod.rs");
1256 assert_eq!(funcs.len(), 1);
1257 assert!(
1258 funcs[0].analysis.assertion_count >= 1,
1259 "#[should_panic] in mod should count as assertion, got {}",
1260 funcs[0].analysis.assertion_count
1261 );
1262 }
1263}