ifc_validate/report/finding.rs
1//! What a validator says when something is wrong.
2//!
3//! # Severity is about conformance, not about how annoyed you should be
4//!
5//! [`Severity::Error`] means the file violates the schema: a required
6//! attribute is absent, a reference points at nothing, a GUID is duplicated.
7//! [`Severity::Warning`] means the file is legal but suspicious.
8//! [`Severity::Unsupported`] means *this validator did not check* -- the rule
9//! exists in the schema and is not implemented here.
10//! [`Severity::EvaluationError`] means an implemented rule applied to an
11//! instance and *could not be decided* for it: an operand has a shape the rule
12//! cannot read, names a target the file does not contain, or is missing from
13//! the schema tables the rule was written against.
14//!
15//! The last two variants are the important ones. A validator that silently
16//! skips what it cannot evaluate reports a clean file and is worse than
17//! useless, because a clean report is exactly what a user acts on. Counting
18//! the unchecked rules is what makes "no errors" mean something.
19//!
20//! They differ in what they say about the file. `Unsupported` is a fact about
21//! this validator for every file alike, so it leaves conformance alone. An
22//! `EvaluationError` is about *this* instance: the rule may well be violated
23//! there, so a report carrying one is not conformant.
24
25use std::fmt;
26
27use super::path::Path;
28
29/// How serious a finding is.
30///
31/// Declaration order is the report's sort order, most actionable first.
32/// Non-exhaustive: a new category of finding must not break every match.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
34#[non_exhaustive]
35pub enum Severity {
36 /// The file violates the schema.
37 Error,
38 /// An implemented rule applies to this instance but could not be
39 /// decided for it. Makes the report non-conformant, because "could not
40 /// check" is not "passed".
41 EvaluationError,
42 /// Legal, but very likely a mistake.
43 Warning,
44 /// A rule this validator does not implement. Not a verdict on the file.
45 Unsupported,
46}
47
48impl fmt::Display for Severity {
49 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
50 let text = match self {
51 Self::Error => "error",
52 Self::EvaluationError => "evaluation-error",
53 Self::Warning => "warning",
54 Self::Unsupported => "unsupported",
55 };
56 formatter.write_str(text)
57 }
58}
59
60/// One thing a validator has to say about a file.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct Finding {
63 /// How serious it is.
64 pub severity: Severity,
65 /// A stable identifier for the check that produced this, e.g.
66 /// `structure.reference.dangling` or `where.IfcRoot.WR1`. Callers filter
67 /// and suppress on this, so it is part of the contract.
68 pub rule: String,
69 /// Where the problem is.
70 pub path: Path,
71 /// What is wrong, in one sentence, without restating the rule id.
72 pub message: String,
73}
74
75impl Finding {
76 /// A schema violation.
77 #[must_use]
78 pub fn error(rule: impl Into<String>, path: Path, message: impl Into<String>) -> Self {
79 Self {
80 severity: Severity::Error,
81 rule: rule.into(),
82 path,
83 message: message.into(),
84 }
85 }
86
87 /// A legal but suspicious condition.
88 #[must_use]
89 pub fn warning(rule: impl Into<String>, path: Path, message: impl Into<String>) -> Self {
90 Self {
91 severity: Severity::Warning,
92 rule: rule.into(),
93 path,
94 message: message.into(),
95 }
96 }
97
98 /// An implemented rule that could not be decided for one instance.
99 ///
100 /// `rule` is the rule's own id, so a caller filtering on it sees both
101 /// verdicts; the severity says which one this is.
102 #[must_use]
103 pub fn evaluation_error(
104 rule: impl Into<String>,
105 path: Path,
106 message: impl Into<String>,
107 ) -> Self {
108 Self {
109 severity: Severity::EvaluationError,
110 rule: rule.into(),
111 path,
112 message: message.into(),
113 }
114 }
115
116 /// A rule this validator did not evaluate.
117 #[must_use]
118 pub fn unsupported(rule: impl Into<String>, path: Path, message: impl Into<String>) -> Self {
119 Self {
120 severity: Severity::Unsupported,
121 rule: rule.into(),
122 path,
123 message: message.into(),
124 }
125 }
126}
127
128impl fmt::Display for Finding {
129 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
130 write!(
131 formatter,
132 "{}: {} at {}: {}",
133 self.severity, self.rule, self.path, self.message
134 )
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use ifc_model::EntityId;
142
143 /// Severity ordering is what report sorting relies on.
144 #[test]
145 fn errors_sort_before_warnings_before_unsupported() {
146 let mut severities = [
147 Severity::Unsupported,
148 Severity::Warning,
149 Severity::EvaluationError,
150 Severity::Error,
151 ];
152 severities.sort();
153 assert_eq!(
154 severities,
155 [
156 Severity::Error,
157 Severity::EvaluationError,
158 Severity::Warning,
159 Severity::Unsupported
160 ]
161 );
162 }
163
164 /// Each severity renders distinctly, so a printed report cannot
165 /// conflate "violated" with "could not decide".
166 #[test]
167 fn every_severity_renders_distinctly() {
168 let rendered: Vec<String> = [
169 Severity::Error,
170 Severity::EvaluationError,
171 Severity::Warning,
172 Severity::Unsupported,
173 ]
174 .iter()
175 .map(ToString::to_string)
176 .collect();
177 let mut unique = rendered.clone();
178 unique.sort();
179 unique.dedup();
180 assert_eq!(unique.len(), rendered.len(), "{rendered:?}");
181 }
182
183 /// A finding renders its path so a reader can find the entity.
184 #[test]
185 fn a_finding_names_where_it_applies() {
186 let finding = Finding::error(
187 "structure.dangling",
188 Path::Attribute {
189 entity: EntityId(12),
190 index: 3,
191 name: Some("Representation".into()),
192 },
193 "points at #99, which does not exist",
194 );
195 assert_eq!(finding.path.to_string(), "#12.Representation");
196 assert_eq!(finding.severity, Severity::Error);
197 }
198}