Skip to main content

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)]
62#[non_exhaustive]
63pub struct Finding {
64    /// How serious it is.
65    pub severity: Severity,
66    /// A stable identifier for the check that produced this, e.g.
67    /// `structure.reference.dangling` or `where.IfcRoot.WR1`. Callers filter
68    /// and suppress on this, so it is part of the contract.
69    pub rule: String,
70    /// Where the problem is.
71    pub path: Path,
72    /// What is wrong, in one sentence, without restating the rule id.
73    pub message: String,
74}
75
76impl Finding {
77    /// A schema violation.
78    #[must_use]
79    pub fn error(rule: impl Into<String>, path: Path, message: impl Into<String>) -> Self {
80        Self {
81            severity: Severity::Error,
82            rule: rule.into(),
83            path,
84            message: message.into(),
85        }
86    }
87
88    /// A legal but suspicious condition.
89    #[must_use]
90    pub fn warning(rule: impl Into<String>, path: Path, message: impl Into<String>) -> Self {
91        Self {
92            severity: Severity::Warning,
93            rule: rule.into(),
94            path,
95            message: message.into(),
96        }
97    }
98
99    /// An implemented rule that could not be decided for one instance.
100    ///
101    /// `rule` is the rule's own id, so a caller filtering on it sees both
102    /// verdicts; the severity says which one this is.
103    #[must_use]
104    pub fn evaluation_error(
105        rule: impl Into<String>,
106        path: Path,
107        message: impl Into<String>,
108    ) -> Self {
109        Self {
110            severity: Severity::EvaluationError,
111            rule: rule.into(),
112            path,
113            message: message.into(),
114        }
115    }
116
117    /// A rule this validator did not evaluate.
118    #[must_use]
119    pub fn unsupported(rule: impl Into<String>, path: Path, message: impl Into<String>) -> Self {
120        Self {
121            severity: Severity::Unsupported,
122            rule: rule.into(),
123            path,
124            message: message.into(),
125        }
126    }
127}
128
129impl fmt::Display for Finding {
130    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
131        write!(
132            formatter,
133            "{}: {} at {}: {}",
134            self.severity, self.rule, self.path, self.message
135        )
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use ifc_model::EntityId;
143
144    /// Severity ordering is what report sorting relies on.
145    #[test]
146    fn errors_sort_before_warnings_before_unsupported() {
147        let mut severities = [
148            Severity::Unsupported,
149            Severity::Warning,
150            Severity::EvaluationError,
151            Severity::Error,
152        ];
153        severities.sort();
154        assert_eq!(
155            severities,
156            [
157                Severity::Error,
158                Severity::EvaluationError,
159                Severity::Warning,
160                Severity::Unsupported
161            ]
162        );
163    }
164
165    /// Each severity renders distinctly, so a printed report cannot
166    /// conflate "violated" with "could not decide".
167    #[test]
168    fn every_severity_renders_distinctly() {
169        let rendered: Vec<String> = [
170            Severity::Error,
171            Severity::EvaluationError,
172            Severity::Warning,
173            Severity::Unsupported,
174        ]
175        .iter()
176        .map(ToString::to_string)
177        .collect();
178        let mut unique = rendered.clone();
179        unique.sort();
180        unique.dedup();
181        assert_eq!(unique.len(), rendered.len(), "{rendered:?}");
182    }
183
184    /// A finding renders its path so a reader can find the entity.
185    #[test]
186    fn a_finding_names_where_it_applies() {
187        let finding = Finding::error(
188            "structure.dangling",
189            Path::Attribute {
190                entity: EntityId(12),
191                index: 3,
192                name: Some("Representation".into()),
193            },
194            "points at #99, which does not exist",
195        );
196        assert_eq!(finding.path.to_string(), "#12.Representation");
197        assert_eq!(finding.severity, Severity::Error);
198    }
199}