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    /// Requested capability, platform, or tool contract is not supported.
89    Unsupported,
90    /// An external tool or process failed to provide usable evidence.
91    InstrumentFailure,
92    /// Internal invariant failure (should not happen).
93    Internal,
94    /// Unclassified — preserved for backward compatibility with `new()`.
95    Unknown,
96}
97
98impl CargoAllowErrorKind {
99    /// All error kinds currently defined by this version.
100    ///
101    /// The enum is non-exhaustive; callers must still handle future kinds.
102    pub const ALL: &[Self] = &[
103        Self::Usage,
104        Self::InvalidConfig,
105        Self::InvalidPolicy,
106        Self::Inventory,
107        Self::Scan,
108        Self::PolicyViolation,
109        Self::Artifact,
110        Self::Unsupported,
111        Self::InstrumentFailure,
112        Self::Internal,
113        Self::Unknown,
114    ];
115
116    /// Render the kind as a stable, lowercase identifier suitable for
117    /// machine consumption (e.g. receipt `error.kind` fields).
118    pub fn as_str(self) -> &'static str {
119        match self {
120            Self::Usage => "usage",
121            Self::InvalidConfig => "invalid_config",
122            Self::InvalidPolicy => "invalid_policy",
123            Self::Inventory => "inventory",
124            Self::Scan => "scan",
125            Self::PolicyViolation => "policy_violation",
126            Self::Artifact => "artifact",
127            Self::Unsupported => "unsupported",
128            Self::InstrumentFailure => "instrument_failure",
129            Self::Internal => "internal",
130            Self::Unknown => "unknown",
131        }
132    }
133
134    /// Return the stable machine-readable error code for this kind.
135    ///
136    /// Codes are part of the public contract and must not be reused for a
137    /// different failure class. See `docs/error-codes.md` for the registry.
138    pub const fn code(self) -> &'static str {
139        match self {
140            Self::Usage => "E0001_USAGE",
141            Self::InvalidConfig => "E0002_INVALID_CONFIG",
142            Self::InvalidPolicy => "E0003_INVALID_POLICY",
143            Self::Inventory => "E0004_INVENTORY",
144            Self::Scan => "E0005_SCAN",
145            Self::PolicyViolation => "E0006_POLICY_VIOLATION",
146            Self::Artifact => "E0007_ARTIFACT",
147            Self::Internal => "E0008_INTERNAL",
148            Self::Unknown => "E0009_UNKNOWN",
149            Self::Unsupported => "E0010_UNSUPPORTED",
150            Self::InstrumentFailure => "E0011_INSTRUMENT_FAILURE",
151        }
152    }
153}
154
155impl fmt::Display for CargoAllowErrorKind {
156    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157        f.write_str(self.as_str())
158    }
159}
160
161/// Linked cause node for [`std::error::Error::source`] walks.
162#[derive(Debug, Clone)]
163struct CauseError {
164    message: String,
165    next: Option<Box<CauseError>>,
166}
167
168impl fmt::Display for CauseError {
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        f.write_str(&self.message)
171    }
172}
173
174impl std::error::Error for CauseError {
175    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
176        self.next
177            .as_ref()
178            .map(|next| next.as_ref() as &(dyn std::error::Error + 'static))
179    }
180}
181
182/// The unified error type for the cargo-allow workspace.
183///
184/// Carries a structured [`CargoAllowErrorKind`], a human-readable message, and
185/// an optional cause chain (rendered as a `caused by:` suffix in `Display`).
186/// The `kind()` accessor lets programmatic consumers branch on error class
187/// without string-matching.
188#[derive(Debug, Clone)]
189pub struct CargoAllowError {
190    kind: CargoAllowErrorKind,
191    message: String,
192    location: Option<CargoAllowErrorLocation>,
193    diagnostics: Vec<CargoAllowDiagnostic>,
194    /// Rendered cause chain (each element is the `Display` of an underlying
195    /// error). Stored as strings so the struct stays `Clone` + `PartialEq`.
196    causes: Vec<String>,
197    /// Linked cause chain for `Error::source` / `successors` walks.
198    source: Option<Box<CauseError>>,
199}
200
201impl CargoAllowError {
202    /// Create an error with [`CargoAllowErrorKind::Unknown`] (backward compat).
203    pub fn new(message: impl Into<String>) -> Self {
204        Self {
205            kind: CargoAllowErrorKind::Unknown,
206            message: message.into(),
207            location: None,
208            diagnostics: Vec::new(),
209            causes: Vec::new(),
210            source: None,
211        }
212    }
213
214    /// Create an error with a structured kind.
215    pub fn with_kind(kind: CargoAllowErrorKind, message: impl Into<String>) -> Self {
216        Self {
217            kind,
218            message: message.into(),
219            location: None,
220            diagnostics: Vec::new(),
221            causes: Vec::new(),
222            source: None,
223        }
224    }
225
226    /// Reclassify an existing error without discarding its structured metadata.
227    ///
228    /// Aggregation and adapter layers should use this when the surrounding
229    /// contract provides a more precise kind. Rebuilding with [`Self::with_kind`]
230    /// would lose locations, diagnostics, and causes.
231    pub fn with_kind_preserving_metadata(mut self, kind: CargoAllowErrorKind) -> Self {
232        self.kind = kind;
233        self
234    }
235
236    /// Attach a cause (underlying error) to this error, returning a new value.
237    ///
238    /// The cause is rendered as a `caused by:` line in `Display` and linked for
239    /// [`std::error::Error::source`] walks.
240    pub fn with_cause(mut self, cause: &(impl std::error::Error + ?Sized)) -> Self {
241        let message = cause.to_string();
242        self.causes.push(message.clone());
243        let node = Box::new(CauseError {
244            message,
245            next: None,
246        });
247        match self.source.as_mut() {
248            None => self.source = Some(node),
249            Some(head) => append_cause(head, node),
250        }
251        self
252    }
253
254    /// Prefix the human-readable message without discarding structured error
255    /// metadata such as the kind, source location, diagnostics, or causes.
256    ///
257    /// Context layers should use this instead of rebuilding an error from its
258    /// rendered string. Rebuilding loses information that machine consumers
259    /// and editor integrations rely on.
260    pub fn with_message_prefix(mut self, prefix: impl AsRef<str>) -> Self {
261        let prefix = prefix.as_ref();
262        if !prefix.is_empty() {
263            self.message.insert_str(0, prefix);
264        }
265        self
266    }
267
268    /// Append a message suffix without discarding structured error metadata.
269    ///
270    /// Context layers should use this when adding remediation guidance after
271    /// an existing message. Rebuilding an error from its rendered string loses
272    /// metadata that machine consumers and editor integrations rely on.
273    pub fn with_message_suffix(mut self, suffix: impl AsRef<str>) -> Self {
274        let suffix = suffix.as_ref();
275        if !suffix.is_empty() {
276            self.message.push_str(suffix);
277        }
278        self
279    }
280
281    /// Rendered cause messages in attachment order (outermost first).
282    pub fn causes(&self) -> &[String] {
283        &self.causes
284    }
285
286    /// Attach one structured diagnostic detail, returning a new value.
287    pub fn with_diagnostic(mut self, diagnostic: CargoAllowDiagnostic) -> Self {
288        self.diagnostics.push(diagnostic);
289        self
290    }
291
292    /// Attach multiple structured diagnostic details, returning a new value.
293    pub fn with_diagnostics(
294        mut self,
295        diagnostics: impl IntoIterator<Item = CargoAllowDiagnostic>,
296    ) -> Self {
297        self.diagnostics.extend(diagnostics);
298        self
299    }
300
301    /// Machine-readable details associated with this error.
302    pub fn diagnostics(&self) -> &[CargoAllowDiagnostic] {
303        &self.diagnostics
304    }
305
306    /// The structured error kind.
307    pub fn kind(&self) -> CargoAllowErrorKind {
308        self.kind
309    }
310
311    /// The stable machine-readable code for this error.
312    pub fn code(&self) -> &'static str {
313        self.kind.code()
314    }
315
316    /// Attach a one-based source location derived from a TOML byte span.
317    ///
318    /// TOML reports byte offsets. This conversion keeps the public error
319    /// contract independent of the parser's error-display text and reports a
320    /// character column suitable for editor and CI diagnostics.
321    pub fn with_toml_span(
322        mut self,
323        path: Option<&Path>,
324        source: &str,
325        span: Option<Range<usize>>,
326    ) -> Self {
327        let Some(span) = span else {
328            return self;
329        };
330        let prefix = source.get(..span.start).unwrap_or(source);
331        let line = prefix.bytes().filter(|byte| *byte == b'\n').count() + 1;
332        let column = prefix
333            .rsplit_once('\n')
334            .map(|(_, line)| line.chars().count() + 1)
335            .unwrap_or_else(|| prefix.chars().count() + 1);
336        self.location = Some(CargoAllowErrorLocation {
337            path: path.map(|value| value.display().to_string()),
338            line: u32::try_from(line).unwrap_or(u32::MAX),
339            column: u32::try_from(column).unwrap_or(u32::MAX),
340        });
341        for diagnostic in &mut self.diagnostics {
342            diagnostic.path = path.map(|value| value.display().to_string());
343            diagnostic.span = self.location.clone();
344        }
345        self
346    }
347
348    /// Structured source location, when the error originated from located
349    /// input such as a TOML parse.
350    pub fn location(&self) -> Option<&CargoAllowErrorLocation> {
351        self.location.as_ref()
352    }
353
354    /// The human-readable message (without the cause chain).
355    pub fn message(&self) -> &str {
356        &self.message
357    }
358}
359
360impl fmt::Display for CargoAllowError {
361    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
362        write!(f, "{}", self.message)?;
363        for cause in &self.causes {
364            write!(f, "\n  caused by: {cause}")?;
365        }
366        Ok(())
367    }
368}
369
370impl std::error::Error for CargoAllowError {
371    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
372        self.source
373            .as_ref()
374            .map(|cause| cause.as_ref() as &(dyn std::error::Error + 'static))
375    }
376}
377
378/// Auto-convert `io::Error` so `?` works at IO call sites without manual
379/// `map_err`. The kind is [`CargoAllowErrorKind::Unknown`]; callers that want
380/// a specific kind (e.g. `Inventory`) should use `with_kind` explicitly.
381impl From<std::io::Error> for CargoAllowError {
382    fn from(e: std::io::Error) -> Self {
383        let message = e.to_string();
384        let mut err = CargoAllowError::with_kind(CargoAllowErrorKind::Unknown, message.clone());
385        err.kind = match e.kind() {
386            std::io::ErrorKind::NotFound => CargoAllowErrorKind::InvalidConfig,
387            std::io::ErrorKind::PermissionDenied => CargoAllowErrorKind::Inventory,
388            _ => CargoAllowErrorKind::Unknown,
389        };
390        err.message = message.clone();
391        // Keep the IO error visible to `Error::source` walkers without
392        // duplicating the same text under Display's `caused by:` lines.
393        err.source = Some(Box::new(CauseError {
394            message,
395            next: None,
396        }));
397        err
398    }
399}
400
401fn append_cause(head: &mut CauseError, node: Box<CauseError>) {
402    match head.next.as_mut() {
403        Some(next) => append_cause(next, node),
404        None => head.next = Some(node),
405    }
406}
407
408pub type CargoAllowResult<T> = Result<T, CargoAllowError>;
409
410#[cfg(test)]
411#[path = "error_tests.rs"]
412mod tests;