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