Skip to main content

tardis_cli/
errors.rs

1//! Centralised error and exit‑handling utilities for the **TARDIS** CLI.
2//!
3//! This module provides a single [`Error`] enum that groups together all
4//! *user* and *system* failures plus two convenience macros for constructing
5//! those errors ergonomically.  [`Error::exit`] maps any error value to an
6//! appropriate process exit code.  All public items live behind concise
7//! documentation so that generated docs.rs output remains immediately useful
8//! without excessive inline comments.
9
10/// POSIX sysexits-compatible exit codes.
11const EX_USAGE: i32 = 64;
12const EX_IOERR: i32 = 74;
13const EX_CONFIG: i32 = 78;
14
15/// All possible failures surfaced by the CLI.
16#[non_exhaustive]
17#[derive(thiserror::Error, Debug, PartialEq, Eq)]
18pub enum Error {
19    /// Problems attributable to the user (bad flags, invalid input, …).
20    #[error(transparent)]
21    UserInput(#[from] UserInputError),
22    /// Issues the user cannot fix without changing the environment
23    /// (config corruption, I/O failures, …).
24    #[error(transparent)]
25    System(#[from] SystemError),
26}
27
28/// Human‑error variants.
29#[non_exhaustive]
30#[derive(thiserror::Error, Debug, PartialEq, Eq)]
31pub enum UserInputError {
32    #[error("Invalid date format: {0}")]
33    InvalidDateFormat(String),
34    #[error("Unsupported format: {0}")]
35    UnsupportedFormat(String),
36    #[error("Invalid date: {0}")]
37    InvalidDate(String),
38    #[error("Ambiguous datetime: {0}")]
39    AmbiguousDateTime(String),
40    #[error("Unsupported timezone: {0}")]
41    UnsupportedTimezone(String),
42    #[error("Invalid 'now' argument: {0}")]
43    InvalidNow(String),
44    #[error("Missing required argument: {0}")]
45    MissingArgument(String),
46}
47
48/// Failures that stem from the operating environment or runtime.
49#[non_exhaustive]
50#[derive(thiserror::Error, Debug)]
51pub enum SystemError {
52    #[error("Configuration error: {0}")]
53    Config(String),
54    #[error("IO error: {0}")]
55    Io(#[from] std::io::Error),
56}
57
58/// Crate‑wide `Result` alias that uses the consolidated [`Error`] type.
59pub type Result<T> = std::result::Result<T, Error>;
60
61impl Error {
62    /// Print a diagnostic message to stderr and exit with the appropriate code.
63    pub fn exit(self) -> ! {
64        match self {
65            Error::UserInput(err) => {
66                eprintln!("{}", colorize_suggestion(&format!("{err}")));
67                std::process::exit(EX_USAGE);
68            }
69            Error::System(err) => {
70                eprintln!("System error: {}", err);
71
72                match err {
73                    SystemError::Config(_) => std::process::exit(EX_CONFIG),
74                    SystemError::Io(_) => std::process::exit(EX_IOERR),
75                }
76            }
77        }
78    }
79}
80
81/// Apply yellow ANSI coloring to the suggested word in "Did you mean '...'?" messages.
82/// Only colorizes when stderr is a terminal and NO_COLOR is not set.
83fn colorize_suggestion(msg: &str) -> String {
84    use std::io::IsTerminal;
85
86    if !std::io::stderr().is_terminal() || std::env::var("NO_COLOR").is_ok() {
87        return msg.to_string();
88    }
89
90    if let Some(start) = msg.find("Did you mean '") {
91        let prefix_end = start + "Did you mean '".len();
92        if let Some(end) = msg[prefix_end..].find("'?") {
93            let word = &msg[prefix_end..prefix_end + end];
94            return format!(
95                "{}Did you mean '\x1b[33m{}\x1b[0m'?{}",
96                &msg[..start],
97                word,
98                &msg[prefix_end + end + 2..],
99            );
100        }
101    }
102    msg.to_string()
103}
104
105impl PartialEq for SystemError {
106    fn eq(&self, other: &Self) -> bool {
107        use SystemError::*;
108        match (self, other) {
109            (Config(a), Config(b)) => a == b,
110            (Io(a), Io(b)) => a.kind() == b.kind(),
111            _ => false,
112        }
113    }
114}
115
116impl Eq for SystemError {}
117
118/// Create an [`Error::UserInput`] of the requested variant with minimal boilerplate.
119#[macro_export]
120macro_rules! user_input_error {
121    ($err_type:ident, $msg:expr) => {
122        $crate::errors::Error::UserInput($crate::errors::UserInputError::$err_type($msg.to_string()))
123    };
124
125    ($err_type:ident, $($arg:tt)*) => {
126        $crate::errors::Error::UserInput($crate::errors::UserInputError::$err_type(format!($($arg)*)))
127    };
128
129    ($err_type:ident) => {
130        $crate::errors::Error::UserInput($crate::errors::UserInputError::$err_type(String::new()))
131    };
132}
133
134/// Create an [`Error::System`] of the requested variant with minimal boilerplate.
135#[macro_export]
136macro_rules! system_error {
137    ($err_type:ident, $msg:expr) => {
138        $crate::errors::Error::System($crate::errors::SystemError::$err_type($msg.to_string()))
139    };
140    ($err_type:ident, $($arg:tt)*) => {
141        $crate::errors::Error::System($crate::errors::SystemError::$err_type(format!($($arg)*)))
142    };
143    ($err_type:ident) => {
144        $crate::errors::Error::System($crate::errors::SystemError::$err_type(String::new()))
145    };
146}
147
148#[cfg(test)]
149mod tests {
150    #![allow(clippy::unwrap_used, clippy::expect_used)]
151
152    use super::*;
153
154    #[test]
155    fn user_input_macro_literal() {
156        let err = user_input_error!(InvalidDateFormat, "foo");
157        assert!(matches!(
158            err,
159            Error::UserInput(UserInputError::InvalidDateFormat(ref s)) if s == "foo"
160        ));
161    }
162
163    #[test]
164    fn user_input_macro_formatted() {
165        let err = user_input_error!(MissingArgument, "missing {}", "--format");
166        assert!(matches!(
167            err,
168            Error::UserInput(UserInputError::MissingArgument(ref s)) if s == "missing --format"
169        ));
170    }
171
172    #[test]
173    fn user_input_macro_empty() {
174        let err = user_input_error!(InvalidNow);
175        assert!(matches!(
176            err,
177            Error::UserInput(UserInputError::InvalidNow(ref s)) if s.is_empty()
178        ));
179    }
180
181    #[test]
182    fn system_error_macro_literal() {
183        let err = system_error!(Config, "invalid field");
184        assert!(matches!(
185            err,
186            Error::System(SystemError::Config(ref s)) if s == "invalid field"
187        ));
188    }
189
190    #[test]
191    fn system_error_macro_formatted() {
192        let err = system_error!(Config, "failed to read {}", "/tmp/foo");
193        assert!(matches!(
194            err,
195            Error::System(SystemError::Config(ref s)) if s == "failed to read /tmp/foo"
196        ));
197    }
198
199    #[test]
200    fn system_error_macro_empty() {
201        let err = system_error!(Config);
202        assert!(matches!(
203            err,
204            Error::System(SystemError::Config(ref s)) if s.is_empty()
205        ));
206    }
207
208    #[test]
209    fn unsupported_format_error() {
210        let err = user_input_error!(UnsupportedFormat, "bad format");
211        assert!(matches!(
212            err,
213            Error::UserInput(UserInputError::UnsupportedFormat(ref s)) if s == "bad format"
214        ));
215    }
216
217    #[test]
218    fn conversion_from_io_error() {
219        let err: Error = std::io::Error::from(std::io::ErrorKind::PermissionDenied).into();
220        assert!(matches!(err, Error::System(SystemError::Io(_))));
221    }
222
223    #[test]
224    fn system_error_partial_eq_config() {
225        let a = SystemError::Config("x".into());
226        let b = SystemError::Config("x".into());
227        let c = SystemError::Config("y".into());
228        assert_eq!(a, b);
229        assert_ne!(a, c);
230    }
231
232    #[test]
233    fn system_error_partial_eq_io() {
234        let a = SystemError::Io(std::io::Error::from(std::io::ErrorKind::NotFound));
235        let b = SystemError::Io(std::io::Error::from(std::io::ErrorKind::NotFound));
236        let c = SystemError::Io(std::io::Error::from(std::io::ErrorKind::PermissionDenied));
237        assert_eq!(a, b);
238        assert_ne!(a, c);
239    }
240
241    #[test]
242    fn system_error_partial_eq_different_variants() {
243        let a = SystemError::Config("x".into());
244        let b = SystemError::Io(std::io::Error::from(std::io::ErrorKind::NotFound));
245        assert_ne!(a, b);
246    }
247
248    #[test]
249    fn error_display_user_input() {
250        let err = user_input_error!(InvalidDateFormat, "bad date");
251        assert_eq!(format!("{err}"), "Invalid date format: bad date");
252    }
253
254    #[test]
255    fn error_display_system() {
256        let err = system_error!(Config, "broken");
257        assert_eq!(format!("{err}"), "Configuration error: broken");
258    }
259
260    #[test]
261    fn new_error_variants_display() {
262        let err = user_input_error!(InvalidDate, "bad");
263        assert_eq!(format!("{err}"), "Invalid date: bad");
264
265        let err = user_input_error!(AmbiguousDateTime, "ambiguous");
266        assert_eq!(format!("{err}"), "Ambiguous datetime: ambiguous");
267    }
268}