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