Skip to main content

core_invoice/
report.rs

1//! Validation findings. [`Severity::Fatal`] fails [`Report::ok`]; [`Severity::Warning`] does not.
2
3use crate::bt::Path;
4use std::fmt;
5
6/// Finding severity. Only [`Severity::Fatal`] fails [`Report::ok`].
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
8pub enum Severity {
9    /// Fails [`Report::ok`].
10    Fatal,
11    /// Does not fail [`Report::ok`].
12    Warning,
13    /// Does not fail [`Report::ok`].
14    Info,
15}
16
17/// Provenance of a registered rule id.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Source {
20    /// Standard text and artefacts agree.
21    Both,
22    /// Standard text only.
23    StandardOnly,
24    /// Artefact-only (eval may be a no-op).
25    ArtefactOnly,
26    /// Crate-owned id (`CORE-*`, `PINT-TAX`, `IBR-*-MY`).
27    Crate,
28}
29
30/// One rule hit at a [`Path`].
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Finding {
33    /// Registered rule id (`BR-02`, `PINT-TAX`).
34    pub id: &'static str,
35    /// Fatal fails [`Report::ok`]; Warning and Info do not.
36    pub severity: Severity,
37    /// Finding location ([`Path`]; index is 0-based).
38    pub path: Path,
39    /// Authority wording. Arithmetic expected/actual belongs in [`Self::detail`].
40    pub message: String,
41    /// Optional expected/actual. Not appended to [`Self::message`] by constructors.
42    pub detail: Option<String>,
43    /// Optional hint. Not shown by `Display`.
44    pub hint: Option<String>,
45}
46
47impl Finding {
48    /// Fatal finding. Fails [`Report::ok`].
49    pub fn fatal(id: &'static str, path: Path, message: impl Into<String>) -> Self {
50        Self {
51            id,
52            severity: Severity::Fatal,
53            path,
54            message: message.into(),
55            detail: None,
56            hint: None,
57        }
58    }
59
60    /// Warning finding. Does not fail [`Report::ok`].
61    pub fn warning(id: &'static str, path: Path, message: impl Into<String>) -> Self {
62        Self {
63            id,
64            severity: Severity::Warning,
65            path,
66            message: message.into(),
67            detail: None,
68            hint: None,
69        }
70    }
71
72    /// Info finding. Does not fail [`Report::ok`].
73    pub fn info(id: &'static str, path: Path, message: impl Into<String>) -> Self {
74        Self {
75            id,
76            severity: Severity::Info,
77            path,
78            message: message.into(),
79            detail: None,
80            hint: None,
81        }
82    }
83}
84
85/// Validation result. [`Self::ok`] is “no Fatal”, not “no findings”.
86#[derive(Debug, Clone, Default, PartialEq, Eq)]
87pub struct Report {
88    /// Collected hits, unsorted until [`Self::sort_stable`].
89    pub findings: Vec<Finding>,
90    /// Profile slug of the invoice that was checked.
91    pub profile_slug: &'static str,
92    /// Number of rule evals invoked (CORE + extras).
93    pub rules_checked: usize,
94}
95
96impl Report {
97    /// `true` when no finding is [`Severity::Fatal`]. Warning and Info do not fail.
98    pub fn ok(&self) -> bool {
99        !self.findings.iter().any(|f| f.severity == Severity::Fatal)
100    }
101
102    /// Append a finding. Does not sort.
103    pub fn push(&mut self, finding: Finding) {
104        self.findings.push(finding);
105    }
106
107    /// Sort by severity, then path display, then id.
108    pub fn sort_stable(&mut self) {
109        self.findings.sort_by(|a, b| {
110            a.severity
111                .cmp(&b.severity)
112                .then_with(|| a.path.to_string().cmp(&b.path.to_string()))
113                .then_with(|| a.id.cmp(b.id))
114        });
115    }
116}
117
118impl fmt::Display for Finding {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        write!(f, "[{}] {} — {}", self.id, self.path, self.message)?;
121        if let Some(d) = &self.detail {
122            write!(f, " ({d})")?;
123        }
124        Ok(())
125    }
126}
127
128impl fmt::Display for Report {
129    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130        if self.ok() {
131            return write!(f, "valid");
132        }
133        for finding in &self.findings {
134            writeln!(f, "{finding}")?;
135        }
136        Ok(())
137    }
138}