Skip to main content

unifi_cli/
output.rs

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