markdown_org_extract/
error.rs1use std::fmt;
8use std::io;
9
10#[derive(Debug)]
12pub enum AppError {
13 Io {
19 context: String,
24 source: io::Error,
26 },
27 InvalidDirectory(String),
29 InvalidGlob(String),
31 InvalidDate(String),
33 InvalidTimezone(String),
35 InvalidOutput(String),
37 DateRange(String),
44 Serialization(String),
46 Regex(String),
48}
49
50impl fmt::Display for AppError {
51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52 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 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 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 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 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 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 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}