Skip to main content

allow_core/
error.rs

1use std::fmt;
2use std::ops::Range;
3use std::path::Path;
4
5/// One-based source location attached to a parse or validation diagnostic.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct CargoAllowErrorLocation {
8    /// Source path when the caller had one; `None` means the input was an
9    /// in-memory document without a known path.
10    pub path: Option<String>,
11    pub line: u32,
12    pub column: u32,
13}
14
15/// Severity for a machine-readable diagnostic carried by a command error.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum CargoAllowDiagnosticSeverity {
18    Error,
19    Warning,
20    Info,
21}
22
23/// Structured validation or execution detail.
24///
25/// The fields are intentionally owned and optional so diagnostics can be
26/// produced by policy, federation, import, and command layers without making
27/// those layers depend on a parser-specific representation.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct CargoAllowDiagnostic {
30    pub code: String,
31    pub category: String,
32    pub severity: CargoAllowDiagnosticSeverity,
33    pub path: Option<String>,
34    pub span: Option<CargoAllowErrorLocation>,
35    pub entry_id: Option<String>,
36    pub field: Option<String>,
37    pub message: String,
38    pub help: Option<String>,
39    pub causes: Vec<String>,
40}
41
42impl CargoAllowDiagnostic {
43    pub fn error(
44        code: impl Into<String>,
45        category: impl Into<String>,
46        entry_id: Option<&str>,
47        field: Option<&str>,
48        message: impl Into<String>,
49    ) -> Self {
50        Self {
51            code: code.into(),
52            category: category.into(),
53            severity: CargoAllowDiagnosticSeverity::Error,
54            path: None,
55            span: None,
56            entry_id: entry_id.map(str::to_owned),
57            field: field.map(str::to_owned),
58            message: message.into(),
59            help: None,
60            causes: Vec::new(),
61        }
62    }
63}
64
65/// Structured kind for [`CargoAllowError`], enabling programmatic consumers
66/// (CI tooling, sibling tools) to branch on error class instead of
67/// string-matching the rendered message.
68///
69/// This enum is `#[non_exhaustive]` so new kinds can be added without a
70/// breaking change for downstream library consumers.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
72#[non_exhaustive]
73pub enum CargoAllowErrorKind {
74    /// CLI usage error (bad flags, conflicting arguments).
75    Usage,
76    /// Invalid or missing configuration file/values.
77    InvalidConfig,
78    /// Invalid policy ledger (validation failure, parse error, unknown field).
79    InvalidPolicy,
80    /// Inventory discovery failure (git error, unreadable directory).
81    Inventory,
82    /// Scan failure (read error, parse error in a source file).
83    Scan,
84    /// Policy violation (check/diff gate failed).
85    PolicyViolation,
86    /// Artifact or write failure (receipt rendering, policy write).
87    Artifact,
88    /// Internal invariant failure (should not happen).
89    Internal,
90    /// Unclassified — preserved for backward compatibility with `new()`.
91    Unknown,
92}
93
94impl CargoAllowErrorKind {
95    /// All error kinds currently defined by this version.
96    ///
97    /// The enum is non-exhaustive; callers must still handle future kinds.
98    pub const ALL: &[Self] = &[
99        Self::Usage,
100        Self::InvalidConfig,
101        Self::InvalidPolicy,
102        Self::Inventory,
103        Self::Scan,
104        Self::PolicyViolation,
105        Self::Artifact,
106        Self::Internal,
107        Self::Unknown,
108    ];
109
110    /// Render the kind as a stable, lowercase identifier suitable for
111    /// machine consumption (e.g. receipt `error.kind` fields).
112    pub fn as_str(self) -> &'static str {
113        match self {
114            Self::Usage => "usage",
115            Self::InvalidConfig => "invalid_config",
116            Self::InvalidPolicy => "invalid_policy",
117            Self::Inventory => "inventory",
118            Self::Scan => "scan",
119            Self::PolicyViolation => "policy_violation",
120            Self::Artifact => "artifact",
121            Self::Internal => "internal",
122            Self::Unknown => "unknown",
123        }
124    }
125
126    /// Return the stable machine-readable error code for this kind.
127    ///
128    /// Codes are part of the public contract and must not be reused for a
129    /// different failure class. See `docs/error-codes.md` for the registry.
130    pub const fn code(self) -> &'static str {
131        match self {
132            Self::Usage => "E0001_USAGE",
133            Self::InvalidConfig => "E0002_INVALID_CONFIG",
134            Self::InvalidPolicy => "E0003_INVALID_POLICY",
135            Self::Inventory => "E0004_INVENTORY",
136            Self::Scan => "E0005_SCAN",
137            Self::PolicyViolation => "E0006_POLICY_VIOLATION",
138            Self::Artifact => "E0007_ARTIFACT",
139            Self::Internal => "E0008_INTERNAL",
140            Self::Unknown => "E0009_UNKNOWN",
141        }
142    }
143}
144
145impl fmt::Display for CargoAllowErrorKind {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        f.write_str(self.as_str())
148    }
149}
150
151/// Linked cause node for [`std::error::Error::source`] walks.
152#[derive(Debug, Clone)]
153struct CauseError {
154    message: String,
155    next: Option<Box<CauseError>>,
156}
157
158impl fmt::Display for CauseError {
159    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160        f.write_str(&self.message)
161    }
162}
163
164impl std::error::Error for CauseError {
165    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
166        self.next
167            .as_ref()
168            .map(|next| next.as_ref() as &(dyn std::error::Error + 'static))
169    }
170}
171
172/// The unified error type for the cargo-allow workspace.
173///
174/// Carries a structured [`CargoAllowErrorKind`], a human-readable message, and
175/// an optional cause chain (rendered as a `caused by:` suffix in `Display`).
176/// The `kind()` accessor lets programmatic consumers branch on error class
177/// without string-matching.
178#[derive(Debug, Clone)]
179pub struct CargoAllowError {
180    kind: CargoAllowErrorKind,
181    message: String,
182    location: Option<CargoAllowErrorLocation>,
183    diagnostics: Vec<CargoAllowDiagnostic>,
184    /// Rendered cause chain (each element is the `Display` of an underlying
185    /// error). Stored as strings so the struct stays `Clone` + `PartialEq`.
186    causes: Vec<String>,
187    /// Linked cause chain for `Error::source` / `successors` walks.
188    source: Option<Box<CauseError>>,
189}
190
191impl CargoAllowError {
192    /// Create an error with [`CargoAllowErrorKind::Unknown`] (backward compat).
193    pub fn new(message: impl Into<String>) -> Self {
194        Self {
195            kind: CargoAllowErrorKind::Unknown,
196            message: message.into(),
197            location: None,
198            diagnostics: Vec::new(),
199            causes: Vec::new(),
200            source: None,
201        }
202    }
203
204    /// Create an error with a structured kind.
205    pub fn with_kind(kind: CargoAllowErrorKind, message: impl Into<String>) -> Self {
206        Self {
207            kind,
208            message: message.into(),
209            location: None,
210            diagnostics: Vec::new(),
211            causes: Vec::new(),
212            source: None,
213        }
214    }
215
216    /// Attach a cause (underlying error) to this error, returning a new value.
217    ///
218    /// The cause is rendered as a `caused by:` line in `Display` and linked for
219    /// [`std::error::Error::source`] walks.
220    pub fn with_cause(mut self, cause: &(impl std::error::Error + ?Sized)) -> Self {
221        let message = cause.to_string();
222        self.causes.push(message.clone());
223        let node = Box::new(CauseError {
224            message,
225            next: None,
226        });
227        match self.source.as_mut() {
228            None => self.source = Some(node),
229            Some(head) => append_cause(head, node),
230        }
231        self
232    }
233
234    /// Rendered cause messages in attachment order (outermost first).
235    pub fn causes(&self) -> &[String] {
236        &self.causes
237    }
238
239    /// Attach one structured diagnostic detail, returning a new value.
240    pub fn with_diagnostic(mut self, diagnostic: CargoAllowDiagnostic) -> Self {
241        self.diagnostics.push(diagnostic);
242        self
243    }
244
245    /// Attach multiple structured diagnostic details, returning a new value.
246    pub fn with_diagnostics(
247        mut self,
248        diagnostics: impl IntoIterator<Item = CargoAllowDiagnostic>,
249    ) -> Self {
250        self.diagnostics.extend(diagnostics);
251        self
252    }
253
254    /// Machine-readable details associated with this error.
255    pub fn diagnostics(&self) -> &[CargoAllowDiagnostic] {
256        &self.diagnostics
257    }
258
259    /// The structured error kind.
260    pub fn kind(&self) -> CargoAllowErrorKind {
261        self.kind
262    }
263
264    /// The stable machine-readable code for this error.
265    pub fn code(&self) -> &'static str {
266        self.kind.code()
267    }
268
269    /// Attach a one-based source location derived from a TOML byte span.
270    ///
271    /// TOML reports byte offsets. This conversion keeps the public error
272    /// contract independent of the parser's error-display text and reports a
273    /// character column suitable for editor and CI diagnostics.
274    pub fn with_toml_span(
275        mut self,
276        path: Option<&Path>,
277        source: &str,
278        span: Option<Range<usize>>,
279    ) -> Self {
280        let Some(span) = span else {
281            return self;
282        };
283        let prefix = source.get(..span.start).unwrap_or(source);
284        let line = prefix.bytes().filter(|byte| *byte == b'\n').count() + 1;
285        let column = prefix
286            .rsplit_once('\n')
287            .map(|(_, line)| line.chars().count() + 1)
288            .unwrap_or_else(|| prefix.chars().count() + 1);
289        self.location = Some(CargoAllowErrorLocation {
290            path: path.map(|value| value.display().to_string()),
291            line: u32::try_from(line).unwrap_or(u32::MAX),
292            column: u32::try_from(column).unwrap_or(u32::MAX),
293        });
294        for diagnostic in &mut self.diagnostics {
295            diagnostic.path = path.map(|value| value.display().to_string());
296            diagnostic.span = self.location.clone();
297        }
298        self
299    }
300
301    /// Structured source location, when the error originated from located
302    /// input such as a TOML parse.
303    pub fn location(&self) -> Option<&CargoAllowErrorLocation> {
304        self.location.as_ref()
305    }
306
307    /// The human-readable message (without the cause chain).
308    pub fn message(&self) -> &str {
309        &self.message
310    }
311}
312
313impl fmt::Display for CargoAllowError {
314    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
315        write!(f, "{}", self.message)?;
316        for cause in &self.causes {
317            write!(f, "\n  caused by: {cause}")?;
318        }
319        Ok(())
320    }
321}
322
323impl std::error::Error for CargoAllowError {
324    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
325        self.source
326            .as_ref()
327            .map(|cause| cause.as_ref() as &(dyn std::error::Error + 'static))
328    }
329}
330
331/// `PartialEq` compares kind + message only (not the cause chain), so tests
332/// that `assert_eq!` on constructed errors are not sensitive to cause text.
333impl PartialEq for CargoAllowError {
334    fn eq(&self, other: &Self) -> bool {
335        self.kind == other.kind && self.message == other.message
336    }
337}
338
339impl Eq for CargoAllowError {}
340
341/// Auto-convert `io::Error` so `?` works at IO call sites without manual
342/// `map_err`. The kind is [`CargoAllowErrorKind::Unknown`]; callers that want
343/// a specific kind (e.g. `Inventory`) should use `with_kind` explicitly.
344impl From<std::io::Error> for CargoAllowError {
345    fn from(e: std::io::Error) -> Self {
346        let message = e.to_string();
347        let mut err = CargoAllowError::with_kind(CargoAllowErrorKind::Unknown, message.clone());
348        err.kind = match e.kind() {
349            std::io::ErrorKind::NotFound => CargoAllowErrorKind::InvalidConfig,
350            std::io::ErrorKind::PermissionDenied => CargoAllowErrorKind::Inventory,
351            _ => CargoAllowErrorKind::Unknown,
352        };
353        err.message = message.clone();
354        // Keep the IO error visible to `Error::source` walkers without
355        // duplicating the same text under Display's `caused by:` lines.
356        err.source = Some(Box::new(CauseError {
357            message,
358            next: None,
359        }));
360        err
361    }
362}
363
364fn append_cause(head: &mut CauseError, node: Box<CauseError>) {
365    match head.next.as_mut() {
366        Some(next) => append_cause(next, node),
367        None => head.next = Some(node),
368    }
369}
370
371pub type CargoAllowResult<T> = Result<T, CargoAllowError>;
372
373#[cfg(test)]
374#[path = "error_tests.rs"]
375mod tests;