Skip to main content

ifc_validate/report/
summary.rs

1//! Deterministic counts and the assembled report.
2
3use std::fmt;
4
5use super::finding::{Finding, Severity};
6use super::path::path_key;
7
8/// Counts by severity, for a one-line verdict.
9#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
10pub struct Summary {
11    /// How many schema violations.
12    pub errors: usize,
13    /// How many suspicious-but-legal findings.
14    pub warnings: usize,
15    /// How many rules went unevaluated.
16    pub unsupported: usize,
17}
18
19impl Summary {
20    /// Whether the file violated anything this validator checked.
21    ///
22    /// Deliberately ignores [`Severity::Unsupported`]: an unchecked rule is a
23    /// statement about the validator, not about the file. Callers that want
24    /// "clean *and* fully checked" must also test [`Summary::unsupported`],
25    /// and the distinction is the point.
26    #[must_use]
27    pub const fn is_conformant(&self) -> bool {
28        self.errors == 0
29    }
30}
31
32impl fmt::Display for Summary {
33    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
34        write!(
35            formatter,
36            "{} errors, {} warnings, {} unsupported",
37            self.errors, self.warnings, self.unsupported
38        )
39    }
40}
41
42/// Everything a validation run produced.
43#[derive(Debug, Clone)]
44pub struct Report {
45    findings: Vec<Finding>,
46    truncated: bool,
47    max_findings: usize,
48}
49
50impl Default for Report {
51    fn default() -> Self {
52        Self {
53            findings: Vec::new(),
54            truncated: false,
55            max_findings: usize::MAX,
56        }
57    }
58}
59
60impl PartialEq for Report {
61    fn eq(&self, other: &Self) -> bool {
62        self.findings == other.findings && self.truncated == other.truncated
63    }
64}
65
66impl Eq for Report {}
67
68impl Report {
69    /// An empty report.
70    #[must_use]
71    pub fn new() -> Self {
72        Self::default()
73    }
74
75    /// An empty report that stores at most `max_findings` entries.
76    pub(crate) fn with_max_findings(max_findings: usize) -> Self {
77        Self {
78            max_findings,
79            ..Self::default()
80        }
81    }
82
83    /// Records a finding, or marks the report truncated when its cap is full.
84    pub fn push(&mut self, finding: Finding) {
85        if self.findings.len() >= self.max_findings {
86            self.truncated = true;
87            return;
88        }
89        self.findings.push(finding);
90    }
91
92    /// Records that the run stopped early because it hit its finding budget.
93    ///
94    /// A report that silently stops at N findings claims the file has N
95    /// problems. This flag is what keeps that claim honest.
96    pub fn mark_truncated(&mut self) {
97        self.truncated = true;
98    }
99
100    /// Whether the run stopped before checking everything.
101    #[must_use]
102    pub const fn is_truncated(&self) -> bool {
103        self.truncated
104    }
105
106    /// Every finding, in the order produced.
107    ///
108    /// Callers wanting a stable order across runs should use
109    /// [`Report::sorted`]: production order follows traversal, which is
110    /// deterministic for a given model but not meaningful.
111    #[must_use]
112    pub fn findings(&self) -> &[Finding] {
113        &self.findings
114    }
115
116    /// How many findings of each severity.
117    #[must_use]
118    pub fn summary(&self) -> Summary {
119        let mut summary = Summary::default();
120        for finding in &self.findings {
121            match finding.severity {
122                Severity::Error => summary.errors += 1,
123                Severity::Warning => summary.warnings += 1,
124                Severity::Unsupported => summary.unsupported += 1,
125            }
126        }
127        summary
128    }
129
130    /// Findings in a stable, explicitly defined order.
131    ///
132    /// Sorted by severity, then rule id, then entity id, then slot. Two runs
133    /// over the same file always produce byte-identical output, which is what
134    /// makes a report diffable in CI.
135    #[must_use]
136    pub fn sorted(&self) -> Vec<&Finding> {
137        let mut sorted: Vec<&Finding> = self.findings.iter().collect();
138        sorted.sort_by(|left, right| {
139            left.severity
140                .cmp(&right.severity)
141                .then_with(|| left.rule.cmp(&right.rule))
142                .then_with(|| path_key(&left.path).cmp(&path_key(&right.path)))
143                .then_with(|| left.message.cmp(&right.message))
144        });
145        sorted
146    }
147
148    /// Merges another report into this one, preserving the truncation flag.
149    pub fn extend(&mut self, other: Self) {
150        let other_truncated = other.truncated;
151        for finding in other.findings {
152            self.push(finding);
153        }
154        self.truncated |= other_truncated;
155    }
156
157    /// Whether anything this validator checked was violated.
158    #[must_use]
159    pub fn is_conformant(&self) -> bool {
160        self.summary().is_conformant()
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::report::Path;
168    use ifc_model::EntityId;
169
170    #[test]
171    fn unsupported_rules_do_not_make_a_file_non_conformant() {
172        let mut report = Report::new();
173        report.push(Finding::unsupported(
174            "where.IfcRoot.WR1",
175            Path::File,
176            "not implemented",
177        ));
178        assert!(
179            report.is_conformant(),
180            "an unchecked rule is a fact about the validator"
181        );
182        assert_eq!(report.summary().unsupported, 1);
183    }
184
185    #[test]
186    fn sorting_is_total_and_stable() {
187        let mut report = Report::new();
188        report.push(Finding::warning("b.rule", Path::Entity(EntityId(9)), "w"));
189        report.push(Finding::error("a.rule", Path::Entity(EntityId(2)), "e"));
190        report.push(Finding::error("a.rule", Path::Entity(EntityId(1)), "e"));
191        let order: Vec<String> = report
192            .sorted()
193            .iter()
194            .map(|finding| finding.path.to_string())
195            .collect();
196        assert_eq!(order, ["#1", "#2", "#9"], "errors first, then by entity");
197    }
198
199    /// A capped report cannot be overfilled through a merge.
200    #[test]
201    fn extend_honors_the_storage_cap() {
202        let mut target = Report::with_max_findings(1);
203        let mut source = Report::new();
204        source.push(Finding::error("a", Path::File, "first"));
205        source.push(Finding::error("b", Path::File, "second"));
206
207        target.extend(source);
208
209        assert_eq!(target.findings().len(), 1);
210        assert!(target.is_truncated());
211    }
212
213    /// A truncated report says so, rather than implying the file is clean.
214    #[test]
215    fn truncation_survives_a_merge() {
216        let mut left = Report::new();
217        let mut right = Report::new();
218        right.mark_truncated();
219        left.extend(right);
220        assert!(left.is_truncated());
221    }
222}