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::Conflict(_) => exit_codes::CONFLICT,
118            crate::api::ApiError::Http(_) | crate::api::ApiError::Other(_) => {
119                exit_codes::GENERAL_ERROR
120            }
121        }
122    } else {
123        exit_codes::GENERAL_ERROR
124    }
125}
126
127/// Map an error to its kind string and exit code.
128pub fn error_kind_and_code(err: &(dyn std::error::Error + 'static)) -> (&'static str, i32) {
129    if let Some(api_err) = err.downcast_ref::<crate::api::ApiError>() {
130        match api_err {
131            crate::api::ApiError::Auth(_) => ("auth_error", exit_codes::AUTH_ERROR),
132            crate::api::ApiError::NotFound(_) => ("not_found", exit_codes::NOT_FOUND),
133            // 408 and 429 are the two 4xx that invite the same request again,
134            // so they stay retryable even though they are client errors.
135            crate::api::ApiError::Api {
136                status: 408 | 429, ..
137            } => ("retry_later", exit_codes::API_ERROR),
138            // Any other 4xx means the request itself was rejected, so retrying
139            // it unchanged cannot help. It shares exit code 5 with api_error
140            // but reports a distinct kind, so an agent branching on
141            // `retryable` does not loop on a permanent failure.
142            crate::api::ApiError::Api { status, .. } if (400..500).contains(status) => {
143                ("client_error", exit_codes::API_ERROR)
144            }
145            crate::api::ApiError::Api { .. } => ("api_error", exit_codes::API_ERROR),
146            crate::api::ApiError::Conflict(_) => ("conflict", exit_codes::CONFLICT),
147            crate::api::ApiError::Http(_) | crate::api::ApiError::Other(_) => {
148                ("general_error", exit_codes::GENERAL_ERROR)
149            }
150        }
151    } else {
152        ("general_error", exit_codes::GENERAL_ERROR)
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::api::ApiError;
160
161    #[test]
162    fn exit_code_for_auth_error() {
163        let err = ApiError::Auth("bad key".into());
164        assert_eq!(exit_code_for_error(&err), exit_codes::AUTH_ERROR);
165    }
166
167    #[test]
168    fn exit_code_for_not_found() {
169        let err = ApiError::NotFound("Client with MAC aa:bb".into());
170        assert_eq!(exit_code_for_error(&err), exit_codes::NOT_FOUND);
171    }
172
173    #[test]
174    fn exit_code_for_api_error() {
175        let err = ApiError::Api {
176            status: 500,
177            message: "Internal Server Error".into(),
178        };
179        assert_eq!(exit_code_for_error(&err), exit_codes::API_ERROR);
180    }
181
182    #[test]
183    fn exit_code_for_other_error() {
184        let err = ApiError::Other("something".into());
185        assert_eq!(exit_code_for_error(&err), exit_codes::GENERAL_ERROR);
186    }
187
188    #[test]
189    fn exit_code_for_non_api_error() {
190        let err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
191        assert_eq!(exit_code_for_error(&err), exit_codes::GENERAL_ERROR);
192    }
193
194    #[test]
195    fn output_format_explicit_text_is_not_json() {
196        let out = OutputConfig::new(OutputFormat::Text, false);
197        assert!(!out.is_json());
198    }
199
200    #[test]
201    fn output_format_explicit_json_is_json() {
202        let out = OutputConfig::new(OutputFormat::Json, false);
203        assert!(out.is_json());
204    }
205
206    #[test]
207    fn error_kind_and_code_auth() {
208        let err = ApiError::Auth("bad".into());
209        let (kind, code) = error_kind_and_code(&err);
210        assert_eq!(kind, "auth_error");
211        assert_eq!(code, exit_codes::AUTH_ERROR);
212    }
213
214    #[test]
215    fn error_kind_and_code_not_found() {
216        let err = ApiError::NotFound("x".into());
217        let (kind, code) = error_kind_and_code(&err);
218        assert_eq!(kind, "not_found");
219        assert_eq!(code, exit_codes::NOT_FOUND);
220    }
221
222    #[test]
223    fn error_kind_and_code_client_error_for_a_rejected_request() {
224        // The controller answers `power-cycle` on a PoE-disabled port with
225        // HTTP 400 api.err.InvalidTargetPort. Retrying that unchanged can only
226        // fail again, so it must not be published as the retryable api_error.
227        let err = ApiError::Api {
228            status: 400,
229            message: "api.err.InvalidTargetPort".into(),
230        };
231        let (kind, code) = error_kind_and_code(&err);
232        assert_eq!(kind, "client_error");
233        assert_eq!(code, exit_codes::API_ERROR);
234    }
235
236    #[test]
237    fn error_kind_and_code_keeps_408_and_429_retryable() {
238        // Both statuses ask for the same request again, so they must not land
239        // in the permanent client_error bucket an agent gives up on.
240        for status in [408u16, 429] {
241            let err = ApiError::Api {
242                status,
243                message: "slow down".into(),
244            };
245            let (kind, code) = error_kind_and_code(&err);
246            assert_eq!(kind, "retry_later", "status {status}");
247            assert_eq!(code, exit_codes::API_ERROR, "status {status}");
248        }
249    }
250
251    #[test]
252    fn error_kind_and_code_api_error_stays_for_server_side_failures() {
253        for status in [500u16, 502, 503] {
254            let err = ApiError::Api {
255                status,
256                message: "upstream failure".into(),
257            };
258            let (kind, code) = error_kind_and_code(&err);
259            assert_eq!(kind, "api_error", "status {status}");
260            assert_eq!(code, exit_codes::API_ERROR, "status {status}");
261        }
262    }
263
264    #[test]
265    fn error_envelope_is_valid_json() {
266        let envelope = serde_json::json!({
267            "error": {
268                "kind": "auth_error",
269                "message": "Authentication error: bad key",
270            }
271        });
272        assert!(envelope["error"]["kind"].as_str().is_some());
273        assert!(envelope["error"]["message"].as_str().is_some());
274    }
275
276    #[test]
277    fn exit_code_for_conflict() {
278        let err = ApiError::Conflict("port has no PoE".into());
279        assert_eq!(exit_code_for_error(&err), exit_codes::CONFLICT);
280    }
281
282    #[test]
283    fn error_kind_and_code_conflict() {
284        let err = ApiError::Conflict("port has no PoE".into());
285        let (kind, code) = error_kind_and_code(&err);
286        assert_eq!(kind, "conflict");
287        assert_eq!(code, 6);
288    }
289}