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}
108
109/// Map an error to a specific exit code by downcasting to ApiError.
110pub fn exit_code_for_error(err: &(dyn std::error::Error + 'static)) -> i32 {
111    if let Some(api_err) = err.downcast_ref::<crate::api::ApiError>() {
112        match api_err {
113            crate::api::ApiError::Auth(_) => exit_codes::AUTH_ERROR,
114            crate::api::ApiError::NotFound(_) => exit_codes::NOT_FOUND,
115            crate::api::ApiError::Api { .. } => exit_codes::API_ERROR,
116            crate::api::ApiError::Http(_) | crate::api::ApiError::Other(_) => {
117                exit_codes::GENERAL_ERROR
118            }
119        }
120    } else {
121        exit_codes::GENERAL_ERROR
122    }
123}
124
125/// Map an error to its kind string and exit code.
126pub fn error_kind_and_code(err: &(dyn std::error::Error + 'static)) -> (&'static str, i32) {
127    if let Some(api_err) = err.downcast_ref::<crate::api::ApiError>() {
128        match api_err {
129            crate::api::ApiError::Auth(_) => ("auth_error", exit_codes::AUTH_ERROR),
130            crate::api::ApiError::NotFound(_) => ("not_found", exit_codes::NOT_FOUND),
131            crate::api::ApiError::Api { .. } => ("api_error", exit_codes::API_ERROR),
132            crate::api::ApiError::Http(_) | crate::api::ApiError::Other(_) => {
133                ("general_error", exit_codes::GENERAL_ERROR)
134            }
135        }
136    } else {
137        ("general_error", exit_codes::GENERAL_ERROR)
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::api::ApiError;
145
146    #[test]
147    fn exit_code_for_auth_error() {
148        let err = ApiError::Auth("bad key".into());
149        assert_eq!(exit_code_for_error(&err), exit_codes::AUTH_ERROR);
150    }
151
152    #[test]
153    fn exit_code_for_not_found() {
154        let err = ApiError::NotFound("Client with MAC aa:bb".into());
155        assert_eq!(exit_code_for_error(&err), exit_codes::NOT_FOUND);
156    }
157
158    #[test]
159    fn exit_code_for_api_error() {
160        let err = ApiError::Api {
161            status: 500,
162            message: "Internal Server Error".into(),
163        };
164        assert_eq!(exit_code_for_error(&err), exit_codes::API_ERROR);
165    }
166
167    #[test]
168    fn exit_code_for_other_error() {
169        let err = ApiError::Other("something".into());
170        assert_eq!(exit_code_for_error(&err), exit_codes::GENERAL_ERROR);
171    }
172
173    #[test]
174    fn exit_code_for_non_api_error() {
175        let err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
176        assert_eq!(exit_code_for_error(&err), exit_codes::GENERAL_ERROR);
177    }
178
179    #[test]
180    fn output_format_explicit_text_is_not_json() {
181        let out = OutputConfig::new(OutputFormat::Text, false);
182        assert!(!out.is_json());
183    }
184
185    #[test]
186    fn output_format_explicit_json_is_json() {
187        let out = OutputConfig::new(OutputFormat::Json, false);
188        assert!(out.is_json());
189    }
190
191    #[test]
192    fn error_kind_and_code_auth() {
193        let err = ApiError::Auth("bad".into());
194        let (kind, code) = error_kind_and_code(&err);
195        assert_eq!(kind, "auth_error");
196        assert_eq!(code, exit_codes::AUTH_ERROR);
197    }
198
199    #[test]
200    fn error_kind_and_code_not_found() {
201        let err = ApiError::NotFound("x".into());
202        let (kind, code) = error_kind_and_code(&err);
203        assert_eq!(kind, "not_found");
204        assert_eq!(code, exit_codes::NOT_FOUND);
205    }
206
207    #[test]
208    fn error_envelope_is_valid_json() {
209        let envelope = serde_json::json!({
210            "error": {
211                "kind": "auth_error",
212                "message": "Authentication error: bad key",
213            }
214        });
215        assert!(envelope["error"]["kind"].as_str().is_some());
216        assert!(envelope["error"]["message"].as_str().is_some());
217    }
218}