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::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
128pub 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 crate::api::ApiError::Api {
137 status: 408 | 429, ..
138 } => ("retry_later", exit_codes::API_ERROR),
139 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 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 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 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 #[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 #[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 #[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}