1use std::io::IsTerminal;
2
3pub fn use_color() -> bool {
5 std::io::stdout().is_terminal()
6}
7
8#[derive(Clone, Copy, PartialEq, Eq)]
10pub enum OutputFormat {
11 Auto,
13 Text,
15 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#[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 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 pub fn print_data(&self, data: &str) {
56 println!("{data}");
57 }
58
59 pub fn print_message(&self, msg: &str) {
61 if !self.quiet {
62 eprintln!("{msg}");
63 }
64 }
65
66 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
80pub 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
97pub 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
110pub 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
127pub 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 {
136 status: 408 | 429, ..
137 } => ("retry_later", exit_codes::API_ERROR),
138 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 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 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}