Skip to main content

markdown_org_extract/
error.rs

1//! The single error type returned by the library.
2//!
3//! Every fallible entry point yields [`AppError`], so an embedder matches on
4//! one enum instead of the several error types the internals use. The binary
5//! maps the same variants to exit codes.
6
7use std::fmt;
8use std::io;
9
10/// Application error. Wraps IO and validation failures encountered by the CLI.
11#[derive(Debug)]
12pub enum AppError {
13    /// Underlying IO error (file read, write, etc.) with the path or
14    /// context label that triggered it. Use the `AppError::io` constructor;
15    /// the blanket `From<io::Error>` is intentionally absent because losing
16    /// the path on every `?` is exactly what this variant was reshaped to
17    /// prevent.
18    Io {
19        /// Path or sentinel that identifies *what* failed (e.g.
20        /// `/tmp/out.json`, `<stdout>`). Embedded in `Display`; the
21        /// underlying `io::Error` is exposed through `Error::source()`
22        /// so callers using `anyhow`-style chaining see both layers.
23        context: String,
24        /// The failure as reported by the operating system.
25        source: io::Error,
26    },
27    /// `--dir` does not exist or is not a directory
28    InvalidDirectory(String),
29    /// `--glob` pattern is malformed or uses an unsupported feature
30    InvalidGlob(String),
31    /// CLI date argument is not parseable as YYYY-MM-DD
32    InvalidDate(String),
33    /// `--tz` is not a valid IANA timezone
34    InvalidTimezone(String),
35    /// `--output` path is unsafe (missing parent, symlink, etc.)
36    InvalidOutput(String),
37    /// `--from` and `--to` form an invalid range
38    DateRange(String),
39    /// JSON or other serializer reported an error
40    Serialization(String),
41    /// Regex compilation failed
42    Regex(String),
43}
44
45impl fmt::Display for AppError {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        // The CLI prepends `error: ` to whatever this returns. Avoid adding a
48        // second category prefix when the inner `msg` is already a complete
49        // sentence; for opaque variants (Io / Regex / Serialization /
50        // InvalidTimezone) keep a short lowercase tag so output stays grepable.
51        match self {
52            AppError::Io { context, source } => write!(f, "io: {context}: {source}"),
53            AppError::InvalidDirectory(msg) => write!(f, "{msg}"),
54            AppError::InvalidGlob(msg) => write!(f, "{msg}"),
55            AppError::InvalidDate(msg) => write!(f, "{msg}"),
56            AppError::InvalidTimezone(tz) => write!(f, "invalid timezone: {tz}"),
57            AppError::InvalidOutput(msg) => write!(f, "{msg}"),
58            AppError::DateRange(msg) => write!(f, "{msg}"),
59            AppError::Serialization(msg) => write!(f, "serialization: {msg}"),
60            AppError::Regex(msg) => write!(f, "regex: {msg}"),
61        }
62    }
63}
64
65impl AppError {
66    /// Process exit code that classifies this error category.
67    ///
68    /// - `2`  -- usage / input-validation failures the user can correct by
69    ///   changing CLI arguments (matches clap's own argument-error exit).
70    /// - `74` -- IO failures (`EX_IOERR` from `sysexits.h`): unreadable files,
71    ///   write failures.
72    /// - `70` -- internal software errors (`EX_SOFTWARE`): a regex we built
73    ///   ourselves did not compile, or our own serializer failed.
74    pub fn exit_code(&self) -> i32 {
75        match self {
76            AppError::InvalidDirectory(_)
77            | AppError::InvalidGlob(_)
78            | AppError::InvalidDate(_)
79            | AppError::InvalidTimezone(_)
80            | AppError::InvalidOutput(_)
81            | AppError::DateRange(_) => 2,
82            AppError::Io { .. } => 74,
83            AppError::Regex(_) | AppError::Serialization(_) => 70,
84        }
85    }
86
87    /// Construct an `AppError::Io` while preserving the underlying source.
88    ///
89    /// The `context` is a free-form label printed by `Display`: prefer the
90    /// real filesystem path when one is available (`p.display()`), fall back
91    /// to the sentinel `<stdout>` / `<stderr>` for the standard streams.
92    /// Use this in place of `?` on `io::Error` — the blanket `From` was
93    /// removed precisely so that no IO failure can sneak through without a
94    /// caller-supplied location.
95    pub fn io(context: impl Into<String>, source: io::Error) -> Self {
96        AppError::Io {
97            context: context.into(),
98            source,
99        }
100    }
101}
102
103impl std::error::Error for AppError {
104    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
105        // Only Io currently carries a wrapped source. Surfacing it lets
106        // downstream tooling (anyhow, log formatters) walk the chain and
107        // print the underlying OS error verbatim — important for diagnosing
108        // EACCES / ENOSPC / EROFS where the raw errno text is the most
109        // useful signal.
110        match self {
111            AppError::Io { source, .. } => Some(source),
112            _ => None,
113        }
114    }
115}
116
117impl From<serde_json::Error> for AppError {
118    fn from(err: serde_json::Error) -> Self {
119        AppError::Serialization(err.to_string())
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use std::io::{self, ErrorKind};
127
128    #[test]
129    fn display_invalid_directory() {
130        let e = AppError::InvalidDirectory("directory does not exist: /no/such".into());
131        assert_eq!(e.to_string(), "directory does not exist: /no/such");
132    }
133
134    #[test]
135    fn display_invalid_glob() {
136        let e = AppError::InvalidGlob("invalid pattern '[': ...".into());
137        assert_eq!(e.to_string(), "invalid pattern '[': ...");
138    }
139
140    #[test]
141    fn display_invalid_timezone() {
142        assert_eq!(
143            AppError::InvalidTimezone("X".into()).to_string(),
144            "invalid timezone: X"
145        );
146    }
147
148    #[test]
149    fn display_invalid_output() {
150        assert_eq!(
151            AppError::InvalidOutput("refusing to overwrite symlink: /tmp/foo".into()).to_string(),
152            "refusing to overwrite symlink: /tmp/foo"
153        );
154    }
155
156    #[test]
157    fn display_date_range() {
158        assert_eq!(
159            AppError::DateRange("from > to".into()).to_string(),
160            "from > to"
161        );
162    }
163
164    #[test]
165    fn io_constructor_preserves_context_and_source() {
166        // `AppError::io` is the only path that produces the Io variant now
167        // (the blanket From<io::Error> was removed). Both the context label
168        // and the underlying source must round-trip without loss — context
169        // in Display and source via std::error::Error::source().
170        use std::error::Error as _;
171        let io_err = io::Error::new(ErrorKind::NotFound, "missing");
172        let e = AppError::io("/tmp/out.json", io_err);
173        assert!(matches!(e, AppError::Io { .. }));
174        let msg = e.to_string();
175        assert!(msg.starts_with("io: "), "got: {msg}");
176        assert!(msg.contains("/tmp/out.json"), "got: {msg}");
177        assert!(msg.contains("missing"), "got: {msg}");
178        let src = e.source().expect("source should be set for Io");
179        assert!(src.to_string().contains("missing"));
180    }
181
182    #[test]
183    fn source_returns_none_for_non_io_variants() {
184        // Only Io currently chains a source. Pin the contract so a future
185        // refactor that adds source() for other variants doesn't ship
186        // accidentally — every new chained-source variant deserves a test
187        // here and a CHANGELOG line.
188        use std::error::Error as _;
189        assert!(AppError::InvalidDirectory("x".into()).source().is_none());
190        assert!(AppError::Regex("x".into()).source().is_none());
191        assert!(AppError::Serialization("x".into()).source().is_none());
192    }
193
194    #[test]
195    fn from_serde_json_error_wraps() {
196        let parsed: Result<serde_json::Value, _> = serde_json::from_str("not json");
197        let e: AppError = parsed.unwrap_err().into();
198        assert!(matches!(e, AppError::Serialization(_)));
199        assert!(e.to_string().starts_with("serialization: "));
200    }
201
202    #[test]
203    fn errors_are_send_sync() {
204        // Compile-time check that AppError can flow across threads — matters if
205        // we ever spawn worker threads (e.g. for parallel walker).
206        fn is_send_sync<T: Send + Sync>() {}
207        is_send_sync::<AppError>();
208    }
209
210    #[test]
211    fn exit_code_usage_errors_return_2() {
212        assert_eq!(
213            AppError::InvalidDirectory("x".into()).exit_code(),
214            2,
215            "InvalidDirectory is a usage error and must map to exit 2"
216        );
217        assert_eq!(AppError::InvalidGlob("x".into()).exit_code(), 2);
218        assert_eq!(AppError::InvalidDate("x".into()).exit_code(), 2);
219        assert_eq!(AppError::InvalidTimezone("x".into()).exit_code(), 2);
220        assert_eq!(AppError::InvalidOutput("x".into()).exit_code(), 2);
221        assert_eq!(AppError::DateRange("x".into()).exit_code(), 2);
222    }
223
224    #[test]
225    fn exit_code_io_returns_74() {
226        let io = io::Error::new(ErrorKind::NotFound, "missing");
227        assert_eq!(
228            AppError::io("/tmp/x", io).exit_code(),
229            74,
230            "Io maps to EX_IOERR (74) from sysexits.h"
231        );
232    }
233
234    #[test]
235    fn exit_code_software_errors_return_70() {
236        assert_eq!(
237            AppError::Regex("x".into()).exit_code(),
238            70,
239            "Regex compile failure is an internal software error (EX_SOFTWARE = 70)"
240        );
241        assert_eq!(AppError::Serialization("x".into()).exit_code(), 70);
242    }
243}