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}