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            crate::api::ApiError::Api { .. } => ("api_error", exit_codes::API_ERROR),
134            crate::api::ApiError::Conflict(_) => ("conflict", exit_codes::CONFLICT),
135            crate::api::ApiError::Http(_) | crate::api::ApiError::Other(_) => {
136                ("general_error", exit_codes::GENERAL_ERROR)
137            }
138        }
139    } else {
140        ("general_error", exit_codes::GENERAL_ERROR)
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::api::ApiError;
148
149    #[test]
150    fn exit_code_for_auth_error() {
151        let err = ApiError::Auth("bad key".into());
152        assert_eq!(exit_code_for_error(&err), exit_codes::AUTH_ERROR);
153    }
154
155    #[test]
156    fn exit_code_for_not_found() {
157        let err = ApiError::NotFound("Client with MAC aa:bb".into());
158        assert_eq!(exit_code_for_error(&err), exit_codes::NOT_FOUND);
159    }
160
161    #[test]
162    fn exit_code_for_api_error() {
163        let err = ApiError::Api {
164            status: 500,
165            message: "Internal Server Error".into(),
166        };
167        assert_eq!(exit_code_for_error(&err), exit_codes::API_ERROR);
168    }
169
170    #[test]
171    fn exit_code_for_other_error() {
172        let err = ApiError::Other("something".into());
173        assert_eq!(exit_code_for_error(&err), exit_codes::GENERAL_ERROR);
174    }
175
176    #[test]
177    fn exit_code_for_non_api_error() {
178        let err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
179        assert_eq!(exit_code_for_error(&err), exit_codes::GENERAL_ERROR);
180    }
181
182    #[test]
183    fn output_format_explicit_text_is_not_json() {
184        let out = OutputConfig::new(OutputFormat::Text, false);
185        assert!(!out.is_json());
186    }
187
188    #[test]
189    fn output_format_explicit_json_is_json() {
190        let out = OutputConfig::new(OutputFormat::Json, false);
191        assert!(out.is_json());
192    }
193
194    #[test]
195    fn error_kind_and_code_auth() {
196        let err = ApiError::Auth("bad".into());
197        let (kind, code) = error_kind_and_code(&err);
198        assert_eq!(kind, "auth_error");
199        assert_eq!(code, exit_codes::AUTH_ERROR);
200    }
201
202    #[test]
203    fn error_kind_and_code_not_found() {
204        let err = ApiError::NotFound("x".into());
205        let (kind, code) = error_kind_and_code(&err);
206        assert_eq!(kind, "not_found");
207        assert_eq!(code, exit_codes::NOT_FOUND);
208    }
209
210    #[test]
211    fn error_envelope_is_valid_json() {
212        let envelope = serde_json::json!({
213            "error": {
214                "kind": "auth_error",
215                "message": "Authentication error: bad key",
216            }
217        });
218        assert!(envelope["error"]["kind"].as_str().is_some());
219        assert!(envelope["error"]["message"].as_str().is_some());
220    }
221
222    #[test]
223    fn exit_code_for_conflict() {
224        let err = ApiError::Conflict("port has no PoE".into());
225        assert_eq!(exit_code_for_error(&err), exit_codes::CONFLICT);
226    }
227
228    #[test]
229    fn error_kind_and_code_conflict() {
230        let err = ApiError::Conflict("port has no PoE".into());
231        let (kind, code) = error_kind_and_code(&err);
232        assert_eq!(kind, "conflict");
233        assert_eq!(code, 6);
234    }
235}