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    /// Another process holds the lock this run needs. Nothing was written.
53    #[error("busy: {0}")]
54    Busy(String),
55
56    /// A run the process did not finish could not be put back, so the
57    /// destinations it names are in an unknown state and no new work may
58    /// start over them.
59    #[error("unrecovered: {0}")]
60    Unrecovered(String),
61
62    /// The apply could not write the record that vouches for what it wrote.
63    /// The files were put back, because a landing this tool cannot vouch
64    /// for is a landing it will refuse to take back.
65    #[error("receipt: {0}")]
66    Receipt(String),
67
68    /// A tracked-upstream lookup failed. The code is the sysexit the failure
69    /// class maps to, chosen where the `git` adapter is called.
70    #[error("git: {message}")]
71    Git {
72        /// The redacted failure reason.
73        message: String,
74        /// The sysexit code for this failure class.
75        code: u8,
76    },
77
78    /// Filesystem failure, classified by its I/O kind.
79    #[error("io: {0}")]
80    Io(#[from] std::io::Error),
81
82    /// Escape hatch for ad-hoc contexts at the binary boundary.
83    #[error(transparent)]
84    Other(#[from] anyhow::Error),
85}
86
87impl AppError {
88    /// Map to the process exit code.
89    ///
90    /// `1` means a completed check found violations; `64..=78` follow BSD
91    /// `sysexits(3)` and mean the tool itself could not do its job.
92    #[must_use]
93    pub fn exit_code(&self) -> u8 {
94        match self {
95            Self::Violations { .. } => 1,
96            Self::Usage(_) => 64,
97            Self::ManifestInvalid(_)
98            | Self::Marker(_)
99            | Self::Debt(
100                DebtError::Shape(_) | DebtError::Malformed { .. } | DebtError::TwoFormats,
101            ) => 65,
102            Self::ManifestMissing(_) => 66,
103            Self::Refused(_)
104            | Self::Busy(_)
105            | Self::Unrecovered(_)
106            | Self::Receipt(_)
107            | Self::Debt(_) => 73,
108            Self::Git { code, .. } => *code,
109            Self::Io(e) if e.kind() == std::io::ErrorKind::NotFound => 66,
110            Self::Io(e) if e.kind() == std::io::ErrorKind::PermissionDenied => 77,
111            Self::Io(_) => 74,
112            Self::Other(_) => 70,
113        }
114    }
115
116    /// Stable machine-readable kind for the error envelope.
117    #[must_use]
118    pub const fn kind(&self) -> &'static str {
119        match self {
120            Self::Usage(_) => "Usage",
121            Self::Violations { .. } => "Violations",
122            Self::ManifestMissing(_) => "ManifestMissing",
123            Self::ManifestInvalid(_) => "ManifestInvalid",
124            Self::Marker(_) => "Marker",
125            Self::Debt(_) => "Debt",
126            Self::Refused(_) => "Refused",
127            Self::Busy(_) => "Busy",
128            Self::Unrecovered(_) => "Unrecovered",
129            Self::Receipt(_) => "Receipt",
130            Self::Git { .. } => "Git",
131            Self::Io(_) => "Io",
132            Self::Other(_) => "Other",
133        }
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    fn io(kind: std::io::ErrorKind) -> AppError {
142        AppError::Io(std::io::Error::from(kind))
143    }
144
145    #[test]
146    fn violations_are_one() {
147        assert_eq!(AppError::Violations { count: 3 }.exit_code(), 1);
148    }
149
150    #[test]
151    fn usage_is_sixty_four() {
152        assert_eq!(AppError::Usage("bad target".into()).exit_code(), 64);
153    }
154
155    #[test]
156    fn invalid_manifest_is_sixty_five() {
157        assert_eq!(
158            AppError::ManifestInvalid("schema_version 3".into()).exit_code(),
159            65
160        );
161    }
162
163    #[test]
164    fn malformed_marker_is_sixty_five() {
165        assert_eq!(AppError::Marker(MarkerError::Malformed).exit_code(), 65);
166    }
167
168    #[test]
169    fn a_malformed_debt_file_is_sixty_five_and_a_refused_debt_verb_is_seventy_three() {
170        assert_eq!(
171            AppError::Debt(DebtError::Shape("not yaml".into())).exit_code(),
172            65
173        );
174        assert_eq!(AppError::Debt(DebtError::TwoFormats).exit_code(), 65);
175        assert_eq!(AppError::Debt(DebtError::AlreadyBaselined).exit_code(), 73);
176        assert_eq!(AppError::Debt(DebtError::NothingToMigrate).exit_code(), 73);
177    }
178
179    #[test]
180    fn missing_manifest_is_sixty_six() {
181        assert_eq!(
182            AppError::ManifestMissing("x/.spec-driven-docs/manifest.json".into()).exit_code(),
183            66
184        );
185    }
186
187    #[test]
188    fn refused_is_seventy_three() {
189        assert_eq!(
190            AppError::Refused("apply aborted; the target was restored".into()).exit_code(),
191            73
192        );
193    }
194
195    #[test]
196    fn not_found_io_is_sixty_six() {
197        assert_eq!(io(std::io::ErrorKind::NotFound).exit_code(), 66);
198    }
199
200    #[test]
201    fn permission_denied_io_is_seventy_seven() {
202        assert_eq!(io(std::io::ErrorKind::PermissionDenied).exit_code(), 77);
203    }
204
205    #[test]
206    fn other_io_is_seventy_four() {
207        assert_eq!(io(std::io::ErrorKind::BrokenPipe).exit_code(), 74);
208    }
209
210    #[test]
211    fn anyhow_is_seventy() {
212        assert_eq!(AppError::Other(anyhow::anyhow!("boom")).exit_code(), 70);
213    }
214}