Skip to main content

perspt_coding/diag/
pytest.rs

1//! Pytest JUnit XML results (PSP-10 system 26): test identity preserved.
2//!
3//! The parser targets exactly the JUnit shape pytest emits
4//! (`--junit-xml`): `<testcase classname=".." name="..">` elements whose
5//! body contains `<failure` or `<error`. It is a constrained hand-rolled
6//! scan, fixture-tested; a general XML dependency buys nothing this
7//! grammar needs.
8
9use perspt_sdk::ResidualClass;
10
11use super::types::StructuredDiagnostic;
12
13/// Parser identity; enters the sensor fingerprint.
14pub const PARSER_ID: &str = "pytest-junit-v1";
15
16/// Parse failed test cases from a JUnit XML report.
17pub fn parse(raw: &str) -> Vec<StructuredDiagnostic> {
18    let mut failures = Vec::new();
19    let mut rest = raw;
20    while let Some(start) = rest.find("<testcase") {
21        rest = &rest[start..];
22        let Some(tag_end) = rest.find('>') else { break };
23        let tag = &rest[..tag_end + 1];
24        let self_closing = tag.ends_with("/>");
25        let body_end = if self_closing {
26            tag_end + 1
27        } else {
28            match rest.find("</testcase>") {
29                Some(end) => end,
30                None => break,
31            }
32        };
33        let body = &rest[tag_end + 1..body_end];
34        if !self_closing && (body.contains("<failure") || body.contains("<error")) {
35            let classname = attribute(tag, "classname").unwrap_or_default();
36            let name = attribute(tag, "name").unwrap_or_default();
37            let test_id = if classname.is_empty() {
38                name.clone()
39            } else {
40                format!("{classname}::{name}")
41            };
42            failures.push(StructuredDiagnostic {
43                class: ResidualClass::TestFailure,
44                code: Some(test_id.clone()),
45                message: failure_message(body).unwrap_or_else(|| format!("{test_id} failed")),
46                path: attribute(tag, "file"),
47                line: attribute(tag, "line").and_then(|line| line.parse().ok()),
48                column: None,
49                primary: true,
50                suggestion: None,
51            });
52        }
53        rest = &rest[body_end..];
54    }
55    failures
56}
57
58fn attribute(tag: &str, name: &str) -> Option<String> {
59    // Whitespace before the name keeps `name=` from matching inside
60    // `classname=`.
61    let marker = format!("{name}=\"");
62    let mut search = 0;
63    loop {
64        let found = tag[search..].find(&marker)? + search;
65        let boundary = found == 0 || tag[..found].ends_with(char::is_whitespace);
66        if boundary {
67            let start = found + marker.len();
68            let end = tag[start..].find('"')? + start;
69            return Some(unescape(&tag[start..end]));
70        }
71        search = found + marker.len();
72    }
73}
74
75fn failure_message(body: &str) -> Option<String> {
76    let start = body.find("<failure").or_else(|| body.find("<error"))?;
77    let tag = &body[start..];
78    attribute(&tag[..tag.find('>')? + 1], "message")
79}
80
81fn unescape(value: &str) -> String {
82    value
83        .replace("&lt;", "<")
84        .replace("&gt;", ">")
85        .replace("&quot;", "\"")
86        .replace("&apos;", "'")
87        .replace("&amp;", "&")
88}