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 configuration for agent-friendly CLI design.
9///
10/// Supports TTY detection (auto-JSON when piped), quiet mode,
11/// and structured JSON output for all commands including mutations.
12#[derive(Clone, Copy)]
13pub struct OutputConfig {
14    pub json: bool,
15    pub quiet: bool,
16}
17
18impl OutputConfig {
19    pub fn new(json_flag: bool, quiet: bool) -> Self {
20        let json = json_flag || !std::io::stdout().is_terminal();
21        Self { json, quiet }
22    }
23
24    /// Print data to stdout (tables or JSON). Always shown.
25    pub fn print_data(&self, data: &str) {
26        println!("{data}");
27    }
28
29    /// Print a human-readable message to stderr. Suppressed by --quiet.
30    pub fn print_message(&self, msg: &str) {
31        if !self.quiet {
32            eprintln!("{msg}");
33        }
34    }
35
36    /// Print a structured JSON result for mutation commands.
37    /// In JSON mode, prints to stdout. In human mode, prints message to stderr.
38    pub fn print_result(&self, json_value: &serde_json::Value, human_message: &str) {
39        if self.json {
40            println!(
41                "{}",
42                serde_json::to_string_pretty(json_value).expect("failed to serialize JSON")
43            );
44        } else {
45            self.print_message(human_message);
46        }
47    }
48}
49
50/// Exit codes for agent-friendly error handling.
51/// Agents can branch on specific failure modes without parsing error text.
52pub mod exit_codes {
53    pub const SUCCESS: i32 = 0;
54    pub const CONFIG_ERROR: i32 = 2;
55    pub const AUTH_ERROR: i32 = 3;
56    pub const NOT_FOUND: i32 = 4;
57    pub const API_ERROR: i32 = 5;
58    pub const GENERAL_ERROR: i32 = 1;
59}
60
61/// Map an error to a specific exit code by downcasting to ApiError.
62pub fn exit_code_for_error(err: &(dyn std::error::Error + 'static)) -> i32 {
63    if let Some(api_err) = err.downcast_ref::<crate::api::ApiError>() {
64        match api_err {
65            crate::api::ApiError::Auth(_) => exit_codes::AUTH_ERROR,
66            crate::api::ApiError::NotFound(_) => exit_codes::NOT_FOUND,
67            crate::api::ApiError::Api { .. } => exit_codes::API_ERROR,
68            crate::api::ApiError::Http(_) | crate::api::ApiError::Other(_) => {
69                exit_codes::GENERAL_ERROR
70            }
71        }
72    } else {
73        exit_codes::GENERAL_ERROR
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use crate::api::ApiError;
81
82    #[test]
83    fn exit_code_for_auth_error() {
84        let err = ApiError::Auth("bad key".into());
85        assert_eq!(exit_code_for_error(&err), exit_codes::AUTH_ERROR);
86    }
87
88    #[test]
89    fn exit_code_for_not_found() {
90        let err = ApiError::NotFound("Client with MAC aa:bb".into());
91        assert_eq!(exit_code_for_error(&err), exit_codes::NOT_FOUND);
92    }
93
94    #[test]
95    fn exit_code_for_api_error() {
96        let err = ApiError::Api {
97            status: 500,
98            message: "Internal Server Error".into(),
99        };
100        assert_eq!(exit_code_for_error(&err), exit_codes::API_ERROR);
101    }
102
103    #[test]
104    fn exit_code_for_other_error() {
105        let err = ApiError::Other("something".into());
106        assert_eq!(exit_code_for_error(&err), exit_codes::GENERAL_ERROR);
107    }
108
109    #[test]
110    fn exit_code_for_non_api_error() {
111        let err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
112        assert_eq!(exit_code_for_error(&err), exit_codes::GENERAL_ERROR);
113    }
114}