Skip to main content

dsp_cli/
diagnostic.rs

1//! Diagnostics — errors, exit codes, and logging setup. See ADR-0012.
2//!
3//! `Diagnostic` is the library-level error type. Each variant carries a stable
4//! `kind` mapped via `exit_category()` to one of the four `ExitCategory`
5//! values that the binary turns into a process exit code.
6
7use thiserror::Error;
8use tracing_subscriber::EnvFilter;
9
10/// Process exit categories. See the table in ADR-0012.
11#[repr(u8)]
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ExitCategory {
14    /// Command ran successfully.
15    Success = 0,
16
17    /// Runtime error: HTTP failure, deserialisation, I/O, server 5xx, …
18    Runtime = 1,
19
20    /// Usage error: bad flag, missing argument, unknown identifier.
21    Usage = 2,
22
23    /// Authentication required: endpoint demands auth or token rejected.
24    AuthRequired = 3,
25}
26
27/// Library error type. Stable enum variants per ADR-0012.
28///
29/// `Clone` is derived so that mock test helpers can hand out
30/// `Result<_, Diagnostic>` values repeatedly without consuming them.
31/// All current variants hold `String`, which is `Clone`.
32#[derive(Debug, Clone, Error)]
33pub enum Diagnostic {
34    /// Bad input from the caller — bad flag, missing argument, unparseable id.
35    #[error("usage error: {0}")]
36    Usage(String),
37
38    /// The server demanded auth and none was supplied (or the token was rejected).
39    #[error("authentication required: {0}")]
40    AuthRequired(String),
41
42    /// The named project / data-model / resource-type does not exist.
43    #[error("not found: {0}")]
44    NotFound(String),
45
46    /// The server responded with a 5xx or an unparseable response.
47    #[error("server error: {0}")]
48    ServerError(String),
49
50    /// Could not reach the server.
51    #[error("network error: {0}")]
52    Network(String),
53
54    /// A server-side resource is busy or already exists.
55    ///
56    /// Examples: a dump for this project is already in progress or present;
57    /// a `DELETE` was attempted while the dump was still being produced.
58    ///
59    /// Maps to `ExitCategory::Runtime` (exit code 1).
60    #[error("conflict: {0}")]
61    Conflict(String),
62
63    /// A local filesystem operation that the user explicitly requested failed.
64    ///
65    /// Example: writing or renaming the dump file the user asked for.
66    ///
67    /// **Distinction from `Internal`**: `Internal` wraps unexpected internal
68    /// I/O (renderer writes, plumbing) and is reached via the blanket
69    /// `From<std::io::Error>` impl. `Io` is for user-visible filesystem work
70    /// (e.g. writing the dump output file) and must be constructed *explicitly*
71    /// with a message that includes the target path.
72    ///
73    /// **Warning**: the dump file-write path must never use bare `?` on a
74    /// `std::io::Error`. Bare `?` will hit `From<io::Error>` and mis-classify
75    /// the error as `Internal` instead of `Io`. Always map explicitly:
76    /// `.map_err(|e| Diagnostic::Io(format!("…{path}…: {e}")))`.
77    /// See the scoped helper `stream_dump_to_path` in the action (Step 8).
78    ///
79    /// Maps to `ExitCategory::Runtime` (exit code 1).
80    #[error("io error: {0}")]
81    Io(String),
82
83    /// Unexpected state in dsp-cli itself; should be reported as a bug.
84    #[error("internal error: {0}")]
85    Internal(String),
86
87    /// Placeholder for work-in-progress dispatch paths during Phase 1.
88    #[error("not implemented: {0}")]
89    NotImplemented(String),
90}
91
92/// Bridge from `std::io::Error` so that `?` works inside renderer methods
93/// (and any other function returning `Result<_, Diagnostic>`) without
94/// spelling out `.map_err(|e| Diagnostic::Internal(...))` at every call site.
95impl From<std::io::Error> for Diagnostic {
96    fn from(e: std::io::Error) -> Self {
97        Self::Internal(format!("io error: {e}"))
98    }
99}
100
101impl Diagnostic {
102    pub fn not_implemented(msg: impl Into<String>) -> Self {
103        Self::NotImplemented(msg.into())
104    }
105
106    /// Maps a diagnostic to its exit category (per ADR-0012).
107    pub fn exit_category(&self) -> ExitCategory {
108        match self {
109            Self::Usage(_) => ExitCategory::Usage,
110            Self::AuthRequired(_) => ExitCategory::AuthRequired,
111            Self::NotFound(_)
112            | Self::ServerError(_)
113            | Self::Network(_)
114            | Self::Conflict(_)
115            | Self::Io(_)
116            | Self::Internal(_)
117            | Self::NotImplemented(_) => ExitCategory::Runtime,
118        }
119    }
120}
121
122/// Initialise the global tracing subscriber.
123///
124/// Per ADR-0012: default level WARN; `-v` raises to INFO, `-vv` to DEBUG,
125/// `-vvv` to TRACE; `RUST_LOG` overrides if set. Output goes to stderr.
126pub fn init_tracing(verbose: u8) {
127    let filter = if let Ok(env) = std::env::var("RUST_LOG") {
128        EnvFilter::new(env)
129    } else {
130        let level = match verbose {
131            0 => "warn",
132            1 => "info",
133            2 => "debug",
134            _ => "trace",
135        };
136        EnvFilter::new(level)
137    };
138
139    tracing_subscriber::fmt()
140        .with_env_filter(filter)
141        .with_writer(std::io::stderr)
142        .with_target(false)
143        .compact()
144        .try_init()
145        .ok();
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn usage_maps_to_exit_code_2() {
154        let d = Diagnostic::Usage("missing --server".into());
155        assert_eq!(d.exit_category(), ExitCategory::Usage);
156    }
157
158    #[test]
159    fn auth_required_maps_to_exit_code_3() {
160        let d = Diagnostic::AuthRequired("login first".into());
161        assert_eq!(d.exit_category(), ExitCategory::AuthRequired);
162    }
163
164    #[test]
165    fn runtime_errors_map_to_exit_code_1() {
166        // This list must cover every Diagnostic variant that maps to Runtime so
167        // that adding a new Runtime variant without updating this test fails CI.
168        for d in [
169            Diagnostic::NotFound("x".into()),
170            Diagnostic::ServerError("x".into()),
171            Diagnostic::Network("x".into()),
172            Diagnostic::Internal("x".into()),
173            Diagnostic::Conflict("x".into()),
174            Diagnostic::Io("x".into()),
175            Diagnostic::NotImplemented("x".into()),
176        ] {
177            assert_eq!(
178                d.exit_category(),
179                ExitCategory::Runtime,
180                "{d:?} must map to Runtime"
181            );
182        }
183    }
184
185    #[test]
186    fn not_implemented_maps_to_exit_code_1() {
187        let d = Diagnostic::not_implemented("vre project list");
188        assert_eq!(d.exit_category(), ExitCategory::Runtime);
189    }
190
191    #[test]
192    fn diagnostic_is_clone() {
193        // `Clone` is derived so mock test helpers can hand out Result<_, Diagnostic>
194        // values repeatedly. If any variant loses Clone this test catches it.
195        let original = Diagnostic::AuthRequired("test".into());
196        let cloned = original.clone();
197        assert_eq!(format!("{original}"), format!("{cloned}"));
198    }
199
200    #[test]
201    fn conflict_maps_to_runtime() {
202        let d = Diagnostic::Conflict("dump already in progress".into());
203        assert_eq!(d.exit_category(), ExitCategory::Runtime);
204    }
205
206    #[test]
207    fn io_maps_to_runtime() {
208        let d = Diagnostic::Io("failed to write /tmp/0001.zip: permission denied".into());
209        assert_eq!(d.exit_category(), ExitCategory::Runtime);
210    }
211
212    #[test]
213    fn from_io_error_yields_internal() {
214        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
215        let diag = Diagnostic::from(io_err);
216        assert!(matches!(diag, Diagnostic::Internal(_)));
217        assert!(diag.to_string().contains("io error"));
218    }
219}