Skip to main content

aperture_cli/cli/
errors.rs

1//! Error display formatting for the CLI.
2//!
3//! Error output is written directly to `stderr` rather than routed through
4//! `tracing`. A tracing subscriber may suppress output depending on the
5//! configured log level and would add unwanted structure (timestamps, targets,
6//! etc.) to user-facing messages.
7//!
8//! The formatting logic lives in `write_error<W: Write>`, which accepts an
9//! arbitrary writer so tests can capture output without redirecting the
10//! process-global stderr. The public `print_error` function wires that writer
11//! to `stderr`. `write_error` is private; the test submodule accesses it
12//! directly as a child of this module. The `eprintln!` call in
13//! `print_error_with_json` is excluded from the `no-println` lint via the
14//! rule's `ignores` list.
15
16use crate::constants;
17use crate::error::Error;
18
19/// Prints an error message, either as JSON or user-friendly format.
20pub fn print_error_with_json(error: &Error, json_format: bool) {
21    if !json_format {
22        print_error(error);
23        return;
24    }
25    let json_error = error.to_json();
26    let Ok(json_output) = serde_json::to_string_pretty(&json_error) else {
27        print_error(error);
28        return;
29    };
30    eprintln!("{json_output}");
31}
32
33/// Prints a user-friendly error message with context and suggestions.
34pub fn print_error(error: &Error) {
35    write_error(error, &mut std::io::stderr());
36}
37
38/// Writes a user-friendly error message to `writer`.
39///
40/// Extracted from `print_error` so that tests can capture output without
41/// redirecting the process-global stderr.
42#[allow(clippy::too_many_lines)]
43fn write_error<W: std::io::Write>(error: &Error, writer: &mut W) {
44    match error {
45        Error::Internal {
46            kind,
47            message,
48            context,
49        } => {
50            let _ = writeln!(writer, "{kind}: {message}");
51            let Some(ctx) = context else { return };
52            if let Some(suggestion) = &ctx.suggestion {
53                let _ = writeln!(writer, "\nHint: {suggestion}");
54            }
55        }
56        Error::Io(io_err) => match io_err.kind() {
57            std::io::ErrorKind::NotFound => {
58                let _ = writeln!(
59                    writer,
60                    "File Not Found\n{io_err}\n\nHint: {}",
61                    constants::ERR_FILE_NOT_FOUND
62                );
63            }
64            std::io::ErrorKind::PermissionDenied => {
65                let _ = writeln!(
66                    writer,
67                    "Permission Denied\n{io_err}\n\nHint: {}",
68                    constants::ERR_PERMISSION
69                );
70            }
71            _ => {
72                let _ = writeln!(writer, "File System Error\n{io_err}");
73            }
74        },
75        Error::Network(req_err) => {
76            if req_err.is_connect() {
77                let _ = writeln!(
78                    writer,
79                    "Connection Error\n{req_err}\n\nHint: {}",
80                    constants::ERR_CONNECTION
81                );
82                return;
83            }
84            if req_err.is_timeout() {
85                let _ = writeln!(
86                    writer,
87                    "Timeout Error\n{req_err}\n\nHint: {}",
88                    constants::ERR_TIMEOUT
89                );
90                return;
91            }
92            if !req_err.is_status() {
93                let _ = writeln!(writer, "Network Error\n{req_err}");
94                return;
95            }
96            let Some(status) = req_err.status() else {
97                let _ = writeln!(writer, "Network Error\n{req_err}");
98                return;
99            };
100            match status.as_u16() {
101                401 => {
102                    let _ = writeln!(
103                        writer,
104                        "Authentication Error\n{req_err}\n\nHint: {}",
105                        constants::ERR_API_CREDENTIALS
106                    );
107                }
108                403 => {
109                    let _ = writeln!(
110                        writer,
111                        "Permission Error\n{req_err}\n\nHint: {}",
112                        constants::ERR_PERMISSION_DENIED
113                    );
114                }
115                404 => {
116                    let _ = writeln!(
117                        writer,
118                        "Not Found Error\n{req_err}\n\nHint: {}",
119                        constants::ERR_ENDPOINT_NOT_FOUND
120                    );
121                }
122                429 => {
123                    let _ = writeln!(
124                        writer,
125                        "Rate Limited\n{req_err}\n\nHint: {}",
126                        constants::ERR_RATE_LIMITED
127                    );
128                }
129                500..=599 => {
130                    let _ = writeln!(
131                        writer,
132                        "Server Error\n{req_err}\n\nHint: {}",
133                        constants::ERR_SERVER_ERROR
134                    );
135                }
136                _ => {
137                    let _ = writeln!(writer, "HTTP Error\n{req_err}");
138                }
139            }
140        }
141        Error::Yaml(yaml_err) => {
142            let _ = writeln!(
143                writer,
144                "YAML Parsing Error\n{yaml_err}\n\nHint: {}",
145                constants::ERR_YAML_SYNTAX
146            );
147        }
148        Error::Json(json_err) => {
149            let _ = writeln!(
150                writer,
151                "JSON Parsing Error\n{json_err}\n\nHint: {}",
152                constants::ERR_JSON_SYNTAX
153            );
154        }
155        Error::Toml(toml_err) => {
156            let _ = writeln!(
157                writer,
158                "TOML Parsing Error\n{toml_err}\n\nHint: {}",
159                constants::ERR_TOML_SYNTAX
160            );
161        }
162        Error::Anyhow(anyhow_err) => {
163            let _ = writeln!(writer, "Error\n{anyhow_err}");
164        }
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use std::time::Duration;
172    use wiremock::matchers::{method, path};
173    use wiremock::{Mock, MockServer, ResponseTemplate};
174
175    fn capture(error: &Error) -> String {
176        let mut buf = Vec::new();
177        write_error(error, &mut buf);
178        String::from_utf8(buf).expect("output is valid UTF-8")
179    }
180
181    // ---- Internal / non-Network variants ----
182
183    #[test]
184    fn test_internal_without_suggestion() {
185        let err = Error::validation_error("bad input");
186        let out = capture(&err);
187        assert!(out.contains("Validation"));
188        assert!(out.contains("bad input"));
189    }
190
191    #[test]
192    fn test_internal_with_suggestion() {
193        let err = Error::spec_not_found("my-api");
194        let out = capture(&err);
195        assert!(out.contains("Specification"));
196        assert!(out.contains("my-api"));
197        assert!(out.contains("Hint:"));
198    }
199
200    #[test]
201    fn test_io_not_found() {
202        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
203        let err = Error::Io(io_err);
204        let out = capture(&err);
205        assert!(out.contains("File Not Found"));
206        assert!(out.contains(constants::ERR_FILE_NOT_FOUND));
207    }
208
209    #[test]
210    fn test_io_permission_denied() {
211        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
212        let err = Error::Io(io_err);
213        let out = capture(&err);
214        assert!(out.contains("Permission Denied"));
215        assert!(out.contains(constants::ERR_PERMISSION));
216    }
217
218    #[test]
219    fn test_io_other() {
220        let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "broken pipe");
221        let err = Error::Io(io_err);
222        let out = capture(&err);
223        assert!(out.contains("File System Error"));
224    }
225
226    #[test]
227    fn test_yaml_error() {
228        let yaml_err = serde_yaml::from_str::<serde_yaml::Value>("key: - value").unwrap_err();
229        let err = Error::Yaml(yaml_err);
230        let out = capture(&err);
231        assert!(out.contains("YAML Parsing Error"));
232        assert!(out.contains(constants::ERR_YAML_SYNTAX));
233    }
234
235    #[test]
236    fn test_json_error() {
237        let json_err = serde_json::from_str::<serde_json::Value>("{bad").unwrap_err();
238        let err = Error::Json(json_err);
239        let out = capture(&err);
240        assert!(out.contains("JSON Parsing Error"));
241        assert!(out.contains(constants::ERR_JSON_SYNTAX));
242    }
243
244    #[test]
245    fn test_toml_error() {
246        let toml_err = toml::from_str::<toml::Value>("key = ").unwrap_err();
247        let err = Error::Toml(toml_err);
248        let out = capture(&err);
249        assert!(out.contains("TOML Parsing Error"));
250        assert!(out.contains(constants::ERR_TOML_SYNTAX));
251    }
252
253    #[test]
254    fn test_anyhow_error() {
255        let err = Error::Anyhow(anyhow::anyhow!("something went wrong"));
256        let out = capture(&err);
257        assert!(out.contains("Error"));
258        assert!(out.contains("something went wrong"));
259    }
260
261    // ---- Network variants (require live sockets) ----
262
263    /// Produce a status-bearing `reqwest::Error` by hitting a wiremock endpoint
264    /// with `error_for_status()`.
265    async fn status_error(status: u16) -> reqwest::Error {
266        let server = MockServer::start().await;
267        Mock::given(method("GET"))
268            .and(path("/err"))
269            .respond_with(ResponseTemplate::new(status))
270            .mount(&server)
271            .await;
272
273        reqwest::Client::new()
274            .get(format!("{}/err", server.uri()))
275            .send()
276            .await
277            .expect("request reached mock server")
278            .error_for_status()
279            .expect_err("status >= 400 must produce an error")
280    }
281
282    #[tokio::test]
283    async fn test_network_connect_error() {
284        // Port 1 is not in use on CI machines; produces ECONNREFUSED (is_connect).
285        let req_err = reqwest::Client::new()
286            .get("http://127.0.0.1:1/")
287            .send()
288            .await
289            .expect_err("port 1 must refuse connections");
290        assert!(req_err.is_connect(), "expected a connect error");
291        let out = capture(&Error::Network(req_err));
292        assert!(out.contains("Connection Error"));
293        assert!(out.contains(constants::ERR_CONNECTION));
294    }
295
296    #[tokio::test]
297    async fn test_network_timeout_error() {
298        let server = MockServer::start().await;
299        Mock::given(method("GET"))
300            .and(path("/slow"))
301            .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(10)))
302            .mount(&server)
303            .await;
304
305        let req_err = reqwest::Client::builder()
306            .timeout(Duration::from_millis(1))
307            .build()
308            .unwrap()
309            .get(format!("{}/slow", server.uri()))
310            .send()
311            .await
312            .expect_err("request must time out");
313        assert!(req_err.is_timeout(), "expected a timeout error");
314        let out = capture(&Error::Network(req_err));
315        assert!(out.contains("Timeout Error"));
316        assert!(out.contains(constants::ERR_TIMEOUT));
317    }
318
319    #[tokio::test]
320    async fn test_network_401() {
321        let err = status_error(401).await;
322        let out = capture(&Error::Network(err));
323        assert!(out.contains("Authentication Error"));
324        assert!(out.contains(constants::ERR_API_CREDENTIALS));
325    }
326
327    #[tokio::test]
328    async fn test_network_403() {
329        let err = status_error(403).await;
330        let out = capture(&Error::Network(err));
331        assert!(out.contains("Permission Error"));
332        assert!(out.contains(constants::ERR_PERMISSION_DENIED));
333    }
334
335    #[tokio::test]
336    async fn test_network_404() {
337        let err = status_error(404).await;
338        let out = capture(&Error::Network(err));
339        assert!(out.contains("Not Found Error"));
340        assert!(out.contains(constants::ERR_ENDPOINT_NOT_FOUND));
341    }
342
343    #[tokio::test]
344    async fn test_network_429() {
345        let err = status_error(429).await;
346        let out = capture(&Error::Network(err));
347        assert!(out.contains("Rate Limited"));
348        assert!(out.contains(constants::ERR_RATE_LIMITED));
349    }
350
351    #[tokio::test]
352    async fn test_network_503() {
353        let err = status_error(503).await;
354        let out = capture(&Error::Network(err));
355        assert!(out.contains("Server Error"));
356        assert!(out.contains(constants::ERR_SERVER_ERROR));
357    }
358
359    /// 400 (Bad Request) is a 4xx status that is not explicitly matched — exercises
360    /// the `_ =>` fallback arm.
361    #[tokio::test]
362    async fn test_network_400_fallback() {
363        let err = status_error(400).await;
364        let out = capture(&Error::Network(err));
365        assert!(out.contains("HTTP Error"));
366    }
367}