Skip to main content

exspec_lang_rust/
lib.rs

1use std::sync::OnceLock;
2
3use exspec_core::extractor::{FileAnalysis, LanguageExtractor, TestAnalysis, TestFunction};
4use exspec_core::query_utils::{
5    collect_mock_class_names, count_captures, count_captures_within_context,
6    count_duplicate_literals, extract_suppression_from_previous_line, has_any_match,
7};
8use streaming_iterator::StreamingIterator;
9use tree_sitter::{Node, Parser, Query, QueryCursor};
10
11const TEST_FUNCTION_QUERY: &str = include_str!("../queries/test_function.scm");
12const ASSERTION_QUERY: &str = include_str!("../queries/assertion.scm");
13const MOCK_USAGE_QUERY: &str = include_str!("../queries/mock_usage.scm");
14const MOCK_ASSIGNMENT_QUERY: &str = include_str!("../queries/mock_assignment.scm");
15const PARAMETERIZED_QUERY: &str = include_str!("../queries/parameterized.scm");
16const IMPORT_PBT_QUERY: &str = include_str!("../queries/import_pbt.scm");
17const IMPORT_CONTRACT_QUERY: &str = include_str!("../queries/import_contract.scm");
18const HOW_NOT_WHAT_QUERY: &str = include_str!("../queries/how_not_what.scm");
19const PRIVATE_IN_ASSERTION_QUERY: &str = include_str!("../queries/private_in_assertion.scm");
20const ERROR_TEST_QUERY: &str = include_str!("../queries/error_test.scm");
21const RELATIONAL_ASSERTION_QUERY: &str = include_str!("../queries/relational_assertion.scm");
22const WAIT_AND_SEE_QUERY: &str = include_str!("../queries/wait_and_see.scm");
23
24fn rust_language() -> tree_sitter::Language {
25    tree_sitter_rust::LANGUAGE.into()
26}
27
28fn cached_query<'a>(lock: &'a OnceLock<Query>, source: &str) -> &'a Query {
29    lock.get_or_init(|| Query::new(&rust_language(), source).expect("invalid query"))
30}
31
32static TEST_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
33static ASSERTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
34static MOCK_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
35static MOCK_ASSIGN_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
36static PARAMETERIZED_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
37static IMPORT_PBT_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
38static IMPORT_CONTRACT_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
39static HOW_NOT_WHAT_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
40static PRIVATE_IN_ASSERTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
41static ERROR_TEST_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
42static RELATIONAL_ASSERTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
43static WAIT_AND_SEE_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
44
45pub struct RustExtractor;
46
47impl RustExtractor {
48    pub fn new() -> Self {
49        Self
50    }
51
52    pub fn parser() -> Parser {
53        let mut parser = Parser::new();
54        let language = tree_sitter_rust::LANGUAGE;
55        parser
56            .set_language(&language.into())
57            .expect("failed to load Rust grammar");
58        parser
59    }
60}
61
62impl Default for RustExtractor {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68fn extract_mock_class_name(var_name: &str) -> String {
69    // Rust uses snake_case: mock_service -> "service"
70    if let Some(stripped) = var_name.strip_prefix("mock_") {
71        if !stripped.is_empty() {
72            return stripped.to_string();
73        }
74    }
75    // camelCase: mockService -> "Service" (less common in Rust but handle it)
76    if let Some(stripped) = var_name.strip_prefix("mock") {
77        if !stripped.is_empty() && stripped.starts_with(|c: char| c.is_uppercase()) {
78            return stripped.to_string();
79        }
80    }
81    var_name.to_string()
82}
83
84struct TestMatch {
85    name: String,
86    fn_start_byte: usize,
87    fn_end_byte: usize,
88    fn_start_row: usize,
89    fn_end_row: usize,
90    /// Row of attribute_item (for suppression lookup)
91    attr_start_row: usize,
92    /// Whether #[should_panic] is present (counts as assertion for T001)
93    has_should_panic: bool,
94}
95
96/// Find the root object of a field_expression chain (method call chain).
97/// e.g. `Config::builder().timeout(30).build()`:
98///   call_expression { function: field_expression { value: call_expression { function: field_expression { value: call_expression { function: scoped_identifier } } } } }
99///   → root call_expression's function is scoped_identifier
100/// Check if a call_expression is a "constructor" (setup) or "method on local" (action).
101/// Returns true for fixture-like calls: Type::new(), free_func(), builder chains from constructors.
102/// Returns false for method calls on local variables: service.create(), result.unwrap().
103fn is_constructor_call(node: Node) -> bool {
104    let func = match node.child_by_field_name("function") {
105        Some(f) => f,
106        None => return true, // conservative
107    };
108    match func.kind() {
109        // Type::new(), Config::default() — constructor
110        "scoped_identifier" => true,
111        // add(1, 2), create_user() — free function call
112        "identifier" => true,
113        // obj.method() or chain.method() — need to find the root
114        "field_expression" => {
115            let value = match func.child_by_field_name("value") {
116                Some(v) => v,
117                None => return true,
118            };
119            if value.kind() == "call_expression" {
120                // Chain: inner_call().method() — recurse to check inner call
121                is_constructor_call(value)
122            } else {
123                // Root is a local variable: service.create(), result.unwrap()
124                false
125            }
126        }
127        _ => true,
128    }
129}
130
131/// Check if a let value expression represents fixture/setup (not action/prep).
132/// In tree-sitter-rust, `obj.method()` is `call_expression { function: field_expression }`.
133/// Fixture: Type::new(), struct literals, macros, free function calls, builder chains from constructors.
134/// Non-fixture: method calls on local variables (e.g. service.create(), result.unwrap()).
135fn is_fixture_value(node: Node) -> bool {
136    match node.kind() {
137        "call_expression" => is_constructor_call(node),
138        "struct_expression" | "macro_invocation" => true,
139        _ => true, // literals, etc. are test data (fixture-like)
140    }
141}
142
143/// Count Rust assertion macros that have a message argument.
144/// assert!(expr, "msg") has 1+ top-level commas in token_tree.
145/// assert_eq!(a, b, "msg") has 2+ top-level commas in token_tree.
146fn count_assertion_messages_rust(assertion_query: &Query, fn_node: Node, source: &[u8]) -> usize {
147    let assertion_idx = match assertion_query.capture_index_for_name("assertion") {
148        Some(idx) => idx,
149        None => return 0,
150    };
151    let mut cursor = QueryCursor::new();
152    let mut matches = cursor.matches(assertion_query, fn_node, source);
153    let mut count = 0;
154    while let Some(m) = matches.next() {
155        for cap in m.captures.iter().filter(|c| c.index == assertion_idx) {
156            let node = cap.node;
157            let macro_name = node
158                .child_by_field_name("macro")
159                .and_then(|n| n.utf8_text(source).ok())
160                .unwrap_or("");
161
162            // Find token_tree child
163            let token_tree = (0..node.child_count()).find_map(|i| {
164                let child = node.child(i)?;
165                if child.kind() == "token_tree" {
166                    Some(child)
167                } else {
168                    None
169                }
170            });
171
172            if let Some(tt) = token_tree {
173                // Count top-level commas in token_tree.
174                // token_tree includes outer delimiters "(", ")".
175                // Only count commas that are direct children of this token_tree
176                // (not inside nested token_tree children).
177                let mut comma_count = 0;
178                for i in 0..tt.child_count() {
179                    if let Some(child) = tt.child(i) {
180                        if child.kind() == "," {
181                            comma_count += 1;
182                        }
183                    }
184                }
185
186                // assert!(expr): needs 1+ comma for msg
187                // assert_eq!(a, b): needs 2+ commas for msg
188                let min_commas = if macro_name.contains("_eq") || macro_name.contains("_ne") {
189                    2
190                } else {
191                    1
192                };
193                if comma_count >= min_commas {
194                    count += 1;
195                }
196            }
197        }
198    }
199    count
200}
201
202/// Count fixture-like `let` declarations in a Rust function body.
203/// Excludes method calls on local variables (action/assertion prep).
204fn count_fixture_lets(fn_node: Node) -> usize {
205    let body = match fn_node.child_by_field_name("body") {
206        Some(n) => n,
207        None => return 0,
208    };
209
210    let mut count = 0;
211    let mut cursor = body.walk();
212    if cursor.goto_first_child() {
213        loop {
214            let node = cursor.node();
215            if node.kind() == "let_declaration" {
216                match node.child_by_field_name("value") {
217                    Some(value) => {
218                        if is_fixture_value(value) {
219                            count += 1;
220                        }
221                    }
222                    None => count += 1, // `let x;` without value — count conservatively
223                }
224            }
225            if !cursor.goto_next_sibling() {
226                break;
227            }
228        }
229    }
230    count
231}
232
233fn extract_functions_from_tree(source: &str, file_path: &str, root: Node) -> Vec<TestFunction> {
234    let test_query = cached_query(&TEST_QUERY_CACHE, TEST_FUNCTION_QUERY);
235    let assertion_query = cached_query(&ASSERTION_QUERY_CACHE, ASSERTION_QUERY);
236    let mock_query = cached_query(&MOCK_QUERY_CACHE, MOCK_USAGE_QUERY);
237    let mock_assign_query = cached_query(&MOCK_ASSIGN_QUERY_CACHE, MOCK_ASSIGNMENT_QUERY);
238    let how_not_what_query = cached_query(&HOW_NOT_WHAT_QUERY_CACHE, HOW_NOT_WHAT_QUERY);
239    let private_query = cached_query(
240        &PRIVATE_IN_ASSERTION_QUERY_CACHE,
241        PRIVATE_IN_ASSERTION_QUERY,
242    );
243    let wait_query = cached_query(&WAIT_AND_SEE_QUERY_CACHE, WAIT_AND_SEE_QUERY);
244
245    let source_bytes = source.as_bytes();
246
247    // test_function.scm captures @test_attr (attribute_item).
248    // The corresponding function_item is the next sibling of attribute_item.
249    let attr_idx = test_query
250        .capture_index_for_name("test_attr")
251        .expect("no @test_attr capture");
252
253    let mut test_matches: Vec<TestMatch> = Vec::new();
254    let mut seen_fn_bytes: std::collections::HashSet<usize> = std::collections::HashSet::new();
255
256    {
257        let mut cursor = QueryCursor::new();
258        let mut matches = cursor.matches(test_query, root, source_bytes);
259        while let Some(m) = matches.next() {
260            let attr_capture = match m.captures.iter().find(|c| c.index == attr_idx) {
261                Some(c) => c,
262                None => continue,
263            };
264            let attr_node = attr_capture.node;
265            let attr_start_row = attr_node.start_position().row;
266
267            // Check previous siblings for #[should_panic] (handles #[should_panic] before #[test]).
268            // Also update attr_start_row to the earliest attribute for suppression comment lookup.
269            let mut has_should_panic = false;
270            let mut attr_start_row = attr_start_row;
271            {
272                let mut prev = attr_node.prev_sibling();
273                while let Some(p) = prev {
274                    if p.kind() == "attribute_item" {
275                        attr_start_row = p.start_position().row;
276                        if let Ok(text) = p.utf8_text(source_bytes) {
277                            if text.contains("should_panic") {
278                                has_should_panic = true;
279                            }
280                        }
281                    } else if p.kind() != "line_comment" && p.kind() != "block_comment" {
282                        break;
283                    }
284                    prev = p.prev_sibling();
285                }
286            }
287
288            // Walk next siblings to find the function_item.
289            // Also detect #[should_panic] among sibling attribute_items.
290            let mut sibling = attr_node.next_sibling();
291            while let Some(s) = sibling {
292                if s.kind() == "function_item" {
293                    let fn_start_byte = s.start_byte();
294                    if seen_fn_bytes.insert(fn_start_byte) {
295                        let name = s
296                            .child_by_field_name("name")
297                            .and_then(|n| n.utf8_text(source_bytes).ok())
298                            .unwrap_or("")
299                            .to_string();
300                        if !name.is_empty() {
301                            test_matches.push(TestMatch {
302                                name,
303                                fn_start_byte,
304                                fn_end_byte: s.end_byte(),
305                                fn_start_row: s.start_position().row,
306                                fn_end_row: s.end_position().row,
307                                attr_start_row,
308                                has_should_panic,
309                            });
310                        }
311                    }
312                    break;
313                }
314                // Skip over other attribute_items or whitespace nodes
315                // If we hit something that is not an attribute_item, stop
316                if s.kind() == "attribute_item" {
317                    if let Ok(text) = s.utf8_text(source_bytes) {
318                        if text.contains("should_panic") {
319                            has_should_panic = true;
320                        }
321                    }
322                } else if s.kind() != "line_comment" && s.kind() != "block_comment" {
323                    break;
324                }
325                sibling = s.next_sibling();
326            }
327        }
328    }
329
330    let mut functions = Vec::new();
331    for tm in &test_matches {
332        let fn_node = match root.descendant_for_byte_range(tm.fn_start_byte, tm.fn_end_byte) {
333            Some(n) => n,
334            None => continue,
335        };
336
337        let line = tm.fn_start_row + 1;
338        let end_line = tm.fn_end_row + 1;
339        let line_count = end_line - line + 1;
340
341        let mut assertion_count =
342            count_captures(assertion_query, "assertion", fn_node, source_bytes);
343
344        // #[should_panic] is outside fn_node (sibling attribute), detected during sibling walk
345        if tm.has_should_panic {
346            assertion_count += 1;
347        }
348        let mock_count = count_captures(mock_query, "mock", fn_node, source_bytes);
349        let mock_classes = collect_mock_class_names(
350            mock_assign_query,
351            fn_node,
352            source_bytes,
353            extract_mock_class_name,
354        );
355        let how_not_what_count =
356            count_captures(how_not_what_query, "how_pattern", fn_node, source_bytes);
357
358        let private_in_assertion_count = count_captures_within_context(
359            assertion_query,
360            "assertion",
361            private_query,
362            "private_access",
363            fn_node,
364            source_bytes,
365        );
366
367        let fixture_count = count_fixture_lets(fn_node);
368
369        // T108: wait-and-see detection
370        let has_wait = has_any_match(wait_query, "wait", fn_node, source_bytes);
371
372        // T107: assertion message count
373        let assertion_message_count =
374            count_assertion_messages_rust(assertion_query, fn_node, source_bytes);
375
376        // T106: duplicate literal count
377        let duplicate_literal_count = count_duplicate_literals(
378            assertion_query,
379            fn_node,
380            source_bytes,
381            &["integer_literal", "float_literal", "string_literal"],
382        );
383
384        // Suppression comment is the line before the attribute_item
385        let suppressed_rules = extract_suppression_from_previous_line(source, tm.attr_start_row);
386
387        functions.push(TestFunction {
388            name: tm.name.clone(),
389            file: file_path.to_string(),
390            line,
391            end_line,
392            analysis: TestAnalysis {
393                assertion_count,
394                mock_count,
395                mock_classes,
396                line_count,
397                how_not_what_count: how_not_what_count + private_in_assertion_count,
398                fixture_count,
399                has_wait,
400                assertion_message_count,
401                duplicate_literal_count,
402                suppressed_rules,
403            },
404        });
405    }
406
407    functions
408}
409
410impl LanguageExtractor for RustExtractor {
411    fn extract_test_functions(&self, source: &str, file_path: &str) -> Vec<TestFunction> {
412        let mut parser = Self::parser();
413        let tree = match parser.parse(source, None) {
414            Some(t) => t,
415            None => return Vec::new(),
416        };
417        extract_functions_from_tree(source, file_path, tree.root_node())
418    }
419
420    fn extract_file_analysis(&self, source: &str, file_path: &str) -> FileAnalysis {
421        let mut parser = Self::parser();
422        let tree = match parser.parse(source, None) {
423            Some(t) => t,
424            None => {
425                return FileAnalysis {
426                    file: file_path.to_string(),
427                    functions: Vec::new(),
428                    has_pbt_import: false,
429                    has_contract_import: false,
430                    has_error_test: false,
431                    has_relational_assertion: false,
432                    parameterized_count: 0,
433                };
434            }
435        };
436
437        let root = tree.root_node();
438        let source_bytes = source.as_bytes();
439
440        let functions = extract_functions_from_tree(source, file_path, root);
441
442        let param_query = cached_query(&PARAMETERIZED_QUERY_CACHE, PARAMETERIZED_QUERY);
443        let parameterized_count = count_captures(param_query, "parameterized", root, source_bytes);
444
445        let pbt_query = cached_query(&IMPORT_PBT_QUERY_CACHE, IMPORT_PBT_QUERY);
446        let has_pbt_import = has_any_match(pbt_query, "pbt_import", root, source_bytes);
447
448        let contract_query = cached_query(&IMPORT_CONTRACT_QUERY_CACHE, IMPORT_CONTRACT_QUERY);
449        let has_contract_import =
450            has_any_match(contract_query, "contract_import", root, source_bytes);
451
452        let error_test_query = cached_query(&ERROR_TEST_QUERY_CACHE, ERROR_TEST_QUERY);
453        let has_error_test = has_any_match(error_test_query, "error_test", root, source_bytes);
454
455        let relational_query = cached_query(
456            &RELATIONAL_ASSERTION_QUERY_CACHE,
457            RELATIONAL_ASSERTION_QUERY,
458        );
459        let has_relational_assertion =
460            has_any_match(relational_query, "relational", root, source_bytes);
461
462        FileAnalysis {
463            file: file_path.to_string(),
464            functions,
465            has_pbt_import,
466            has_contract_import,
467            has_error_test,
468            has_relational_assertion,
469            parameterized_count,
470        }
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    fn fixture(name: &str) -> String {
479        let path = format!(
480            "{}/tests/fixtures/rust/{}",
481            env!("CARGO_MANIFEST_DIR").replace("/crates/lang-rust", ""),
482            name
483        );
484        std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}"))
485    }
486
487    // --- Basic parser ---
488
489    #[test]
490    fn parse_rust_source() {
491        let source = "#[test]\nfn test_example() {\n    assert_eq!(1, 1);\n}\n";
492        let mut parser = RustExtractor::parser();
493        let tree = parser.parse(source, None).unwrap();
494        assert_eq!(tree.root_node().kind(), "source_file");
495    }
496
497    #[test]
498    fn rust_extractor_implements_language_extractor() {
499        let extractor = RustExtractor::new();
500        let _: &dyn exspec_core::extractor::LanguageExtractor = &extractor;
501    }
502
503    // --- Test function extraction (TC-01, TC-02, TC-03) ---
504
505    #[test]
506    fn extract_single_test() {
507        // TC-01: #[test] function is extracted
508        let source = fixture("t001_pass.rs");
509        let extractor = RustExtractor::new();
510        let funcs = extractor.extract_test_functions(&source, "t001_pass.rs");
511        assert_eq!(funcs.len(), 1, "should extract exactly 1 test function");
512        assert_eq!(funcs[0].name, "test_create_user");
513    }
514
515    #[test]
516    fn non_test_function_not_extracted() {
517        // TC-02: functions without #[test] are not extracted
518        let source = "fn helper() -> i32 { 42 }\n";
519        let extractor = RustExtractor::new();
520        let funcs = extractor.extract_test_functions(&source, "helper.rs");
521        assert_eq!(funcs.len(), 0, "non-test fn should not be extracted");
522    }
523
524    #[test]
525    fn extract_tokio_test() {
526        // TC-03: #[tokio::test] is extracted
527        let source =
528            "#[tokio::test]\nasync fn test_async_operation() {\n    assert_eq!(1, 1);\n}\n";
529        let extractor = RustExtractor::new();
530        let funcs = extractor.extract_test_functions(&source, "tokio_test.rs");
531        assert_eq!(funcs.len(), 1, "should extract #[tokio::test] function");
532        assert_eq!(funcs[0].name, "test_async_operation");
533    }
534
535    // --- Assertion detection (TC-04, TC-05, TC-06, TC-07) ---
536
537    #[test]
538    fn assertion_count_zero_for_violation() {
539        // TC-04: assertion-free test has count 0
540        let source = fixture("t001_violation.rs");
541        let extractor = RustExtractor::new();
542        let funcs = extractor.extract_test_functions(&source, "t001_violation.rs");
543        assert_eq!(funcs.len(), 1);
544        assert_eq!(
545            funcs[0].analysis.assertion_count, 0,
546            "violation file should have 0 assertions"
547        );
548    }
549
550    #[test]
551    fn assertion_count_positive_for_pass() {
552        // TC-05: assert_eq! is counted
553        let source = fixture("t001_pass.rs");
554        let extractor = RustExtractor::new();
555        let funcs = extractor.extract_test_functions(&source, "t001_pass.rs");
556        assert_eq!(funcs.len(), 1);
557        assert!(
558            funcs[0].analysis.assertion_count >= 1,
559            "pass file should have >= 1 assertion"
560        );
561    }
562
563    #[test]
564    fn all_assert_macros_counted() {
565        // TC-06: assert!, assert_eq!, assert_ne! all counted
566        let source = "#[test]\nfn test_all_asserts() {\n    assert!(true);\n    assert_eq!(1, 1);\n    assert_ne!(1, 2);\n}\n";
567        let extractor = RustExtractor::new();
568        let funcs = extractor.extract_test_functions(&source, "test_asserts.rs");
569        assert_eq!(funcs.len(), 1);
570        assert_eq!(
571            funcs[0].analysis.assertion_count, 3,
572            "should count assert!, assert_eq!, assert_ne!"
573        );
574    }
575
576    #[test]
577    fn debug_assert_counted() {
578        // TC-07: debug_assert! is also counted
579        let source = "#[test]\nfn test_debug_assert() {\n    debug_assert!(true);\n}\n";
580        let extractor = RustExtractor::new();
581        let funcs = extractor.extract_test_functions(&source, "test_debug.rs");
582        assert_eq!(funcs.len(), 1);
583        assert_eq!(
584            funcs[0].analysis.assertion_count, 1,
585            "debug_assert! should be counted"
586        );
587    }
588
589    // --- Mock detection (TC-08, TC-09, TC-10, TC-11) ---
590
591    #[test]
592    fn mock_pattern_detected() {
593        // TC-08: MockXxx::new() is detected
594        let source = "#[test]\nfn test_with_mock() {\n    let mock_svc = MockService::new();\n    assert_eq!(mock_svc.len(), 0);\n}\n";
595        let extractor = RustExtractor::new();
596        let funcs = extractor.extract_test_functions(&source, "test_mock.rs");
597        assert_eq!(funcs.len(), 1);
598        assert!(
599            funcs[0].analysis.mock_count >= 1,
600            "MockService::new() should be detected"
601        );
602    }
603
604    #[test]
605    fn mock_count_for_violation() {
606        // TC-09: mock_count > 5 triggers T002
607        let source = fixture("t002_violation.rs");
608        let extractor = RustExtractor::new();
609        let funcs = extractor.extract_test_functions(&source, "t002_violation.rs");
610        assert_eq!(funcs.len(), 1);
611        assert!(
612            funcs[0].analysis.mock_count > 5,
613            "violation file should have > 5 mocks, got {}",
614            funcs[0].analysis.mock_count
615        );
616    }
617
618    #[test]
619    fn mock_count_for_pass() {
620        // TC-10: mock_count <= 5 passes
621        let source = fixture("t002_pass.rs");
622        let extractor = RustExtractor::new();
623        let funcs = extractor.extract_test_functions(&source, "t002_pass.rs");
624        assert_eq!(funcs.len(), 1);
625        assert_eq!(
626            funcs[0].analysis.mock_count, 1,
627            "pass file should have 1 mock"
628        );
629        assert_eq!(funcs[0].analysis.mock_classes, vec!["repo"]);
630    }
631
632    #[test]
633    fn mock_class_name_extraction() {
634        // TC-11: mock class name stripping
635        assert_eq!(extract_mock_class_name("mock_service"), "service");
636        assert_eq!(extract_mock_class_name("mock_db"), "db");
637        assert_eq!(extract_mock_class_name("service"), "service");
638        assert_eq!(extract_mock_class_name("mockService"), "Service");
639    }
640
641    // --- Giant test (TC-12, TC-13) ---
642
643    #[test]
644    fn giant_test_line_count() {
645        // TC-12: > 50 lines triggers T003
646        let source = fixture("t003_violation.rs");
647        let extractor = RustExtractor::new();
648        let funcs = extractor.extract_test_functions(&source, "t003_violation.rs");
649        assert_eq!(funcs.len(), 1);
650        assert!(
651            funcs[0].analysis.line_count > 50,
652            "violation file line_count should > 50, got {}",
653            funcs[0].analysis.line_count
654        );
655    }
656
657    #[test]
658    fn short_test_line_count() {
659        // TC-13: <= 50 lines passes
660        let source = fixture("t003_pass.rs");
661        let extractor = RustExtractor::new();
662        let funcs = extractor.extract_test_functions(&source, "t003_pass.rs");
663        assert_eq!(funcs.len(), 1);
664        assert!(
665            funcs[0].analysis.line_count <= 50,
666            "pass file line_count should <= 50, got {}",
667            funcs[0].analysis.line_count
668        );
669    }
670
671    // --- File-level rules (TC-14, TC-15, TC-16, TC-17, TC-18) ---
672
673    #[test]
674    fn file_analysis_detects_parameterized() {
675        // TC-14: #[rstest] detected
676        let source = fixture("t004_pass.rs");
677        let extractor = RustExtractor::new();
678        let fa = extractor.extract_file_analysis(&source, "t004_pass.rs");
679        assert!(
680            fa.parameterized_count >= 1,
681            "should detect #[rstest], got {}",
682            fa.parameterized_count
683        );
684    }
685
686    #[test]
687    fn file_analysis_no_parameterized() {
688        // TC-15: no #[rstest] means parameterized_count = 0
689        let source = fixture("t004_violation.rs");
690        let extractor = RustExtractor::new();
691        let fa = extractor.extract_file_analysis(&source, "t004_violation.rs");
692        assert_eq!(
693            fa.parameterized_count, 0,
694            "violation file should have 0 parameterized"
695        );
696    }
697
698    #[test]
699    fn file_analysis_pbt_import() {
700        // TC-16: use proptest detected
701        let source = fixture("t005_pass.rs");
702        let extractor = RustExtractor::new();
703        let fa = extractor.extract_file_analysis(&source, "t005_pass.rs");
704        assert!(fa.has_pbt_import, "should detect proptest import");
705    }
706
707    #[test]
708    fn file_analysis_no_pbt_import() {
709        // TC-17: no PBT import
710        let source = fixture("t005_violation.rs");
711        let extractor = RustExtractor::new();
712        let fa = extractor.extract_file_analysis(&source, "t005_violation.rs");
713        assert!(!fa.has_pbt_import, "should not detect PBT import");
714    }
715
716    #[test]
717    fn file_analysis_no_contract() {
718        // TC-18: T008 always INFO for Rust (no contract library)
719        let source = fixture("t008_violation.rs");
720        let extractor = RustExtractor::new();
721        let fa = extractor.extract_file_analysis(&source, "t008_violation.rs");
722        assert!(!fa.has_contract_import, "Rust has no contract library");
723    }
724
725    // --- prop_assert detection (#10) ---
726
727    #[test]
728    fn prop_assert_counts_as_assertion() {
729        // #10: prop_assert_eq! should be counted as assertion
730        let source = fixture("t001_proptest_pass.rs");
731        let extractor = RustExtractor::new();
732        let funcs = extractor.extract_test_functions(&source, "t001_proptest_pass.rs");
733        assert_eq!(funcs.len(), 1, "should extract test from proptest! macro");
734        assert!(
735            funcs[0].analysis.assertion_count >= 1,
736            "prop_assert_eq! should be counted, got {}",
737            funcs[0].analysis.assertion_count
738        );
739    }
740
741    // --- Inline suppression (TC-19) ---
742
743    #[test]
744    fn suppressed_test_has_suppressed_rules() {
745        // TC-19: // exspec-ignore: T001 suppresses T001
746        let source = fixture("suppressed.rs");
747        let extractor = RustExtractor::new();
748        let funcs = extractor.extract_test_functions(&source, "suppressed.rs");
749        assert_eq!(funcs.len(), 1);
750        assert!(
751            funcs[0]
752                .analysis
753                .suppressed_rules
754                .iter()
755                .any(|r| r.0 == "T001"),
756            "T001 should be suppressed, got: {:?}",
757            funcs[0].analysis.suppressed_rules
758        );
759    }
760
761    // --- Query capture name verification (#14) ---
762
763    fn make_query(scm: &str) -> Query {
764        Query::new(&rust_language(), scm).unwrap()
765    }
766
767    #[test]
768    fn query_capture_names_test_function() {
769        let q = make_query(include_str!("../queries/test_function.scm"));
770        assert!(
771            q.capture_index_for_name("test_attr").is_some(),
772            "test_function.scm must define @test_attr capture"
773        );
774    }
775
776    #[test]
777    fn query_capture_names_assertion() {
778        let q = make_query(include_str!("../queries/assertion.scm"));
779        assert!(
780            q.capture_index_for_name("assertion").is_some(),
781            "assertion.scm must define @assertion capture"
782        );
783    }
784
785    #[test]
786    fn query_capture_names_mock_usage() {
787        let q = make_query(include_str!("../queries/mock_usage.scm"));
788        assert!(
789            q.capture_index_for_name("mock").is_some(),
790            "mock_usage.scm must define @mock capture"
791        );
792    }
793
794    #[test]
795    fn query_capture_names_mock_assignment() {
796        let q = make_query(include_str!("../queries/mock_assignment.scm"));
797        assert!(
798            q.capture_index_for_name("var_name").is_some(),
799            "mock_assignment.scm must define @var_name (required by collect_mock_class_names .expect())"
800        );
801    }
802
803    #[test]
804    fn query_capture_names_parameterized() {
805        let q = make_query(include_str!("../queries/parameterized.scm"));
806        assert!(
807            q.capture_index_for_name("parameterized").is_some(),
808            "parameterized.scm must define @parameterized capture"
809        );
810    }
811
812    #[test]
813    fn query_capture_names_import_pbt() {
814        let q = make_query(include_str!("../queries/import_pbt.scm"));
815        assert!(
816            q.capture_index_for_name("pbt_import").is_some(),
817            "import_pbt.scm must define @pbt_import capture"
818        );
819    }
820
821    // Comment-only file by design (Rust has no contract validation library).
822    // This assertion will fail when a real library is added.
823    // When that happens, update the has_any_match call site in extract_file_analysis() accordingly.
824    #[test]
825    fn query_capture_names_import_contract_comment_only() {
826        let q = make_query(include_str!("../queries/import_contract.scm"));
827        assert!(
828            q.capture_index_for_name("contract_import").is_none(),
829            "Rust import_contract.scm is intentionally comment-only"
830        );
831    }
832
833    // --- T103: missing-error-test ---
834
835    #[test]
836    fn error_test_should_panic() {
837        let source = fixture("t103_pass.rs");
838        let extractor = RustExtractor::new();
839        let fa = extractor.extract_file_analysis(&source, "t103_pass.rs");
840        assert!(
841            fa.has_error_test,
842            "#[should_panic] should set has_error_test"
843        );
844    }
845
846    #[test]
847    fn error_test_unwrap_err() {
848        let source = fixture("t103_pass_unwrap_err.rs");
849        let extractor = RustExtractor::new();
850        let fa = extractor.extract_file_analysis(&source, "t103_pass_unwrap_err.rs");
851        assert!(fa.has_error_test, ".unwrap_err() should set has_error_test");
852    }
853
854    #[test]
855    fn error_test_no_patterns() {
856        let source = fixture("t103_violation.rs");
857        let extractor = RustExtractor::new();
858        let fa = extractor.extract_file_analysis(&source, "t103_violation.rs");
859        assert!(
860            !fa.has_error_test,
861            "no error patterns should set has_error_test=false"
862        );
863    }
864
865    #[test]
866    fn error_test_is_err_only_not_sufficient() {
867        let source = fixture("t103_is_err_only.rs");
868        let extractor = RustExtractor::new();
869        let fa = extractor.extract_file_analysis(&source, "t103_is_err_only.rs");
870        assert!(
871            !fa.has_error_test,
872            ".is_err() alone should not count as error test (weak proxy)"
873        );
874    }
875
876    #[test]
877    fn query_capture_names_error_test() {
878        let q = make_query(include_str!("../queries/error_test.scm"));
879        assert!(
880            q.capture_index_for_name("error_test").is_some(),
881            "error_test.scm must define @error_test capture"
882        );
883    }
884
885    // --- T105: deterministic-no-metamorphic ---
886
887    #[test]
888    fn relational_assertion_pass_contains() {
889        let source = fixture("t105_pass.rs");
890        let extractor = RustExtractor::new();
891        let fa = extractor.extract_file_analysis(&source, "t105_pass.rs");
892        assert!(
893            fa.has_relational_assertion,
894            ".contains() should set has_relational_assertion"
895        );
896    }
897
898    #[test]
899    fn relational_assertion_violation() {
900        let source = fixture("t105_violation.rs");
901        let extractor = RustExtractor::new();
902        let fa = extractor.extract_file_analysis(&source, "t105_violation.rs");
903        assert!(
904            !fa.has_relational_assertion,
905            "only assert_eq! should not set has_relational_assertion"
906        );
907    }
908
909    #[test]
910    fn query_capture_names_relational_assertion() {
911        let q = make_query(include_str!("../queries/relational_assertion.scm"));
912        assert!(
913            q.capture_index_for_name("relational").is_some(),
914            "relational_assertion.scm must define @relational capture"
915        );
916    }
917
918    // --- T101: how-not-what ---
919
920    #[test]
921    fn how_not_what_expect_method() {
922        let source = fixture("t101_violation.rs");
923        let extractor = RustExtractor::new();
924        let funcs = extractor.extract_test_functions(&source, "t101_violation.rs");
925        assert!(
926            funcs[0].analysis.how_not_what_count > 0,
927            "mock.expect_save() should trigger how_not_what, got {}",
928            funcs[0].analysis.how_not_what_count
929        );
930    }
931
932    #[test]
933    fn how_not_what_pass() {
934        let source = fixture("t101_pass.rs");
935        let extractor = RustExtractor::new();
936        let funcs = extractor.extract_test_functions(&source, "t101_pass.rs");
937        assert_eq!(
938            funcs[0].analysis.how_not_what_count, 0,
939            "no mock patterns should have how_not_what_count=0"
940        );
941    }
942
943    #[test]
944    fn how_not_what_private_field_limited_by_token_tree() {
945        // Rust macro arguments are token_tree (not AST), so field_expression
946        // with _name inside assert_eq!() is not detectable.
947        // Private field access outside macros IS detected as field_expression,
948        // but count_captures_within_context requires it to be inside an
949        // assertion node (macro_invocation), which doesn't contain field_expression.
950        let source = fixture("t101_private_violation.rs");
951        let extractor = RustExtractor::new();
952        let funcs = extractor.extract_test_functions(&source, "t101_private_violation.rs");
953        assert_eq!(
954            funcs[0].analysis.how_not_what_count, 0,
955            "Rust token_tree limitation: private field access in test is not detected"
956        );
957    }
958
959    #[test]
960    fn query_capture_names_how_not_what() {
961        let q = make_query(include_str!("../queries/how_not_what.scm"));
962        assert!(
963            q.capture_index_for_name("how_pattern").is_some(),
964            "how_not_what.scm must define @how_pattern capture"
965        );
966    }
967
968    #[test]
969    fn query_capture_names_private_in_assertion() {
970        let q = make_query(include_str!("../queries/private_in_assertion.scm"));
971        assert!(
972            q.capture_index_for_name("private_access").is_some(),
973            "private_in_assertion.scm must define @private_access capture"
974        );
975    }
976
977    // --- T102: fixture-sprawl ---
978
979    #[test]
980    fn fixture_count_for_violation() {
981        let source = fixture("t102_violation.rs");
982        let extractor = RustExtractor::new();
983        let funcs = extractor.extract_test_functions(&source, "t102_violation.rs");
984        assert_eq!(
985            funcs[0].analysis.fixture_count, 7,
986            "expected 7 let bindings as fixture_count"
987        );
988    }
989
990    #[test]
991    fn fixture_count_for_pass() {
992        let source = fixture("t102_pass.rs");
993        let extractor = RustExtractor::new();
994        let funcs = extractor.extract_test_functions(&source, "t102_pass.rs");
995        assert_eq!(
996            funcs[0].analysis.fixture_count, 1,
997            "expected 1 let binding as fixture_count"
998        );
999    }
1000
1001    #[test]
1002    fn fixture_count_excludes_method_calls_on_locals() {
1003        let source = fixture("t102_method_chain.rs");
1004        let extractor = RustExtractor::new();
1005        let funcs = extractor.extract_test_functions(&source, "t102_method_chain.rs");
1006        assert_eq!(
1007            funcs[0].analysis.fixture_count, 6,
1008            "scoped calls (3) + struct (1) + macro (1) + builder chain (1) = 6, method calls on locals excluded"
1009        );
1010    }
1011
1012    // --- T108: wait-and-see ---
1013
1014    #[test]
1015    fn wait_and_see_violation_sleep() {
1016        let source = fixture("t108_violation_sleep.rs");
1017        let extractor = RustExtractor::new();
1018        let funcs = extractor.extract_test_functions(&source, "t108_violation_sleep.rs");
1019        assert!(!funcs.is_empty());
1020        for func in &funcs {
1021            assert!(
1022                func.analysis.has_wait,
1023                "test '{}' should have has_wait=true",
1024                func.name
1025            );
1026        }
1027    }
1028
1029    #[test]
1030    fn wait_and_see_pass_no_sleep() {
1031        let source = fixture("t108_pass_no_sleep.rs");
1032        let extractor = RustExtractor::new();
1033        let funcs = extractor.extract_test_functions(&source, "t108_pass_no_sleep.rs");
1034        assert_eq!(funcs.len(), 1);
1035        assert!(
1036            !funcs[0].analysis.has_wait,
1037            "test without sleep should have has_wait=false"
1038        );
1039    }
1040
1041    #[test]
1042    fn query_capture_names_wait_and_see() {
1043        let q = make_query(include_str!("../queries/wait_and_see.scm"));
1044        assert!(
1045            q.capture_index_for_name("wait").is_some(),
1046            "wait_and_see.scm must define @wait capture"
1047        );
1048    }
1049
1050    // --- T107: assertion-roulette ---
1051
1052    #[test]
1053    fn t107_violation_no_messages() {
1054        let source = fixture("t107_violation.rs");
1055        let extractor = RustExtractor::new();
1056        let funcs = extractor.extract_test_functions(&source, "t107_violation.rs");
1057        assert_eq!(funcs.len(), 1);
1058        assert!(
1059            funcs[0].analysis.assertion_count >= 2,
1060            "should have multiple assertions"
1061        );
1062        assert_eq!(
1063            funcs[0].analysis.assertion_message_count, 0,
1064            "no assertion should have a message"
1065        );
1066    }
1067
1068    #[test]
1069    fn t107_pass_with_messages() {
1070        let source = fixture("t107_pass_with_messages.rs");
1071        let extractor = RustExtractor::new();
1072        let funcs = extractor.extract_test_functions(&source, "t107_pass_with_messages.rs");
1073        assert_eq!(funcs.len(), 1);
1074        assert!(
1075            funcs[0].analysis.assertion_message_count >= 1,
1076            "assertions with messages should be counted"
1077        );
1078    }
1079
1080    // --- T109: undescriptive-test-name ---
1081
1082    #[test]
1083    fn t109_violation_names_detected() {
1084        let source = fixture("t109_violation.rs");
1085        let extractor = RustExtractor::new();
1086        let funcs = extractor.extract_test_functions(&source, "t109_violation.rs");
1087        assert!(!funcs.is_empty());
1088        for func in &funcs {
1089            assert!(
1090                exspec_core::rules::is_undescriptive_test_name(&func.name),
1091                "test '{}' should be undescriptive",
1092                func.name
1093            );
1094        }
1095    }
1096
1097    #[test]
1098    fn t109_pass_descriptive_names() {
1099        let source = fixture("t109_pass.rs");
1100        let extractor = RustExtractor::new();
1101        let funcs = extractor.extract_test_functions(&source, "t109_pass.rs");
1102        assert!(!funcs.is_empty());
1103        for func in &funcs {
1104            assert!(
1105                !exspec_core::rules::is_undescriptive_test_name(&func.name),
1106                "test '{}' should be descriptive",
1107                func.name
1108            );
1109        }
1110    }
1111
1112    // --- T106: duplicate-literal-assertion ---
1113
1114    #[test]
1115    fn t106_violation_duplicate_literal() {
1116        let source = fixture("t106_violation.rs");
1117        let extractor = RustExtractor::new();
1118        let funcs = extractor.extract_test_functions(&source, "t106_violation.rs");
1119        assert_eq!(funcs.len(), 1);
1120        assert!(
1121            funcs[0].analysis.duplicate_literal_count >= 3,
1122            "42 appears 3 times, should be >= 3: got {}",
1123            funcs[0].analysis.duplicate_literal_count
1124        );
1125    }
1126
1127    #[test]
1128    fn t106_pass_no_duplicates() {
1129        let source = fixture("t106_pass_no_duplicates.rs");
1130        let extractor = RustExtractor::new();
1131        let funcs = extractor.extract_test_functions(&source, "t106_pass_no_duplicates.rs");
1132        assert_eq!(funcs.len(), 1);
1133        assert!(
1134            funcs[0].analysis.duplicate_literal_count < 3,
1135            "each literal appears once: got {}",
1136            funcs[0].analysis.duplicate_literal_count
1137        );
1138    }
1139
1140    // --- T001 FP fix: #[should_panic] as assertion (#25) ---
1141
1142    #[test]
1143    fn t001_should_panic_counts_as_assertion() {
1144        // TC-09: #[should_panic] only -> T001 should NOT fire
1145        let source = fixture("t001_should_panic.rs");
1146        let extractor = RustExtractor::new();
1147        let funcs = extractor.extract_test_functions(&source, "t001_should_panic.rs");
1148        assert_eq!(funcs.len(), 1);
1149        assert!(
1150            funcs[0].analysis.assertion_count >= 1,
1151            "#[should_panic] should count as assertion, got {}",
1152            funcs[0].analysis.assertion_count
1153        );
1154    }
1155
1156    #[test]
1157    fn t001_should_panic_before_test_counts_as_assertion() {
1158        // TC-09c: #[should_panic] BEFORE #[test] -> T001 should NOT fire
1159        let source = fixture("t001_should_panic_before_test.rs");
1160        let extractor = RustExtractor::new();
1161        let funcs = extractor.extract_test_functions(&source, "t001_should_panic_before_test.rs");
1162        assert_eq!(funcs.len(), 1);
1163        assert!(
1164            funcs[0].analysis.assertion_count >= 1,
1165            "#[should_panic] before #[test] should count as assertion, got {}",
1166            funcs[0].analysis.assertion_count
1167        );
1168    }
1169
1170    #[test]
1171    fn t001_should_panic_in_mod_counts_as_assertion() {
1172        // TC-09b: #[should_panic] inside mod tests {} -> T001 should NOT fire
1173        let source = fixture("t001_should_panic_in_mod.rs");
1174        let extractor = RustExtractor::new();
1175        let funcs = extractor.extract_test_functions(&source, "t001_should_panic_in_mod.rs");
1176        assert_eq!(funcs.len(), 1);
1177        assert!(
1178            funcs[0].analysis.assertion_count >= 1,
1179            "#[should_panic] in mod should count as assertion, got {}",
1180            funcs[0].analysis.assertion_count
1181        );
1182    }
1183}