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