Skip to main content

document_svg/document/
coverage.rs

1//! Bounded JaCoCo/Cobertura-compatible coverage XML previews.
2//!
3//! Coverage reports can include source paths, class internals and raw execution
4//! logs. This adapter renders package-level class and counter summaries only;
5//! source code, file paths, session payloads, external entities and test
6//! execution remain inert.
7
8use std::path::Path;
9
10use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
11use crate::document::html::{HtmlBlock, render_blocks_to_pages};
12use crate::error::{Error, Result};
13use crate::geospatial::xml_tree::{XmlElement, XmlLimits, parse_xml_tree};
14use crate::table::{TableAlign, TableData};
15
16const MAX_COVERAGE_BYTES: u64 = 32 * 1024 * 1024;
17const MAX_COVERAGE_EVENTS: usize = 500_000;
18const MAX_COVERAGE_NODES: usize = 300_000;
19const MAX_COVERAGE_DEPTH: usize = 80;
20const MAX_COVERAGE_TEXT_BYTES: usize = 2 * 1024 * 1024;
21const MAX_COVERAGE_PACKAGES: usize = 100_000;
22const MAX_COVERAGE_STRING_BYTES: usize = 512 * 1024;
23
24pub(crate) fn looks_like_prefix(bytes: &[u8]) -> bool {
25    let mut reader = quick_xml::Reader::from_reader(std::io::Cursor::new(bytes));
26    reader.config_mut().trim_text(true);
27    let mut buffer = Vec::new();
28    loop {
29        match reader.read_event_into(&mut buffer) {
30            Ok(quick_xml::events::Event::Start(element))
31            | Ok(quick_xml::events::Event::Empty(element)) => {
32                let element_name = element.name();
33                let name = crate::ooxml::local_name(element_name.as_ref());
34                return (name == b"report" || name == b"coverage")
35                    && (bytes
36                        .windows(b"<package".len())
37                        .any(|window| window == b"<package")
38                        || bytes
39                            .windows(b"<packages".len())
40                            .any(|window| window == b"<packages"));
41            }
42            Ok(quick_xml::events::Event::DocType(_)) => return false,
43            Ok(quick_xml::events::Event::Eof) | Err(_) => return false,
44            _ => buffer.clear(),
45        }
46        buffer.clear();
47    }
48}
49
50struct CoveragePageSink<'a> {
51    inner: &'a mut dyn PageConsumer,
52    warnings: &'a [String],
53}
54
55impl PageConsumer for CoveragePageSink<'_> {
56    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
57        page.source_format = "coverage".into();
58        if page.title.is_empty() {
59            page.title = "Coverage report".into();
60        }
61        page.description =
62            "JaCoCo/Cobertura coverage counters are rendered as inert package summaries; source paths and execution payloads are omitted".into();
63        for warning in self.warnings {
64            page.warn(warning.clone());
65        }
66        self.inner.consume(page)
67    }
68}
69
70pub(crate) fn convert(
71    path: &Path,
72    options: &ConvertOptions,
73    sink: &mut dyn PageConsumer,
74) -> Result<Vec<String>> {
75    let bytes = read_limited_file(
76        path,
77        options.max_input_bytes.min(MAX_COVERAGE_BYTES),
78        "coverage XML input",
79    )?;
80    let root = parse_report(&bytes)?;
81    let (table, metadata, warnings) = summarize(&root)?;
82    let blocks = vec![
83        HtmlBlock::Heading {
84            level: 1,
85            text: "Coverage report".into(),
86        },
87        HtmlBlock::Paragraph { text: metadata },
88        HtmlBlock::Table(table),
89    ];
90    let mut page_sink = CoveragePageSink {
91        inner: sink,
92        warnings: &warnings,
93    };
94    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
95    Ok(warnings)
96}
97
98fn parse_report(bytes: &[u8]) -> Result<XmlElement> {
99    if bytes.len() as u64 > MAX_COVERAGE_BYTES {
100        return Err(Error::LimitExceeded(format!(
101            "coverage XML exceeds {MAX_COVERAGE_BYTES} bytes"
102        )));
103    }
104    let root = parse_xml_tree(
105        bytes,
106        &XmlLimits {
107            max_events: MAX_COVERAGE_EVENTS,
108            max_nodes: MAX_COVERAGE_NODES,
109            max_depth: MAX_COVERAGE_DEPTH,
110            max_text_bytes: MAX_COVERAGE_TEXT_BYTES,
111        },
112        "coverage",
113    )?;
114    if root.name != "report" && root.name != "coverage" {
115        return Err(Error::InvalidInput(
116            "coverage XML root must be report (JaCoCo) or coverage (Cobertura)".into(),
117        ));
118    }
119    Ok(root)
120}
121
122#[derive(Clone, Copy, Default)]
123struct Counter {
124    missed: usize,
125    covered: usize,
126}
127
128impl Counter {
129    fn total(self) -> usize {
130        self.missed.saturating_add(self.covered)
131    }
132
133    fn text(self) -> String {
134        format!("{}/{}", self.covered, self.missed)
135    }
136
137    fn add(&mut self, other: Self) {
138        self.missed = self.missed.saturating_add(other.missed);
139        self.covered = self.covered.saturating_add(other.covered);
140    }
141}
142
143#[derive(Clone, Copy, Default)]
144struct Counters {
145    line: Counter,
146    branch: Counter,
147    method: Counter,
148    class: Counter,
149}
150
151impl Counters {
152    fn add(&mut self, other: Self) {
153        self.line.add(other.line);
154        self.branch.add(other.branch);
155        self.method.add(other.method);
156        self.class.add(other.class);
157    }
158}
159
160fn summarize(root: &XmlElement) -> Result<(TableData, String, Vec<String>)> {
161    let mut packages = Vec::new();
162    collect_packages(root, &mut packages)?;
163    if packages.is_empty() {
164        return Err(Error::InvalidInput(
165            "coverage XML contains no package elements".into(),
166        ));
167    }
168    let mut rows = Vec::with_capacity(packages.len());
169    let mut total = Counters::default();
170    let mut class_count = 0usize;
171    for package in packages {
172        let classes = count_descendants(package, "class");
173        class_count = class_count.saturating_add(classes);
174        let counters = package_counters(package);
175        total.add(counters);
176        let name = package
177            .attribute("name")
178            .filter(|value| !value.is_empty())
179            .unwrap_or("(unnamed package)");
180        rows.push(vec![
181            truncate(name),
182            classes.to_string(),
183            counters.line.text(),
184            counters.branch.text(),
185            counters.method.text(),
186            rate(counters.line),
187        ]);
188    }
189    let report_name = root.attribute("name").unwrap_or("—");
190    let metadata = format!(
191        "Name: {}\nPackages: {}\nClasses: {class_count}\nLines covered/missed: {}\nBranches covered/missed: {}\nMethods covered/missed: {}\nLine coverage: {}",
192        truncate(report_name),
193        rows.len(),
194        total.line.text(),
195        total.branch.text(),
196        total.method.text(),
197        rate(total.line)
198    );
199    let warnings = vec![
200        "Coverage source paths, class/method details, session information, execution logs, XML properties and attachments are omitted; tests and external resources are never executed or fetched".into(),
201        "JaCoCo/Cobertura producer-specific counters are summarized conservatively; no coverage threshold or quality gate is evaluated".into(),
202    ];
203    Ok((
204        TableData {
205            headers: vec![
206                "Package".into(),
207                "Cls".into(),
208                "Line C/M".into(),
209                "Br C/M".into(),
210                "Meth C/M".into(),
211                "Line%".into(),
212            ],
213            rows,
214            alignments: vec![TableAlign::Left; 6],
215            raw_source: String::new(),
216        },
217        metadata,
218        warnings,
219    ))
220}
221
222fn collect_packages<'a>(element: &'a XmlElement, packages: &mut Vec<&'a XmlElement>) -> Result<()> {
223    if element.name == "package" {
224        if packages.len() >= MAX_COVERAGE_PACKAGES {
225            return Err(Error::LimitExceeded(format!(
226                "coverage packages exceed {MAX_COVERAGE_PACKAGES}"
227            )));
228        }
229        packages.push(element);
230    }
231    for child in &element.children {
232        collect_packages(child, packages)?;
233    }
234    Ok(())
235}
236
237fn package_counters(package: &XmlElement) -> Counters {
238    let mut counters = Counters::default();
239    let mut direct_counter = false;
240    for child in &package.children {
241        if child.name == "counter" {
242            direct_counter = true;
243            apply_counter(&mut counters, child);
244        }
245    }
246    if !direct_counter {
247        let line_rate = package
248            .attribute("line-rate")
249            .and_then(|value| value.parse::<f64>().ok())
250            .filter(|value| value.is_finite() && *value >= 0.0);
251        if let Some(rate) = line_rate {
252            counters.line.covered = (rate * 1000.0).round() as usize;
253            counters.line.missed = 1000usize.saturating_sub(counters.line.covered);
254        }
255        let branch_rate = package
256            .attribute("branch-rate")
257            .and_then(|value| value.parse::<f64>().ok())
258            .filter(|value| value.is_finite() && *value >= 0.0);
259        if let Some(rate) = branch_rate {
260            counters.branch.covered = (rate * 1000.0).round() as usize;
261            counters.branch.missed = 1000usize.saturating_sub(counters.branch.covered);
262        }
263    }
264    counters
265}
266
267fn apply_counter(counters: &mut Counters, element: &XmlElement) {
268    let counter = Counter {
269        missed: parse_usize(element.attribute("missed")),
270        covered: parse_usize(element.attribute("covered")),
271    };
272    match element
273        .attribute("type")
274        .unwrap_or_default()
275        .to_ascii_uppercase()
276        .as_str()
277    {
278        "LINE" => counters.line = counter,
279        "BRANCH" => counters.branch = counter,
280        "METHOD" | "FUNCTION" => counters.method = counter,
281        "CLASS" => counters.class = counter,
282        _ => {}
283    }
284}
285
286fn count_descendants(element: &XmlElement, name: &str) -> usize {
287    let own = usize::from(element.name == name);
288    own.saturating_add(
289        element
290            .children
291            .iter()
292            .map(|child| count_descendants(child, name))
293            .sum(),
294    )
295}
296
297fn parse_usize(value: Option<&str>) -> usize {
298    value
299        .and_then(|value| value.parse::<usize>().ok())
300        .unwrap_or(0)
301}
302
303fn rate(counter: Counter) -> String {
304    if counter.total() == 0 {
305        return "—".into();
306    }
307    format!(
308        "{:.1}%",
309        (counter.covered as f64 / counter.total() as f64) * 100.0
310    )
311}
312
313fn truncate(value: &str) -> String {
314    if value.len() <= MAX_COVERAGE_STRING_BYTES {
315        return value.to_owned();
316    }
317    let mut end = MAX_COVERAGE_STRING_BYTES;
318    while !value.is_char_boundary(end) {
319        end -= 1;
320    }
321    format!("{}…", &value[..end])
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn recognizes_jacoco_and_cobertura_roots() {
330        assert!(looks_like_prefix(b"<report><package name=\"x\"/></report>"));
331        assert!(looks_like_prefix(
332            b"<coverage><packages><package name=\"x\"/></packages></coverage>"
333        ));
334        assert!(!looks_like_prefix(b"<report><other/></report>"));
335    }
336
337    #[test]
338    fn summarizes_package_counters_without_source_payloads() {
339        let root = parse_report(
340            br#"<report name="unit"><sessioninfo id="secret"/><package name="com/example"><class name="A"><sourcefile name="private.java"/></class><counter type="LINE" missed="2" covered="5"/><counter type="BRANCH" missed="1" covered="3"/><counter type="METHOD" missed="1" covered="4"/><system-out>very-secret</system-out></package></report>"#,
341        )
342        .unwrap();
343        let (table, metadata, warnings) = summarize(&root).unwrap();
344        assert!(metadata.contains("Packages: 1"));
345        assert_eq!(table.rows[0][2], "5/2");
346        assert_eq!(table.rows[0][5], "71.4%");
347        assert!(
348            !table
349                .rows
350                .iter()
351                .flatten()
352                .any(|value| value.contains("secret") || value.contains("private"))
353        );
354        assert!(warnings.iter().any(|warning| warning.contains("omitted")));
355    }
356}