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    apply_same_file_helper_tracing, collect_mock_class_names, count_captures,
8    count_captures_within_context, count_duplicate_literals,
9    extract_suppression_from_previous_line, has_any_match,
10};
11use streaming_iterator::StreamingIterator;
12use tree_sitter::{Node, Parser, Query, QueryCursor};
13
14const TEST_FUNCTION_QUERY: &str = include_str!("../queries/test_function.scm");
15const ASSERTION_QUERY: &str = include_str!("../queries/assertion.scm");
16const MOCK_USAGE_QUERY: &str = include_str!("../queries/mock_usage.scm");
17const MOCK_ASSIGNMENT_QUERY: &str = include_str!("../queries/mock_assignment.scm");
18const PARAMETERIZED_QUERY: &str = include_str!("../queries/parameterized.scm");
19const IMPORT_PBT_QUERY: &str = include_str!("../queries/import_pbt.scm");
20const IMPORT_CONTRACT_QUERY: &str = include_str!("../queries/import_contract.scm");
21const HOW_NOT_WHAT_QUERY: &str = include_str!("../queries/how_not_what.scm");
22const PRIVATE_IN_ASSERTION_QUERY: &str = include_str!("../queries/private_in_assertion.scm");
23const ERROR_TEST_QUERY: &str = include_str!("../queries/error_test.scm");
24const RELATIONAL_ASSERTION_QUERY: &str = include_str!("../queries/relational_assertion.scm");
25const WAIT_AND_SEE_QUERY: &str = include_str!("../queries/wait_and_see.scm");
26const HELPER_TRACE_QUERY: &str = include_str!("../queries/helper_trace.scm");
27
28fn rust_language() -> tree_sitter::Language {
29    tree_sitter_rust::LANGUAGE.into()
30}
31
32/// Check if an `attribute_item` node has an exact attribute name.
33/// Walks the tree-sitter structure: attribute_item → attribute → identifier.
34/// Avoids substring matches that could falsely trigger on names like
35/// `my_crate::should_panic_handler`.
36fn attribute_has_name(node: &Node, source_bytes: &[u8], name: &str) -> bool {
37    // tree-sitter-rust structure:
38    //   attribute_item → "#" → "[" → attribute → "]"
39    //   attribute → path (identifier | scoped_identifier) [+ arguments]
40    let mut cursor = node.walk();
41    for child in node.children(&mut cursor) {
42        // Direct identifier child (simple attribute like #[test])
43        if child.kind() == "identifier" {
44            if let Ok(text) = child.utf8_text(source_bytes) {
45                if text == name {
46                    return true;
47                }
48            }
49        }
50        // attribute or meta_item child
51        if child.kind() == "attribute" || child.kind() == "meta_item" {
52            let mut inner_cursor = child.walk();
53            for inner in child.children(&mut inner_cursor) {
54                if inner.kind() == "identifier" {
55                    if let Ok(text) = inner.utf8_text(source_bytes) {
56                        if text == name {
57                            return true;
58                        }
59                    }
60                    // Only check the first identifier (the attribute name, not arguments)
61                    break;
62                }
63            }
64        }
65    }
66    false
67}
68
69fn cached_query<'a>(lock: &'a OnceLock<Query>, source: &str) -> &'a Query {
70    lock.get_or_init(|| Query::new(&rust_language(), source).expect("invalid query"))
71}
72
73static TEST_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
74static ASSERTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
75static MOCK_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
76static MOCK_ASSIGN_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
77static PARAMETERIZED_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
78static IMPORT_PBT_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
79static IMPORT_CONTRACT_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
80static HOW_NOT_WHAT_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
81static PRIVATE_IN_ASSERTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
82static ERROR_TEST_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
83static RELATIONAL_ASSERTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
84static WAIT_AND_SEE_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
85static HELPER_TRACE_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
86
87pub struct RustExtractor;
88
89impl RustExtractor {
90    pub fn new() -> Self {
91        Self
92    }
93
94    pub fn parser() -> Parser {
95        let mut parser = Parser::new();
96        let language = tree_sitter_rust::LANGUAGE;
97        parser
98            .set_language(&language.into())
99            .expect("failed to load Rust grammar");
100        parser
101    }
102}
103
104impl Default for RustExtractor {
105    fn default() -> Self {
106        Self::new()
107    }
108}
109
110fn extract_mock_class_name(var_name: &str) -> String {
111    // Rust uses snake_case: mock_service -> "service"
112    if let Some(stripped) = var_name.strip_prefix("mock_") {
113        if !stripped.is_empty() {
114            return stripped.to_string();
115        }
116    }
117    // camelCase: mockService -> "Service" (less common in Rust but handle it)
118    if let Some(stripped) = var_name.strip_prefix("mock") {
119        if !stripped.is_empty() && stripped.starts_with(|c: char| c.is_uppercase()) {
120            return stripped.to_string();
121        }
122    }
123    var_name.to_string()
124}
125
126struct TestMatch {
127    name: String,
128    fn_start_byte: usize,
129    fn_end_byte: usize,
130    fn_start_row: usize,
131    fn_end_row: usize,
132    /// Row of attribute_item (for suppression lookup)
133    attr_start_row: usize,
134    /// Whether #[should_panic] is present (counts as assertion for T001)
135    has_should_panic: bool,
136}
137
138/// Find the root object of a field_expression chain (method call chain).
139/// e.g. `Config::builder().timeout(30).build()`:
140///   call_expression { function: field_expression { value: call_expression { function: field_expression { value: call_expression { function: scoped_identifier } } } } }
141///   → root call_expression's function is scoped_identifier
142/// Check if a call_expression is a "constructor" (setup) or "method on local" (action).
143/// Returns true for fixture-like calls: Type::new(), free_func(), builder chains from constructors.
144/// Returns false for method calls on local variables: service.create(), result.unwrap().
145fn is_constructor_call(node: Node) -> bool {
146    let func = match node.child_by_field_name("function") {
147        Some(f) => f,
148        None => return true, // conservative
149    };
150    match func.kind() {
151        // Type::new(), Config::default() — constructor
152        "scoped_identifier" => true,
153        // add(1, 2), create_user() — free function call
154        "identifier" => true,
155        // obj.method() or chain.method() — need to find the root
156        "field_expression" => {
157            let value = match func.child_by_field_name("value") {
158                Some(v) => v,
159                None => return true,
160            };
161            if value.kind() == "call_expression" {
162                // Chain: inner_call().method() — recurse to check inner call
163                is_constructor_call(value)
164            } else {
165                // Root is a local variable: service.create(), result.unwrap()
166                false
167            }
168        }
169        _ => true,
170    }
171}
172
173/// Check if a let value expression represents fixture/setup (not action/prep).
174/// In tree-sitter-rust, `obj.method()` is `call_expression { function: field_expression }`.
175/// Fixture: Type::new(), struct literals, macros, free function calls, builder chains from constructors.
176/// Non-fixture: method calls on local variables (e.g. service.create(), result.unwrap()).
177fn is_fixture_value(node: Node) -> bool {
178    match node.kind() {
179        "call_expression" => is_constructor_call(node),
180        "struct_expression" | "macro_invocation" => true,
181        _ => true, // literals, etc. are test data (fixture-like)
182    }
183}
184
185/// Count Rust assertion macros that have a message argument.
186/// assert!(expr, "msg") has 1+ top-level commas in token_tree.
187/// assert_eq!(a, b, "msg") has 2+ top-level commas in token_tree.
188fn count_assertion_messages_rust(assertion_query: &Query, fn_node: Node, source: &[u8]) -> usize {
189    let assertion_idx = match assertion_query.capture_index_for_name("assertion") {
190        Some(idx) => idx,
191        None => return 0,
192    };
193    let mut cursor = QueryCursor::new();
194    let mut matches = cursor.matches(assertion_query, fn_node, source);
195    let mut count = 0;
196    while let Some(m) = matches.next() {
197        for cap in m.captures.iter().filter(|c| c.index == assertion_idx) {
198            let node = cap.node;
199            let macro_name = node
200                .child_by_field_name("macro")
201                .and_then(|n| n.utf8_text(source).ok())
202                .unwrap_or("");
203
204            // Find token_tree child
205            let token_tree = (0..node.child_count()).find_map(|i| {
206                let child = node.child(i)?;
207                if child.kind() == "token_tree" {
208                    Some(child)
209                } else {
210                    None
211                }
212            });
213
214            if let Some(tt) = token_tree {
215                // Count top-level commas in token_tree.
216                // token_tree includes outer delimiters "(", ")".
217                // Only count commas that are direct children of this token_tree
218                // (not inside nested token_tree children).
219                let mut comma_count = 0;
220                for i in 0..tt.child_count() {
221                    if let Some(child) = tt.child(i) {
222                        if child.kind() == "," {
223                            comma_count += 1;
224                        }
225                    }
226                }
227
228                // assert!(expr): needs 1+ comma for msg
229                // assert_eq!(a, b): needs 2+ commas for msg
230                let min_commas = if macro_name.contains("_eq") || macro_name.contains("_ne") {
231                    2
232                } else {
233                    1
234                };
235                if comma_count >= min_commas {
236                    count += 1;
237                }
238            }
239        }
240    }
241    count
242}
243
244/// Count fixture-like `let` declarations in a Rust function body.
245/// Excludes method calls on local variables (action/assertion prep).
246fn count_fixture_lets(fn_node: Node) -> usize {
247    let body = match fn_node.child_by_field_name("body") {
248        Some(n) => n,
249        None => return 0,
250    };
251
252    let mut count = 0;
253    let mut cursor = body.walk();
254    if cursor.goto_first_child() {
255        loop {
256            let node = cursor.node();
257            if node.kind() == "let_declaration" {
258                match node.child_by_field_name("value") {
259                    Some(value) => {
260                        if is_fixture_value(value) {
261                            count += 1;
262                        }
263                    }
264                    None => count += 1, // `let x;` without value — count conservatively
265                }
266            }
267            if !cursor.goto_next_sibling() {
268                break;
269            }
270        }
271    }
272    count
273}
274
275fn extract_functions_from_tree(source: &str, file_path: &str, root: Node) -> Vec<TestFunction> {
276    let test_query = cached_query(&TEST_QUERY_CACHE, TEST_FUNCTION_QUERY);
277    let assertion_query = cached_query(&ASSERTION_QUERY_CACHE, ASSERTION_QUERY);
278    let mock_query = cached_query(&MOCK_QUERY_CACHE, MOCK_USAGE_QUERY);
279    let mock_assign_query = cached_query(&MOCK_ASSIGN_QUERY_CACHE, MOCK_ASSIGNMENT_QUERY);
280    let how_not_what_query = cached_query(&HOW_NOT_WHAT_QUERY_CACHE, HOW_NOT_WHAT_QUERY);
281    let private_query = cached_query(
282        &PRIVATE_IN_ASSERTION_QUERY_CACHE,
283        PRIVATE_IN_ASSERTION_QUERY,
284    );
285    let wait_query = cached_query(&WAIT_AND_SEE_QUERY_CACHE, WAIT_AND_SEE_QUERY);
286
287    let source_bytes = source.as_bytes();
288
289    // test_function.scm captures @test_attr (attribute_item).
290    // The corresponding function_item is the next sibling of attribute_item.
291    let attr_idx = test_query
292        .capture_index_for_name("test_attr")
293        .expect("no @test_attr capture");
294
295    let mut test_matches: Vec<TestMatch> = Vec::new();
296    let mut seen_fn_bytes: std::collections::HashSet<usize> = std::collections::HashSet::new();
297
298    {
299        let mut cursor = QueryCursor::new();
300        let mut matches = cursor.matches(test_query, root, source_bytes);
301        while let Some(m) = matches.next() {
302            let attr_capture = match m.captures.iter().find(|c| c.index == attr_idx) {
303                Some(c) => c,
304                None => continue,
305            };
306            let attr_node = attr_capture.node;
307            let attr_start_row = attr_node.start_position().row;
308
309            // Check previous siblings for #[should_panic] (handles #[should_panic] before #[test]).
310            // Also update attr_start_row to the earliest attribute for suppression comment lookup.
311            let mut has_should_panic = false;
312            let mut attr_start_row = attr_start_row;
313            {
314                let mut prev = attr_node.prev_sibling();
315                while let Some(p) = prev {
316                    if p.kind() == "attribute_item" {
317                        attr_start_row = p.start_position().row;
318                        if attribute_has_name(&p, source_bytes, "should_panic") {
319                            has_should_panic = true;
320                        }
321                    } else if p.kind() != "line_comment" && p.kind() != "block_comment" {
322                        break;
323                    }
324                    prev = p.prev_sibling();
325                }
326            }
327
328            // Walk next siblings to find the function_item.
329            // Also detect #[should_panic] among sibling attribute_items.
330            let mut sibling = attr_node.next_sibling();
331            while let Some(s) = sibling {
332                if s.kind() == "function_item" {
333                    let fn_start_byte = s.start_byte();
334                    if seen_fn_bytes.insert(fn_start_byte) {
335                        let name = s
336                            .child_by_field_name("name")
337                            .and_then(|n| n.utf8_text(source_bytes).ok())
338                            .unwrap_or("")
339                            .to_string();
340                        if !name.is_empty() {
341                            test_matches.push(TestMatch {
342                                name,
343                                fn_start_byte,
344                                fn_end_byte: s.end_byte(),
345                                fn_start_row: s.start_position().row,
346                                fn_end_row: s.end_position().row,
347                                attr_start_row,
348                                has_should_panic,
349                            });
350                        }
351                    }
352                    break;
353                }
354                // Skip over other attribute_items or whitespace nodes
355                // If we hit something that is not an attribute_item, stop
356                if s.kind() == "attribute_item" {
357                    if attribute_has_name(&s, source_bytes, "should_panic") {
358                        has_should_panic = true;
359                    }
360                } else if s.kind() != "line_comment" && s.kind() != "block_comment" {
361                    break;
362                }
363                sibling = s.next_sibling();
364            }
365        }
366    }
367
368    let mut functions = Vec::new();
369    for tm in &test_matches {
370        let fn_node = match root.descendant_for_byte_range(tm.fn_start_byte, tm.fn_end_byte) {
371            Some(n) => n,
372            None => continue,
373        };
374
375        let line = tm.fn_start_row + 1;
376        let end_line = tm.fn_end_row + 1;
377        let line_count = end_line - line + 1;
378
379        let mut assertion_count =
380            count_captures(assertion_query, "assertion", fn_node, source_bytes);
381
382        // #[should_panic] is outside fn_node (sibling attribute), detected during sibling walk
383        if tm.has_should_panic {
384            assertion_count += 1;
385        }
386        let mock_count = count_captures(mock_query, "mock", fn_node, source_bytes);
387        let mock_classes = collect_mock_class_names(
388            mock_assign_query,
389            fn_node,
390            source_bytes,
391            extract_mock_class_name,
392        );
393        let how_not_what_count =
394            count_captures(how_not_what_query, "how_pattern", fn_node, source_bytes);
395
396        let private_in_assertion_count = count_captures_within_context(
397            assertion_query,
398            "assertion",
399            private_query,
400            "private_access",
401            fn_node,
402            source_bytes,
403        );
404
405        let fixture_count = count_fixture_lets(fn_node);
406
407        // T108: wait-and-see detection
408        let has_wait = has_any_match(wait_query, "wait", fn_node, source_bytes);
409
410        // T107: assertion message count
411        let assertion_message_count =
412            count_assertion_messages_rust(assertion_query, fn_node, source_bytes);
413
414        // T106: duplicate literal count
415        let duplicate_literal_count = count_duplicate_literals(
416            assertion_query,
417            fn_node,
418            source_bytes,
419            &["integer_literal", "float_literal", "string_literal"],
420        );
421
422        // Suppression comment is the line before the attribute_item
423        let suppressed_rules = extract_suppression_from_previous_line(source, tm.attr_start_row);
424
425        functions.push(TestFunction {
426            name: tm.name.clone(),
427            file: file_path.to_string(),
428            line,
429            end_line,
430            analysis: TestAnalysis {
431                assertion_count,
432                mock_count,
433                mock_classes,
434                line_count,
435                how_not_what_count: how_not_what_count + private_in_assertion_count,
436                fixture_count,
437                has_wait,
438                has_skip_call: false,
439                assertion_message_count,
440                duplicate_literal_count,
441                suppressed_rules,
442            },
443        });
444    }
445
446    functions
447}
448
449impl LanguageExtractor for RustExtractor {
450    fn extract_test_functions(&self, source: &str, file_path: &str) -> Vec<TestFunction> {
451        let mut parser = Self::parser();
452        let tree = match parser.parse(source, None) {
453            Some(t) => t,
454            None => return Vec::new(),
455        };
456        extract_functions_from_tree(source, file_path, tree.root_node())
457    }
458
459    fn extract_file_analysis(&self, source: &str, file_path: &str) -> FileAnalysis {
460        let mut parser = Self::parser();
461        let tree = match parser.parse(source, None) {
462            Some(t) => t,
463            None => {
464                return FileAnalysis {
465                    file: file_path.to_string(),
466                    functions: Vec::new(),
467                    has_pbt_import: false,
468                    has_contract_import: false,
469                    has_error_test: false,
470                    has_relational_assertion: false,
471                    parameterized_count: 0,
472                };
473            }
474        };
475
476        let root = tree.root_node();
477        let source_bytes = source.as_bytes();
478
479        let functions = extract_functions_from_tree(source, file_path, root);
480
481        let param_query = cached_query(&PARAMETERIZED_QUERY_CACHE, PARAMETERIZED_QUERY);
482        let parameterized_count = count_captures(param_query, "parameterized", root, source_bytes);
483
484        let pbt_query = cached_query(&IMPORT_PBT_QUERY_CACHE, IMPORT_PBT_QUERY);
485        let has_pbt_import = has_any_match(pbt_query, "pbt_import", root, source_bytes);
486
487        let contract_query = cached_query(&IMPORT_CONTRACT_QUERY_CACHE, IMPORT_CONTRACT_QUERY);
488        let has_contract_import =
489            has_any_match(contract_query, "contract_import", root, source_bytes);
490
491        let error_test_query = cached_query(&ERROR_TEST_QUERY_CACHE, ERROR_TEST_QUERY);
492        let has_error_test = has_any_match(error_test_query, "error_test", root, source_bytes);
493
494        let relational_query = cached_query(
495            &RELATIONAL_ASSERTION_QUERY_CACHE,
496            RELATIONAL_ASSERTION_QUERY,
497        );
498        let has_relational_assertion =
499            has_any_match(relational_query, "relational", root, source_bytes);
500
501        let mut file_analysis = FileAnalysis {
502            file: file_path.to_string(),
503            functions,
504            has_pbt_import,
505            has_contract_import,
506            has_error_test,
507            has_relational_assertion,
508            parameterized_count,
509        };
510
511        // Apply same-file helper tracing (Phase 23a)
512        let helper_trace_query = cached_query(&HELPER_TRACE_QUERY_CACHE, HELPER_TRACE_QUERY);
513        let assertion_query_for_trace = cached_query(&ASSERTION_QUERY_CACHE, ASSERTION_QUERY);
514        apply_same_file_helper_tracing(
515            &mut file_analysis,
516            &tree,
517            source_bytes,
518            helper_trace_query,
519            helper_trace_query,
520            assertion_query_for_trace,
521        );
522
523        file_analysis
524    }
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530
531    fn fixture(name: &str) -> String {
532        let path = format!(
533            "{}/tests/fixtures/rust/{}",
534            env!("CARGO_MANIFEST_DIR").replace("/crates/lang-rust", ""),
535            name
536        );
537        std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}"))
538    }
539
540    // --- Basic parser ---
541
542    #[test]
543    fn parse_rust_source() {
544        let source = "#[test]\nfn test_example() {\n    assert_eq!(1, 1);\n}\n";
545        let mut parser = RustExtractor::parser();
546        let tree = parser.parse(source, None).unwrap();
547        assert_eq!(tree.root_node().kind(), "source_file");
548    }
549
550    #[test]
551    fn rust_extractor_implements_language_extractor() {
552        let extractor = RustExtractor::new();
553        let _: &dyn exspec_core::extractor::LanguageExtractor = &extractor;
554    }
555
556    // --- Test function extraction (TC-01, TC-02, TC-03) ---
557
558    #[test]
559    fn extract_single_test() {
560        // TC-01: #[test] function is extracted
561        let source = fixture("t001_pass.rs");
562        let extractor = RustExtractor::new();
563        let funcs = extractor.extract_test_functions(&source, "t001_pass.rs");
564        assert_eq!(funcs.len(), 1, "should extract exactly 1 test function");
565        assert_eq!(funcs[0].name, "test_create_user");
566    }
567
568    #[test]
569    fn non_test_function_not_extracted() {
570        // TC-02: functions without #[test] are not extracted
571        let source = "fn helper() -> i32 { 42 }\n";
572        let extractor = RustExtractor::new();
573        let funcs = extractor.extract_test_functions(&source, "helper.rs");
574        assert_eq!(funcs.len(), 0, "non-test fn should not be extracted");
575    }
576
577    #[test]
578    fn extract_tokio_test() {
579        // TC-03: #[tokio::test] is extracted
580        let source =
581            "#[tokio::test]\nasync fn test_async_operation() {\n    assert_eq!(1, 1);\n}\n";
582        let extractor = RustExtractor::new();
583        let funcs = extractor.extract_test_functions(&source, "tokio_test.rs");
584        assert_eq!(funcs.len(), 1, "should extract #[tokio::test] function");
585        assert_eq!(funcs[0].name, "test_async_operation");
586    }
587
588    // --- Assertion detection (TC-04, TC-05, TC-06, TC-07) ---
589
590    #[test]
591    fn assertion_count_zero_for_violation() {
592        // TC-04: assertion-free test has count 0
593        let source = fixture("t001_violation.rs");
594        let extractor = RustExtractor::new();
595        let funcs = extractor.extract_test_functions(&source, "t001_violation.rs");
596        assert_eq!(funcs.len(), 1);
597        assert_eq!(
598            funcs[0].analysis.assertion_count, 0,
599            "violation file should have 0 assertions"
600        );
601    }
602
603    #[test]
604    fn assertion_count_positive_for_pass() {
605        // TC-05: assert_eq! is counted
606        let source = fixture("t001_pass.rs");
607        let extractor = RustExtractor::new();
608        let funcs = extractor.extract_test_functions(&source, "t001_pass.rs");
609        assert_eq!(funcs.len(), 1);
610        assert!(
611            funcs[0].analysis.assertion_count >= 1,
612            "pass file should have >= 1 assertion"
613        );
614    }
615
616    #[test]
617    fn all_assert_macros_counted() {
618        // TC-06: assert!, assert_eq!, assert_ne! all counted
619        let source = "#[test]\nfn test_all_asserts() {\n    assert!(true);\n    assert_eq!(1, 1);\n    assert_ne!(1, 2);\n}\n";
620        let extractor = RustExtractor::new();
621        let funcs = extractor.extract_test_functions(&source, "test_asserts.rs");
622        assert_eq!(funcs.len(), 1);
623        assert_eq!(
624            funcs[0].analysis.assertion_count, 3,
625            "should count assert!, assert_eq!, assert_ne!"
626        );
627    }
628
629    #[test]
630    fn debug_assert_counted() {
631        // TC-07: debug_assert! is also counted
632        let source = "#[test]\nfn test_debug_assert() {\n    debug_assert!(true);\n}\n";
633        let extractor = RustExtractor::new();
634        let funcs = extractor.extract_test_functions(&source, "test_debug.rs");
635        assert_eq!(funcs.len(), 1);
636        assert_eq!(
637            funcs[0].analysis.assertion_count, 1,
638            "debug_assert! should be counted"
639        );
640    }
641
642    // --- Helper delegation assertion detection (#66) ---
643
644    #[test]
645    fn simple_assert_fn_call_detected() {
646        // T1: assert_matches() function call counts as assertion
647        let source = fixture("t001_pass_helper_delegation.rs");
648        let extractor = RustExtractor::new();
649        let funcs = extractor.extract_test_functions(&source, "t001_pass_helper_delegation.rs");
650        let simple = funcs
651            .iter()
652            .find(|f| f.name == "test_simple_helper")
653            .unwrap();
654        assert!(
655            simple.analysis.assertion_count >= 1,
656            "assert_matches() fn call should be counted as assertion, got {}",
657            simple.analysis.assertion_count
658        );
659    }
660
661    #[test]
662    fn scoped_assert_fn_call_detected() {
663        // T2: common::assert_matches() scoped function call counts as assertion
664        let source = fixture("t001_pass_helper_delegation.rs");
665        let extractor = RustExtractor::new();
666        let funcs = extractor.extract_test_functions(&source, "t001_pass_helper_delegation.rs");
667        let scoped = funcs
668            .iter()
669            .find(|f| f.name == "test_scoped_helper")
670            .unwrap();
671        assert!(
672            scoped.analysis.assertion_count >= 1,
673            "common::assert_matches() should be counted as assertion, got {}",
674            scoped.analysis.assertion_count
675        );
676    }
677
678    #[test]
679    fn mixed_macro_and_fn_call_counted() {
680        // T3: Both macro and function call counted
681        let source = fixture("t001_pass_helper_delegation.rs");
682        let extractor = RustExtractor::new();
683        let funcs = extractor.extract_test_functions(&source, "t001_pass_helper_delegation.rs");
684        let mixed = funcs
685            .iter()
686            .find(|f| f.name == "test_mixed_macro_and_fn")
687            .unwrap();
688        assert_eq!(
689            mixed.analysis.assertion_count, 2,
690            "assert_eq! macro + assert_matches() fn call should total 2, got {}",
691            mixed.analysis.assertion_count
692        );
693    }
694
695    #[test]
696    fn assertion_prefix_not_counted() {
697        // T5: assertion_helper() should NOT be counted (^assert_ not ^assert)
698        let source = "#[test]\nfn test_foo() {\n    assertion_helper(expected, actual);\n}\n";
699        let extractor = RustExtractor::new();
700        let funcs = extractor.extract_test_functions(&source, "test_negative.rs");
701        assert_eq!(funcs.len(), 1);
702        assert_eq!(
703            funcs[0].analysis.assertion_count, 0,
704            "assertion_helper() should NOT be counted as assertion"
705        );
706    }
707
708    #[test]
709    fn ordinary_helper_not_counted() {
710        // T6: helper_check() should NOT be counted
711        let source = "#[test]\nfn test_foo() {\n    helper_check(expected, actual);\n}\n";
712        let extractor = RustExtractor::new();
713        let funcs = extractor.extract_test_functions(&source, "test_negative2.rs");
714        assert_eq!(funcs.len(), 1);
715        assert_eq!(
716            funcs[0].analysis.assertion_count, 0,
717            "helper_check() should NOT be counted as assertion"
718        );
719    }
720
721    // --- Mock detection (TC-08, TC-09, TC-10, TC-11) ---
722
723    #[test]
724    fn mock_pattern_detected() {
725        // TC-08: MockXxx::new() is detected
726        let source = "#[test]\nfn test_with_mock() {\n    let mock_svc = MockService::new();\n    assert_eq!(mock_svc.len(), 0);\n}\n";
727        let extractor = RustExtractor::new();
728        let funcs = extractor.extract_test_functions(&source, "test_mock.rs");
729        assert_eq!(funcs.len(), 1);
730        assert!(
731            funcs[0].analysis.mock_count >= 1,
732            "MockService::new() should be detected"
733        );
734    }
735
736    #[test]
737    fn mock_count_for_violation() {
738        // TC-09: mock_count > 5 triggers T002
739        let source = fixture("t002_violation.rs");
740        let extractor = RustExtractor::new();
741        let funcs = extractor.extract_test_functions(&source, "t002_violation.rs");
742        assert_eq!(funcs.len(), 1);
743        assert!(
744            funcs[0].analysis.mock_count > 5,
745            "violation file should have > 5 mocks, got {}",
746            funcs[0].analysis.mock_count
747        );
748    }
749
750    #[test]
751    fn mock_count_for_pass() {
752        // TC-10: mock_count <= 5 passes
753        let source = fixture("t002_pass.rs");
754        let extractor = RustExtractor::new();
755        let funcs = extractor.extract_test_functions(&source, "t002_pass.rs");
756        assert_eq!(funcs.len(), 1);
757        assert_eq!(
758            funcs[0].analysis.mock_count, 1,
759            "pass file should have 1 mock"
760        );
761        assert_eq!(funcs[0].analysis.mock_classes, vec!["repo"]);
762    }
763
764    #[test]
765    fn mock_class_name_extraction() {
766        // TC-11: mock class name stripping
767        assert_eq!(extract_mock_class_name("mock_service"), "service");
768        assert_eq!(extract_mock_class_name("mock_db"), "db");
769        assert_eq!(extract_mock_class_name("service"), "service");
770        assert_eq!(extract_mock_class_name("mockService"), "Service");
771    }
772
773    // --- Giant test (TC-12, TC-13) ---
774
775    #[test]
776    fn giant_test_line_count() {
777        // TC-12: > 50 lines triggers T003
778        let source = fixture("t003_violation.rs");
779        let extractor = RustExtractor::new();
780        let funcs = extractor.extract_test_functions(&source, "t003_violation.rs");
781        assert_eq!(funcs.len(), 1);
782        assert!(
783            funcs[0].analysis.line_count > 50,
784            "violation file line_count should > 50, got {}",
785            funcs[0].analysis.line_count
786        );
787    }
788
789    #[test]
790    fn short_test_line_count() {
791        // TC-13: <= 50 lines passes
792        let source = fixture("t003_pass.rs");
793        let extractor = RustExtractor::new();
794        let funcs = extractor.extract_test_functions(&source, "t003_pass.rs");
795        assert_eq!(funcs.len(), 1);
796        assert!(
797            funcs[0].analysis.line_count <= 50,
798            "pass file line_count should <= 50, got {}",
799            funcs[0].analysis.line_count
800        );
801    }
802
803    // --- File-level rules (TC-14, TC-15, TC-16, TC-17, TC-18) ---
804
805    #[test]
806    fn file_analysis_detects_parameterized() {
807        // TC-14: #[rstest] detected
808        let source = fixture("t004_pass.rs");
809        let extractor = RustExtractor::new();
810        let fa = extractor.extract_file_analysis(&source, "t004_pass.rs");
811        assert!(
812            fa.parameterized_count >= 1,
813            "should detect #[rstest], got {}",
814            fa.parameterized_count
815        );
816    }
817
818    #[test]
819    fn file_analysis_no_parameterized() {
820        // TC-15: no #[rstest] means parameterized_count = 0
821        let source = fixture("t004_violation.rs");
822        let extractor = RustExtractor::new();
823        let fa = extractor.extract_file_analysis(&source, "t004_violation.rs");
824        assert_eq!(
825            fa.parameterized_count, 0,
826            "violation file should have 0 parameterized"
827        );
828    }
829
830    #[test]
831    fn file_analysis_pbt_import() {
832        // TC-16: use proptest detected
833        let source = fixture("t005_pass.rs");
834        let extractor = RustExtractor::new();
835        let fa = extractor.extract_file_analysis(&source, "t005_pass.rs");
836        assert!(fa.has_pbt_import, "should detect proptest import");
837    }
838
839    #[test]
840    fn file_analysis_no_pbt_import() {
841        // TC-17: no PBT import
842        let source = fixture("t005_violation.rs");
843        let extractor = RustExtractor::new();
844        let fa = extractor.extract_file_analysis(&source, "t005_violation.rs");
845        assert!(!fa.has_pbt_import, "should not detect PBT import");
846    }
847
848    #[test]
849    fn file_analysis_no_contract() {
850        // TC-18: T008 always INFO for Rust (no contract library)
851        let source = fixture("t008_violation.rs");
852        let extractor = RustExtractor::new();
853        let fa = extractor.extract_file_analysis(&source, "t008_violation.rs");
854        assert!(!fa.has_contract_import, "Rust has no contract library");
855    }
856
857    // --- prop_assert detection (#10) ---
858
859    #[test]
860    fn prop_assert_counts_as_assertion() {
861        // #10: prop_assert_eq! should be counted as assertion
862        let source = fixture("t001_proptest_pass.rs");
863        let extractor = RustExtractor::new();
864        let funcs = extractor.extract_test_functions(&source, "t001_proptest_pass.rs");
865        assert_eq!(funcs.len(), 1, "should extract test from proptest! macro");
866        assert!(
867            funcs[0].analysis.assertion_count >= 1,
868            "prop_assert_eq! should be counted, got {}",
869            funcs[0].analysis.assertion_count
870        );
871    }
872
873    // --- Inline suppression (TC-19) ---
874
875    #[test]
876    fn suppressed_test_has_suppressed_rules() {
877        // TC-19: // exspec-ignore: T001 suppresses T001
878        let source = fixture("suppressed.rs");
879        let extractor = RustExtractor::new();
880        let funcs = extractor.extract_test_functions(&source, "suppressed.rs");
881        assert_eq!(funcs.len(), 1);
882        assert!(
883            funcs[0]
884                .analysis
885                .suppressed_rules
886                .iter()
887                .any(|r| r.0 == "T001"),
888            "T001 should be suppressed, got: {:?}",
889            funcs[0].analysis.suppressed_rules
890        );
891    }
892
893    // --- Query capture name verification (#14) ---
894
895    fn make_query(scm: &str) -> Query {
896        Query::new(&rust_language(), scm).unwrap()
897    }
898
899    #[test]
900    fn query_capture_names_test_function() {
901        let q = make_query(include_str!("../queries/test_function.scm"));
902        assert!(
903            q.capture_index_for_name("test_attr").is_some(),
904            "test_function.scm must define @test_attr capture"
905        );
906    }
907
908    #[test]
909    fn query_capture_names_assertion() {
910        let q = make_query(include_str!("../queries/assertion.scm"));
911        assert!(
912            q.capture_index_for_name("assertion").is_some(),
913            "assertion.scm must define @assertion capture"
914        );
915    }
916
917    #[test]
918    fn query_capture_names_mock_usage() {
919        let q = make_query(include_str!("../queries/mock_usage.scm"));
920        assert!(
921            q.capture_index_for_name("mock").is_some(),
922            "mock_usage.scm must define @mock capture"
923        );
924    }
925
926    #[test]
927    fn query_capture_names_mock_assignment() {
928        let q = make_query(include_str!("../queries/mock_assignment.scm"));
929        assert!(
930            q.capture_index_for_name("var_name").is_some(),
931            "mock_assignment.scm must define @var_name (required by collect_mock_class_names .expect())"
932        );
933    }
934
935    #[test]
936    fn query_capture_names_parameterized() {
937        let q = make_query(include_str!("../queries/parameterized.scm"));
938        assert!(
939            q.capture_index_for_name("parameterized").is_some(),
940            "parameterized.scm must define @parameterized capture"
941        );
942    }
943
944    #[test]
945    fn query_capture_names_import_pbt() {
946        let q = make_query(include_str!("../queries/import_pbt.scm"));
947        assert!(
948            q.capture_index_for_name("pbt_import").is_some(),
949            "import_pbt.scm must define @pbt_import capture"
950        );
951    }
952
953    // Comment-only file by design (Rust has no contract validation library).
954    // This assertion will fail when a real library is added.
955    // When that happens, update the has_any_match call site in extract_file_analysis() accordingly.
956    #[test]
957    fn query_capture_names_import_contract_comment_only() {
958        let q = make_query(include_str!("../queries/import_contract.scm"));
959        assert!(
960            q.capture_index_for_name("contract_import").is_none(),
961            "Rust import_contract.scm is intentionally comment-only"
962        );
963    }
964
965    // --- T103: missing-error-test ---
966
967    #[test]
968    fn error_test_should_panic() {
969        let source = fixture("t103_pass.rs");
970        let extractor = RustExtractor::new();
971        let fa = extractor.extract_file_analysis(&source, "t103_pass.rs");
972        assert!(
973            fa.has_error_test,
974            "#[should_panic] should set has_error_test"
975        );
976    }
977
978    #[test]
979    fn error_test_unwrap_err() {
980        let source = fixture("t103_pass_unwrap_err.rs");
981        let extractor = RustExtractor::new();
982        let fa = extractor.extract_file_analysis(&source, "t103_pass_unwrap_err.rs");
983        assert!(fa.has_error_test, ".unwrap_err() should set has_error_test");
984    }
985
986    #[test]
987    fn error_test_no_patterns() {
988        let source = fixture("t103_violation.rs");
989        let extractor = RustExtractor::new();
990        let fa = extractor.extract_file_analysis(&source, "t103_violation.rs");
991        assert!(
992            !fa.has_error_test,
993            "no error patterns should set has_error_test=false"
994        );
995    }
996
997    #[test]
998    fn error_test_is_err_only_not_sufficient() {
999        let source = fixture("t103_is_err_only.rs");
1000        let extractor = RustExtractor::new();
1001        let fa = extractor.extract_file_analysis(&source, "t103_is_err_only.rs");
1002        assert!(
1003            !fa.has_error_test,
1004            ".is_err() alone should not count as error test (weak proxy)"
1005        );
1006    }
1007
1008    #[test]
1009    fn query_capture_names_error_test() {
1010        let q = make_query(include_str!("../queries/error_test.scm"));
1011        assert!(
1012            q.capture_index_for_name("error_test").is_some(),
1013            "error_test.scm must define @error_test capture"
1014        );
1015    }
1016
1017    // --- T105: deterministic-no-metamorphic ---
1018
1019    #[test]
1020    fn relational_assertion_pass_contains() {
1021        let source = fixture("t105_pass.rs");
1022        let extractor = RustExtractor::new();
1023        let fa = extractor.extract_file_analysis(&source, "t105_pass.rs");
1024        assert!(
1025            fa.has_relational_assertion,
1026            ".contains() should set has_relational_assertion"
1027        );
1028    }
1029
1030    #[test]
1031    fn relational_assertion_violation() {
1032        let source = fixture("t105_violation.rs");
1033        let extractor = RustExtractor::new();
1034        let fa = extractor.extract_file_analysis(&source, "t105_violation.rs");
1035        assert!(
1036            !fa.has_relational_assertion,
1037            "only assert_eq! should not set has_relational_assertion"
1038        );
1039    }
1040
1041    #[test]
1042    fn query_capture_names_relational_assertion() {
1043        let q = make_query(include_str!("../queries/relational_assertion.scm"));
1044        assert!(
1045            q.capture_index_for_name("relational").is_some(),
1046            "relational_assertion.scm must define @relational capture"
1047        );
1048    }
1049
1050    // --- T101: how-not-what ---
1051
1052    #[test]
1053    fn how_not_what_expect_method() {
1054        let source = fixture("t101_violation.rs");
1055        let extractor = RustExtractor::new();
1056        let funcs = extractor.extract_test_functions(&source, "t101_violation.rs");
1057        assert!(
1058            funcs[0].analysis.how_not_what_count > 0,
1059            "mock.expect_save() should trigger how_not_what, got {}",
1060            funcs[0].analysis.how_not_what_count
1061        );
1062    }
1063
1064    #[test]
1065    fn how_not_what_pass() {
1066        let source = fixture("t101_pass.rs");
1067        let extractor = RustExtractor::new();
1068        let funcs = extractor.extract_test_functions(&source, "t101_pass.rs");
1069        assert_eq!(
1070            funcs[0].analysis.how_not_what_count, 0,
1071            "no mock patterns should have how_not_what_count=0"
1072        );
1073    }
1074
1075    #[test]
1076    fn how_not_what_private_field_limited_by_token_tree() {
1077        // Rust macro arguments are token_tree (not AST), so field_expression
1078        // with _name inside assert_eq!() is not detectable.
1079        // Private field access outside macros IS detected as field_expression,
1080        // but count_captures_within_context requires it to be inside an
1081        // assertion node (macro_invocation), which doesn't contain field_expression.
1082        let source = fixture("t101_private_violation.rs");
1083        let extractor = RustExtractor::new();
1084        let funcs = extractor.extract_test_functions(&source, "t101_private_violation.rs");
1085        assert_eq!(
1086            funcs[0].analysis.how_not_what_count, 0,
1087            "Rust token_tree limitation: private field access in test is not detected"
1088        );
1089    }
1090
1091    #[test]
1092    fn query_capture_names_how_not_what() {
1093        let q = make_query(include_str!("../queries/how_not_what.scm"));
1094        assert!(
1095            q.capture_index_for_name("how_pattern").is_some(),
1096            "how_not_what.scm must define @how_pattern capture"
1097        );
1098    }
1099
1100    #[test]
1101    fn query_capture_names_private_in_assertion() {
1102        let q = make_query(include_str!("../queries/private_in_assertion.scm"));
1103        assert!(
1104            q.capture_index_for_name("private_access").is_some(),
1105            "private_in_assertion.scm must define @private_access capture"
1106        );
1107    }
1108
1109    // --- T102: fixture-sprawl ---
1110
1111    #[test]
1112    fn fixture_count_for_violation() {
1113        let source = fixture("t102_violation.rs");
1114        let extractor = RustExtractor::new();
1115        let funcs = extractor.extract_test_functions(&source, "t102_violation.rs");
1116        assert_eq!(
1117            funcs[0].analysis.fixture_count, 7,
1118            "expected 7 let bindings as fixture_count"
1119        );
1120    }
1121
1122    #[test]
1123    fn fixture_count_for_pass() {
1124        let source = fixture("t102_pass.rs");
1125        let extractor = RustExtractor::new();
1126        let funcs = extractor.extract_test_functions(&source, "t102_pass.rs");
1127        assert_eq!(
1128            funcs[0].analysis.fixture_count, 1,
1129            "expected 1 let binding as fixture_count"
1130        );
1131    }
1132
1133    #[test]
1134    fn fixture_count_excludes_method_calls_on_locals() {
1135        let source = fixture("t102_method_chain.rs");
1136        let extractor = RustExtractor::new();
1137        let funcs = extractor.extract_test_functions(&source, "t102_method_chain.rs");
1138        assert_eq!(
1139            funcs[0].analysis.fixture_count, 6,
1140            "scoped calls (3) + struct (1) + macro (1) + builder chain (1) = 6, method calls on locals excluded"
1141        );
1142    }
1143
1144    // --- T108: wait-and-see ---
1145
1146    #[test]
1147    fn wait_and_see_violation_sleep() {
1148        let source = fixture("t108_violation_sleep.rs");
1149        let extractor = RustExtractor::new();
1150        let funcs = extractor.extract_test_functions(&source, "t108_violation_sleep.rs");
1151        assert!(!funcs.is_empty());
1152        for func in &funcs {
1153            assert!(
1154                func.analysis.has_wait,
1155                "test '{}' should have has_wait=true",
1156                func.name
1157            );
1158        }
1159    }
1160
1161    #[test]
1162    fn wait_and_see_pass_no_sleep() {
1163        let source = fixture("t108_pass_no_sleep.rs");
1164        let extractor = RustExtractor::new();
1165        let funcs = extractor.extract_test_functions(&source, "t108_pass_no_sleep.rs");
1166        assert_eq!(funcs.len(), 1);
1167        assert!(
1168            !funcs[0].analysis.has_wait,
1169            "test without sleep should have has_wait=false"
1170        );
1171    }
1172
1173    #[test]
1174    fn query_capture_names_wait_and_see() {
1175        let q = make_query(include_str!("../queries/wait_and_see.scm"));
1176        assert!(
1177            q.capture_index_for_name("wait").is_some(),
1178            "wait_and_see.scm must define @wait capture"
1179        );
1180    }
1181
1182    // --- T107: assertion-roulette ---
1183
1184    #[test]
1185    fn t107_violation_no_messages() {
1186        let source = fixture("t107_violation.rs");
1187        let extractor = RustExtractor::new();
1188        let funcs = extractor.extract_test_functions(&source, "t107_violation.rs");
1189        assert_eq!(funcs.len(), 1);
1190        assert!(
1191            funcs[0].analysis.assertion_count >= 2,
1192            "should have multiple assertions"
1193        );
1194        assert_eq!(
1195            funcs[0].analysis.assertion_message_count, 0,
1196            "no assertion should have a message"
1197        );
1198    }
1199
1200    #[test]
1201    fn t107_pass_with_messages() {
1202        let source = fixture("t107_pass_with_messages.rs");
1203        let extractor = RustExtractor::new();
1204        let funcs = extractor.extract_test_functions(&source, "t107_pass_with_messages.rs");
1205        assert_eq!(funcs.len(), 1);
1206        assert!(
1207            funcs[0].analysis.assertion_message_count >= 1,
1208            "assertions with messages should be counted"
1209        );
1210    }
1211
1212    // --- T109: undescriptive-test-name ---
1213
1214    #[test]
1215    fn t109_violation_names_detected() {
1216        let source = fixture("t109_violation.rs");
1217        let extractor = RustExtractor::new();
1218        let funcs = extractor.extract_test_functions(&source, "t109_violation.rs");
1219        assert!(!funcs.is_empty());
1220        for func in &funcs {
1221            assert!(
1222                exspec_core::rules::is_undescriptive_test_name(&func.name),
1223                "test '{}' should be undescriptive",
1224                func.name
1225            );
1226        }
1227    }
1228
1229    #[test]
1230    fn t109_pass_descriptive_names() {
1231        let source = fixture("t109_pass.rs");
1232        let extractor = RustExtractor::new();
1233        let funcs = extractor.extract_test_functions(&source, "t109_pass.rs");
1234        assert!(!funcs.is_empty());
1235        for func in &funcs {
1236            assert!(
1237                !exspec_core::rules::is_undescriptive_test_name(&func.name),
1238                "test '{}' should be descriptive",
1239                func.name
1240            );
1241        }
1242    }
1243
1244    // --- T106: duplicate-literal-assertion ---
1245
1246    #[test]
1247    fn t106_violation_duplicate_literal() {
1248        let source = fixture("t106_violation.rs");
1249        let extractor = RustExtractor::new();
1250        let funcs = extractor.extract_test_functions(&source, "t106_violation.rs");
1251        assert_eq!(funcs.len(), 1);
1252        assert!(
1253            funcs[0].analysis.duplicate_literal_count >= 3,
1254            "42 appears 3 times, should be >= 3: got {}",
1255            funcs[0].analysis.duplicate_literal_count
1256        );
1257    }
1258
1259    #[test]
1260    fn t106_pass_no_duplicates() {
1261        let source = fixture("t106_pass_no_duplicates.rs");
1262        let extractor = RustExtractor::new();
1263        let funcs = extractor.extract_test_functions(&source, "t106_pass_no_duplicates.rs");
1264        assert_eq!(funcs.len(), 1);
1265        assert!(
1266            funcs[0].analysis.duplicate_literal_count < 3,
1267            "each literal appears once: got {}",
1268            funcs[0].analysis.duplicate_literal_count
1269        );
1270    }
1271
1272    // --- T001 FP fix: #[should_panic] as assertion (#25) ---
1273
1274    #[test]
1275    fn t001_should_panic_counts_as_assertion() {
1276        // TC-09: #[should_panic] only -> T001 should NOT fire
1277        let source = fixture("t001_should_panic.rs");
1278        let extractor = RustExtractor::new();
1279        let funcs = extractor.extract_test_functions(&source, "t001_should_panic.rs");
1280        assert_eq!(funcs.len(), 1);
1281        assert!(
1282            funcs[0].analysis.assertion_count >= 1,
1283            "#[should_panic] should count as assertion, got {}",
1284            funcs[0].analysis.assertion_count
1285        );
1286    }
1287
1288    #[test]
1289    fn t001_should_panic_before_test_counts_as_assertion() {
1290        // TC-09c: #[should_panic] BEFORE #[test] -> T001 should NOT fire
1291        let source = fixture("t001_should_panic_before_test.rs");
1292        let extractor = RustExtractor::new();
1293        let funcs = extractor.extract_test_functions(&source, "t001_should_panic_before_test.rs");
1294        assert_eq!(funcs.len(), 1);
1295        assert!(
1296            funcs[0].analysis.assertion_count >= 1,
1297            "#[should_panic] before #[test] should count as assertion, got {}",
1298            funcs[0].analysis.assertion_count
1299        );
1300    }
1301
1302    #[test]
1303    fn t001_should_panic_in_mod_counts_as_assertion() {
1304        // TC-09b: #[should_panic] inside mod tests {} -> T001 should NOT fire
1305        let source = fixture("t001_should_panic_in_mod.rs");
1306        let extractor = RustExtractor::new();
1307        let funcs = extractor.extract_test_functions(&source, "t001_should_panic_in_mod.rs");
1308        assert_eq!(funcs.len(), 1);
1309        assert!(
1310            funcs[0].analysis.assertion_count >= 1,
1311            "#[should_panic] in mod should count as assertion, got {}",
1312            funcs[0].analysis.assertion_count
1313        );
1314    }
1315
1316    #[test]
1317    fn t001_should_panic_substring_not_matched() {
1318        // TC-09d: #[my_should_panic_wrapper] should NOT count as #[should_panic]
1319        // Substring match was tightened to exact identifier match (#29)
1320        let source = fixture("t001_should_panic_substring_no_match.rs");
1321        let extractor = RustExtractor::new();
1322        let funcs =
1323            extractor.extract_test_functions(&source, "t001_should_panic_substring_no_match.rs");
1324        assert_eq!(funcs.len(), 1);
1325        assert_eq!(
1326            funcs[0].analysis.assertion_count, 0,
1327            "#[my_should_panic_wrapper] should NOT count as assertion (exact match only), got {}",
1328            funcs[0].analysis.assertion_count
1329        );
1330    }
1331
1332    // --- Phase 22: custom assert macro auto-detection ---
1333
1334    #[test]
1335    fn tc01_assert_pending_macro_counted_as_assertion() {
1336        // TC-01: assert_pending!(val) should be counted as assertion
1337        let source = fixture("t001_pass_custom_assert_macro.rs");
1338        let extractor = RustExtractor::new();
1339        let funcs = extractor.extract_test_functions(&source, "t001_pass_custom_assert_macro.rs");
1340        let func = funcs
1341            .iter()
1342            .find(|f| f.name == "test_with_assert_pending")
1343            .expect("test_with_assert_pending not found");
1344        assert!(
1345            func.analysis.assertion_count >= 1,
1346            "assert_pending! should be counted as assertion, got {}",
1347            func.analysis.assertion_count
1348        );
1349    }
1350
1351    #[test]
1352    fn tc02_assert_ready_ok_macro_counted_as_assertion() {
1353        // TC-02: assert_ready_ok!(future) should be counted as assertion
1354        let source = fixture("t001_pass_custom_assert_macro.rs");
1355        let extractor = RustExtractor::new();
1356        let funcs = extractor.extract_test_functions(&source, "t001_pass_custom_assert_macro.rs");
1357        let func = funcs
1358            .iter()
1359            .find(|f| f.name == "test_with_assert_ready_ok")
1360            .expect("test_with_assert_ready_ok not found");
1361        assert!(
1362            func.analysis.assertion_count >= 1,
1363            "assert_ready_ok! should be counted as assertion, got {}",
1364            func.analysis.assertion_count
1365        );
1366    }
1367
1368    #[test]
1369    fn tc03_assert_data_eq_macro_counted_as_assertion() {
1370        // TC-03: assert_data_eq!(actual, expected) should be counted as assertion
1371        let source = fixture("t001_pass_custom_assert_macro.rs");
1372        let extractor = RustExtractor::new();
1373        let funcs = extractor.extract_test_functions(&source, "t001_pass_custom_assert_macro.rs");
1374        let func = funcs
1375            .iter()
1376            .find(|f| f.name == "test_with_assert_data_eq")
1377            .expect("test_with_assert_data_eq not found");
1378        assert!(
1379            func.analysis.assertion_count >= 1,
1380            "assert_data_eq! should be counted as assertion, got {}",
1381            func.analysis.assertion_count
1382        );
1383    }
1384
1385    #[test]
1386    fn tc04_standard_assert_macros_still_detected_regression() {
1387        // TC-04: assert!, assert_eq!, debug_assert!, prop_assert_eq! continue to be detected
1388        let source = "#[test]\nfn test_standard_asserts() {\n    assert!(true);\n    assert_eq!(1, 1);\n    assert_ne!(1, 2);\n    debug_assert!(true);\n    prop_assert_eq!(1, 1);\n}\n";
1389        let extractor = RustExtractor::new();
1390        let funcs = extractor.extract_test_functions(&source, "regression_standard.rs");
1391        assert_eq!(funcs.len(), 1);
1392        assert_eq!(
1393            funcs[0].analysis.assertion_count, 5,
1394            "assert!, assert_eq!, assert_ne!, debug_assert!, prop_assert_eq! should all be counted, got {}",
1395            funcs[0].analysis.assertion_count
1396        );
1397    }
1398
1399    #[test]
1400    fn tc05_assertion_macro_not_counted_as_assertion() {
1401        // TC-05: assertion!() should NOT be counted — prefix guard rejects non-assert macros
1402        let source = "#[test]\nfn test_with_assertion_macro() {\n    assertion!(x == 5);\n}\n";
1403        let extractor = RustExtractor::new();
1404        let funcs = extractor.extract_test_functions(&source, "assertion_macro.rs");
1405        assert_eq!(funcs.len(), 1);
1406        assert_eq!(
1407            funcs[0].analysis.assertion_count, 0,
1408            "assertion!() should NOT be counted as assertion, got {}",
1409            funcs[0].analysis.assertion_count
1410        );
1411    }
1412
1413    // --- Same-file helper tracing (Phase 23a, TC-01 ~ TC-06) ---
1414
1415    #[test]
1416    fn helper_tracing_tc01_delegates_to_helper_with_assertion() {
1417        // TC-01: test that calls a helper with assertion → assertion_count >= 1 after tracing
1418        // RED: apply_same_file_helper_tracing is a stub → assertion_count stays 0 → FAIL expected
1419        let source = fixture("t001_pass_helper_tracing.rs");
1420        let extractor = RustExtractor::new();
1421        let fa = extractor.extract_file_analysis(&source, "t001_pass_helper_tracing.rs");
1422        let func = fa
1423            .functions
1424            .iter()
1425            .find(|f| f.name == "test_delegates_to_helper_with_assertion")
1426            .expect("test_delegates_to_helper_with_assertion not found");
1427        assert!(
1428            func.analysis.assertion_count >= 1,
1429            "TC-01: helper with assertion traced → assertion_count >= 1, got {}",
1430            func.analysis.assertion_count
1431        );
1432    }
1433
1434    #[test]
1435    fn helper_tracing_tc02_delegates_to_helper_without_assertion() {
1436        // TC-02: test that calls a helper WITHOUT assertion → assertion_count stays 0
1437        let source = fixture("t001_pass_helper_tracing.rs");
1438        let extractor = RustExtractor::new();
1439        let fa = extractor.extract_file_analysis(&source, "t001_pass_helper_tracing.rs");
1440        let func = fa
1441            .functions
1442            .iter()
1443            .find(|f| f.name == "test_delegates_to_helper_without_assertion")
1444            .expect("test_delegates_to_helper_without_assertion not found");
1445        assert_eq!(
1446            func.analysis.assertion_count, 0,
1447            "TC-02: helper without assertion → assertion_count == 0, got {}",
1448            func.analysis.assertion_count
1449        );
1450    }
1451
1452    #[test]
1453    fn helper_tracing_tc03_has_own_assertion_and_calls_helper() {
1454        // TC-03: test with own assert_eq! → assertion_count >= 1 (direct assertion, no tracing needed)
1455        let source = fixture("t001_pass_helper_tracing.rs");
1456        let extractor = RustExtractor::new();
1457        let fa = extractor.extract_file_analysis(&source, "t001_pass_helper_tracing.rs");
1458        let func = fa
1459            .functions
1460            .iter()
1461            .find(|f| f.name == "test_has_own_assertion_and_calls_helper")
1462            .expect("test_has_own_assertion_and_calls_helper not found");
1463        assert!(
1464            func.analysis.assertion_count >= 1,
1465            "TC-03: own assertion present → assertion_count >= 1, got {}",
1466            func.analysis.assertion_count
1467        );
1468    }
1469
1470    #[test]
1471    fn helper_tracing_tc04_calls_undefined_function() {
1472        // TC-04: calling a function not defined in the file → no crash, assertion_count stays 0
1473        let source = fixture("t001_pass_helper_tracing.rs");
1474        let extractor = RustExtractor::new();
1475        let fa = extractor.extract_file_analysis(&source, "t001_pass_helper_tracing.rs");
1476        let func = fa
1477            .functions
1478            .iter()
1479            .find(|f| f.name == "test_calls_undefined_function")
1480            .expect("test_calls_undefined_function not found");
1481        assert_eq!(
1482            func.analysis.assertion_count, 0,
1483            "TC-04: undefined function call → no crash, assertion_count == 0, got {}",
1484            func.analysis.assertion_count
1485        );
1486    }
1487
1488    #[test]
1489    fn helper_tracing_tc05_two_hop_not_traced() {
1490        // TC-05: 2-hop helper (intermediate_helper → check_result) — only 1-hop traced.
1491        // intermediate_helper itself has no assertion → assertion_count stays 0
1492        let source = fixture("t001_pass_helper_tracing.rs");
1493        let extractor = RustExtractor::new();
1494        let fa = extractor.extract_file_analysis(&source, "t001_pass_helper_tracing.rs");
1495        let func = fa
1496            .functions
1497            .iter()
1498            .find(|f| f.name == "test_two_hop_not_traced")
1499            .expect("test_two_hop_not_traced not found");
1500        assert_eq!(
1501            func.analysis.assertion_count, 0,
1502            "TC-05: 2-hop helper not traced → assertion_count == 0, got {}",
1503            func.analysis.assertion_count
1504        );
1505    }
1506
1507    #[test]
1508    fn helper_tracing_tc06_all_functions_have_assertions_early_return() {
1509        // TC-06: when all functions already have assertion_count > 0,
1510        // apply_same_file_helper_tracing should early-return without extra cost.
1511        // We verify by calling apply_same_file_helper_tracing on a FileAnalysis
1512        // where all functions have assertion_count > 0 — counts must not change.
1513        use exspec_core::query_utils::apply_same_file_helper_tracing;
1514        use tree_sitter::Query;
1515
1516        // Build a source where the only test has an assertion
1517        let source = "#[test]\nfn test_has_assertion() {\n    assert_eq!(1, 1);\n}\n";
1518        let extractor = RustExtractor::new();
1519        let mut fa = extractor.extract_file_analysis(source, "tc06.rs");
1520
1521        // All functions already have assertion_count > 0
1522        assert!(
1523            fa.functions.iter().all(|f| f.analysis.assertion_count > 0),
1524            "TC-06 precondition: all functions must have assertion_count > 0"
1525        );
1526
1527        // Prepare stub queries (content does not matter since stub does nothing)
1528        let language = tree_sitter_rust::LANGUAGE;
1529        let lang: tree_sitter::Language = language.into();
1530        // Minimal valid queries for Rust
1531        let call_query =
1532            Query::new(&lang, "(call_expression function: (identifier) @call_name)").unwrap();
1533        let def_query = Query::new(
1534            &lang,
1535            "(function_item name: (identifier) @def_name body: (block) @def_body)",
1536        )
1537        .unwrap();
1538        let assertion_query =
1539            Query::new(&lang, "(macro_invocation macro: (identifier) @assertion)").unwrap();
1540
1541        let mut parser = RustExtractor::parser();
1542        let tree = parser.parse(source, None).unwrap();
1543
1544        let before: Vec<usize> = fa
1545            .functions
1546            .iter()
1547            .map(|f| f.analysis.assertion_count)
1548            .collect();
1549
1550        apply_same_file_helper_tracing(
1551            &mut fa,
1552            &tree,
1553            source.as_bytes(),
1554            &call_query,
1555            &def_query,
1556            &assertion_query,
1557        );
1558
1559        let after: Vec<usize> = fa
1560            .functions
1561            .iter()
1562            .map(|f| f.analysis.assertion_count)
1563            .collect();
1564
1565        assert_eq!(
1566            before, after,
1567            "TC-06: assertion_counts must not change when all > 0 (early return)"
1568        );
1569    }
1570
1571    #[test]
1572    fn helper_tracing_tc07_multiple_calls_to_same_helper() {
1573        // TC-07: test that calls same helper multiple times
1574        // Should deduplicate and count helper assertions once, not once per call
1575        let source = fixture("t001_pass_helper_tracing.rs");
1576        let extractor = RustExtractor::new();
1577        let fa = extractor.extract_file_analysis(&source, "t001_pass_helper_tracing.rs");
1578        let func = fa
1579            .functions
1580            .iter()
1581            .find(|f| f.name == "test_calls_helper_twice")
1582            .expect("test_calls_helper_twice not found");
1583
1584        // check_result has 1 assertion. Calling it twice should add 1, not 2.
1585        // Expected: assertion_count == 1 (deduplicated)
1586        // Current bug: assertion_count == 2 (counted per call)
1587        assert_eq!(
1588            func.analysis.assertion_count, 1,
1589            "TC-07: multiple calls to same helper should be deduplicated, got {}",
1590            func.analysis.assertion_count
1591        );
1592    }
1593}