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