ifc_validate/report/
summary.rs1use std::fmt;
4
5use super::finding::{Finding, Severity};
6use super::path::path_key;
7
8#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
13#[non_exhaustive]
14pub struct Summary {
15 pub errors: usize,
17 pub evaluation_errors: usize,
19 pub warnings: usize,
21 pub unsupported: usize,
23}
24
25impl Summary {
26 #[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#[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 #[must_use]
82 pub fn new() -> Self {
83 Self::default()
84 }
85
86 pub(crate) fn with_max_findings(max_findings: usize) -> Self {
88 Self {
89 max_findings,
90 ..Self::default()
91 }
92 }
93
94 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 pub fn mark_truncated(&mut self) {
108 self.truncated = true;
109 }
110
111 #[must_use]
113 pub const fn is_truncated(&self) -> bool {
114 self.truncated
115 }
116
117 #[must_use]
123 pub fn findings(&self) -> &[Finding] {
124 &self.findings
125 }
126
127 #[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 #[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 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 #[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 #[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 #[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 #[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}