Skip to main content

unifi_cli/
output.rs

1use std::io::IsTerminal;
2
3/// Whether to use colored output (only when stdout is a terminal).
4pub fn use_color() -> bool {
5    std::io::stdout().is_terminal()
6}
7
8/// Output format selection.
9#[derive(Clone, Copy, PartialEq, Eq)]
10pub enum OutputFormat {
11    /// Use JSON when stdout is not a terminal, text otherwise.
12    Auto,
13    /// Always output human-readable text.
14    Text,
15    /// Always output JSON.
16    Json,
17}
18
19impl OutputFormat {
20    pub fn parse(s: &str) -> Option<Self> {
21        match s {
22            "auto" => Some(Self::Auto),
23            "text" => Some(Self::Text),
24            "json" => Some(Self::Json),
25            _ => None,
26        }
27    }
28}
29
30/// Output configuration for agent-friendly CLI design.
31///
32/// Supports TTY detection (auto-JSON when piped), quiet mode,
33/// and structured JSON output for all commands including mutations.
34#[derive(Clone, Copy)]
35pub struct OutputConfig {
36    pub format: OutputFormat,
37    pub quiet: bool,
38}
39
40impl OutputConfig {
41    pub fn new(format: OutputFormat, quiet: bool) -> Self {
42        Self { format, quiet }
43    }
44
45    /// True when JSON output is active.
46    pub fn is_json(&self) -> bool {
47        match self.format {
48            OutputFormat::Json => true,
49            OutputFormat::Text => false,
50            OutputFormat::Auto => !std::io::stdout().is_terminal(),
51        }
52    }
53
54    /// Print data to stdout (tables or JSON). Always shown.
55    pub fn print_data(&self, data: &str) {
56        println!("{data}");
57    }
58
59    /// Print a human-readable message to stderr. Suppressed by --quiet.
60    pub fn print_message(&self, msg: &str) {
61        if !self.quiet {
62            eprintln!("{msg}");
63        }
64    }
65
66    /// Print a structured JSON result for mutation commands.
67    /// In JSON mode, prints to stdout. In human mode, prints message to stderr.
68    pub fn print_result(&self, json_value: &serde_json::Value, human_message: &str) {
69        if self.is_json() {
70            println!(
71                "{}",
72                serde_json::to_string_pretty(json_value).expect("failed to serialize JSON")
73            );
74        } else {
75            self.print_message(human_message);
76        }
77    }
78}
79
80/// Write a structured error envelope as the last line of stderr.
81/// Always call this before process::exit on non-zero paths.
82pub fn print_error_envelope(kind: &str, message: &str, hint: Option<&str>) {
83    let mut err = serde_json::json!({
84        "kind": kind,
85        "message": message,
86    });
87    if let Some(h) = hint {
88        err["hint"] = serde_json::Value::String(h.to_string());
89    }
90    eprintln!(
91        "{}",
92        serde_json::to_string(&serde_json::json!({ "error": err }))
93            .expect("failed to serialize error envelope")
94    );
95}
96
97/// Exit codes for agent-friendly error handling.
98/// Agents can branch on specific failure modes without parsing error text.
99pub mod exit_codes {
100    pub const SUCCESS: i32 = 0;
101    pub const GENERAL_ERROR: i32 = 1;
102    pub const CONFIG_ERROR: i32 = 2;
103    pub const CONFIRMATION_REQUIRED: i32 = 2;
104    pub const AUTH_ERROR: i32 = 3;
105    pub const NOT_FOUND: i32 = 4;
106    pub const API_ERROR: i32 = 5;
107    pub const CONFLICT: i32 = 6;
108}
109
110/// Map an error to a specific exit code by downcasting to ApiError.
111pub fn exit_code_for_error(err: &(dyn std::error::Error + 'static)) -> i32 {
112    if let Some(api_err) = err.downcast_ref::<crate::api::ApiError>() {
113        match api_err {
114            crate::api::ApiError::Auth(_) => exit_codes::AUTH_ERROR,
115            crate::api::ApiError::NotFound(_) => exit_codes::NOT_FOUND,
116            crate::api::ApiError::Api { .. } => exit_codes::API_ERROR,
117            crate::api::ApiError::Unsupported { .. } => exit_codes::NOT_FOUND,
118            crate::api::ApiError::Conflict(_) => exit_codes::CONFLICT,
119            crate::api::ApiError::Http(_) | crate::api::ApiError::Other(_) => {
120                exit_codes::GENERAL_ERROR
121            }
122        }
123    } else {
124        exit_codes::GENERAL_ERROR
125    }
126}
127
128/// Map an error to its kind string and exit code.
129pub fn error_kind_and_code(err: &(dyn std::error::Error + 'static)) -> (&'static str, i32) {
130    if let Some(api_err) = err.downcast_ref::<crate::api::ApiError>() {
131        match api_err {
132            crate::api::ApiError::Auth(_) => ("auth_error", exit_codes::AUTH_ERROR),
133            crate::api::ApiError::NotFound(_) => ("not_found", exit_codes::NOT_FOUND),
134            // 408 and 429 are the two 4xx that invite the same request again,
135            // so they stay retryable even though they are client errors.
136            crate::api::ApiError::Api {
137                status: 408 | 429, ..
138            } => ("retry_later", exit_codes::API_ERROR),
139            // Any other 4xx means the request itself was rejected, so retrying
140            // it unchanged cannot help. It shares exit code 5 with api_error
141            // but reports a distinct kind, so an agent branching on
142            // `retryable` does not loop on a permanent failure.
143            crate::api::ApiError::Api { status, .. } if (400..500).contains(status) => {
144                ("client_error", exit_codes::API_ERROR)
145            }
146            crate::api::ApiError::Api { .. } => ("api_error", exit_codes::API_ERROR),
147            // The whole API is absent, not one record, so this shares
148            // not_found's exit code but keeps a distinct kind: there is no
149            // other identifier worth trying.
150            crate::api::ApiError::Unsupported { .. } => ("unsupported", exit_codes::NOT_FOUND),
151            crate::api::ApiError::Conflict(_) => ("conflict", exit_codes::CONFLICT),
152            crate::api::ApiError::Http(_) | crate::api::ApiError::Other(_) => {
153                ("general_error", exit_codes::GENERAL_ERROR)
154            }
155        }
156    } else {
157        ("general_error", exit_codes::GENERAL_ERROR)
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::api::{ApiError, UnsupportedReason};
165
166    #[test]
167    fn exit_code_for_auth_error() {
168        let err = ApiError::Auth("bad key".into());
169        assert_eq!(exit_code_for_error(&err), exit_codes::AUTH_ERROR);
170    }
171
172    #[test]
173    fn exit_code_for_not_found() {
174        let err = ApiError::NotFound("Client with MAC aa:bb".into());
175        assert_eq!(exit_code_for_error(&err), exit_codes::NOT_FOUND);
176    }
177
178    #[test]
179    fn exit_code_for_api_error() {
180        let err = ApiError::Api {
181            status: 500,
182            message: "Internal Server Error".into(),
183        };
184        assert_eq!(exit_code_for_error(&err), exit_codes::API_ERROR);
185    }
186
187    #[test]
188    fn exit_code_for_other_error() {
189        let err = ApiError::Other("something".into());
190        assert_eq!(exit_code_for_error(&err), exit_codes::GENERAL_ERROR);
191    }
192
193    #[test]
194    fn exit_code_for_non_api_error() {
195        let err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
196        assert_eq!(exit_code_for_error(&err), exit_codes::GENERAL_ERROR);
197    }
198
199    #[test]
200    fn output_format_explicit_text_is_not_json() {
201        let out = OutputConfig::new(OutputFormat::Text, false);
202        assert!(!out.is_json());
203    }
204
205    #[test]
206    fn output_format_explicit_json_is_json() {
207        let out = OutputConfig::new(OutputFormat::Json, false);
208        assert!(out.is_json());
209    }
210
211    #[test]
212    fn error_kind_and_code_auth() {
213        let err = ApiError::Auth("bad".into());
214        let (kind, code) = error_kind_and_code(&err);
215        assert_eq!(kind, "auth_error");
216        assert_eq!(code, exit_codes::AUTH_ERROR);
217    }
218
219    #[test]
220    fn error_kind_and_code_not_found() {
221        let err = ApiError::NotFound("x".into());
222        let (kind, code) = error_kind_and_code(&err);
223        assert_eq!(kind, "not_found");
224        assert_eq!(code, exit_codes::NOT_FOUND);
225    }
226
227    #[test]
228    fn error_kind_and_code_client_error_for_a_rejected_request() {
229        // The controller answers `power-cycle` on a PoE-disabled port with
230        // HTTP 400 api.err.InvalidTargetPort. Retrying that unchanged can only
231        // fail again, so it must not be published as the retryable api_error.
232        let err = ApiError::Api {
233            status: 400,
234            message: "api.err.InvalidTargetPort".into(),
235        };
236        let (kind, code) = error_kind_and_code(&err);
237        assert_eq!(kind, "client_error");
238        assert_eq!(code, exit_codes::API_ERROR);
239    }
240
241    #[test]
242    fn error_kind_and_code_keeps_408_and_429_retryable() {
243        // Both statuses ask for the same request again, so they must not land
244        // in the permanent client_error bucket an agent gives up on.
245        for status in [408u16, 429] {
246            let err = ApiError::Api {
247                status,
248                message: "slow down".into(),
249            };
250            let (kind, code) = error_kind_and_code(&err);
251            assert_eq!(kind, "retry_later", "status {status}");
252            assert_eq!(code, exit_codes::API_ERROR, "status {status}");
253        }
254    }
255
256    #[test]
257    fn error_kind_and_code_api_error_stays_for_server_side_failures() {
258        for status in [500u16, 502, 503] {
259            let err = ApiError::Api {
260                status,
261                message: "upstream failure".into(),
262            };
263            let (kind, code) = error_kind_and_code(&err);
264            assert_eq!(kind, "api_error", "status {status}");
265            assert_eq!(code, exit_codes::API_ERROR, "status {status}");
266        }
267    }
268
269    // An absent application shares not_found's exit code (nothing is there to
270    // find) but must keep its own kind: with not_found an agent can sensibly
271    // try another identifier, here there is no identifier that would work.
272    #[test]
273    fn error_kind_and_code_unsupported_is_distinct_from_not_found() {
274        let err = ApiError::Unsupported {
275            endpoint: "/proxy/protect/integration/v1/cameras".into(),
276            reason: UnsupportedReason::NotJson {
277                content_type: "text/html".into(),
278            },
279        };
280        let (kind, code) = error_kind_and_code(&err);
281        assert_eq!(kind, "unsupported");
282        assert_eq!(code, exit_codes::NOT_FOUND);
283        assert_eq!(exit_code_for_error(&err), exit_codes::NOT_FOUND);
284    }
285
286    // Both reasons mean "the endpoint is not there", so they must publish the
287    // same kind and code. A caller that branches on the kind cannot be made to
288    // care which way the controller said it.
289    #[test]
290    fn error_kind_and_code_unsupported_is_the_same_for_a_removed_endpoint() {
291        let err = ApiError::Unsupported {
292            endpoint: "/proxy/network/api/s/default/stat/event?_limit=20".into(),
293            reason: UnsupportedReason::Removed,
294        };
295        let (kind, code) = error_kind_and_code(&err);
296        assert_eq!(kind, "unsupported");
297        assert_eq!(code, exit_codes::NOT_FOUND);
298        assert_eq!(exit_code_for_error(&err), exit_codes::NOT_FOUND);
299    }
300
301    #[test]
302    fn unsupported_message_names_the_endpoint_and_the_content_type() {
303        let err = ApiError::Unsupported {
304            endpoint: "/proxy/protect/integration/v1/cameras".into(),
305            reason: UnsupportedReason::NotJson {
306                content_type: "text/html".into(),
307            },
308        };
309        let message = err.to_string();
310        assert!(
311            message.contains("/proxy/protect/integration/v1/cameras"),
312            "got: {message}"
313        );
314        assert!(message.contains("text/html"), "got: {message}");
315        assert!(
316            message.contains("Protect"),
317            "a Protect endpoint names the application that is missing: {message}"
318        );
319    }
320
321    #[test]
322    fn unsupported_message_for_a_network_endpoint_does_not_blame_protect() {
323        let err = ApiError::Unsupported {
324            endpoint: "/proxy/network/api/s/default/stat/device".into(),
325            reason: UnsupportedReason::NotJson {
326                content_type: "text/html".into(),
327            },
328        };
329        let message = err.to_string();
330        assert!(
331            message.contains("/proxy/network/api/s/default/stat/device"),
332            "got: {message}"
333        );
334        assert!(!message.contains("Protect"), "got: {message}");
335    }
336
337    // A removed endpoint must not be described as a content-type problem: the
338    // controller answered with perfectly good JSON, it just refused the
339    // endpoint. Saying otherwise sends the reader after a fault that is not
340    // there.
341    #[test]
342    fn unsupported_message_for_a_removed_endpoint_does_not_mention_json() {
343        let err = ApiError::Unsupported {
344            endpoint: "/proxy/network/api/s/default/stat/event?_limit=20".into(),
345            reason: UnsupportedReason::Removed,
346        };
347        let message = err.to_string();
348        assert!(message.contains("/stat/event"), "got: {message}");
349        assert!(
350            !message.contains("instead of JSON"),
351            "a removed endpoint is not a decoding problem: {message}"
352        );
353        assert!(
354            message.contains("WebSocket"),
355            "the events case names what is left instead: {message}"
356        );
357        assert!(!message.contains("Protect"), "got: {message}");
358    }
359
360    #[test]
361    fn error_envelope_is_valid_json() {
362        let envelope = serde_json::json!({
363            "error": {
364                "kind": "auth_error",
365                "message": "Authentication error: bad key",
366            }
367        });
368        assert!(envelope["error"]["kind"].as_str().is_some());
369        assert!(envelope["error"]["message"].as_str().is_some());
370    }
371
372    #[test]
373    fn exit_code_for_conflict() {
374        let err = ApiError::Conflict("port has no PoE".into());
375        assert_eq!(exit_code_for_error(&err), exit_codes::CONFLICT);
376    }
377
378    #[test]
379    fn error_kind_and_code_conflict() {
380        let err = ApiError::Conflict("port has no PoE".into());
381        let (kind, code) = error_kind_and_code(&err);
382        assert_eq!(kind, "conflict");
383        assert_eq!(code, 6);
384    }
385}