Skip to main content

exspec_lang_rust/
lib.rs

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    // Rust uses snake_case: mock_service -> "service"
72    if let Some(stripped) = var_name.strip_prefix("mock_") {
73        if !stripped.is_empty() {
74            return stripped.to_string();
75        }
76    }
77    // camelCase: mockService -> "Service" (less common in Rust but handle it)
78    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    /// Row of attribute_item (for suppression lookup)
93    attr_start_row: usize,
94    /// Whether #[should_panic] is present (counts as assertion for T001)
95    has_should_panic: bool,
96}
97
98/// Find the root object of a field_expression chain (method call chain).
99/// e.g. `Config::builder().timeout(30).build()`:
100///   call_expression { function: field_expression { value: call_expression { function: field_expression { value: call_expression { function: scoped_identifier } } } } }
101///   → root call_expression's function is scoped_identifier
102/// Check if a call_expression is a "constructor" (setup) or "method on local" (action).
103/// Returns true for fixture-like calls: Type::new(), free_func(), builder chains from constructors.
104/// Returns false for method calls on local variables: service.create(), result.unwrap().
105fn is_constructor_call(node: Node) -> bool {
106    let func = match node.child_by_field_name("function") {
107        Some(f) => f,
108        None => return true, // conservative
109    };
110    match func.kind() {
111        // Type::new(), Config::default() — constructor
112        "scoped_identifier" => true,
113        // add(1, 2), create_user() — free function call
114        "identifier" => true,
115        // obj.method() or chain.method() — need to find the root
116        "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                // Chain: inner_call().method() — recurse to check inner call
123                is_constructor_call(value)
124            } else {
125                // Root is a local variable: service.create(), result.unwrap()
126                false
127            }
128        }
129        _ => true,
130    }
131}
132
133/// Check if a let value expression represents fixture/setup (not action/prep).
134/// In tree-sitter-rust, `obj.method()` is `call_expression { function: field_expression }`.
135/// Fixture: Type::new(), struct literals, macros, free function calls, builder chains from constructors.
136/// Non-fixture: method calls on local variables (e.g. service.create(), result.unwrap()).
137fn 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, // literals, etc. are test data (fixture-like)
142    }
143}
144
145/// Count Rust assertion macros that have a message argument.
146/// assert!(expr, "msg") has 1+ top-level commas in token_tree.
147/// assert_eq!(a, b, "msg") has 2+ top-level commas in token_tree.
148fn 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            // Find token_tree child
165            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                // Count top-level commas in token_tree.
176                // token_tree includes outer delimiters "(", ")".
177                // Only count commas that are direct children of this token_tree
178                // (not inside nested token_tree children).
179                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                // assert!(expr): needs 1+ comma for msg
189                // assert_eq!(a, b): needs 2+ commas for msg
190                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
204/// Count fixture-like `let` declarations in a Rust function body.
205/// Excludes method calls on local variables (action/assertion prep).
206fn 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, // `let x;` without value — count conservatively
225                }
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    // test_function.scm captures @test_attr (attribute_item).
250    // The corresponding function_item is the next sibling of attribute_item.
251    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            // Check previous siblings for #[should_panic] (handles #[should_panic] before #[test]).
270            // Also update attr_start_row to the earliest attribute for suppression comment lookup.
271            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            // Walk next siblings to find the function_item.
291            // Also detect #[should_panic] among sibling attribute_items.
292            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                // Skip over other attribute_items or whitespace nodes
317                // If we hit something that is not an attribute_item, stop
318                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        // #[should_panic] is outside fn_node (sibling attribute), detected during sibling walk
347        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        // T108: wait-and-see detection
372        let has_wait = has_any_match(wait_query, "wait", fn_node, source_bytes);
373
374        // T107: assertion message count
375        let assertion_message_count =
376            count_assertion_messages_rust(assertion_query, fn_node, source_bytes);
377
378        // T106: duplicate literal count
379        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        // Suppression comment is the line before the attribute_item
387        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    // --- Basic parser ---
491
492    #[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 function extraction (TC-01, TC-02, TC-03) ---
507
508    #[test]
509    fn extract_single_test() {
510        // TC-01: #[test] function is extracted
511        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        // TC-02: functions without #[test] are not extracted
521        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        // TC-03: #[tokio::test] is extracted
530        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    // --- Assertion detection (TC-04, TC-05, TC-06, TC-07) ---
539
540    #[test]
541    fn assertion_count_zero_for_violation() {
542        // TC-04: assertion-free test has count 0
543        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        // TC-05: assert_eq! is counted
556        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        // TC-06: assert!, assert_eq!, assert_ne! all counted
569        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        // TC-07: debug_assert! is also counted
582        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    // --- Helper delegation assertion detection (#66) ---
593
594    #[test]
595    fn simple_assert_fn_call_detected() {
596        // T1: assert_matches() function call counts as assertion
597        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        // T2: common::assert_matches() scoped function call counts as assertion
614        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        // T3: Both macro and function call counted
631        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        // T5: assertion_helper() should NOT be counted (^assert_ not ^assert)
648        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        // T6: helper_check() should NOT be counted
661        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    // --- Mock detection (TC-08, TC-09, TC-10, TC-11) ---
672
673    #[test]
674    fn mock_pattern_detected() {
675        // TC-08: MockXxx::new() is detected
676        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        // TC-09: mock_count > 5 triggers T002
689        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        // TC-10: mock_count <= 5 passes
703        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        // TC-11: mock class name stripping
717        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    // --- Giant test (TC-12, TC-13) ---
724
725    #[test]
726    fn giant_test_line_count() {
727        // TC-12: > 50 lines triggers T003
728        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        // TC-13: <= 50 lines passes
742        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    // --- File-level rules (TC-14, TC-15, TC-16, TC-17, TC-18) ---
754
755    #[test]
756    fn file_analysis_detects_parameterized() {
757        // TC-14: #[rstest] detected
758        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        // TC-15: no #[rstest] means parameterized_count = 0
771        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        // TC-16: use proptest detected
783        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        // TC-17: no PBT import
792        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        // TC-18: T008 always INFO for Rust (no contract library)
801        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    // --- prop_assert detection (#10) ---
808
809    #[test]
810    fn prop_assert_counts_as_assertion() {
811        // #10: prop_assert_eq! should be counted as assertion
812        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    // --- Inline suppression (TC-19) ---
824
825    #[test]
826    fn suppressed_test_has_suppressed_rules() {
827        // TC-19: // exspec-ignore: T001 suppresses T001
828        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    // --- Query capture name verification (#14) ---
844
845    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    // Comment-only file by design (Rust has no contract validation library).
904    // This assertion will fail when a real library is added.
905    // When that happens, update the has_any_match call site in extract_file_analysis() accordingly.
906    #[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    // --- T103: missing-error-test ---
916
917    #[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    // --- T105: deterministic-no-metamorphic ---
968
969    #[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    // --- T101: how-not-what ---
1001
1002    #[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        // Rust macro arguments are token_tree (not AST), so field_expression
1028        // with _name inside assert_eq!() is not detectable.
1029        // Private field access outside macros IS detected as field_expression,
1030        // but count_captures_within_context requires it to be inside an
1031        // assertion node (macro_invocation), which doesn't contain field_expression.
1032        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    // --- T102: fixture-sprawl ---
1060
1061    #[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    // --- T108: wait-and-see ---
1095
1096    #[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    // --- T107: assertion-roulette ---
1133
1134    #[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    // --- T109: undescriptive-test-name ---
1163
1164    #[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    // --- T106: duplicate-literal-assertion ---
1195
1196    #[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    // --- T001 FP fix: #[should_panic] as assertion (#25) ---
1223
1224    #[test]
1225    fn t001_should_panic_counts_as_assertion() {
1226        // TC-09: #[should_panic] only -> T001 should NOT fire
1227        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        // TC-09c: #[should_panic] BEFORE #[test] -> T001 should NOT fire
1241        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        // TC-09b: #[should_panic] inside mod tests {} -> T001 should NOT fire
1255        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}