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