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    /// A date window that cannot be built as asked: `--from` after `--to`, a
38    /// window argument in a scope that has no window (`--agenda tasks`), or a
39    /// first day of the week the scope cannot draw (`--agenda month-grid`
40    /// with `--week-start today`). Named after its first use; the variant is
41    /// public API, so the wider meaning is documented here rather than split
42    /// into a new one.
43    DateRange(String),
44    /// JSON or other serializer reported an error
45    Serialization(String),
46    /// Regex compilation failed
47    Regex(String),
48}
49
50impl fmt::Display for AppError {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        // The CLI prepends `error: ` to whatever this returns. Avoid adding a
53        // second category prefix when the inner `msg` is already a complete
54        // sentence; for opaque variants (Io / Regex / Serialization /
55        // InvalidTimezone) keep a short lowercase tag so output stays grepable.
56        match self {
57            AppError::Io { context, source } => write!(f, "io: {context}: {source}"),
58            AppError::InvalidDirectory(msg) => write!(f, "{msg}"),
59            AppError::InvalidGlob(msg) => write!(f, "{msg}"),
60            AppError::InvalidDate(msg) => write!(f, "{msg}"),
61            AppError::InvalidTimezone(tz) => write!(f, "invalid timezone: {tz}"),
62            AppError::InvalidOutput(msg) => write!(f, "{msg}"),
63            AppError::DateRange(msg) => write!(f, "{msg}"),
64            AppError::Serialization(msg) => write!(f, "serialization: {msg}"),
65            AppError::Regex(msg) => write!(f, "regex: {msg}"),
66        }
67    }
68}
69
70impl AppError {
71    /// Process exit code that classifies this error category.
72    ///
73    /// - `2`  -- usage / input-validation failures the user can correct by
74    ///   changing CLI arguments (matches clap's own argument-error exit).
75    /// - `74` -- IO failures (`EX_IOERR` from `sysexits.h`): unreadable files,
76    ///   write failures.
77    /// - `70` -- internal software errors (`EX_SOFTWARE`): a regex we built
78    ///   ourselves did not compile, or our own serializer failed.
79    pub fn exit_code(&self) -> i32 {
80        match self {
81            AppError::InvalidDirectory(_)
82            | AppError::InvalidGlob(_)
83            | AppError::InvalidDate(_)
84            | AppError::InvalidTimezone(_)
85            | AppError::InvalidOutput(_)
86            | AppError::DateRange(_) => 2,
87            AppError::Io { .. } => 74,
88            AppError::Regex(_) | AppError::Serialization(_) => 70,
89        }
90    }
91
92    /// Construct an `AppError::Io` while preserving the underlying source.
93    ///
94    /// The `context` is a free-form label printed by `Display`: prefer the
95    /// real filesystem path when one is available (`p.display()`), fall back
96    /// to the sentinel `<stdout>` / `<stderr>` for the standard streams.
97    /// Use this in place of `?` on `io::Error` — the blanket `From` was
98    /// removed precisely so that no IO failure can sneak through without a
99    /// caller-supplied location.
100    pub fn io(context: impl Into<String>, source: io::Error) -> Self {
101        AppError::Io {
102            context: context.into(),
103            source,
104        }
105    }
106}
107
108impl std::error::Error for AppError {
109    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
110        // Only Io currently carries a wrapped source. Surfacing it lets
111        // downstream tooling (anyhow, log formatters) walk the chain and
112        // print the underlying OS error verbatim — important for diagnosing
113        // EACCES / ENOSPC / EROFS where the raw errno text is the most
114        // useful signal.
115        match self {
116            AppError::Io { source, .. } => Some(source),
117            _ => None,
118        }
119    }
120}
121
122impl From<serde_json::Error> for AppError {
123    fn from(err: serde_json::Error) -> Self {
124        AppError::Serialization(err.to_string())
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use std::io::{self, ErrorKind};
132
133    #[test]
134    fn display_invalid_directory() {
135        let e = AppError::InvalidDirectory("directory does not exist: /no/such".into());
136        assert_eq!(e.to_string(), "directory does not exist: /no/such");
137    }
138
139    #[test]
140    fn display_invalid_glob() {
141        let e = AppError::InvalidGlob("invalid pattern '[': ...".into());
142        assert_eq!(e.to_string(), "invalid pattern '[': ...");
143    }
144
145    #[test]
146    fn display_invalid_timezone() {
147        assert_eq!(
148            AppError::InvalidTimezone("X".into()).to_string(),
149            "invalid timezone: X"
150        );
151    }
152
153    #[test]
154    fn display_invalid_output() {
155        assert_eq!(
156            AppError::InvalidOutput("refusing to overwrite symlink: /tmp/foo".into()).to_string(),
157            "refusing to overwrite symlink: /tmp/foo"
158        );
159    }
160
161    #[test]
162    fn display_date_range() {
163        assert_eq!(
164            AppError::DateRange("from > to".into()).to_string(),
165            "from > to"
166        );
167    }
168
169    #[test]
170    fn io_constructor_preserves_context_and_source() {
171        // `AppError::io` is the only path that produces the Io variant now
172        // (the blanket From<io::Error> was removed). Both the context label
173        // and the underlying source must round-trip without loss — context
174        // in Display and source via std::error::Error::source().
175        use std::error::Error as _;
176        let io_err = io::Error::new(ErrorKind::NotFound, "missing");
177        let e = AppError::io("/tmp/out.json", io_err);
178        assert!(matches!(e, AppError::Io { .. }));
179        let msg = e.to_string();
180        assert!(msg.starts_with("io: "), "got: {msg}");
181        assert!(msg.contains("/tmp/out.json"), "got: {msg}");
182        assert!(msg.contains("missing"), "got: {msg}");
183        let src = e.source().expect("source should be set for Io");
184        assert!(src.to_string().contains("missing"));
185    }
186
187    #[test]
188    fn source_returns_none_for_non_io_variants() {
189        // Only Io currently chains a source. Pin the contract so a future
190        // refactor that adds source() for other variants doesn't ship
191        // accidentally — every new chained-source variant deserves a test
192        // here and a CHANGELOG line.
193        use std::error::Error as _;
194        assert!(AppError::InvalidDirectory("x".into()).source().is_none());
195        assert!(AppError::Regex("x".into()).source().is_none());
196        assert!(AppError::Serialization("x".into()).source().is_none());
197    }
198
199    #[test]
200    fn from_serde_json_error_wraps() {
201        let parsed: Result<serde_json::Value, _> = serde_json::from_str("not json");
202        let e: AppError = parsed.unwrap_err().into();
203        assert!(matches!(e, AppError::Serialization(_)));
204        assert!(e.to_string().starts_with("serialization: "));
205    }
206
207    #[test]
208    fn errors_are_send_sync() {
209        // Compile-time check that AppError can flow across threads — matters if
210        // we ever spawn worker threads (e.g. for parallel walker).
211        fn is_send_sync<T: Send + Sync>() {}
212        is_send_sync::<AppError>();
213    }
214
215    #[test]
216    fn exit_code_usage_errors_return_2() {
217        assert_eq!(
218            AppError::InvalidDirectory("x".into()).exit_code(),
219            2,
220            "InvalidDirectory is a usage error and must map to exit 2"
221        );
222        assert_eq!(AppError::InvalidGlob("x".into()).exit_code(), 2);
223        assert_eq!(AppError::InvalidDate("x".into()).exit_code(), 2);
224        assert_eq!(AppError::InvalidTimezone("x".into()).exit_code(), 2);
225        assert_eq!(AppError::InvalidOutput("x".into()).exit_code(), 2);
226        assert_eq!(AppError::DateRange("x".into()).exit_code(), 2);
227    }
228
229    #[test]
230    fn exit_code_io_returns_74() {
231        let io = io::Error::new(ErrorKind::NotFound, "missing");
232        assert_eq!(
233            AppError::io("/tmp/x", io).exit_code(),
234            74,
235            "Io maps to EX_IOERR (74) from sysexits.h"
236        );
237    }
238
239    #[test]
240    fn exit_code_software_errors_return_70() {
241        assert_eq!(
242            AppError::Regex("x".into()).exit_code(),
243            70,
244            "Regex compile failure is an internal software error (EX_SOFTWARE = 70)"
245        );
246        assert_eq!(AppError::Serialization("x".into()).exit_code(), 70);
247    }
248}