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 ///
48 /// Also carries a **relayed store rejection** from `dsp vre sparql query`:
49 /// the triplestore's own `4xx` is not dsp-cli's failure to classify, so it
50 /// is reported here with the store's status and its (sanitised, capped)
51 /// message. Exit category `Runtime` (1) — see ADR-0016 / plan 035 D7.
52 #[error("server error: {0}")]
53 ServerError(String),
54
55 /// Could not reach the server.
56 #[error("network error: {0}")]
57 Network(String),
58
59 /// A server-side resource is busy or already exists.
60 ///
61 /// Examples: a dump for this project is already in progress or present;
62 /// a `DELETE` was attempted while the dump was still being produced.
63 ///
64 /// Maps to `ExitCategory::Runtime` (exit code 1).
65 #[error("conflict: {0}")]
66 Conflict(String),
67
68 /// A local filesystem operation that the user explicitly requested failed.
69 ///
70 /// Example: writing or renaming the dump file the user asked for.
71 ///
72 /// **Distinction from `Internal`**: `Internal` wraps unexpected internal
73 /// I/O (renderer writes, plumbing) and is reached via the blanket
74 /// `From<std::io::Error>` impl. `Io` is for user-visible filesystem work
75 /// (e.g. writing the dump output file) and must be constructed *explicitly*
76 /// with a message that includes the target path.
77 ///
78 /// **Warning**: the dump file-write path must never use bare `?` on a
79 /// `std::io::Error`. Bare `?` will hit `From<io::Error>` and mis-classify
80 /// the error as `Internal` instead of `Io`. Always map explicitly:
81 /// `.map_err(|e| Diagnostic::Io(format!("…{path}…: {e}")))`.
82 /// See the scoped helper `stream_dump_to_path` in the action (Step 8).
83 ///
84 /// Maps to `ExitCategory::Runtime` (exit code 1).
85 #[error("io error: {0}")]
86 Io(String),
87
88 /// Unexpected state in dsp-cli itself; should be reported as a bug.
89 #[error("internal error: {0}")]
90 Internal(String),
91
92 /// Placeholder for work-in-progress dispatch paths during Phase 1.
93 #[error("not implemented: {0}")]
94 NotImplemented(String),
95}
96
97/// Bridge from `std::io::Error` so that `?` works inside renderer methods
98/// (and any other function returning `Result<_, Diagnostic>`) without
99/// spelling out `.map_err(|e| Diagnostic::Internal(...))` at every call site.
100impl From<std::io::Error> for Diagnostic {
101 fn from(e: std::io::Error) -> Self {
102 Self::Internal(format!("io error: {e}"))
103 }
104}
105
106impl Diagnostic {
107 pub fn not_implemented(msg: impl Into<String>) -> Self {
108 Self::NotImplemented(msg.into())
109 }
110
111 /// Maps a diagnostic to its exit category (per ADR-0012).
112 pub fn exit_category(&self) -> ExitCategory {
113 match self {
114 Self::Usage(_) => ExitCategory::Usage,
115 Self::AuthRequired(_) => ExitCategory::AuthRequired,
116 Self::NotFound(_)
117 | Self::ServerError(_)
118 | Self::Network(_)
119 | Self::Conflict(_)
120 | Self::Io(_)
121 | Self::Internal(_)
122 | Self::NotImplemented(_) => ExitCategory::Runtime,
123 }
124 }
125}
126
127/// Initialise the global tracing subscriber.
128///
129/// Per ADR-0012: default level WARN; `-v` raises to INFO, `-vv` to DEBUG,
130/// `-vvv` to TRACE; `RUST_LOG` overrides if set. Output goes to stderr.
131pub fn init_tracing(verbose: u8) {
132 let filter = if let Ok(env) = std::env::var("RUST_LOG") {
133 EnvFilter::new(env)
134 } else {
135 let level = match verbose {
136 0 => "warn",
137 1 => "info",
138 2 => "debug",
139 _ => "trace",
140 };
141 EnvFilter::new(level)
142 };
143
144 tracing_subscriber::fmt()
145 .with_env_filter(filter)
146 .with_writer(std::io::stderr)
147 .with_target(false)
148 .compact()
149 .try_init()
150 .ok();
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 #[test]
158 fn usage_maps_to_exit_code_2() {
159 let d = Diagnostic::Usage("missing --server".into());
160 assert_eq!(d.exit_category(), ExitCategory::Usage);
161 }
162
163 #[test]
164 fn auth_required_maps_to_exit_code_3() {
165 let d = Diagnostic::AuthRequired("login first".into());
166 assert_eq!(d.exit_category(), ExitCategory::AuthRequired);
167 }
168
169 #[test]
170 fn runtime_errors_map_to_exit_code_1() {
171 // This list must cover every Diagnostic variant that maps to Runtime so
172 // that adding a new Runtime variant without updating this test fails CI.
173 for d in [
174 Diagnostic::NotFound("x".into()),
175 Diagnostic::ServerError("x".into()),
176 Diagnostic::Network("x".into()),
177 Diagnostic::Internal("x".into()),
178 Diagnostic::Conflict("x".into()),
179 Diagnostic::Io("x".into()),
180 Diagnostic::NotImplemented("x".into()),
181 ] {
182 assert_eq!(
183 d.exit_category(),
184 ExitCategory::Runtime,
185 "{d:?} must map to Runtime"
186 );
187 }
188 }
189
190 #[test]
191 fn not_implemented_maps_to_exit_code_1() {
192 let d = Diagnostic::not_implemented("vre project list");
193 assert_eq!(d.exit_category(), ExitCategory::Runtime);
194 }
195
196 #[test]
197 fn diagnostic_is_clone() {
198 // `Clone` is derived so mock test helpers can hand out Result<_, Diagnostic>
199 // values repeatedly. If any variant loses Clone this test catches it.
200 let original = Diagnostic::AuthRequired("test".into());
201 let cloned = original.clone();
202 assert_eq!(format!("{original}"), format!("{cloned}"));
203 }
204
205 #[test]
206 fn conflict_maps_to_runtime() {
207 let d = Diagnostic::Conflict("dump already in progress".into());
208 assert_eq!(d.exit_category(), ExitCategory::Runtime);
209 }
210
211 #[test]
212 fn io_maps_to_runtime() {
213 let d = Diagnostic::Io("failed to write /tmp/0001.zip: permission denied".into());
214 assert_eq!(d.exit_category(), ExitCategory::Runtime);
215 }
216
217 #[test]
218 fn from_io_error_yields_internal() {
219 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
220 let diag = Diagnostic::from(io_err);
221 assert!(matches!(diag, Diagnostic::Internal(_)));
222 assert!(diag.to_string().contains("io error"));
223 }
224}