Skip to main content

exspec_lang_rust/
lib.rs

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    // Rust uses snake_case: mock_service -> "service"
70    if let Some(stripped) = var_name.strip_prefix("mock_") {
71        if !stripped.is_empty() {
72            return stripped.to_string();
73        }
74    }
75    // camelCase: mockService -> "Service" (less common in Rust but handle it)
76    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    /// Row of attribute_item (for suppression lookup)
91    attr_start_row: usize,
92    /// Whether #[should_panic] is present (counts as assertion for T001)
93    has_should_panic: bool,
94}
95
96/// Find the root object of a field_expression chain (method call chain).
97/// e.g. `Config::builder().timeout(30).build()`:
98///   call_expression { function: field_expression { value: call_expression { function: field_expression { value: call_expression { function: scoped_identifier } } } } }
99///   → root call_expression's function is scoped_identifier
100/// Check if a call_expression is a "constructor" (setup) or "method on local" (action).
101/// Returns true for fixture-like calls: Type::new(), free_func(), builder chains from constructors.
102/// Returns false for method calls on local variables: service.create(), result.unwrap().
103fn is_constructor_call(node: Node) -> bool {
104    let func = match node.child_by_field_name("function") {
105        Some(f) => f,
106        None => return true, // conservative
107    };
108    match func.kind() {
109        // Type::new(), Config::default() — constructor
110        "scoped_identifier" => true,
111        // add(1, 2), create_user() — free function call
112        "identifier" => true,
113        // obj.method() or chain.method() — need to find the root
114        "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                // Chain: inner_call().method() — recurse to check inner call
121                is_constructor_call(value)
122            } else {
123                // Root is a local variable: service.create(), result.unwrap()
124                false
125            }
126        }
127        _ => true,
128    }
129}
130
131/// Check if a let value expression represents fixture/setup (not action/prep).
132/// In tree-sitter-rust, `obj.method()` is `call_expression { function: field_expression }`.
133/// Fixture: Type::new(), struct literals, macros, free function calls, builder chains from constructors.
134/// Non-fixture: method calls on local variables (e.g. service.create(), result.unwrap()).
135fn 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, // literals, etc. are test data (fixture-like)
140    }
141}
142
143/// Count Rust assertion macros that have a message argument.
144/// assert!(expr, "msg") has 1+ top-level commas in token_tree.
145/// assert_eq!(a, b, "msg") has 2+ top-level commas in token_tree.
146fn 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            // Find token_tree child
163            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                // Count top-level commas in token_tree.
174                // token_tree includes outer delimiters "(", ")".
175                // Only count commas that are direct children of this token_tree
176                // (not inside nested token_tree children).
177                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                // assert!(expr): needs 1+ comma for msg
187                // assert_eq!(a, b): needs 2+ commas for msg
188                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
202/// Count fixture-like `let` declarations in a Rust function body.
203/// Excludes method calls on local variables (action/assertion prep).
204fn 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, // `let x;` without value — count conservatively
223                }
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    // test_function.scm captures @test_attr (attribute_item).
248    // The corresponding function_item is the next sibling of attribute_item.
249    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            // Check previous siblings for #[should_panic] (handles #[should_panic] before #[test]).
268            // Also update attr_start_row to the earliest attribute for suppression comment lookup.
269            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            // Walk next siblings to find the function_item.
289            // Also detect #[should_panic] among sibling attribute_items.
290            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                // Skip over other attribute_items or whitespace nodes
315                // If we hit something that is not an attribute_item, stop
316                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        // #[should_panic] is outside fn_node (sibling attribute), detected during sibling walk
345        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        // T108: wait-and-see detection
370        let has_wait = has_any_match(wait_query, "wait", fn_node, source_bytes);
371
372        // T107: assertion message count
373        let assertion_message_count =
374            count_assertion_messages_rust(assertion_query, fn_node, source_bytes);
375
376        // T106: duplicate literal count
377        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        // Suppression comment is the line before the attribute_item
385        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    // --- Basic parser ---
489
490    #[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 function extraction (TC-01, TC-02, TC-03) ---
505
506    #[test]
507    fn extract_single_test() {
508        // TC-01: #[test] function is extracted
509        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        // TC-02: functions without #[test] are not extracted
519        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        // TC-03: #[tokio::test] is extracted
528        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    // --- Assertion detection (TC-04, TC-05, TC-06, TC-07) ---
537
538    #[test]
539    fn assertion_count_zero_for_violation() {
540        // TC-04: assertion-free test has count 0
541        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        // TC-05: assert_eq! is counted
554        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        // TC-06: assert!, assert_eq!, assert_ne! all counted
567        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        // TC-07: debug_assert! is also counted
580        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    // --- Helper delegation assertion detection (#66) ---
591
592    #[test]
593    fn simple_assert_fn_call_detected() {
594        // T1: assert_matches() function call counts as assertion
595        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        // T2: common::assert_matches() scoped function call counts as assertion
612        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        // T3: Both macro and function call counted
629        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        // T5: assertion_helper() should NOT be counted (^assert_ not ^assert)
646        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        // T6: helper_check() should NOT be counted
659        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    // --- Mock detection (TC-08, TC-09, TC-10, TC-11) ---
670
671    #[test]
672    fn mock_pattern_detected() {
673        // TC-08: MockXxx::new() is detected
674        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        // TC-09: mock_count > 5 triggers T002
687        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        // TC-10: mock_count <= 5 passes
701        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        // TC-11: mock class name stripping
715        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    // --- Giant test (TC-12, TC-13) ---
722
723    #[test]
724    fn giant_test_line_count() {
725        // TC-12: > 50 lines triggers T003
726        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        // TC-13: <= 50 lines passes
740        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    // --- File-level rules (TC-14, TC-15, TC-16, TC-17, TC-18) ---
752
753    #[test]
754    fn file_analysis_detects_parameterized() {
755        // TC-14: #[rstest] detected
756        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        // TC-15: no #[rstest] means parameterized_count = 0
769        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        // TC-16: use proptest detected
781        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        // TC-17: no PBT import
790        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        // TC-18: T008 always INFO for Rust (no contract library)
799        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    // --- prop_assert detection (#10) ---
806
807    #[test]
808    fn prop_assert_counts_as_assertion() {
809        // #10: prop_assert_eq! should be counted as assertion
810        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    // --- Inline suppression (TC-19) ---
822
823    #[test]
824    fn suppressed_test_has_suppressed_rules() {
825        // TC-19: // exspec-ignore: T001 suppresses T001
826        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    // --- Query capture name verification (#14) ---
842
843    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    // Comment-only file by design (Rust has no contract validation library).
902    // This assertion will fail when a real library is added.
903    // When that happens, update the has_any_match call site in extract_file_analysis() accordingly.
904    #[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    // --- T103: missing-error-test ---
914
915    #[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    // --- T105: deterministic-no-metamorphic ---
966
967    #[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    // --- T101: how-not-what ---
999
1000    #[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        // Rust macro arguments are token_tree (not AST), so field_expression
1026        // with _name inside assert_eq!() is not detectable.
1027        // Private field access outside macros IS detected as field_expression,
1028        // but count_captures_within_context requires it to be inside an
1029        // assertion node (macro_invocation), which doesn't contain field_expression.
1030        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    // --- T102: fixture-sprawl ---
1058
1059    #[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    // --- T108: wait-and-see ---
1093
1094    #[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    // --- T107: assertion-roulette ---
1131
1132    #[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    // --- T109: undescriptive-test-name ---
1161
1162    #[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    // --- T106: duplicate-literal-assertion ---
1193
1194    #[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    // --- T001 FP fix: #[should_panic] as assertion (#25) ---
1221
1222    #[test]
1223    fn t001_should_panic_counts_as_assertion() {
1224        // TC-09: #[should_panic] only -> T001 should NOT fire
1225        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        // TC-09c: #[should_panic] BEFORE #[test] -> T001 should NOT fire
1239        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        // TC-09b: #[should_panic] inside mod tests {} -> T001 should NOT fire
1253        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}