Skip to main content

rpytest_core/inventory/
nodes.rs

1//! Test node data structures.
2
3use serde::{Deserialize, Serialize};
4
5/// Unique identifier for a test node (pytest node ID format).
6///
7/// Examples:
8/// - `test_module.py::test_function`
9/// - `test_module.py::TestClass::test_method`
10/// - `test_module.py::test_parametrized[param1]`
11pub type TestNodeId = String;
12
13/// The kind of test node.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16#[derive(Default)]
17pub enum TestNodeKind {
18    /// A test function at module level.
19    #[default]
20    Function,
21    /// A test method within a class.
22    Method,
23    /// A test class (container for methods).
24    Class,
25    /// A test module (container for functions/classes).
26    Module,
27}
28
29
30/// A single test node with metadata.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct TestNode {
33    /// The unique node ID (pytest format).
34    pub node_id: TestNodeId,
35
36    /// The kind of node.
37    pub kind: TestNodeKind,
38
39    /// File path relative to the repo root.
40    pub file_path: String,
41
42    /// Line number where the test is defined (1-indexed).
43    pub lineno: Option<u32>,
44
45    /// The test function/method name.
46    pub name: String,
47
48    /// Parent class name (if method).
49    pub class_name: Option<String>,
50
51    /// Markers attached to this test (e.g., "slow", "skip", "xfail").
52    pub markers: Vec<String>,
53
54    /// Keywords for -k filtering (includes name, class, markers, etc.).
55    pub keywords: Vec<String>,
56
57    /// Parameter IDs if this is a parametrized test.
58    pub parameters: Option<String>,
59
60    /// Historical average duration in milliseconds.
61    pub avg_duration_ms: Option<u64>,
62
63    /// Number of times this test has been run.
64    pub run_count: u32,
65
66    /// Number of times this test has failed.
67    pub fail_count: u32,
68
69    /// Whether this test is currently marked as expected to fail.
70    pub xfail: bool,
71
72    /// Whether this test is currently skipped.
73    pub skip: bool,
74}
75
76impl TestNode {
77    /// Create a new test node with minimal information.
78    pub fn new(node_id: impl Into<String>, file_path: impl Into<String>) -> Self {
79        let node_id = node_id.into();
80        let name = Self::extract_name(&node_id);
81        let class_name = Self::extract_class(&node_id);
82        let kind = if class_name.is_some() {
83            TestNodeKind::Method
84        } else {
85            TestNodeKind::Function
86        };
87
88        Self {
89            node_id,
90            kind,
91            file_path: file_path.into(),
92            lineno: None,
93            name,
94            class_name,
95            markers: Vec::new(),
96            keywords: Vec::new(),
97            parameters: None,
98            avg_duration_ms: None,
99            run_count: 0,
100            fail_count: 0,
101            xfail: false,
102            skip: false,
103        }
104    }
105
106    /// Extract the test name from a node ID.
107    fn extract_name(node_id: &str) -> String {
108        // Handle parametrized tests: test_foo[param] -> test_foo
109        let base = if let Some(bracket_pos) = node_id.rfind('[') {
110            &node_id[..bracket_pos]
111        } else {
112            node_id
113        };
114
115        // Get the last :: segment
116        base.rsplit("::").next().unwrap_or(node_id).to_string()
117    }
118
119    /// Extract the class name from a node ID if present.
120    fn extract_class(node_id: &str) -> Option<String> {
121        // Handle parametrized tests first
122        let base = if let Some(bracket_pos) = node_id.rfind('[') {
123            &node_id[..bracket_pos]
124        } else {
125            node_id
126        };
127
128        let parts: Vec<&str> = base.split("::").collect();
129        if parts.len() >= 3 {
130            // file.py::Class::method -> Class
131            Some(parts[parts.len() - 2].to_string())
132        } else {
133            None
134        }
135    }
136
137    /// Add a marker to this test.
138    pub fn add_marker(&mut self, marker: impl Into<String>) {
139        let marker = marker.into();
140        if !self.markers.contains(&marker) {
141            // Update skip/xfail flags
142            match marker.as_str() {
143                "skip" | "skipif" => self.skip = true,
144                "xfail" => self.xfail = true,
145                _ => {}
146            }
147            self.markers.push(marker);
148        }
149    }
150
151    /// Build the keywords list for -k filtering.
152    pub fn build_keywords(&mut self) {
153        self.keywords.clear();
154
155        // Add name
156        self.keywords.push(self.name.clone());
157
158        // Add class name if present
159        if let Some(ref class) = self.class_name {
160            self.keywords.push(class.clone());
161        }
162
163        // Add file name without extension
164        if let Some(file_stem) = self.file_path.rsplit('/').next() {
165            if let Some(stem) = file_stem.strip_suffix(".py") {
166                self.keywords.push(stem.to_string());
167            }
168        }
169
170        // Add markers
171        for marker in &self.markers {
172            self.keywords.push(marker.clone());
173        }
174
175        // Add parameters if present
176        if let Some(ref params) = self.parameters {
177            self.keywords.push(params.clone());
178        }
179    }
180
181    /// Check if this test matches a keyword expression.
182    ///
183    /// Simple implementation supporting:
184    /// - Plain keywords (substring match)
185    /// - `not keyword`
186    /// - `keyword1 and keyword2`
187    /// - `keyword1 or keyword2`
188    pub fn matches_keyword(&self, expr: &str) -> bool {
189        let expr = expr.trim();
190
191        // Handle "not" prefix
192        if let Some(rest) = expr.strip_prefix("not ") {
193            return !self.matches_keyword(rest);
194        }
195
196        // Handle "and"
197        if let Some((left, right)) = expr.split_once(" and ") {
198            return self.matches_keyword(left) && self.matches_keyword(right);
199        }
200
201        // Handle "or"
202        if let Some((left, right)) = expr.split_once(" or ") {
203            return self.matches_keyword(left) || self.matches_keyword(right);
204        }
205
206        // Simple substring match
207        let expr_lower = expr.to_lowercase();
208        self.keywords
209            .iter()
210            .any(|k| k.to_lowercase().contains(&expr_lower))
211            || self.node_id.to_lowercase().contains(&expr_lower)
212    }
213
214    /// Check if this test has a specific marker.
215    pub fn has_marker(&self, marker: &str) -> bool {
216        self.markers.iter().any(|m| m == marker)
217    }
218
219    /// Check if this test matches a marker expression.
220    ///
221    /// Simple implementation supporting:
222    /// - Plain markers
223    /// - `not marker`
224    /// - `marker1 and marker2`
225    /// - `marker1 or marker2`
226    pub fn matches_marker(&self, expr: &str) -> bool {
227        let expr = expr.trim();
228
229        // Handle "not" prefix
230        if let Some(rest) = expr.strip_prefix("not ") {
231            return !self.matches_marker(rest);
232        }
233
234        // Handle "and"
235        if let Some((left, right)) = expr.split_once(" and ") {
236            return self.matches_marker(left) && self.matches_marker(right);
237        }
238
239        // Handle "or"
240        if let Some((left, right)) = expr.split_once(" or ") {
241            return self.matches_marker(left) || self.matches_marker(right);
242        }
243
244        // Simple marker match
245        self.has_marker(expr)
246    }
247
248    /// Update duration statistics after a test run.
249    pub fn record_duration(&mut self, duration_ms: u64) {
250        self.run_count += 1;
251        let current_avg = self.avg_duration_ms.unwrap_or(0);
252        // Simple moving average
253        self.avg_duration_ms =
254            Some((current_avg * (self.run_count - 1) as u64 + duration_ms) / self.run_count as u64);
255    }
256
257    /// Record a test failure.
258    pub fn record_failure(&mut self) {
259        self.fail_count += 1;
260    }
261
262    /// Get the failure rate as a percentage.
263    pub fn failure_rate(&self) -> f64 {
264        if self.run_count == 0 {
265            0.0
266        } else {
267            (self.fail_count as f64 / self.run_count as f64) * 100.0
268        }
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn test_node_creation() {
278        let node = TestNode::new("test_module.py::test_function", "test_module.py");
279
280        assert_eq!(node.name, "test_function");
281        assert_eq!(node.class_name, None);
282        assert_eq!(node.kind, TestNodeKind::Function);
283    }
284
285    #[test]
286    fn test_method_node() {
287        let node = TestNode::new("test_module.py::TestClass::test_method", "test_module.py");
288
289        assert_eq!(node.name, "test_method");
290        assert_eq!(node.class_name, Some("TestClass".to_string()));
291        assert_eq!(node.kind, TestNodeKind::Method);
292    }
293
294    #[test]
295    fn test_parametrized_node() {
296        let node = TestNode::new("test_module.py::test_func[param1-param2]", "test_module.py");
297
298        assert_eq!(node.name, "test_func");
299        assert_eq!(node.class_name, None);
300    }
301
302    #[test]
303    fn test_keyword_matching() {
304        let mut node = TestNode::new(
305            "test_math.py::TestArithmetic::test_addition",
306            "test_math.py",
307        );
308        node.add_marker("slow");
309        node.build_keywords();
310
311        assert!(node.matches_keyword("addition"));
312        assert!(node.matches_keyword("Arithmetic"));
313        assert!(node.matches_keyword("slow"));
314        assert!(node.matches_keyword("test_math"));
315        assert!(!node.matches_keyword("subtraction"));
316
317        // Boolean expressions
318        assert!(node.matches_keyword("addition and slow"));
319        assert!(node.matches_keyword("addition or subtraction"));
320        assert!(!node.matches_keyword("addition and fast"));
321        assert!(node.matches_keyword("not subtraction"));
322    }
323
324    #[test]
325    fn test_marker_matching() {
326        let mut node = TestNode::new("test.py::test_func", "test.py");
327        node.add_marker("slow");
328        node.add_marker("integration");
329
330        assert!(node.matches_marker("slow"));
331        assert!(node.matches_marker("integration"));
332        assert!(!node.matches_marker("unit"));
333
334        assert!(node.matches_marker("slow and integration"));
335        assert!(node.matches_marker("slow or unit"));
336        assert!(!node.matches_marker("slow and unit"));
337        assert!(node.matches_marker("not unit"));
338    }
339
340    #[test]
341    fn test_duration_tracking() {
342        let mut node = TestNode::new("test.py::test_func", "test.py");
343
344        node.record_duration(100);
345        assert_eq!(node.avg_duration_ms, Some(100));
346
347        node.record_duration(200);
348        assert_eq!(node.avg_duration_ms, Some(150));
349
350        node.record_duration(300);
351        assert_eq!(node.avg_duration_ms, Some(200));
352    }
353
354    #[test]
355    fn test_failure_tracking() {
356        let mut node = TestNode::new("test.py::test_func", "test.py");
357
358        node.record_duration(100);
359        node.record_duration(100);
360        node.record_failure();
361        node.record_duration(100);
362        node.record_failure();
363
364        assert_eq!(node.run_count, 3);
365        assert_eq!(node.fail_count, 2);
366        assert!((node.failure_rate() - 66.67).abs() < 0.1);
367    }
368
369    #[test]
370    fn test_skip_xfail_markers() {
371        let mut node = TestNode::new("test.py::test_func", "test.py");
372
373        assert!(!node.skip);
374        assert!(!node.xfail);
375
376        node.add_marker("skip");
377        assert!(node.skip);
378
379        node.add_marker("xfail");
380        assert!(node.xfail);
381    }
382}