Skip to main content

spec_driven_docs/
error.rs

1//! Crate-level error type and exit-code mapping.
2//!
3//! [`AppError`] aggregates errors from every layer via `#[from]`, and
4//! [`AppError::exit_code`] maps each variant to its process exit code. No
5//! other module decides exit codes. Exit `1` is reserved for one meaning —
6//! a check ran and found violations — while the BSD sysexits range covers
7//! the tool failing to do its job at all.
8
9use camino::Utf8PathBuf;
10use thiserror::Error;
11
12use crate::domain::debt::DebtError;
13use crate::domain::marker::MarkerError;
14
15/// Every failure the binary can exit with.
16#[derive(Debug, Error)]
17pub enum AppError {
18    /// Semantically invalid arguments clap cannot reject on shape alone.
19    #[error("usage: {0}")]
20    Usage(String),
21
22    /// A check ran to completion and found violations; the findings are
23    /// already on stdout, so the process only carries the red exit.
24    #[error("{count} violation(s) found")]
25    Violations {
26        /// How many findings were reported.
27        count: usize,
28    },
29
30    /// The target has no instance manifest where one is required.
31    #[error("missing manifest: {0}")]
32    ManifestMissing(Utf8PathBuf),
33
34    /// The manifest exists but does not parse as a supported schema.
35    #[error("invalid manifest: {0}")]
36    ManifestInvalid(String),
37
38    /// The managed pre-commit block or its host file is malformed.
39    #[error(transparent)]
40    Marker(#[from] MarkerError),
41
42    /// The instance's debt file cannot be trusted, or a debt verb was asked
43    /// for a state it refuses.
44    #[error(transparent)]
45    Debt(#[from] DebtError),
46
47    /// The install or upgrade refused to touch the target as found, and the
48    /// target was left (or restored) unchanged.
49    #[error("{0}")]
50    Refused(String),
51
52    /// A tracked-upstream lookup failed. The code is the sysexit the failure
53    /// class maps to, chosen where the `git` adapter is called.
54    #[error("git: {message}")]
55    Git {
56        /// The redacted failure reason.
57        message: String,
58        /// The sysexit code for this failure class.
59        code: u8,
60    },
61
62    /// Filesystem failure, classified by its I/O kind.
63    #[error("io: {0}")]
64    Io(#[from] std::io::Error),
65
66    /// Escape hatch for ad-hoc contexts at the binary boundary.
67    #[error(transparent)]
68    Other(#[from] anyhow::Error),
69}
70
71impl AppError {
72    /// Map to the process exit code.
73    ///
74    /// `1` means a completed check found violations; `64..=78` follow BSD
75    /// `sysexits(3)` and mean the tool itself could not do its job.
76    #[must_use]
77    pub fn exit_code(&self) -> u8 {
78        match self {
79            Self::Violations { .. } => 1,
80            Self::Usage(_) => 64,
81            Self::ManifestInvalid(_)
82            | Self::Marker(_)
83            | Self::Debt(
84                DebtError::Shape(_) | DebtError::Malformed { .. } | DebtError::TwoFormats,
85            ) => 65,
86            Self::ManifestMissing(_) => 66,
87            Self::Refused(_) | Self::Debt(_) => 73,
88            Self::Git { code, .. } => *code,
89            Self::Io(e) if e.kind() == std::io::ErrorKind::NotFound => 66,
90            Self::Io(e) if e.kind() == std::io::ErrorKind::PermissionDenied => 77,
91            Self::Io(_) => 74,
92            Self::Other(_) => 70,
93        }
94    }
95
96    /// Stable machine-readable kind for the error envelope.
97    #[must_use]
98    pub const fn kind(&self) -> &'static str {
99        match self {
100            Self::Usage(_) => "Usage",
101            Self::Violations { .. } => "Violations",
102            Self::ManifestMissing(_) => "ManifestMissing",
103            Self::ManifestInvalid(_) => "ManifestInvalid",
104            Self::Marker(_) => "Marker",
105            Self::Debt(_) => "Debt",
106            Self::Refused(_) => "Refused",
107            Self::Git { .. } => "Git",
108            Self::Io(_) => "Io",
109            Self::Other(_) => "Other",
110        }
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    fn io(kind: std::io::ErrorKind) -> AppError {
119        AppError::Io(std::io::Error::from(kind))
120    }
121
122    #[test]
123    fn violations_are_one() {
124        assert_eq!(AppError::Violations { count: 3 }.exit_code(), 1);
125    }
126
127    #[test]
128    fn usage_is_sixty_four() {
129        assert_eq!(AppError::Usage("bad target".into()).exit_code(), 64);
130    }
131
132    #[test]
133    fn invalid_manifest_is_sixty_five() {
134        assert_eq!(
135            AppError::ManifestInvalid("schema_version 3".into()).exit_code(),
136            65
137        );
138    }
139
140    #[test]
141    fn malformed_marker_is_sixty_five() {
142        assert_eq!(AppError::Marker(MarkerError::Malformed).exit_code(), 65);
143    }
144
145    #[test]
146    fn a_malformed_debt_file_is_sixty_five_and_a_refused_debt_verb_is_seventy_three() {
147        assert_eq!(
148            AppError::Debt(DebtError::Shape("not yaml".into())).exit_code(),
149            65
150        );
151        assert_eq!(AppError::Debt(DebtError::TwoFormats).exit_code(), 65);
152        assert_eq!(AppError::Debt(DebtError::AlreadyBaselined).exit_code(), 73);
153        assert_eq!(AppError::Debt(DebtError::NothingToMigrate).exit_code(), 73);
154    }
155
156    #[test]
157    fn missing_manifest_is_sixty_six() {
158        assert_eq!(
159            AppError::ManifestMissing("x/.spec-driven-docs/manifest.json".into()).exit_code(),
160            66
161        );
162    }
163
164    #[test]
165    fn refused_is_seventy_three() {
166        assert_eq!(
167            AppError::Refused("apply aborted; the target was restored".into()).exit_code(),
168            73
169        );
170    }
171
172    #[test]
173    fn not_found_io_is_sixty_six() {
174        assert_eq!(io(std::io::ErrorKind::NotFound).exit_code(), 66);
175    }
176
177    #[test]
178    fn permission_denied_io_is_seventy_seven() {
179        assert_eq!(io(std::io::ErrorKind::PermissionDenied).exit_code(), 77);
180    }
181
182    #[test]
183    fn other_io_is_seventy_four() {
184        assert_eq!(io(std::io::ErrorKind::BrokenPipe).exit_code(), 74);
185    }
186
187    #[test]
188    fn anyhow_is_seventy() {
189        assert_eq!(AppError::Other(anyhow::anyhow!("boom")).exit_code(), 70);
190    }
191}