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    ///
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}