Skip to main content

rpytest_daemon/
collector.rs

1//! Native AST-based test collection using RustPython parser.
2//!
3//! This collector uses pure Rust AST parsing to find tests without importing pytest.
4//! It's faster than pytest collection but may not detect all tests that pytest does.
5
6use crate::error::Result;
7use crate::models::{NativeTestNode, ParameterizedTestNode};
8use rayon::prelude::*;
9use rustpython_ast::{self as ast, ExprAttribute, ExprName};
10use rustpython_parser::Parse;
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, Mutex};
14use tracing::{debug, warn};
15
16// Type aliases to reduce complexity of deeply nested types
17/// Information about a parameter value: (values, custom_id, marks)
18type ParamValueInfo = (Vec<String>, Option<String>, Vec<String>);
19/// A single parameter combination: (param_names, param_values, custom_id, marks)
20type ParamCombination = (Vec<String>, Vec<String>, Option<String>, Vec<String>);
21
22/// Information extracted from a single parameter value (for pytest.param)
23#[allow(dead_code)]
24#[derive(Debug, Clone)]
25struct ParamValue {
26    values: Vec<String>, // Parameter values
27    id: Option<String>,  // Custom ID from id= keyword
28    marks: Vec<String>,  // Marks from marks= keyword
29}
30
31/// Information about skip/xfail decoration
32#[derive(Debug, Default, Clone)]
33struct SkipXfailInfo {
34    skip: bool,
35    skip_reason: Option<String>,
36    skipif: bool,
37    skipif_condition: Option<String>,
38    xfail: bool,
39    xfail_reason: Option<String>,
40    xfail_condition: Option<String>,
41    xfail_strict: bool,
42    xfail_raises: Option<String>,
43}
44
45/// Context for processing test statements within a class or file
46struct TestProcessingContext<'a> {
47    file_path: &'a str,
48    class_name: &'a str,
49    class_markers: &'a [String],
50    source: &'a str,
51}
52
53/// Context for creating test nodes
54struct TestCreationContext<'a> {
55    fn_name: &'a str,
56    line_number: u32,
57    markers: &'a [String],
58    uses_external_fixtures: bool,
59    skip_xfail: SkipXfailInfo,
60}
61
62/// Decorators that indicate a method is not a test function
63const SKIP_DECORATORS: &[&str] = &["staticmethod", "classmethod", "property", "abstractmethod"];
64
65/// Category of a parameter value for ID generation
66#[derive(Debug, Clone, PartialEq)]
67enum ValueCategory {
68    Dict,
69    List,
70    Simple,
71}
72
73/// Native test collector using RustPython AST parsing.
74#[derive(Debug, Clone)]
75pub struct NativeCollector {
76    /// Root path of the repository
77    repo_path: PathBuf,
78    /// Built-in pytest fixtures that require pytest
79    builtin_fixtures: Vec<&'static str>,
80    /// Collected tests (shared for thread safety)
81    tests: Arc<Mutex<Vec<NativeTestNode>>>,
82    /// Parametrized fixtures: fixture_name -> list of param values
83    fixture_params: Arc<Mutex<HashMap<String, Vec<String>>>>,
84}
85
86impl Default for NativeCollector {
87    fn default() -> Self {
88        NativeCollector {
89            repo_path: PathBuf::from("."),
90            builtin_fixtures: vec![
91                "capsys",
92                "capfd",
93                "capsysbinary",
94                "capfdbinary",
95                "caplog",
96                "tmp_path",
97                "tmp_path_factory",
98                "tmpdir",
99                "tmpdir_factory",
100                "request",
101                "pytestconfig",
102                "cache",
103                "recwarn",
104                "monkeypatch",
105                "doctest_namespace",
106            ],
107            tests: Arc::new(Mutex::new(Vec::new())),
108            fixture_params: Arc::new(Mutex::new(HashMap::new())),
109        }
110    }
111}
112
113impl NativeCollector {
114    /// Create a new collector for the given repository.
115    pub fn new(repo_path: &Path) -> Self {
116        NativeCollector {
117            repo_path: repo_path.to_path_buf(),
118            builtin_fixtures: vec![
119                "capsys",
120                "capfd",
121                "capsysbinary",
122                "capfdbinary",
123                "caplog",
124                "tmp_path",
125                "tmp_path_factory",
126                "tmpdir",
127                "tmpdir_factory",
128                "request",
129                "pytestconfig",
130                "cache",
131                "recwarn",
132                "monkeypatch",
133                "doctest_namespace",
134            ],
135            tests: Arc::new(Mutex::new(Vec::new())),
136            fixture_params: Arc::new(Mutex::new(HashMap::new())),
137        }
138    }
139
140    /// Collect all tests using AST parsing.
141    pub fn collect(&self) -> Result<Vec<NativeTestNode>> {
142        self.fixture_params.lock().unwrap().clear();
143
144        // Find all test files
145        let test_files = self.find_test_files()?;
146
147        // Parse files in parallel and collect results without mutex contention
148        // Each thread returns its own Vec, then we flatten at the end
149        let all_tests: Vec<NativeTestNode> = test_files
150            .par_iter()
151            .filter_map(|file| self.parse_test_file(file).ok())
152            .flatten()
153            .collect();
154
155        // Store the collected tests for fixture expansion
156        {
157            let mut tests = self.tests.lock().unwrap();
158            tests.clear();
159            tests.extend(all_tests);
160        }
161
162        // Post-process: expand tests based on fixture parametrization
163        let expanded_tests = self.expand_tests_by_fixture_params();
164
165        debug!("Collected {} native tests", expanded_tests.len());
166        Ok(expanded_tests)
167    }
168
169    /// Expand tests based on fixture parametrization.
170    /// If a test uses a parametrized fixture, create one variant per fixture param.
171    fn expand_tests_by_fixture_params(&self) -> Vec<NativeTestNode> {
172        let tests = self.tests.lock().unwrap().clone();
173        let fixture_params = self.fixture_params.lock().unwrap().clone();
174
175        if fixture_params.is_empty() {
176            return tests;
177        }
178
179        let mut expanded = Vec::new();
180        for test in tests {
181            // Check if this test uses any parametrized fixtures
182            let fixture_params_for_test = self.get_fixture_params_for_test(&test, &fixture_params);
183
184            if fixture_params_for_test.is_empty() {
185                // No parametrized fixtures used, keep the test as-is
186                expanded.push(test);
187            } else if fixture_params_for_test.len() == 1 {
188                // Single parametrized fixture - expand into multiple variants
189                let (fixture_name, param_values) = &fixture_params_for_test[0];
190                for param_value in param_values.iter() {
191                    let mut variant = test.clone();
192                    // Use the actual param value as ID (like pytest does)
193                    variant.node_id = format!("{}[{}]", test.node_id, param_value);
194                    // Add marker indicating fixture parametrization
195                    variant
196                        .markers
197                        .push(format!("fixture_param:{}={}", fixture_name, param_value));
198                    expanded.push(variant);
199                }
200            } else {
201                // Multiple parametrized fixtures - create cartesian product
202                let combinations =
203                    self.cartesian_product_of_fixture_params(&fixture_params_for_test);
204                for combo in combinations {
205                    let mut variant = test.clone();
206                    // Create ID from all param values
207                    let id_parts: Vec<String> = combo
208                        .iter()
209                        .enumerate()
210                        .map(|(idx, (_, param_value))| format!("{}={}", idx + 1, param_value))
211                        .collect();
212                    variant.node_id = format!("{}[{}]", test.node_id, id_parts.join("-"));
213                    expanded.push(variant);
214                }
215            }
216        }
217
218        expanded
219    }
220
221    /// Get parametrized fixtures used by a test.
222    fn get_fixture_params_for_test(
223        &self,
224        test: &NativeTestNode,
225        fixture_params: &HashMap<String, Vec<String>>,
226    ) -> Vec<(String, Vec<String>)> {
227        let mut result = Vec::new();
228
229        // Check each fixture param to see if the test uses it
230        for (fixture_name, param_values) in fixture_params {
231            // The test uses a fixture if the fixture name appears in its markers
232            // or if we can infer it from the test structure
233            // For now, check if any marker indicates fixture usage
234            let expected_marker = format!("uses_fixture:{}", fixture_name);
235            for marker in &test.markers {
236                if marker == &expected_marker {
237                    result.push((fixture_name.clone(), param_values.clone()));
238                    break;
239                }
240            }
241        }
242
243        result
244    }
245
246    /// Compute cartesian product of fixture param values.
247    fn cartesian_product_of_fixture_params(
248        &self,
249        fixture_params: &[(String, Vec<String>)],
250    ) -> Vec<Vec<(String, String)>> {
251        if fixture_params.is_empty() {
252            return Vec::new();
253        }
254
255        if fixture_params.len() == 1 {
256            return fixture_params[0]
257                .1
258                .iter()
259                .map(|p| vec![(fixture_params[0].0.clone(), p.clone())])
260                .collect();
261        }
262
263        // Recursive cartesian product
264        fn compute_product(
265            params: &[(String, Vec<String>)],
266            index: usize,
267        ) -> Vec<Vec<(String, String)>> {
268            if index >= params.len() {
269                return vec![Vec::new()];
270            }
271
272            let (name, values) = &params[index];
273            let rest = compute_product(params, index + 1);
274
275            let mut result = Vec::new();
276            for value in values {
277                for mut combo in rest.clone() {
278                    combo.insert(0, (name.clone(), value.clone()));
279                    result.push(combo);
280                }
281            }
282
283            result
284        }
285
286        compute_product(fixture_params, 0)
287    }
288
289    /// Find all test files in the repository.
290    fn find_test_files(&self) -> Result<Vec<PathBuf>> {
291        let mut test_files = Vec::new();
292
293        // Common test file patterns
294        for entry in walkdir::WalkDir::new(&self.repo_path)
295            .follow_links(true)
296            .into_iter()
297        {
298            let entry = entry?;
299            let path = entry.path().to_path_buf();
300
301            // Skip directories
302            if path.is_dir() {
303                continue;
304            }
305
306            // Skip venv and hidden directories
307            let path_str = path.to_string_lossy();
308            if path_str.contains("/.venv/")
309                || path_str.contains("/venv/")
310                || path_str.contains("/node_modules/")
311                || path_str.contains("/.git/")
312            {
313                continue;
314            }
315
316            // Check if it's a Python test file
317            if let Some(ext) = path.extension() {
318                if ext == "py" {
319                    let file_name = path.file_name().unwrap().to_string_lossy();
320                    // Match test_*.py or *_test.py patterns
321                    if file_name.starts_with("test_") || file_name.ends_with("_test.py") {
322                        test_files.push(path);
323                    }
324                }
325            }
326        }
327
328        debug!("Found {} test files", test_files.len());
329        Ok(test_files)
330    }
331
332    /// Parse a single test file and extract test nodes.
333    fn parse_test_file(&self, path: &PathBuf) -> Result<Vec<NativeTestNode>> {
334        let content = match std::fs::read_to_string(path) {
335            Ok(c) => c,
336            Err(e) => {
337                warn!("Failed to read {}: {}", path.display(), e);
338                return Ok(Vec::new());
339            }
340        };
341
342        // Parse using rustpython-parser
343        let syntax = ast::Suite::parse(&content, "<test>");
344
345        match syntax {
346            Ok(stmts) => self.extract_tests_from_ast(&stmts, path, &content),
347            Err(e) => {
348                warn!("Failed to parse {}: {}", path.display(), e);
349                Ok(Vec::new())
350            }
351        }
352    }
353
354    /// Convert a byte offset to a 1-based line number.
355    fn byte_offset_to_line(&self, source: &str, offset: usize) -> u32 {
356        let offset = offset.min(source.len());
357        let line = source[..offset].chars().filter(|&c| c == '\n').count() + 1;
358        line as u32
359    }
360
361    /// Extract test nodes from Python AST statements.
362    fn extract_tests_from_ast(
363        &self,
364        stmts: &[ast::Stmt],
365        path: &Path,
366        source: &str,
367    ) -> Result<Vec<NativeTestNode>> {
368        let mut tests = Vec::new();
369        let file_path = path.to_string_lossy().to_string();
370
371        // Get relative path from repo root
372        let relative_path = if let Ok(stripped) = path.strip_prefix(&self.repo_path) {
373            stripped.to_string_lossy().to_string()
374        } else {
375            file_path.clone()
376        };
377
378        // Parse conftest fixtures if this is a conftest
379        let mut conftest_fixtures: Vec<String> = Vec::new();
380        let is_conftest = path.file_name().unwrap().to_string_lossy() == "conftest.py";
381
382        if is_conftest {
383            conftest_fixtures = self.extract_conftest_fixtures(stmts);
384        }
385
386        // Also collect fixtures from this file (for fixture parametrization detection)
387        let file_fixtures = self.extract_conftest_fixtures(stmts);
388
389        // Merge conftest and file-local fixtures
390        let mut all_fixtures = conftest_fixtures;
391        for fixture in &file_fixtures {
392            if !all_fixtures.contains(fixture) {
393                all_fixtures.push(fixture.clone());
394            }
395        }
396
397        // Extract tests from statements
398        self.extract_stmt_items(stmts, "", &relative_path, &all_fixtures, source, &mut tests);
399
400        Ok(tests)
401    }
402
403    /// Extract fixtures from conftest.py (handles both sync and async)
404    fn extract_conftest_fixtures(&self, stmts: &[ast::Stmt]) -> Vec<String> {
405        let mut fixtures = Vec::new();
406
407        for stmt in stmts {
408            match stmt {
409                ast::Stmt::FunctionDef(func) => {
410                    self.check_for_fixture(func, &mut fixtures);
411                }
412                ast::Stmt::AsyncFunctionDef(func) => {
413                    self.check_for_async_fixture(func, &mut fixtures);
414                }
415                _ => {}
416            }
417        }
418
419        fixtures
420    }
421
422    /// Check a function def for @pytest.fixture decorator
423    fn check_for_fixture(&self, func: &ast::StmtFunctionDef, fixtures: &mut Vec<String>) {
424        for decorator in &func.decorator_list {
425            if let Some(name) = self.get_decorator_name(decorator) {
426                // Handle both @fixture and @pytest.fixture
427                if name == "fixture" || name == "pytest.fixture" {
428                    fixtures.push(func.name.to_string());
429                    // Also check for params
430                    self.extract_fixture_params(decorator, &func.name);
431                }
432            }
433        }
434    }
435
436    /// Extract parameters from @pytest.fixture(params=[...]) decorator
437    fn extract_fixture_params(&self, decorator: &ast::Expr, fixture_name: &str) {
438        // Handle both @fixture and @fixture(params=[...])
439        let keywords = match decorator {
440            ast::Expr::Call(ast::ExprCall { keywords, .. }) => keywords,
441            _ => return,
442        };
443
444        // Look for params keyword argument
445        for keyword in keywords {
446            if keyword.arg.as_deref() == Some("params") {
447                // Extract param values
448                if let Some(param_values) = self.extract_list_param_values(&keyword.value) {
449                    let mut fixture_params = self.fixture_params.lock().unwrap();
450                    fixture_params.insert(fixture_name.to_string(), param_values);
451                }
452            }
453        }
454    }
455
456    /// Extract parameter values from a list expression (for fixture params)
457    fn extract_list_param_values(&self, expr: &ast::Expr) -> Option<Vec<String>> {
458        match expr {
459            ast::Expr::List(ast::ExprList { elts, .. }) => {
460                let mut values = Vec::new();
461                for elt in elts {
462                    if let ast::Expr::Constant(ast::ExprConstant { value, .. }) = elt {
463                        values.push(self.format_constant(value));
464                    } else {
465                        // Non-constant element - use repr
466                        values.push(format!("{:?}", elt));
467                    }
468                }
469                Some(values)
470            }
471            _ => None,
472        }
473    }
474
475    /// Check an async function def for @pytest.fixture decorator
476    fn check_for_async_fixture(
477        &self,
478        func: &ast::StmtAsyncFunctionDef,
479        fixtures: &mut Vec<String>,
480    ) {
481        for decorator in &func.decorator_list {
482            if let Some(name) = self.get_decorator_name(decorator) {
483                // Handle both @fixture and @pytest.fixture
484                if name == "fixture" || name == "pytest.fixture" {
485                    fixtures.push(func.name.to_string());
486                    // Also check for params
487                    self.extract_fixture_params(decorator, &func.name);
488                }
489            }
490        }
491    }
492
493    /// Get decorator name from a decorator expression.
494    fn get_decorator_name(&self, decorator: &ast::Expr) -> Option<String> {
495        match decorator {
496            // Handle pytest.mark.something(...) call decorators like @pytest.mark.parametrize(...)
497            ast::Expr::Call(ast::ExprCall { func, .. }) => self.get_decorator_name(func),
498            // Handle pytest.mark.something decorators
499            ast::Expr::Attribute(ExprAttribute { attr, value, .. }) => {
500                let attr_str = attr.to_string();
501                if let Some(inner) = self.get_decorator_name(value) {
502                    // Build qualified name: inner.attr (e.g., "pytest.mark.slow")
503                    Some(format!("{}.{}", inner, attr_str))
504                } else {
505                    // Just the attribute part (e.g., "mark.slow")
506                    Some(attr_str)
507                }
508            }
509            ast::Expr::Name(ExprName { id, .. }) => Some(id.to_string()),
510            _ => None,
511        }
512    }
513
514    /// Extract parametrize info from decorator arguments.
515    fn extract_parametrize_info(&self, decorator: &ast::Expr) -> Option<ParameterizedTestNode> {
516        // Look for @pytest.mark.parametrize(args)
517        if let ast::Expr::Call(ast::ExprCall { func, args, .. }) = decorator {
518            let func_name = self.get_decorator_name(func);
519            if func_name.as_deref() != Some("pytest.mark.parametrize") {
520                return None;
521            }
522
523            // args[0] should be the parameter names string (e.g., "x" or "x,y")
524            // args[1] should be the list of values
525            if args.len() < 2 {
526                return None;
527            }
528
529            // Extract parameter names from first argument
530            let param_names: Vec<String> = match &args[0] {
531                ast::Expr::Constant(ast::ExprConstant {
532                    value: ast::Constant::Str(s),
533                    ..
534                }) => s.split(',').map(|s| s.trim().to_string()).collect(),
535                _ => return None,
536            };
537
538            // Extract parameter values from second argument (with custom IDs and marks)
539            let param_values_with_info = self.extract_param_values_with_info(&args[1])?;
540
541            // Convert to the format expected by ParameterizedTestNode
542            // Now stores (values, custom_id, marks) tuples
543            let param_values: Vec<(Vec<String>, Option<String>, Vec<String>)> =
544                param_values_with_info
545                    .iter()
546                    .map(|(values, custom_id, marks)| {
547                        (values.clone(), custom_id.clone(), marks.clone())
548                    })
549                    .collect();
550
551            Some(ParameterizedTestNode {
552                param_names,
553                param_values,
554                test_id: String::new(),
555            })
556        } else {
557            None
558        }
559    }
560
561    /// Extract parameter values from a parametrize argument list.
562    #[allow(dead_code)]
563    fn extract_param_values(&self, expr: &ast::Expr) -> Option<Vec<Vec<String>>> {
564        match expr {
565            // List of values: [1, 2, 3] or [(1, 2), (3, 4)]
566            ast::Expr::List(ast::ExprList { elts, .. }) => {
567                let mut all_values = Vec::new();
568                for elt in elts {
569                    if let Some((values, _, _)) = self.extract_single_param_value_full(elt) {
570                        all_values.push(values);
571                    } else {
572                        return None;
573                    }
574                }
575                Some(all_values)
576            }
577            _ => None,
578        }
579    }
580
581    /// Extract a single parameter value (can be a scalar or tuple).
582    /// Returns the values, custom ID, and marks from pytest.param.
583    fn extract_single_param_value_full(
584        &self,
585        expr: &ast::Expr,
586    ) -> Option<(Vec<String>, Option<String>, Vec<String>)> {
587        match expr {
588            // Simple value: 1, "string", True, None
589            ast::Expr::Constant(ast::ExprConstant { value, .. }) => {
590                Some((vec![self.format_constant(value)], None, Vec::new()))
591            }
592            // Tuple: (1, 2, 3)
593            ast::Expr::Tuple(ast::ExprTuple { elts, .. }) => {
594                let mut values = Vec::new();
595                for elt in elts {
596                    if let ast::Expr::Constant(ast::ExprConstant { value, .. }) = elt {
597                        values.push(self.format_constant(value));
598                    } else {
599                        // Non-constant tuple element - represent as repr
600                        values.push(format!("{:?}", elt));
601                    }
602                }
603                Some((values, None, Vec::new()))
604            }
605            // Dict: {"a": 1} - use compact representation
606            ast::Expr::Dict(ast::ExprDict { keys, values, .. }) => {
607                // Generate a compact representation like pytest does
608                let parts: Vec<String> = keys
609                    .iter()
610                    .zip(values.iter())
611                    .filter_map(|(k, v)| {
612                        // Collapse nested if-let into single match
613                        if let (
614                            Some(k_const),
615                            ast::Expr::Constant(ast::ExprConstant { value, .. }),
616                        ) = (
617                            k.as_ref().and_then(|k| {
618                                if let ast::Expr::Constant(ast::ExprConstant {
619                                    value: ast::Constant::Str(s),
620                                    ..
621                                }) = k
622                                {
623                                    Some(s)
624                                } else {
625                                    None
626                                }
627                            }),
628                            v,
629                        ) {
630                            Some(format!("{}={}", k_const, self.format_constant(value)))
631                        } else {
632                            None
633                        }
634                    })
635                    .collect();
636                let repr = if parts.is_empty() {
637                    "{}".to_string()
638                } else {
639                    format!("{{{}}}", parts.join(","))
640                };
641                Some((vec![repr], None, Vec::new()))
642            }
643            // List: [1, 2, 3] - use compact representation
644            ast::Expr::List(ast::ExprList { elts, .. }) => {
645                let parts: Vec<String> = elts
646                    .iter()
647                    .filter_map(|elt| {
648                        if let ast::Expr::Constant(ast::ExprConstant { value, .. }) = elt {
649                            Some(self.format_constant(value))
650                        } else {
651                            None
652                        }
653                    })
654                    .collect();
655                let repr = if parts.is_empty() {
656                    "[]".to_string()
657                } else {
658                    format!("[{}]", parts.join(","))
659                };
660                Some((vec![repr], None, Vec::new()))
661            }
662            // pytest.param: pytest.param(1, id="...", marks=pytest.mark.skip(...))
663            ast::Expr::Call(ast::ExprCall {
664                func,
665                args,
666                keywords,
667                ..
668            }) => {
669                // Check if this is a call to pytest.param
670                // The func could be:
671                // - Name("param") - direct param() call
672                // - Attribute(attr="param", value=Name("pytest")) - pytest.param()
673                let is_param = match func.as_ref() {
674                    ast::Expr::Name(name_expr) => name_expr.id.as_str() == "param",
675                    ast::Expr::Attribute(attr) => {
676                        if attr.attr.as_str() == "param" {
677                            if let ast::Expr::Name(name_expr) = &*attr.value {
678                                name_expr.id.as_str() == "param"
679                                    || name_expr.id.as_str() == "pytest"
680                            } else {
681                                false
682                            }
683                        } else {
684                            false
685                        }
686                    }
687                    _ => false,
688                };
689
690                if is_param {
691                    // Extract custom ID from keywords
692                    let custom_id = keywords
693                        .iter()
694                        .find(|kw| kw.arg.as_deref() == Some("id"))
695                        .and_then(|kw| {
696                            if let ast::Expr::Constant(ast::ExprConstant {
697                                value: ast::Constant::Str(s),
698                                ..
699                            }) = &kw.value
700                            {
701                                Some(s.clone())
702                            } else {
703                                None
704                            }
705                        });
706
707                    // Extract marks from keywords
708                    let marks = self.parse_marks_from_keywords(keywords);
709
710                    // Extract value(s) from args
711                    if args.is_empty() {
712                        return Some((vec!["param".to_string()], custom_id, marks));
713                    }
714                    // First arg is the value
715                    if let Some((values, _, _)) = self.extract_single_param_value_full(&args[0]) {
716                        return Some((values, custom_id, marks));
717                    }
718                }
719                None
720            }
721            // UnaryOp: -0, -0.0, -1, etc.
722            ast::Expr::UnaryOp(ast::ExprUnaryOp { op, operand, .. }) => {
723                match (op, operand.as_ref()) {
724                    (ast::UnaryOp::USub, ast::Expr::Constant(ast::ExprConstant { value, .. })) => {
725                        // Negative constant: -0, -0.0, -1, etc.
726                        // Special case: -0 (integer) evaluates to 0 in Python
727                        if let ast::Constant::Int(n) = value {
728                            if n.to_string() == "0" {
729                                // -0 == 0 in Python, so just return 0
730                                return Some((vec!["0".to_string()], None, Vec::new()));
731                            }
732                        }
733                        let formatted = self.format_constant(value);
734                        Some((vec![format!("-{}", formatted)], None, Vec::new()))
735                    }
736                    (ast::UnaryOp::UAdd, ast::Expr::Constant(ast::ExprConstant { value, .. })) => {
737                        // Positive constant: +0, +1, etc. (rare but possible)
738                        Some((vec![self.format_constant(value)], None, Vec::new()))
739                    }
740                    (ast::UnaryOp::Not, _) => {
741                        // not expression - use repr
742                        Some((vec![format!("{:?}", expr)], None, Vec::new()))
743                    }
744                    _ => Some((vec![format!("{:?}", expr)], None, Vec::new())),
745                }
746            }
747            // For other expressions, use debug representation
748            _ => Some((vec![format!("{:?}", expr)], None, Vec::new())),
749        }
750    }
751
752    /// Parse marks from pytest.param keywords.
753    fn parse_marks_from_keywords(&self, keywords: &[ast::Keyword]) -> Vec<String> {
754        let mut marks = Vec::new();
755
756        for keyword in keywords {
757            if keyword.arg.as_deref() == Some("marks") {
758                // Parse the marks expression
759                self.extract_marks_from_expr(&keyword.value, &mut marks);
760            }
761        }
762
763        marks
764    }
765
766    /// Recursively extract marks from an expression.
767    fn extract_marks_from_expr(&self, expr: &ast::Expr, marks: &mut Vec<String>) {
768        match expr {
769            // Single mark: pytest.mark.skip
770            ast::Expr::Call(call) => {
771                if let Some(name) = self.get_decorator_name(&call.func) {
772                    let marker = name.replace("pytest.mark.", "");
773                    marks.push(marker);
774                }
775            }
776            // List of marks: [pytest.mark.skip, pytest.mark.slow]
777            ast::Expr::List(list) => {
778                for elt in &list.elts {
779                    self.extract_marks_from_expr(elt, marks);
780                }
781            }
782            // Attribute access: pytest.mark.skip
783            ast::Expr::Attribute(_attr) => {
784                if let Some(name) = self.get_decorator_name(expr) {
785                    let marker = name.replace("pytest.mark.", "");
786                    marks.push(marker);
787                }
788            }
789            _ => {}
790        }
791    }
792
793    /// Extract parameter values from a parametrize argument list, including custom IDs and marks.
794    /// Returns Vec<ParamValueInfo> where each element is (values, custom_id, marks)
795    fn extract_param_values_with_info(&self, expr: &ast::Expr) -> Option<Vec<ParamValueInfo>> {
796        match expr {
797            // List of values: [1, 2, 3] or [(1, 2), (3, 4)]
798            ast::Expr::List(ast::ExprList { elts, .. }) => {
799                let mut all_values = Vec::new();
800                for elt in elts {
801                    if let Some((values, custom_id, marks)) =
802                        self.extract_single_param_value_full(elt)
803                    {
804                        all_values.push((values, custom_id, marks));
805                    } else {
806                        return None;
807                    }
808                }
809                Some(all_values)
810            }
811            _ => None,
812        }
813    }
814
815    /// Format a Python constant for display in test ID.
816    fn format_constant(&self, value: &ast::Constant) -> String {
817        match value {
818            ast::Constant::Str(s) => {
819                // For test IDs, just return the string as-is (pytest does the same)
820                // Special characters will be handled by format_param_value if needed
821                s.clone()
822            }
823            ast::Constant::Int(n) => n.to_string(),
824            ast::Constant::Float(n) => {
825                let s = n.to_string();
826                // Clean up float representation
827                if s.contains('e') || s.contains('E') {
828                    format!("{:?}", n)
829                } else if !s.contains('.') {
830                    // Ensure floats have decimal point (e.g., 0.0 not 0)
831                    format!("{}.0", s)
832                } else {
833                    s
834                }
835            }
836            ast::Constant::Bool(b) => if *b { "True" } else { "False" }.to_string(),
837            ast::Constant::None => "None".to_string(),
838            _ => format!("{:?}", value),
839        }
840    }
841
842    /// Format a parameter value for test ID.
843    /// Match pytest's ID format: type prefix + index for containers, value for simple types.
844    fn format_param_value(&self, value: &str) -> String {
845        // Detect type and format accordingly
846        if value == "{}" {
847            // Empty dict - pytest uses dctN prefix
848            "dct".to_string()
849        } else if value.starts_with('{') {
850            // Dict with content - pytest uses dctN prefix
851            "dct".to_string()
852        } else if value == "[]" {
853            // Empty list - pytest uses lstN prefix
854            "lst".to_string()
855        } else if value.starts_with('[') {
856            // List with content - pytest uses lstN prefix
857            "lst".to_string()
858        } else if value == "None" {
859            // Python None - use capital N like pytest
860            "None".to_string()
861        } else if value == "True" || value == "False" {
862            // Booleans - pytest uses capitalized True/False
863            value.to_string()
864        } else {
865            // Match pytest behavior: preserve most characters, only escape those that break node_id parsing
866            // pytest only needs to escape: [ ] / :: (node_id separators)
867            // Convert escape sequences to literal backslash representation (what pytest shows)
868            let sanitized = value
869                .replace('[', "_")
870                .replace(']', "_")
871                .replace("::", "_")
872                .replace('/', "_")
873                // Convert actual escape sequences to their escaped representation
874                .replace('\n', "\\n")
875                .replace('\r', "\\r")
876                .replace('\t', "\\t")
877                // Backslash itself should be escaped
878                .replace('\\', "\\");
879            // Limit length
880            if sanitized.len() > 30 {
881                format!("{}...", &sanitized[..27])
882            } else {
883                sanitized
884            }
885        }
886    }
887
888    /// Categorize a value for ID generation purposes.
889    fn categorize_value(&self, value: &str) -> ValueCategory {
890        if value == "{}" || value.starts_with('{') {
891            ValueCategory::Dict
892        } else if value == "[]" || value.starts_with('[') {
893            ValueCategory::List
894        } else {
895            ValueCategory::Simple
896        }
897    }
898
899    /// Check if parameter values have mixed types.
900    /// Returns true if there's a mix of container types (list/dict) with simple types.
901    fn has_mixed_param_types(
902        &self,
903        param_values: &[(Vec<String>, Option<String>, Vec<String>)],
904    ) -> bool {
905        let mut has_dict = false;
906        let mut has_list = false;
907        let mut has_simple = false;
908
909        for (values, _, _) in param_values {
910            for value in values {
911                match self.categorize_value(value) {
912                    ValueCategory::Dict => has_dict = true,
913                    ValueCategory::List => has_list = true,
914                    ValueCategory::Simple => has_simple = true,
915                }
916            }
917        }
918
919        // Mixed if we have containers (dict or list) AND simple types
920        // OR if we have both dicts AND lists
921        (has_dict || has_list) && has_simple
922    }
923
924    /// Deduplicate IDs by adding _0, _1, etc. suffixes for duplicates.
925    /// This matches pytest's behavior when parameter values evaluate to the same thing.
926    fn deduplicate_ids(&self, ids: &[String]) -> Vec<String> {
927        let mut result = Vec::with_capacity(ids.len());
928        let mut seen: std::collections::HashMap<&str, Vec<usize>> =
929            std::collections::HashMap::new();
930
931        // First pass: group indices by ID
932        for (idx, id) in ids.iter().enumerate() {
933            seen.entry(id.as_str()).or_default().push(idx);
934        }
935
936        // Second pass: generate final IDs
937        let mut counters: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
938        for id in ids {
939            let indices = &seen[id.as_str()];
940            if indices.len() > 1 {
941                // This ID appears multiple times, add suffix
942                let counter = counters.entry(id.as_str()).or_insert(0);
943                result.push(format!("{}_{}", id, counter));
944                *counter += 1;
945            } else {
946                // Unique ID, use as-is
947                result.push(id.clone());
948            }
949        }
950
951        result
952    }
953
954    /// Generate pytest-style test ID from parameter values and index.
955    /// Pytest uses: type_prefix + index for containers, or just value for simple types.
956    fn generate_pytest_id(&self, values: &[String], idx: usize) -> String {
957        self.generate_pytest_id_with_context(values, idx, false)
958    }
959
960    /// Generate pytest-style test ID with context about mixed types.
961    /// When use_value_for_containers is true, uses 'value' prefix for lists/dicts (for mixed-type params).
962    fn generate_pytest_id_with_context(
963        &self,
964        values: &[String],
965        idx: usize,
966        use_value_for_containers: bool,
967    ) -> String {
968        if values.len() == 1 {
969            // Single parameter - use format_param_value for the value
970            let formatted = if use_value_for_containers {
971                self.format_param_value_with_context(&values[0], true)
972            } else {
973                self.format_param_value(&values[0])
974            };
975            // For containers (dict, list, value), use prefix + index
976            // For simple types (int, str, None), use value directly
977            if formatted == "dct" || formatted == "lst" || formatted == "value" {
978                format!("{}{}", formatted, idx)
979            } else {
980                formatted
981            }
982        } else {
983            // Multiple parameters - format each and join with hyphens
984            let formatted: Vec<String> = values
985                .iter()
986                .map(|v| {
987                    if use_value_for_containers {
988                        self.format_param_value_with_context(v, true)
989                    } else {
990                        self.format_param_value(v)
991                    }
992                })
993                .collect();
994            formatted.join("-")
995        }
996    }
997
998    /// Format a parameter value with context about whether to use 'value' prefix for containers.
999    fn format_param_value_with_context(
1000        &self,
1001        value: &str,
1002        use_value_for_containers: bool,
1003    ) -> String {
1004        if use_value_for_containers {
1005            // In mixed-type context, use 'value' prefix for containers
1006            if value == "{}" || value.starts_with('{') || value == "[]" || value.starts_with('[') {
1007                return "value".to_string();
1008            }
1009        }
1010        self.format_param_value(value)
1011    }
1012
1013    /// Extract pytest markers from a decorator list (for class-level markers).
1014    fn extract_pytest_markers(&self, decorators: &[ast::Expr]) -> Vec<String> {
1015        let mut markers = Vec::new();
1016
1017        for decorator in decorators {
1018            if let Some(name) = self.get_decorator_name(decorator) {
1019                // Skip builtin fixtures
1020                if self.builtin_fixtures.contains(&name.as_str()) {
1021                    continue;
1022                }
1023
1024                // Check for pytest.mark decorators
1025                if name.starts_with("pytest.mark.") {
1026                    let marker = name.replace("pytest.mark.", "");
1027                    markers.push(marker);
1028                }
1029            }
1030        }
1031
1032        markers
1033    }
1034
1035    /// Recursively extract tests from statements with class marker inheritance.
1036    fn extract_stmt_items(
1037        &self,
1038        stmts: &[ast::Stmt],
1039        class_name: &str,
1040        file_path: &str,
1041        conftest_fixtures: &[String],
1042        source: &str,
1043        tests: &mut Vec<NativeTestNode>,
1044    ) {
1045        for stmt in stmts {
1046            match stmt {
1047                // Handle regular function definitions
1048                ast::Stmt::FunctionDef(func) => {
1049                    self.process_function_def(
1050                        func,
1051                        class_name,
1052                        file_path,
1053                        conftest_fixtures,
1054                        source,
1055                        tests,
1056                    );
1057                }
1058                // Handle async function definitions
1059                ast::Stmt::AsyncFunctionDef(func) => {
1060                    self.process_async_function_def(
1061                        func,
1062                        class_name,
1063                        file_path,
1064                        conftest_fixtures,
1065                        source,
1066                        tests,
1067                    );
1068                }
1069                ast::Stmt::ClassDef(class_def) => {
1070                    let class_name_str = &class_def.name;
1071
1072                    // Pytest only collects test methods from classes starting with "Test"
1073                    // (not Base*, Mixin*, Abstract*, etc.)
1074                    if class_name_str.starts_with("Test") {
1075                        // Extract class-level markers
1076                        let class_markers = self.extract_pytest_markers(&class_def.decorator_list);
1077
1078                        // Extract tests from class methods with inherited markers
1079                        let ctx = TestProcessingContext {
1080                            file_path,
1081                            class_name: class_name_str,
1082                            class_markers: &class_markers,
1083                            source,
1084                        };
1085                        self.extract_stmt_items_with_class_markers(
1086                            &class_def.body,
1087                            &ctx,
1088                            conftest_fixtures,
1089                            tests,
1090                        );
1091
1092                        // Also collect inherited test methods from base classes
1093                        // Extract base class names and look for them in the same file
1094                        let base_class_names = self.extract_base_class_names(class_def);
1095                        for base_name in base_class_names {
1096                            // Find the base class definition in the same file
1097                            if let Some(base_class) = self.find_class_in_stmts(stmts, &base_name) {
1098                                // Collect test methods from base class (recursively handles inheritance)
1099                                self.collect_inherited_tests(
1100                                    base_class,
1101                                    class_name_str, // Use the derived class name
1102                                    &class_markers,
1103                                    file_path,
1104                                    conftest_fixtures,
1105                                    source,
1106                                    tests,
1107                                    stmts, // Pass stmts for recursive base class lookup
1108                                );
1109                            }
1110                        }
1111                    }
1112                }
1113                _ => {}
1114            }
1115        }
1116    }
1117
1118    /// Extract tests from class body with inherited class markers.
1119    fn extract_stmt_items_with_class_markers(
1120        &self,
1121        stmts: &[ast::Stmt],
1122        ctx: &TestProcessingContext,
1123        conftest_fixtures: &[String],
1124        tests: &mut Vec<NativeTestNode>,
1125    ) {
1126        for stmt in stmts {
1127            match stmt {
1128                ast::Stmt::FunctionDef(func) => {
1129                    self.process_function_def_with_class_markers(
1130                        func,
1131                        ctx,
1132                        conftest_fixtures,
1133                        tests,
1134                    );
1135                }
1136                ast::Stmt::AsyncFunctionDef(func) => {
1137                    self.process_async_function_def_with_class_markers(
1138                        func,
1139                        ctx,
1140                        conftest_fixtures,
1141                        tests,
1142                    );
1143                }
1144                ast::Stmt::ClassDef(nested_class) => {
1145                    // Handle nested test classes
1146                    // Pytest only collects test methods from classes starting with "Test"
1147                    if nested_class.name.starts_with("Test") {
1148                        let nested_class_markers =
1149                            self.extract_pytest_markers(&nested_class.decorator_list);
1150                        // Combine outer and inner class markers
1151                        let mut combined_markers = ctx.class_markers.to_vec();
1152                        combined_markers.extend(nested_class_markers);
1153
1154                        let nested_ctx = TestProcessingContext {
1155                            file_path: ctx.file_path,
1156                            class_name: &nested_class.name,
1157                            class_markers: &combined_markers,
1158                            source: ctx.source,
1159                        };
1160                        self.extract_stmt_items_with_class_markers(
1161                            &nested_class.body,
1162                            &nested_ctx,
1163                            conftest_fixtures,
1164                            tests,
1165                        );
1166                    }
1167                }
1168                _ => {}
1169            }
1170        }
1171    }
1172
1173    /// Process a regular function definition and extract tests.
1174    fn process_function_def(
1175        &self,
1176        func: &ast::StmtFunctionDef,
1177        class_name: &str,
1178        file_path: &str,
1179        conftest_fixtures: &[String],
1180        source: &str,
1181        tests: &mut Vec<NativeTestNode>,
1182    ) {
1183        let ctx = TestProcessingContext {
1184            file_path,
1185            class_name,
1186            class_markers: &[],
1187            source,
1188        };
1189        self.process_function_def_with_class_markers(func, &ctx, conftest_fixtures, tests);
1190    }
1191
1192    /// Process a function definition with inherited class markers.
1193    fn process_function_def_with_class_markers(
1194        &self,
1195        func: &ast::StmtFunctionDef,
1196        ctx: &TestProcessingContext,
1197        conftest_fixtures: &[String],
1198        tests: &mut Vec<NativeTestNode>,
1199    ) {
1200        let fn_name = &func.name;
1201
1202        // Check if it's a test function (starts with test_)
1203        if fn_name.starts_with("test_") || fn_name.starts_with("Test") {
1204            let decorators = &func.decorator_list;
1205
1206            // Skip methods decorated with @staticmethod, @classmethod, @property, @abstractmethod
1207            // These are not valid test functions even if they start with test_
1208            let has_skip_decorator = decorators.iter().any(|d| {
1209                if let Some(name) = self.get_decorator_name(d) {
1210                    SKIP_DECORATORS.contains(&name.as_str())
1211                } else {
1212                    false
1213                }
1214            });
1215            if has_skip_decorator {
1216                return;
1217            }
1218
1219            let line_number = self.extract_line_number_stmt(func, ctx.source);
1220
1221            // Extract markers and parametrize info
1222            let (method_markers, parametrize_infos) =
1223                self.extract_markers_and_parametrize(decorators);
1224
1225            // Merge class markers with method markers
1226            let mut all_markers = ctx.class_markers.to_vec();
1227            all_markers.extend(method_markers);
1228
1229            // Get fixtures used by this test and add markers for them
1230            let used_fixtures = self.get_pytest_fixture_usage_func(&func.args, conftest_fixtures);
1231            let uses_external_fixtures = !used_fixtures.is_empty();
1232            for fixture_name in &used_fixtures {
1233                all_markers.push(format!("uses_fixture:{}", fixture_name));
1234            }
1235
1236            // Check for skip/xfail
1237            let skip_xfail = self.extract_skip_xfail_info(decorators);
1238
1239            // Handle parametrization
1240            if parametrize_infos.is_empty() {
1241                // Single test node
1242                let test_ctx = TestCreationContext {
1243                    fn_name,
1244                    line_number,
1245                    markers: &all_markers,
1246                    uses_external_fixtures,
1247                    skip_xfail,
1248                };
1249                self.create_test_node(&test_ctx, ctx.file_path, ctx.class_name, tests);
1250            } else if parametrize_infos.len() == 1 {
1251                // Single parametrize - expand as before
1252                let test_ctx = TestCreationContext {
1253                    fn_name,
1254                    line_number,
1255                    markers: &all_markers,
1256                    uses_external_fixtures,
1257                    skip_xfail,
1258                };
1259                let param_info = parametrize_infos.into_iter().next().unwrap();
1260                self.expand_parametrized_tests(
1261                    &test_ctx,
1262                    ctx.file_path,
1263                    ctx.class_name,
1264                    param_info,
1265                    tests,
1266                );
1267            } else {
1268                // Stacked parametrize - cartesian product
1269                // Reverse the order since pytest applies decorators bottom-to-top
1270                let mut reversed_infos = parametrize_infos.clone();
1271                reversed_infos.reverse();
1272                let test_ctx = TestCreationContext {
1273                    fn_name,
1274                    line_number,
1275                    markers: &all_markers,
1276                    uses_external_fixtures,
1277                    skip_xfail,
1278                };
1279                self.expand_stacked_parametrized_tests(
1280                    &test_ctx,
1281                    ctx.file_path,
1282                    ctx.class_name,
1283                    reversed_infos,
1284                    tests,
1285                );
1286            }
1287        }
1288    }
1289
1290    /// Process an async function definition and extract tests.
1291    fn process_async_function_def(
1292        &self,
1293        func: &ast::StmtAsyncFunctionDef,
1294        class_name: &str,
1295        file_path: &str,
1296        conftest_fixtures: &[String],
1297        source: &str,
1298        tests: &mut Vec<NativeTestNode>,
1299    ) {
1300        let ctx = TestProcessingContext {
1301            file_path,
1302            class_name,
1303            class_markers: &[],
1304            source,
1305        };
1306        self.process_async_function_def_with_class_markers(func, &ctx, conftest_fixtures, tests);
1307    }
1308
1309    /// Process an async function definition with inherited class markers.
1310    fn process_async_function_def_with_class_markers(
1311        &self,
1312        func: &ast::StmtAsyncFunctionDef,
1313        ctx: &TestProcessingContext,
1314        conftest_fixtures: &[String],
1315        tests: &mut Vec<NativeTestNode>,
1316    ) {
1317        let fn_name = &func.name;
1318
1319        // Check if it's a test function (starts with test_)
1320        if fn_name.starts_with("test_") || fn_name.starts_with("Test") {
1321            let decorators = &func.decorator_list;
1322
1323            // Skip methods decorated with @staticmethod, @classmethod, @property, @abstractmethod
1324            // These are not valid test functions even if they start with test_
1325            let has_skip_decorator = decorators.iter().any(|d| {
1326                if let Some(name) = self.get_decorator_name(d) {
1327                    SKIP_DECORATORS.contains(&name.as_str())
1328                } else {
1329                    false
1330                }
1331            });
1332            if has_skip_decorator {
1333                return;
1334            }
1335
1336            let line_number = self.extract_line_number_async_stmt(func, ctx.source);
1337
1338            // Extract markers and parametrize info
1339            let (method_markers, parametrize_infos) =
1340                self.extract_markers_and_parametrize(decorators);
1341
1342            // Merge class markers with method markers
1343            let mut all_markers = ctx.class_markers.to_vec();
1344            all_markers.extend(method_markers);
1345
1346            // Check if test uses external fixtures
1347            let uses_external_fixtures =
1348                self.check_pytest_fixture_usage_async(&func.args, conftest_fixtures);
1349
1350            // Check for skip/xfail
1351            let skip_xfail = self.extract_skip_xfail_info(decorators);
1352
1353            // Handle parametrization
1354            if parametrize_infos.is_empty() {
1355                // Single test node
1356                let test_ctx = TestCreationContext {
1357                    fn_name,
1358                    line_number,
1359                    markers: &all_markers,
1360                    uses_external_fixtures,
1361                    skip_xfail,
1362                };
1363                self.create_test_node(&test_ctx, ctx.file_path, ctx.class_name, tests);
1364            } else if parametrize_infos.len() == 1 {
1365                // Single parametrize - expand as before
1366                let test_ctx = TestCreationContext {
1367                    fn_name,
1368                    line_number,
1369                    markers: &all_markers,
1370                    uses_external_fixtures,
1371                    skip_xfail,
1372                };
1373                let param_info = parametrize_infos.into_iter().next().unwrap();
1374                self.expand_parametrized_tests(
1375                    &test_ctx,
1376                    ctx.file_path,
1377                    ctx.class_name,
1378                    param_info,
1379                    tests,
1380                );
1381            } else {
1382                // Stacked parametrize - cartesian product
1383                // Reverse the order since pytest applies decorators bottom-to-top
1384                let mut reversed_infos = parametrize_infos.clone();
1385                reversed_infos.reverse();
1386                let test_ctx = TestCreationContext {
1387                    fn_name,
1388                    line_number,
1389                    markers: &all_markers,
1390                    uses_external_fixtures,
1391                    skip_xfail,
1392                };
1393                self.expand_stacked_parametrized_tests(
1394                    &test_ctx,
1395                    ctx.file_path,
1396                    ctx.class_name,
1397                    reversed_infos,
1398                    tests,
1399                );
1400            }
1401        }
1402    }
1403
1404    /// Create a single test node.
1405    fn create_test_node(
1406        &self,
1407        ctx: &TestCreationContext,
1408        file_path: &str,
1409        class_name: &str,
1410        tests: &mut Vec<NativeTestNode>,
1411    ) {
1412        let node_id = if class_name.is_empty() {
1413            format!("{}::{}", file_path, ctx.fn_name)
1414        } else {
1415            format!("{}::{}::{}", file_path, class_name, ctx.fn_name)
1416        };
1417
1418        let mut final_markers = ctx.markers.to_vec();
1419        if ctx.skip_xfail.skip {
1420            final_markers.push("skip".to_string());
1421        }
1422        if ctx.skip_xfail.skipif {
1423            final_markers.push("skipif".to_string());
1424        }
1425        if ctx.skip_xfail.xfail {
1426            final_markers.push("xfail".to_string());
1427        }
1428
1429        tests.push(NativeTestNode {
1430            node_id,
1431            file_path: file_path.to_string(),
1432            name: ctx.fn_name.to_string(),
1433            class_name: if class_name.is_empty() {
1434                None
1435            } else {
1436                Some(class_name.to_string())
1437            },
1438            line_number: ctx.line_number,
1439            markers: final_markers,
1440            is_simple: !ctx.uses_external_fixtures,
1441            parameters: Vec::new(),
1442            // Only pre-skip unconditional @pytest.mark.skip, not @pytest.mark.skipif
1443            // skipif conditions can only be evaluated at runtime by pytest
1444            skip: ctx.skip_xfail.skip,
1445            skip_reason: ctx
1446                .skip_xfail
1447                .skip_reason
1448                .clone()
1449                .or(ctx.skip_xfail.skipif_condition.clone()),
1450            xfail: ctx.skip_xfail.xfail,
1451            xfail_reason: ctx.skip_xfail.xfail_reason.clone(),
1452            xfail_strict: ctx.skip_xfail.xfail_strict,
1453        });
1454    }
1455
1456    /// Extract line number from function definition.
1457    fn extract_line_number_stmt(&self, func: &ast::StmtFunctionDef, source: &str) -> u32 {
1458        let offset = func.range.start().to_usize();
1459        self.byte_offset_to_line(source, offset)
1460    }
1461
1462    /// Extract line number from async function definition.
1463    fn extract_line_number_async_stmt(
1464        &self,
1465        func: &ast::StmtAsyncFunctionDef,
1466        source: &str,
1467    ) -> u32 {
1468        let offset = func.range.start().to_usize();
1469        self.byte_offset_to_line(source, offset)
1470    }
1471
1472    /// Extract markers and parametrize info from decorator list.
1473    /// Returns all parametrize decorators (for stacked parametrize support).
1474    fn extract_markers_and_parametrize(
1475        &self,
1476        decorators: &[ast::Expr],
1477    ) -> (Vec<String>, Vec<ParameterizedTestNode>) {
1478        let mut markers = Vec::new();
1479        let mut parametrize_infos = Vec::new();
1480
1481        for decorator in decorators {
1482            if let Some(name) = self.get_decorator_name(decorator) {
1483                // Skip builtin fixtures
1484                if self.builtin_fixtures.contains(&name.as_str()) {
1485                    continue;
1486                }
1487
1488                // Check for parametrize decorator - collect ALL of them
1489                if name == "pytest.mark.parametrize" {
1490                    if let Some(info) = self.extract_parametrize_info(decorator) {
1491                        parametrize_infos.push(info);
1492                    }
1493                    continue;
1494                }
1495
1496                // Check for pytest.mark decorators
1497                if name.starts_with("pytest.mark.") {
1498                    let marker = name.replace("pytest.mark.", "");
1499                    markers.push(marker);
1500                }
1501                // Also add non-pytest markers (like custom markers)
1502                else if !name.starts_with("pytest.") {
1503                    markers.push(name);
1504                }
1505            }
1506        }
1507
1508        (markers, parametrize_infos)
1509    }
1510
1511    /// Extract skip/xfail information from decorators.
1512    fn extract_skip_xfail_info(&self, decorators: &[ast::Expr]) -> SkipXfailInfo {
1513        let mut info = SkipXfailInfo::default();
1514
1515        for decorator in decorators {
1516            if let Some(name) = self.get_decorator_name(decorator) {
1517                // Handle @pytest.mark.skip(reason="...")
1518                if name == "pytest.mark.skip" || name.starts_with("pytest.mark.skip(") {
1519                    info.skip = true;
1520                    info.skip_reason = self.extract_kwarg_string(decorator, "reason");
1521                }
1522                // Handle @pytest.mark.skipif(condition, reason="...")
1523                else if name == "pytest.mark.skipif" || name.starts_with("pytest.mark.skipif(") {
1524                    info.skipif = true;
1525                    info.skipif_condition = self.extract_skipif_condition(decorator);
1526                    info.skip_reason = self.extract_kwarg_string(decorator, "reason");
1527                }
1528                // Handle @pytest.mark.xfail(condition=..., reason=..., strict=..., raises=...)
1529                else if name == "pytest.mark.xfail" || name.starts_with("pytest.mark.xfail(") {
1530                    info.xfail = true;
1531                    info.xfail_condition = self.extract_xfail_condition(decorator);
1532                    info.xfail_reason = self.extract_kwarg_string(decorator, "reason");
1533                    info.xfail_strict = self.extract_kwarg_bool(decorator, "strict");
1534                    info.xfail_raises = self.extract_kwarg_string(decorator, "raises");
1535                }
1536            }
1537        }
1538
1539        info
1540    }
1541
1542    /// Extract a string keyword argument from a decorator call.
1543    fn extract_kwarg_string(&self, decorator: &ast::Expr, arg: &str) -> Option<String> {
1544        if let ast::Expr::Call(ast::ExprCall { keywords, .. }) = decorator {
1545            for keyword in keywords {
1546                if keyword.arg.as_deref() == Some(arg) {
1547                    if let ast::Expr::Constant(ast::ExprConstant {
1548                        value: ast::Constant::Str(s),
1549                        ..
1550                    }) = &keyword.value
1551                    {
1552                        return Some(s.clone());
1553                    }
1554                }
1555            }
1556        }
1557        None
1558    }
1559
1560    /// Extract a boolean keyword argument from a decorator call.
1561    fn extract_kwarg_bool(&self, decorator: &ast::Expr, arg: &str) -> bool {
1562        if let ast::Expr::Call(ast::ExprCall { keywords, .. }) = decorator {
1563            for keyword in keywords {
1564                if keyword.arg.as_deref() == Some(arg) {
1565                    if let ast::Expr::Constant(ast::ExprConstant {
1566                        value: ast::Constant::Bool(b),
1567                        ..
1568                    }) = &keyword.value
1569                    {
1570                        return *b;
1571                    }
1572                }
1573            }
1574        }
1575        false
1576    }
1577
1578    /// Extract skipif condition from decorator.
1579    fn extract_skipif_condition(&self, decorator: &ast::Expr) -> Option<String> {
1580        if let ast::Expr::Call(ast::ExprCall { args, keywords, .. }) = decorator {
1581            // Try first positional argument
1582            if let Some(first_arg) = args.first() {
1583                return Some(format!("{:?}", first_arg));
1584            }
1585            // Try condition= keyword
1586            for keyword in keywords {
1587                if keyword.arg.as_deref() == Some("condition") {
1588                    return Some(format!("{:?}", keyword.value));
1589                }
1590            }
1591        }
1592        None
1593    }
1594
1595    /// Extract xfail condition from decorator.
1596    fn extract_xfail_condition(&self, decorator: &ast::Expr) -> Option<String> {
1597        if let ast::Expr::Call(ast::ExprCall { args, keywords, .. }) = decorator {
1598            // Try first positional argument
1599            if let Some(first_arg) = args.first() {
1600                return Some(format!("{:?}", first_arg));
1601            }
1602            // Try condition= keyword
1603            for keyword in keywords {
1604                if keyword.arg.as_deref() == Some("condition") {
1605                    return Some(format!("{:?}", keyword.value));
1606                }
1607            }
1608        }
1609        None
1610    }
1611
1612    /// Extract base class names from a class definition.
1613    fn extract_base_class_names(&self, class_def: &ast::StmtClassDef) -> Vec<String> {
1614        let mut base_names = Vec::new();
1615        for base in &class_def.bases {
1616            if let ast::Expr::Name(name) = base {
1617                base_names.push(name.id.to_string());
1618            } else if let ast::Expr::Attribute(attr) = base {
1619                // Handle module.ClassName style
1620                base_names.push(attr.attr.to_string());
1621            }
1622        }
1623        base_names
1624    }
1625
1626    /// Find a class definition by name in a list of statements.
1627    fn find_class_in_stmts<'a>(
1628        &self,
1629        stmts: &'a [ast::Stmt],
1630        name: &str,
1631    ) -> Option<&'a ast::StmtClassDef> {
1632        for stmt in stmts {
1633            if let ast::Stmt::ClassDef(class_def) = stmt {
1634                if class_def.name.as_str() == name {
1635                    return Some(class_def);
1636                }
1637            }
1638        }
1639        None
1640    }
1641
1642    /// Collect inherited test methods from a base class.
1643    fn collect_inherited_tests(
1644        &self,
1645        base_class: &ast::StmtClassDef,
1646        derived_class_name: &str,
1647        derived_class_markers: &[String],
1648        file_path: &str,
1649        conftest_fixtures: &[String],
1650        source: &str,
1651        tests: &mut Vec<NativeTestNode>,
1652        all_stmts: &[ast::Stmt],
1653    ) {
1654        // Collect existing method names from derived class to avoid duplicates
1655        let existing_methods: std::collections::HashSet<String> = tests
1656            .iter()
1657            .filter(|t| t.class_name.as_deref() == Some(derived_class_name))
1658            .map(|t| t.name.clone())
1659            .collect();
1660
1661        // Process test methods from base class
1662        let ctx = TestProcessingContext {
1663            file_path,
1664            class_name: derived_class_name, // Use derived class name for node_id
1665            class_markers: derived_class_markers,
1666            source,
1667        };
1668
1669        for stmt in &base_class.body {
1670            match stmt {
1671                ast::Stmt::FunctionDef(func) => {
1672                    // Only process test methods not already defined in derived class
1673                    if func.name.starts_with("test_")
1674                        && !existing_methods.contains(&func.name.to_string())
1675                    {
1676                        self.process_function_def_with_class_markers(
1677                            func,
1678                            &ctx,
1679                            conftest_fixtures,
1680                            tests,
1681                        );
1682                    }
1683                }
1684                ast::Stmt::AsyncFunctionDef(func) => {
1685                    if func.name.starts_with("test_")
1686                        && !existing_methods.contains(&func.name.to_string())
1687                    {
1688                        self.process_async_function_def_with_class_markers(
1689                            func,
1690                            &ctx,
1691                            conftest_fixtures,
1692                            tests,
1693                        );
1694                    }
1695                }
1696                _ => {}
1697            }
1698        }
1699
1700        // Recursively collect from base class's base classes
1701        let base_base_names = self.extract_base_class_names(base_class);
1702        for base_name in base_base_names {
1703            if let Some(base_base_class) = self.find_class_in_stmts(all_stmts, &base_name) {
1704                self.collect_inherited_tests(
1705                    base_base_class,
1706                    derived_class_name,
1707                    derived_class_markers,
1708                    file_path,
1709                    conftest_fixtures,
1710                    source,
1711                    tests,
1712                    all_stmts,
1713                );
1714            }
1715        }
1716    }
1717
1718    /// Expand a parametrized test into multiple test nodes.
1719    fn expand_parametrized_tests(
1720        &self,
1721        ctx: &TestCreationContext,
1722        file_path: &str,
1723        class_name: &str,
1724        param_info: ParameterizedTestNode,
1725        tests: &mut Vec<NativeTestNode>,
1726    ) {
1727        let param_names = &param_info.param_names;
1728        let param_values = &param_info.param_values;
1729
1730        // Detect if this parametrize has mixed types (to determine ID prefix strategy)
1731        // pytest uses type-specific prefix (lst, dct) when all params are same container type,
1732        // but uses 'value' prefix for containers when types are mixed
1733        let use_value_for_containers = self.has_mixed_param_types(param_values);
1734
1735        // First pass: generate all base IDs to detect duplicates
1736        let mut base_ids: Vec<String> = Vec::with_capacity(param_values.len());
1737        for (idx, (values, custom_id, _)) in param_values.iter().enumerate() {
1738            let base_id = if let Some(id) = custom_id {
1739                id.clone()
1740            } else {
1741                self.generate_pytest_id_with_context(values, idx, use_value_for_containers)
1742            };
1743            base_ids.push(base_id);
1744        }
1745
1746        // Detect duplicates and assign suffixes
1747        let final_ids = self.deduplicate_ids(&base_ids);
1748
1749        for (idx, (values, _custom_id, variant_marks)) in param_values.iter().enumerate() {
1750            // Use the deduplicated ID
1751            let test_id = final_ids[idx].clone();
1752
1753            // Build node ID with test ID suffix
1754            let node_id = if class_name.is_empty() {
1755                format!("{}::{}[{}]", file_path, ctx.fn_name, test_id)
1756            } else {
1757                format!(
1758                    "{}::{}::{}[{}]",
1759                    file_path, class_name, ctx.fn_name, test_id
1760                )
1761            };
1762
1763            // Build markers - include base markers, skip/xfail, and per-variant marks
1764            let mut markers = ctx.markers.to_vec();
1765            markers.extend(variant_marks.clone());
1766            if ctx.skip_xfail.skip {
1767                markers.push("skip".to_string());
1768            }
1769            if ctx.skip_xfail.skipif {
1770                markers.push("skipif".to_string());
1771            }
1772            if ctx.skip_xfail.xfail {
1773                markers.push("xfail".to_string());
1774            }
1775
1776            // Store parameter info for test generation
1777            let parameters = vec![serde_json::json!({
1778                "names": param_names,
1779                "values": values,
1780                "id": test_id
1781            })];
1782
1783            tests.push(NativeTestNode {
1784                node_id,
1785                file_path: file_path.to_string(),
1786                name: ctx.fn_name.to_string(),
1787                class_name: if class_name.is_empty() {
1788                    None
1789                } else {
1790                    Some(class_name.to_string())
1791                },
1792                line_number: ctx.line_number,
1793                markers,
1794                is_simple: !ctx.uses_external_fixtures,
1795                parameters,
1796                // Only pre-skip unconditional @pytest.mark.skip, not @pytest.mark.skipif
1797                // skipif conditions can only be evaluated at runtime by pytest
1798                skip: ctx.skip_xfail.skip,
1799                skip_reason: ctx
1800                    .skip_xfail
1801                    .skip_reason
1802                    .clone()
1803                    .or(ctx.skip_xfail.skipif_condition.clone()),
1804                xfail: ctx.skip_xfail.xfail,
1805                xfail_reason: ctx.skip_xfail.xfail_reason.clone(),
1806                xfail_strict: ctx.skip_xfail.xfail_strict,
1807            });
1808        }
1809    }
1810
1811    /// Expand stacked parametrized tests (multiple @pytest.mark.parametrize decorators).
1812    /// Generates Cartesian product of all parameter combinations.
1813    fn expand_stacked_parametrized_tests(
1814        &self,
1815        ctx: &TestCreationContext,
1816        file_path: &str,
1817        class_name: &str,
1818        param_infos: Vec<ParameterizedTestNode>,
1819        tests: &mut Vec<NativeTestNode>,
1820    ) {
1821        // Generate cartesian product of all parameter combinations
1822        let combinations = self.generate_cartesian_product(&param_infos);
1823
1824        for (combo_idx, combo) in combinations.into_iter().enumerate() {
1825            // combo is Vec<(param_names, param_values, custom_id)>
1826            // Flatten into single set of names and values
1827            let mut all_names = Vec::new();
1828            let mut all_values = Vec::new();
1829            let mut all_marks = Vec::new(); // Combine marks from all param sets
1830
1831            for (names, values, _, marks) in combo {
1832                all_names.extend(names);
1833                all_values.extend(values);
1834                all_marks.extend(marks);
1835            }
1836
1837            // Generate test ID using pytest-style format
1838            let test_id = self.generate_pytest_id(&all_values, combo_idx);
1839
1840            // Build node ID with test ID suffix
1841            let node_id = if class_name.is_empty() {
1842                format!("{}::{}[{}]", file_path, ctx.fn_name, test_id)
1843            } else {
1844                format!(
1845                    "{}::{}::{}[{}]",
1846                    file_path, class_name, ctx.fn_name, test_id
1847                )
1848            };
1849
1850            // Build markers - combine base markers with param-specific marks
1851            let mut markers = ctx.markers.to_vec();
1852            markers.extend(all_marks);
1853            if ctx.skip_xfail.skip {
1854                markers.push("skip".to_string());
1855            }
1856            if ctx.skip_xfail.skipif {
1857                markers.push("skipif".to_string());
1858            }
1859            if ctx.skip_xfail.xfail {
1860                markers.push("xfail".to_string());
1861            }
1862
1863            // Store parameter info for test generation
1864            let parameters = vec![serde_json::json!({
1865                "names": all_names,
1866                "values": all_values,
1867                "id": test_id
1868            })];
1869
1870            tests.push(NativeTestNode {
1871                node_id,
1872                file_path: file_path.to_string(),
1873                name: ctx.fn_name.to_string(),
1874                class_name: if class_name.is_empty() {
1875                    None
1876                } else {
1877                    Some(class_name.to_string())
1878                },
1879                line_number: ctx.line_number,
1880                markers,
1881                is_simple: !ctx.uses_external_fixtures,
1882                parameters,
1883                // Only pre-skip unconditional @pytest.mark.skip, not @pytest.mark.skipif
1884                // skipif conditions can only be evaluated at runtime by pytest
1885                skip: ctx.skip_xfail.skip,
1886                skip_reason: ctx
1887                    .skip_xfail
1888                    .skip_reason
1889                    .clone()
1890                    .or(ctx.skip_xfail.skipif_condition.clone()),
1891                xfail: ctx.skip_xfail.xfail,
1892                xfail_reason: ctx.skip_xfail.xfail_reason.clone(),
1893                xfail_strict: ctx.skip_xfail.xfail_strict,
1894            });
1895        }
1896    }
1897
1898    /// Generate Cartesian product of all parameter combinations from stacked parametrize.
1899    /// Each ParameterizedTestNode has its own param_names and param_values.
1900    /// Returns Vec<Vec<ParamCombination>> where each inner Vec represents
1901    /// one combination across all parametrize decorators.
1902    fn generate_cartesian_product(
1903        &self,
1904        param_infos: &[ParameterizedTestNode],
1905    ) -> Vec<Vec<ParamCombination>> {
1906        if param_infos.is_empty() {
1907            return Vec::new();
1908        }
1909
1910        if param_infos.len() == 1 {
1911            // Single parametrize - just return each value as a separate combination
1912            let info = &param_infos[0];
1913            return info
1914                .param_values
1915                .iter()
1916                .map(|(values, custom_id, marks)| {
1917                    vec![(
1918                        info.param_names.clone(),
1919                        values.clone(),
1920                        custom_id.clone(),
1921                        marks.clone(),
1922                    )]
1923                })
1924                .collect();
1925        }
1926
1927        // Multiple parametrizes - compute cartesian product
1928        self.compute_cartesian_recursive(param_infos, 0)
1929    }
1930
1931    /// Recursive helper for Cartesian product computation.
1932    fn compute_cartesian_recursive(
1933        &self,
1934        param_infos: &[ParameterizedTestNode],
1935        index: usize,
1936    ) -> Vec<Vec<ParamCombination>> {
1937        if index >= param_infos.len() {
1938            // Base case: return empty combination
1939            return vec![Vec::new()];
1940        }
1941
1942        let info = &param_infos[index];
1943        let remaining = self.compute_cartesian_recursive(param_infos, index + 1);
1944
1945        let mut result = Vec::new();
1946        for (values, custom_id, marks) in info.param_values.iter() {
1947            for mut combo in remaining.clone() {
1948                combo.insert(
1949                    0,
1950                    (
1951                        info.param_names.clone(),
1952                        values.clone(),
1953                        custom_id.clone(),
1954                        marks.clone(),
1955                    ),
1956                );
1957                result.push(combo);
1958            }
1959        }
1960
1961        result
1962    }
1963
1964    /// Get list of fixtures used by a function.
1965    fn get_pytest_fixture_usage_func(
1966        &self,
1967        args: &ast::Arguments,
1968        conftest_fixtures: &[String],
1969    ) -> Vec<String> {
1970        let mut used = Vec::new();
1971        for arg in &args.args {
1972            let arg_name = &arg.def.arg.to_string();
1973            if conftest_fixtures.contains(arg_name) {
1974                used.push(arg_name.clone());
1975            }
1976        }
1977        used
1978    }
1979
1980    /// Check if an async function uses external fixtures.
1981    fn check_pytest_fixture_usage_async(
1982        &self,
1983        args: &ast::Arguments,
1984        conftest_fixtures: &[String],
1985    ) -> bool {
1986        for arg in &args.args {
1987            let arg_name = &arg.def.arg.to_string();
1988            if conftest_fixtures.contains(arg_name) {
1989                return true;
1990            }
1991        }
1992        false
1993    }
1994}
1995
1996/// Check if a file is a Python test file.
1997pub fn is_test_file(path: &Path) -> bool {
1998    if let Some(ext) = path.extension() {
1999        if ext != "py" {
2000            return false;
2001        }
2002    } else {
2003        return false;
2004    }
2005
2006    if let Some(file_name) = path.file_name() {
2007        let name = file_name.to_string_lossy();
2008        name.starts_with("test_") || name.ends_with("_test.py")
2009    } else {
2010        false
2011    }
2012}
2013
2014#[cfg(test)]
2015mod tests {
2016    use super::*;
2017    use std::fs;
2018    use tempfile::TempDir;
2019
2020    #[test]
2021    fn test_collect_simple() {
2022        let dir = TempDir::new().unwrap();
2023        let test_file = dir.path().join("test_example.py");
2024        fs::write(
2025            &test_file,
2026            r#"
2027def test_simple():
2028    assert True
2029
2030def test_another():
2031    assert 1 + 1 == 2
2032
2033class TestClass:
2034    def test_method(self):
2035        assert True
2036"#,
2037        )
2038        .unwrap();
2039
2040        let collector = NativeCollector::new(dir.path());
2041        let tests = collector.collect().unwrap();
2042
2043        // Should find 3 tests using rustpython-parser
2044        assert_eq!(tests.len(), 3);
2045
2046        // Check test names
2047        let test_names: Vec<&str> = tests.iter().map(|t| t.name.as_str()).collect();
2048        assert!(test_names.contains(&"test_simple"));
2049        assert!(test_names.contains(&"test_another"));
2050        assert!(test_names.contains(&"test_method"));
2051    }
2052
2053    #[test]
2054    fn test_ignore_non_test_files() {
2055        let dir = TempDir::new().unwrap();
2056
2057        // Create a non-test file
2058        let regular_file = dir.path().join("regular.py");
2059        fs::write(&regular_file, "def regular_func():\n    pass").unwrap();
2060
2061        let collector = NativeCollector::new(dir.path());
2062        let tests = collector.collect().unwrap();
2063
2064        // Should not find any tests (no test_ prefix)
2065        assert!(tests.is_empty());
2066    }
2067
2068    #[test]
2069    fn test_collect_with_markers() {
2070        let dir = TempDir::new().unwrap();
2071        let test_file = dir.path().join("test_marked.py");
2072        fs::write(
2073            &test_file,
2074            r#"
2075import pytest
2076
2077@pytest.mark.slow
2078def test_expensive():
2079    pass
2080
2081@pytest.mark.parametrize("x", [1, 2, 3])
2082def test_param(x):
2083    pass
2084"#,
2085        )
2086        .unwrap();
2087
2088        let collector = NativeCollector::new(dir.path());
2089        let tests = collector.collect().unwrap();
2090
2091        // Should find 4 tests (1 + 3 parametrized)
2092        assert_eq!(tests.len(), 4);
2093
2094        // Check markers on slow test
2095        let slow_test = tests.iter().find(|t| t.name == "test_expensive").unwrap();
2096        assert!(slow_test.markers.contains(&"slow".to_string()));
2097
2098        // Check parametrized tests have correct IDs
2099        let param_tests: Vec<&NativeTestNode> =
2100            tests.iter().filter(|t| t.name == "test_param").collect();
2101        assert_eq!(param_tests.len(), 3);
2102
2103        // Check that each parametrized test has a unique ID
2104        let ids: Vec<&str> = param_tests
2105            .iter()
2106            .filter_map(|t| {
2107                // Extract ID from node_id: test_marked.py::test_param::[id]
2108                if let Some(start) = t.node_id.find("[") {
2109                    if let Some(end) = t.node_id.find("]") {
2110                        return Some(&t.node_id[start + 1..end]);
2111                    }
2112                }
2113                None
2114            })
2115            .collect();
2116        assert_eq!(ids.len(), 3);
2117    }
2118
2119    #[test]
2120    fn test_collect_async() {
2121        let dir = TempDir::new().unwrap();
2122        let test_file = dir.path().join("test_async.py");
2123        fs::write(
2124            &test_file,
2125            r#"
2126import pytest
2127
2128@pytest.mark.asyncio
2129async def test_async_basic():
2130    assert True
2131
2132async def test_async_another():
2133    assert True
2134"#,
2135        )
2136        .unwrap();
2137
2138        let collector = NativeCollector::new(dir.path());
2139        let tests = collector.collect().unwrap();
2140
2141        // Should find 2 async tests
2142        assert_eq!(tests.len(), 2);
2143
2144        let test_names: Vec<&str> = tests.iter().map(|t| t.name.as_str()).collect();
2145        assert!(test_names.contains(&"test_async_basic"));
2146        assert!(test_names.contains(&"test_async_another"));
2147    }
2148
2149    #[test]
2150    fn test_collect_skip_xfail() {
2151        let dir = TempDir::new().unwrap();
2152        let test_file = dir.path().join("test_skip.py");
2153        fs::write(
2154            &test_file,
2155            r#"
2156import pytest
2157
2158@pytest.mark.skip(reason="intentional")
2159def test_skipped():
2160    pass
2161
2162@pytest.mark.xfail
2163def test_xfailed():
2164    assert False
2165"#,
2166        )
2167        .unwrap();
2168
2169        let collector = NativeCollector::new(dir.path());
2170        let tests = collector.collect().unwrap();
2171
2172        // Should find 2 tests
2173        assert_eq!(tests.len(), 2);
2174
2175        let skipped = tests.iter().find(|t| t.name == "test_skipped").unwrap();
2176        assert!(skipped.markers.contains(&"skip".to_string()));
2177
2178        let xfailed = tests.iter().find(|t| t.name == "test_xfailed").unwrap();
2179        assert!(xfailed.markers.contains(&"xfail".to_string()));
2180    }
2181
2182    #[test]
2183    fn test_collect_parametrized_class_methods() {
2184        let dir = TempDir::new().unwrap();
2185        let test_file = dir.path().join("test_class_param.py");
2186        fs::write(
2187            &test_file,
2188            r#"
2189import pytest
2190
2191class TestParametrized:
2192    @pytest.mark.parametrize("n", [1, 2, 3])
2193    def test_method(self, n):
2194        assert n > 0
2195"#,
2196        )
2197        .unwrap();
2198
2199        let collector = NativeCollector::new(dir.path());
2200        let tests = collector.collect().unwrap();
2201
2202        // Should find 3 parametrized method tests
2203        assert_eq!(tests.len(), 3);
2204
2205        // Check class name is set correctly
2206        let param_tests: Vec<&NativeTestNode> = tests
2207            .iter()
2208            .filter(|t| t.name == "test_method" && t.class_name.is_some())
2209            .collect();
2210        assert_eq!(param_tests.len(), 3);
2211        assert_eq!(
2212            param_tests[0].class_name,
2213            Some("TestParametrized".to_string())
2214        );
2215    }
2216
2217    #[test]
2218    fn test_collect_stacked_parametrize() {
2219        let dir = TempDir::new().unwrap();
2220        let test_file = dir.path().join("test_stacked.py");
2221        fs::write(
2222            &test_file,
2223            r#"
2224import pytest
2225
2226@pytest.mark.parametrize("x", [1, 2])
2227@pytest.mark.parametrize("y", [10, 20])
2228def test_stacked_parametrize(x, y):
2229    """Stacked parametrize creates cartesian product."""
2230    assert True
2231"#,
2232        )
2233        .unwrap();
2234
2235        let collector = NativeCollector::new(dir.path());
2236        let tests = collector.collect().unwrap();
2237
2238        // Should find 4 tests (cartesian product of 2 x 2)
2239        assert_eq!(tests.len(), 4);
2240
2241        // Check that all combinations are present
2242        let node_ids: Vec<&str> = tests
2243            .iter()
2244            .filter_map(|t| {
2245                if t.name == "test_stacked_parametrize" {
2246                    // Extract ID from node_id
2247                    if let Some(start) = t.node_id.find("[") {
2248                        if let Some(end) = t.node_id.find("]") {
2249                            return Some(&t.node_id[start + 1..end]);
2250                        }
2251                    }
2252                }
2253                None
2254            })
2255            .collect();
2256
2257        assert_eq!(node_ids.len(), 4);
2258        // All combinations should be present
2259        assert!(node_ids.contains(&"10-1"));
2260        assert!(node_ids.contains(&"10-2"));
2261        assert!(node_ids.contains(&"20-1"));
2262        assert!(node_ids.contains(&"20-2"));
2263    }
2264
2265    #[test]
2266    fn test_collect_class_markers() {
2267        let dir = TempDir::new().unwrap();
2268        let test_file = dir.path().join("test_class_markers.py");
2269        fs::write(
2270            &test_file,
2271            r#"
2272import pytest
2273
2274@pytest.mark.slow
2275class TestMarkedClass:
2276    def test_one(self):
2277        assert True
2278
2279    def test_two(self):
2280        assert True
2281"#,
2282        )
2283        .unwrap();
2284
2285        let collector = NativeCollector::new(dir.path());
2286        let tests = collector.collect().unwrap();
2287
2288        // Should find 2 tests
2289        assert_eq!(tests.len(), 2);
2290
2291        // Both tests should inherit the "slow" marker from the class
2292        for test in &tests {
2293            assert!(
2294                test.markers.contains(&"slow".to_string()),
2295                "Test {} should have 'slow' marker from class",
2296                test.name
2297            );
2298        }
2299    }
2300
2301    #[test]
2302    fn test_collect_skipif() {
2303        let dir = TempDir::new().unwrap();
2304        let test_file = dir.path().join("test_skipif.py");
2305        fs::write(
2306            &test_file,
2307            r#"
2308import pytest
2309import sys
2310
2311@pytest.mark.skipif(sys.version_info > (0, 0), reason="always skips")
2312def test_skipif():
2313    pass
2314"#,
2315        )
2316        .unwrap();
2317
2318        let collector = NativeCollector::new(dir.path());
2319        let tests = collector.collect().unwrap();
2320
2321        // Should find 1 test
2322        assert_eq!(tests.len(), 1);
2323
2324        let test = &tests[0];
2325        // Should have skipif marker (not pre-skipped, since we can't evaluate the condition)
2326        assert!(test.markers.contains(&"skipif".to_string()));
2327        // skipif tests should NOT have skip=true since we can't evaluate the condition at collection time
2328        assert!(!test.skip);
2329    }
2330
2331    #[test]
2332    fn test_collect_xfail_with_condition() {
2333        let dir = TempDir::new().unwrap();
2334        let test_file = dir.path().join("test_xfail_cond.py");
2335        fs::write(
2336            &test_file,
2337            r#"
2338import pytest
2339
2340@pytest.mark.xfail(condition=False, reason="condition is false")
2341def test_xfail_false_condition():
2342    assert True
2343"#,
2344        )
2345        .unwrap();
2346
2347        let collector = NativeCollector::new(dir.path());
2348        let tests = collector.collect().unwrap();
2349
2350        // Should find 1 test
2351        assert_eq!(tests.len(), 1);
2352
2353        let test = &tests[0];
2354        // Should have xfail marker
2355        assert!(test.markers.contains(&"xfail".to_string()));
2356    }
2357
2358    #[test]
2359    fn test_collect_param_with_per_variant_marks() {
2360        let dir = TempDir::new().unwrap();
2361        let test_file = dir.path().join("test_param_marks.py");
2362        fs::write(
2363            &test_file,
2364            r#"
2365import pytest
2366
2367class TestParamMarks:
2368    @pytest.mark.parametrize("x", [
2369        pytest.param(1, marks=pytest.mark.slow),
2370        pytest.param(2),
2371        pytest.param(3, marks=[pytest.mark.skip(reason="test skip")]),
2372    ])
2373    def test_param_marks(self, x):
2374        assert x > 0
2375"#,
2376        )
2377        .unwrap();
2378
2379        let collector = NativeCollector::new(dir.path());
2380        let tests = collector.collect().unwrap();
2381
2382        // Should find 3 parametrized tests (one per variant)
2383        assert_eq!(tests.len(), 3);
2384
2385        // Check that markers are applied per-variant
2386        // Test 1 should have "slow" marker
2387        let test1 = tests.iter().find(|t| t.node_id.contains("[1]")).unwrap();
2388        assert!(
2389            test1.markers.contains(&"slow".to_string()),
2390            "Test 1 should have 'slow' marker"
2391        );
2392
2393        // Test 2 should have no extra markers
2394        let test2 = tests.iter().find(|t| t.node_id.contains("[2]")).unwrap();
2395        assert!(
2396            !test2.markers.contains(&"slow".to_string()),
2397            "Test 2 should not have 'slow' marker"
2398        );
2399        assert!(
2400            !test2.markers.contains(&"skip".to_string()),
2401            "Test 2 should not have 'skip' marker"
2402        );
2403
2404        // Test 3 should have "skip" marker (from pytest.param marks)
2405        let test3 = tests.iter().find(|t| t.node_id.contains("[3]")).unwrap();
2406        assert!(
2407            test3.markers.contains(&"skip".to_string()),
2408            "Test 3 should have 'skip' marker from pytest.param"
2409        );
2410    }
2411
2412    #[test]
2413    fn test_line_numbers() {
2414        let dir = TempDir::new().unwrap();
2415        let test_file = dir.path().join("test_lines.py");
2416        // Create a file with known line numbers (1-indexed)
2417        // Line 1: comment
2418        // Line 2: comment
2419        // Line 3: empty
2420        // Line 4: def test_first()
2421        // Line 5: body
2422        // Line 6: empty
2423        // Line 7: def test_second()
2424        // Line 8: body
2425        // Line 9: empty
2426        // Line 10: class
2427        // Line 11: def test_method
2428        fs::write(
2429            &test_file,
2430            r#"# Line 1 - comment
2431# Line 2 - comment
2432
2433def test_first():
2434    assert True
2435
2436def test_second():
2437    pass
2438
2439class TestClass:
2440    def test_method(self):
2441        pass
2442"#,
2443        )
2444        .unwrap();
2445
2446        let collector = NativeCollector::new(dir.path());
2447        let tests = collector.collect().unwrap();
2448
2449        // Should find 3 tests
2450        assert_eq!(tests.len(), 3);
2451
2452        // Verify line numbers (functions start with 'def' keyword)
2453        let test_first = tests.iter().find(|t| t.name == "test_first").unwrap();
2454        assert_eq!(test_first.line_number, 4, "test_first should be at line 4");
2455
2456        let test_second = tests.iter().find(|t| t.name == "test_second").unwrap();
2457        assert_eq!(
2458            test_second.line_number, 7,
2459            "test_second should be at line 7"
2460        );
2461
2462        let test_method = tests.iter().find(|t| t.name == "test_method").unwrap();
2463        assert_eq!(
2464            test_method.line_number, 11,
2465            "test_method should be at line 11"
2466        );
2467    }
2468}