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 assertion_message_count,
401 duplicate_literal_count,
402 suppressed_rules,
403 },
404 });
405 }
406
407 functions
408}
409
410impl LanguageExtractor for RustExtractor {
411 fn extract_test_functions(&self, source: &str, file_path: &str) -> Vec<TestFunction> {
412 let mut parser = Self::parser();
413 let tree = match parser.parse(source, None) {
414 Some(t) => t,
415 None => return Vec::new(),
416 };
417 extract_functions_from_tree(source, file_path, tree.root_node())
418 }
419
420 fn extract_file_analysis(&self, source: &str, file_path: &str) -> FileAnalysis {
421 let mut parser = Self::parser();
422 let tree = match parser.parse(source, None) {
423 Some(t) => t,
424 None => {
425 return FileAnalysis {
426 file: file_path.to_string(),
427 functions: Vec::new(),
428 has_pbt_import: false,
429 has_contract_import: false,
430 has_error_test: false,
431 has_relational_assertion: false,
432 parameterized_count: 0,
433 };
434 }
435 };
436
437 let root = tree.root_node();
438 let source_bytes = source.as_bytes();
439
440 let functions = extract_functions_from_tree(source, file_path, root);
441
442 let param_query = cached_query(&PARAMETERIZED_QUERY_CACHE, PARAMETERIZED_QUERY);
443 let parameterized_count = count_captures(param_query, "parameterized", root, source_bytes);
444
445 let pbt_query = cached_query(&IMPORT_PBT_QUERY_CACHE, IMPORT_PBT_QUERY);
446 let has_pbt_import = has_any_match(pbt_query, "pbt_import", root, source_bytes);
447
448 let contract_query = cached_query(&IMPORT_CONTRACT_QUERY_CACHE, IMPORT_CONTRACT_QUERY);
449 let has_contract_import =
450 has_any_match(contract_query, "contract_import", root, source_bytes);
451
452 let error_test_query = cached_query(&ERROR_TEST_QUERY_CACHE, ERROR_TEST_QUERY);
453 let has_error_test = has_any_match(error_test_query, "error_test", root, source_bytes);
454
455 let relational_query = cached_query(
456 &RELATIONAL_ASSERTION_QUERY_CACHE,
457 RELATIONAL_ASSERTION_QUERY,
458 );
459 let has_relational_assertion =
460 has_any_match(relational_query, "relational", root, source_bytes);
461
462 FileAnalysis {
463 file: file_path.to_string(),
464 functions,
465 has_pbt_import,
466 has_contract_import,
467 has_error_test,
468 has_relational_assertion,
469 parameterized_count,
470 }
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 fn fixture(name: &str) -> String {
479 let path = format!(
480 "{}/tests/fixtures/rust/{}",
481 env!("CARGO_MANIFEST_DIR").replace("/crates/lang-rust", ""),
482 name
483 );
484 std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}"))
485 }
486
487 #[test]
490 fn parse_rust_source() {
491 let source = "#[test]\nfn test_example() {\n assert_eq!(1, 1);\n}\n";
492 let mut parser = RustExtractor::parser();
493 let tree = parser.parse(source, None).unwrap();
494 assert_eq!(tree.root_node().kind(), "source_file");
495 }
496
497 #[test]
498 fn rust_extractor_implements_language_extractor() {
499 let extractor = RustExtractor::new();
500 let _: &dyn exspec_core::extractor::LanguageExtractor = &extractor;
501 }
502
503 #[test]
506 fn extract_single_test() {
507 let source = fixture("t001_pass.rs");
509 let extractor = RustExtractor::new();
510 let funcs = extractor.extract_test_functions(&source, "t001_pass.rs");
511 assert_eq!(funcs.len(), 1, "should extract exactly 1 test function");
512 assert_eq!(funcs[0].name, "test_create_user");
513 }
514
515 #[test]
516 fn non_test_function_not_extracted() {
517 let source = "fn helper() -> i32 { 42 }\n";
519 let extractor = RustExtractor::new();
520 let funcs = extractor.extract_test_functions(&source, "helper.rs");
521 assert_eq!(funcs.len(), 0, "non-test fn should not be extracted");
522 }
523
524 #[test]
525 fn extract_tokio_test() {
526 let source =
528 "#[tokio::test]\nasync fn test_async_operation() {\n assert_eq!(1, 1);\n}\n";
529 let extractor = RustExtractor::new();
530 let funcs = extractor.extract_test_functions(&source, "tokio_test.rs");
531 assert_eq!(funcs.len(), 1, "should extract #[tokio::test] function");
532 assert_eq!(funcs[0].name, "test_async_operation");
533 }
534
535 #[test]
538 fn assertion_count_zero_for_violation() {
539 let source = fixture("t001_violation.rs");
541 let extractor = RustExtractor::new();
542 let funcs = extractor.extract_test_functions(&source, "t001_violation.rs");
543 assert_eq!(funcs.len(), 1);
544 assert_eq!(
545 funcs[0].analysis.assertion_count, 0,
546 "violation file should have 0 assertions"
547 );
548 }
549
550 #[test]
551 fn assertion_count_positive_for_pass() {
552 let source = fixture("t001_pass.rs");
554 let extractor = RustExtractor::new();
555 let funcs = extractor.extract_test_functions(&source, "t001_pass.rs");
556 assert_eq!(funcs.len(), 1);
557 assert!(
558 funcs[0].analysis.assertion_count >= 1,
559 "pass file should have >= 1 assertion"
560 );
561 }
562
563 #[test]
564 fn all_assert_macros_counted() {
565 let source = "#[test]\nfn test_all_asserts() {\n assert!(true);\n assert_eq!(1, 1);\n assert_ne!(1, 2);\n}\n";
567 let extractor = RustExtractor::new();
568 let funcs = extractor.extract_test_functions(&source, "test_asserts.rs");
569 assert_eq!(funcs.len(), 1);
570 assert_eq!(
571 funcs[0].analysis.assertion_count, 3,
572 "should count assert!, assert_eq!, assert_ne!"
573 );
574 }
575
576 #[test]
577 fn debug_assert_counted() {
578 let source = "#[test]\nfn test_debug_assert() {\n debug_assert!(true);\n}\n";
580 let extractor = RustExtractor::new();
581 let funcs = extractor.extract_test_functions(&source, "test_debug.rs");
582 assert_eq!(funcs.len(), 1);
583 assert_eq!(
584 funcs[0].analysis.assertion_count, 1,
585 "debug_assert! should be counted"
586 );
587 }
588
589 #[test]
592 fn mock_pattern_detected() {
593 let source = "#[test]\nfn test_with_mock() {\n let mock_svc = MockService::new();\n assert_eq!(mock_svc.len(), 0);\n}\n";
595 let extractor = RustExtractor::new();
596 let funcs = extractor.extract_test_functions(&source, "test_mock.rs");
597 assert_eq!(funcs.len(), 1);
598 assert!(
599 funcs[0].analysis.mock_count >= 1,
600 "MockService::new() should be detected"
601 );
602 }
603
604 #[test]
605 fn mock_count_for_violation() {
606 let source = fixture("t002_violation.rs");
608 let extractor = RustExtractor::new();
609 let funcs = extractor.extract_test_functions(&source, "t002_violation.rs");
610 assert_eq!(funcs.len(), 1);
611 assert!(
612 funcs[0].analysis.mock_count > 5,
613 "violation file should have > 5 mocks, got {}",
614 funcs[0].analysis.mock_count
615 );
616 }
617
618 #[test]
619 fn mock_count_for_pass() {
620 let source = fixture("t002_pass.rs");
622 let extractor = RustExtractor::new();
623 let funcs = extractor.extract_test_functions(&source, "t002_pass.rs");
624 assert_eq!(funcs.len(), 1);
625 assert_eq!(
626 funcs[0].analysis.mock_count, 1,
627 "pass file should have 1 mock"
628 );
629 assert_eq!(funcs[0].analysis.mock_classes, vec!["repo"]);
630 }
631
632 #[test]
633 fn mock_class_name_extraction() {
634 assert_eq!(extract_mock_class_name("mock_service"), "service");
636 assert_eq!(extract_mock_class_name("mock_db"), "db");
637 assert_eq!(extract_mock_class_name("service"), "service");
638 assert_eq!(extract_mock_class_name("mockService"), "Service");
639 }
640
641 #[test]
644 fn giant_test_line_count() {
645 let source = fixture("t003_violation.rs");
647 let extractor = RustExtractor::new();
648 let funcs = extractor.extract_test_functions(&source, "t003_violation.rs");
649 assert_eq!(funcs.len(), 1);
650 assert!(
651 funcs[0].analysis.line_count > 50,
652 "violation file line_count should > 50, got {}",
653 funcs[0].analysis.line_count
654 );
655 }
656
657 #[test]
658 fn short_test_line_count() {
659 let source = fixture("t003_pass.rs");
661 let extractor = RustExtractor::new();
662 let funcs = extractor.extract_test_functions(&source, "t003_pass.rs");
663 assert_eq!(funcs.len(), 1);
664 assert!(
665 funcs[0].analysis.line_count <= 50,
666 "pass file line_count should <= 50, got {}",
667 funcs[0].analysis.line_count
668 );
669 }
670
671 #[test]
674 fn file_analysis_detects_parameterized() {
675 let source = fixture("t004_pass.rs");
677 let extractor = RustExtractor::new();
678 let fa = extractor.extract_file_analysis(&source, "t004_pass.rs");
679 assert!(
680 fa.parameterized_count >= 1,
681 "should detect #[rstest], got {}",
682 fa.parameterized_count
683 );
684 }
685
686 #[test]
687 fn file_analysis_no_parameterized() {
688 let source = fixture("t004_violation.rs");
690 let extractor = RustExtractor::new();
691 let fa = extractor.extract_file_analysis(&source, "t004_violation.rs");
692 assert_eq!(
693 fa.parameterized_count, 0,
694 "violation file should have 0 parameterized"
695 );
696 }
697
698 #[test]
699 fn file_analysis_pbt_import() {
700 let source = fixture("t005_pass.rs");
702 let extractor = RustExtractor::new();
703 let fa = extractor.extract_file_analysis(&source, "t005_pass.rs");
704 assert!(fa.has_pbt_import, "should detect proptest import");
705 }
706
707 #[test]
708 fn file_analysis_no_pbt_import() {
709 let source = fixture("t005_violation.rs");
711 let extractor = RustExtractor::new();
712 let fa = extractor.extract_file_analysis(&source, "t005_violation.rs");
713 assert!(!fa.has_pbt_import, "should not detect PBT import");
714 }
715
716 #[test]
717 fn file_analysis_no_contract() {
718 let source = fixture("t008_violation.rs");
720 let extractor = RustExtractor::new();
721 let fa = extractor.extract_file_analysis(&source, "t008_violation.rs");
722 assert!(!fa.has_contract_import, "Rust has no contract library");
723 }
724
725 #[test]
728 fn prop_assert_counts_as_assertion() {
729 let source = fixture("t001_proptest_pass.rs");
731 let extractor = RustExtractor::new();
732 let funcs = extractor.extract_test_functions(&source, "t001_proptest_pass.rs");
733 assert_eq!(funcs.len(), 1, "should extract test from proptest! macro");
734 assert!(
735 funcs[0].analysis.assertion_count >= 1,
736 "prop_assert_eq! should be counted, got {}",
737 funcs[0].analysis.assertion_count
738 );
739 }
740
741 #[test]
744 fn suppressed_test_has_suppressed_rules() {
745 let source = fixture("suppressed.rs");
747 let extractor = RustExtractor::new();
748 let funcs = extractor.extract_test_functions(&source, "suppressed.rs");
749 assert_eq!(funcs.len(), 1);
750 assert!(
751 funcs[0]
752 .analysis
753 .suppressed_rules
754 .iter()
755 .any(|r| r.0 == "T001"),
756 "T001 should be suppressed, got: {:?}",
757 funcs[0].analysis.suppressed_rules
758 );
759 }
760
761 fn make_query(scm: &str) -> Query {
764 Query::new(&rust_language(), scm).unwrap()
765 }
766
767 #[test]
768 fn query_capture_names_test_function() {
769 let q = make_query(include_str!("../queries/test_function.scm"));
770 assert!(
771 q.capture_index_for_name("test_attr").is_some(),
772 "test_function.scm must define @test_attr capture"
773 );
774 }
775
776 #[test]
777 fn query_capture_names_assertion() {
778 let q = make_query(include_str!("../queries/assertion.scm"));
779 assert!(
780 q.capture_index_for_name("assertion").is_some(),
781 "assertion.scm must define @assertion capture"
782 );
783 }
784
785 #[test]
786 fn query_capture_names_mock_usage() {
787 let q = make_query(include_str!("../queries/mock_usage.scm"));
788 assert!(
789 q.capture_index_for_name("mock").is_some(),
790 "mock_usage.scm must define @mock capture"
791 );
792 }
793
794 #[test]
795 fn query_capture_names_mock_assignment() {
796 let q = make_query(include_str!("../queries/mock_assignment.scm"));
797 assert!(
798 q.capture_index_for_name("var_name").is_some(),
799 "mock_assignment.scm must define @var_name (required by collect_mock_class_names .expect())"
800 );
801 }
802
803 #[test]
804 fn query_capture_names_parameterized() {
805 let q = make_query(include_str!("../queries/parameterized.scm"));
806 assert!(
807 q.capture_index_for_name("parameterized").is_some(),
808 "parameterized.scm must define @parameterized capture"
809 );
810 }
811
812 #[test]
813 fn query_capture_names_import_pbt() {
814 let q = make_query(include_str!("../queries/import_pbt.scm"));
815 assert!(
816 q.capture_index_for_name("pbt_import").is_some(),
817 "import_pbt.scm must define @pbt_import capture"
818 );
819 }
820
821 #[test]
825 fn query_capture_names_import_contract_comment_only() {
826 let q = make_query(include_str!("../queries/import_contract.scm"));
827 assert!(
828 q.capture_index_for_name("contract_import").is_none(),
829 "Rust import_contract.scm is intentionally comment-only"
830 );
831 }
832
833 #[test]
836 fn error_test_should_panic() {
837 let source = fixture("t103_pass.rs");
838 let extractor = RustExtractor::new();
839 let fa = extractor.extract_file_analysis(&source, "t103_pass.rs");
840 assert!(
841 fa.has_error_test,
842 "#[should_panic] should set has_error_test"
843 );
844 }
845
846 #[test]
847 fn error_test_unwrap_err() {
848 let source = fixture("t103_pass_unwrap_err.rs");
849 let extractor = RustExtractor::new();
850 let fa = extractor.extract_file_analysis(&source, "t103_pass_unwrap_err.rs");
851 assert!(fa.has_error_test, ".unwrap_err() should set has_error_test");
852 }
853
854 #[test]
855 fn error_test_no_patterns() {
856 let source = fixture("t103_violation.rs");
857 let extractor = RustExtractor::new();
858 let fa = extractor.extract_file_analysis(&source, "t103_violation.rs");
859 assert!(
860 !fa.has_error_test,
861 "no error patterns should set has_error_test=false"
862 );
863 }
864
865 #[test]
866 fn error_test_is_err_only_not_sufficient() {
867 let source = fixture("t103_is_err_only.rs");
868 let extractor = RustExtractor::new();
869 let fa = extractor.extract_file_analysis(&source, "t103_is_err_only.rs");
870 assert!(
871 !fa.has_error_test,
872 ".is_err() alone should not count as error test (weak proxy)"
873 );
874 }
875
876 #[test]
877 fn query_capture_names_error_test() {
878 let q = make_query(include_str!("../queries/error_test.scm"));
879 assert!(
880 q.capture_index_for_name("error_test").is_some(),
881 "error_test.scm must define @error_test capture"
882 );
883 }
884
885 #[test]
888 fn relational_assertion_pass_contains() {
889 let source = fixture("t105_pass.rs");
890 let extractor = RustExtractor::new();
891 let fa = extractor.extract_file_analysis(&source, "t105_pass.rs");
892 assert!(
893 fa.has_relational_assertion,
894 ".contains() should set has_relational_assertion"
895 );
896 }
897
898 #[test]
899 fn relational_assertion_violation() {
900 let source = fixture("t105_violation.rs");
901 let extractor = RustExtractor::new();
902 let fa = extractor.extract_file_analysis(&source, "t105_violation.rs");
903 assert!(
904 !fa.has_relational_assertion,
905 "only assert_eq! should not set has_relational_assertion"
906 );
907 }
908
909 #[test]
910 fn query_capture_names_relational_assertion() {
911 let q = make_query(include_str!("../queries/relational_assertion.scm"));
912 assert!(
913 q.capture_index_for_name("relational").is_some(),
914 "relational_assertion.scm must define @relational capture"
915 );
916 }
917
918 #[test]
921 fn how_not_what_expect_method() {
922 let source = fixture("t101_violation.rs");
923 let extractor = RustExtractor::new();
924 let funcs = extractor.extract_test_functions(&source, "t101_violation.rs");
925 assert!(
926 funcs[0].analysis.how_not_what_count > 0,
927 "mock.expect_save() should trigger how_not_what, got {}",
928 funcs[0].analysis.how_not_what_count
929 );
930 }
931
932 #[test]
933 fn how_not_what_pass() {
934 let source = fixture("t101_pass.rs");
935 let extractor = RustExtractor::new();
936 let funcs = extractor.extract_test_functions(&source, "t101_pass.rs");
937 assert_eq!(
938 funcs[0].analysis.how_not_what_count, 0,
939 "no mock patterns should have how_not_what_count=0"
940 );
941 }
942
943 #[test]
944 fn how_not_what_private_field_limited_by_token_tree() {
945 let source = fixture("t101_private_violation.rs");
951 let extractor = RustExtractor::new();
952 let funcs = extractor.extract_test_functions(&source, "t101_private_violation.rs");
953 assert_eq!(
954 funcs[0].analysis.how_not_what_count, 0,
955 "Rust token_tree limitation: private field access in test is not detected"
956 );
957 }
958
959 #[test]
960 fn query_capture_names_how_not_what() {
961 let q = make_query(include_str!("../queries/how_not_what.scm"));
962 assert!(
963 q.capture_index_for_name("how_pattern").is_some(),
964 "how_not_what.scm must define @how_pattern capture"
965 );
966 }
967
968 #[test]
969 fn query_capture_names_private_in_assertion() {
970 let q = make_query(include_str!("../queries/private_in_assertion.scm"));
971 assert!(
972 q.capture_index_for_name("private_access").is_some(),
973 "private_in_assertion.scm must define @private_access capture"
974 );
975 }
976
977 #[test]
980 fn fixture_count_for_violation() {
981 let source = fixture("t102_violation.rs");
982 let extractor = RustExtractor::new();
983 let funcs = extractor.extract_test_functions(&source, "t102_violation.rs");
984 assert_eq!(
985 funcs[0].analysis.fixture_count, 7,
986 "expected 7 let bindings as fixture_count"
987 );
988 }
989
990 #[test]
991 fn fixture_count_for_pass() {
992 let source = fixture("t102_pass.rs");
993 let extractor = RustExtractor::new();
994 let funcs = extractor.extract_test_functions(&source, "t102_pass.rs");
995 assert_eq!(
996 funcs[0].analysis.fixture_count, 1,
997 "expected 1 let binding as fixture_count"
998 );
999 }
1000
1001 #[test]
1002 fn fixture_count_excludes_method_calls_on_locals() {
1003 let source = fixture("t102_method_chain.rs");
1004 let extractor = RustExtractor::new();
1005 let funcs = extractor.extract_test_functions(&source, "t102_method_chain.rs");
1006 assert_eq!(
1007 funcs[0].analysis.fixture_count, 6,
1008 "scoped calls (3) + struct (1) + macro (1) + builder chain (1) = 6, method calls on locals excluded"
1009 );
1010 }
1011
1012 #[test]
1015 fn wait_and_see_violation_sleep() {
1016 let source = fixture("t108_violation_sleep.rs");
1017 let extractor = RustExtractor::new();
1018 let funcs = extractor.extract_test_functions(&source, "t108_violation_sleep.rs");
1019 assert!(!funcs.is_empty());
1020 for func in &funcs {
1021 assert!(
1022 func.analysis.has_wait,
1023 "test '{}' should have has_wait=true",
1024 func.name
1025 );
1026 }
1027 }
1028
1029 #[test]
1030 fn wait_and_see_pass_no_sleep() {
1031 let source = fixture("t108_pass_no_sleep.rs");
1032 let extractor = RustExtractor::new();
1033 let funcs = extractor.extract_test_functions(&source, "t108_pass_no_sleep.rs");
1034 assert_eq!(funcs.len(), 1);
1035 assert!(
1036 !funcs[0].analysis.has_wait,
1037 "test without sleep should have has_wait=false"
1038 );
1039 }
1040
1041 #[test]
1042 fn query_capture_names_wait_and_see() {
1043 let q = make_query(include_str!("../queries/wait_and_see.scm"));
1044 assert!(
1045 q.capture_index_for_name("wait").is_some(),
1046 "wait_and_see.scm must define @wait capture"
1047 );
1048 }
1049
1050 #[test]
1053 fn t107_violation_no_messages() {
1054 let source = fixture("t107_violation.rs");
1055 let extractor = RustExtractor::new();
1056 let funcs = extractor.extract_test_functions(&source, "t107_violation.rs");
1057 assert_eq!(funcs.len(), 1);
1058 assert!(
1059 funcs[0].analysis.assertion_count >= 2,
1060 "should have multiple assertions"
1061 );
1062 assert_eq!(
1063 funcs[0].analysis.assertion_message_count, 0,
1064 "no assertion should have a message"
1065 );
1066 }
1067
1068 #[test]
1069 fn t107_pass_with_messages() {
1070 let source = fixture("t107_pass_with_messages.rs");
1071 let extractor = RustExtractor::new();
1072 let funcs = extractor.extract_test_functions(&source, "t107_pass_with_messages.rs");
1073 assert_eq!(funcs.len(), 1);
1074 assert!(
1075 funcs[0].analysis.assertion_message_count >= 1,
1076 "assertions with messages should be counted"
1077 );
1078 }
1079
1080 #[test]
1083 fn t109_violation_names_detected() {
1084 let source = fixture("t109_violation.rs");
1085 let extractor = RustExtractor::new();
1086 let funcs = extractor.extract_test_functions(&source, "t109_violation.rs");
1087 assert!(!funcs.is_empty());
1088 for func in &funcs {
1089 assert!(
1090 exspec_core::rules::is_undescriptive_test_name(&func.name),
1091 "test '{}' should be undescriptive",
1092 func.name
1093 );
1094 }
1095 }
1096
1097 #[test]
1098 fn t109_pass_descriptive_names() {
1099 let source = fixture("t109_pass.rs");
1100 let extractor = RustExtractor::new();
1101 let funcs = extractor.extract_test_functions(&source, "t109_pass.rs");
1102 assert!(!funcs.is_empty());
1103 for func in &funcs {
1104 assert!(
1105 !exspec_core::rules::is_undescriptive_test_name(&func.name),
1106 "test '{}' should be descriptive",
1107 func.name
1108 );
1109 }
1110 }
1111
1112 #[test]
1115 fn t106_violation_duplicate_literal() {
1116 let source = fixture("t106_violation.rs");
1117 let extractor = RustExtractor::new();
1118 let funcs = extractor.extract_test_functions(&source, "t106_violation.rs");
1119 assert_eq!(funcs.len(), 1);
1120 assert!(
1121 funcs[0].analysis.duplicate_literal_count >= 3,
1122 "42 appears 3 times, should be >= 3: got {}",
1123 funcs[0].analysis.duplicate_literal_count
1124 );
1125 }
1126
1127 #[test]
1128 fn t106_pass_no_duplicates() {
1129 let source = fixture("t106_pass_no_duplicates.rs");
1130 let extractor = RustExtractor::new();
1131 let funcs = extractor.extract_test_functions(&source, "t106_pass_no_duplicates.rs");
1132 assert_eq!(funcs.len(), 1);
1133 assert!(
1134 funcs[0].analysis.duplicate_literal_count < 3,
1135 "each literal appears once: got {}",
1136 funcs[0].analysis.duplicate_literal_count
1137 );
1138 }
1139
1140 #[test]
1143 fn t001_should_panic_counts_as_assertion() {
1144 let source = fixture("t001_should_panic.rs");
1146 let extractor = RustExtractor::new();
1147 let funcs = extractor.extract_test_functions(&source, "t001_should_panic.rs");
1148 assert_eq!(funcs.len(), 1);
1149 assert!(
1150 funcs[0].analysis.assertion_count >= 1,
1151 "#[should_panic] should count as assertion, got {}",
1152 funcs[0].analysis.assertion_count
1153 );
1154 }
1155
1156 #[test]
1157 fn t001_should_panic_before_test_counts_as_assertion() {
1158 let source = fixture("t001_should_panic_before_test.rs");
1160 let extractor = RustExtractor::new();
1161 let funcs = extractor.extract_test_functions(&source, "t001_should_panic_before_test.rs");
1162 assert_eq!(funcs.len(), 1);
1163 assert!(
1164 funcs[0].analysis.assertion_count >= 1,
1165 "#[should_panic] before #[test] should count as assertion, got {}",
1166 funcs[0].analysis.assertion_count
1167 );
1168 }
1169
1170 #[test]
1171 fn t001_should_panic_in_mod_counts_as_assertion() {
1172 let source = fixture("t001_should_panic_in_mod.rs");
1174 let extractor = RustExtractor::new();
1175 let funcs = extractor.extract_test_functions(&source, "t001_should_panic_in_mod.rs");
1176 assert_eq!(funcs.len(), 1);
1177 assert!(
1178 funcs[0].analysis.assertion_count >= 1,
1179 "#[should_panic] in mod should count as assertion, got {}",
1180 funcs[0].analysis.assertion_count
1181 );
1182 }
1183}