Skip to main content

homeassistant_cli/
output.rs

1use std::io::IsTerminal;
2
3use crate::api::HaError;
4
5#[derive(Clone, Copy, Debug, PartialEq, clap::ValueEnum)]
6pub enum OutputFormat {
7    Json,
8    Table,
9    Plain,
10}
11
12#[derive(Clone, Copy)]
13pub struct OutputConfig {
14    pub format: OutputFormat,
15    pub quiet: bool,
16}
17
18impl OutputConfig {
19    pub fn new(format_arg: Option<OutputFormat>, quiet: bool) -> Self {
20        let format = format_arg.unwrap_or_else(|| {
21            if std::io::stdout().is_terminal() {
22                OutputFormat::Table
23            } else {
24                OutputFormat::Json
25            }
26        });
27        Self { format, quiet }
28    }
29
30    pub fn is_json(&self) -> bool {
31        matches!(self.format, OutputFormat::Json)
32    }
33
34    /// Print data (tables, JSON, values) to stdout. Always shown.
35    pub fn print_data(&self, data: &str) {
36        println!("{data}");
37    }
38
39    /// Print informational message to stderr. Suppressed by --quiet.
40    pub fn print_message(&self, msg: &str) {
41        if !self.quiet {
42            eprintln!("{msg}");
43        }
44    }
45
46    /// Print an error. In JSON mode, emits the structured error envelope to stdout.
47    /// In human mode, prints to stderr.
48    pub fn print_error(&self, e: &HaError) {
49        if self.is_json() {
50            let envelope = serde_json::json!({
51                "ok": false,
52                "error": {
53                    "code": e.error_code(),
54                    "message": e.to_string()
55                }
56            });
57            println!(
58                "{}",
59                serde_json::to_string_pretty(&envelope).expect("serialize")
60            );
61        } else {
62            eprintln!("{e}");
63        }
64    }
65
66    /// Print a JSON result or human message depending on format.
67    pub fn print_result(&self, json_value: &serde_json::Value, human_message: &str) {
68        if self.is_json() {
69            println!(
70                "{}",
71                serde_json::to_string_pretty(json_value).expect("serialize")
72            );
73        } else {
74            println!("{human_message}");
75        }
76    }
77}
78
79pub mod exit_codes {
80    use super::HaError;
81
82    pub const SUCCESS: i32 = 0;
83    pub const GENERAL_ERROR: i32 = 1;
84    pub const CONFIG_ERROR: i32 = 2;
85    pub const NOT_FOUND: i32 = 3;
86    pub const CONNECTION_ERROR: i32 = 4;
87
88    pub fn for_error(e: &HaError) -> i32 {
89        match e {
90            HaError::Auth(_) | HaError::InvalidInput(_) => CONFIG_ERROR,
91            HaError::NotFound(_) => NOT_FOUND,
92            HaError::Connection(_) => CONNECTION_ERROR,
93            _ => GENERAL_ERROR,
94        }
95    }
96}
97
98/// Mask a credential for safe display.
99/// Keeps first 6 and last 4 chars for long values; fully obscures short values.
100pub fn mask_credential(s: &str) -> String {
101    if s.len() <= 10 {
102        return "•".repeat(s.len());
103    }
104    format!("{}…{}", &s[..6], &s[s.len() - 4..])
105}
106
107/// Render a two-column key/value block with aligned values.
108pub fn kv_block(pairs: &[(&str, String)]) -> String {
109    let max_key = pairs.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
110    pairs
111        .iter()
112        .map(|(k, v)| format!("{:width$}  {}", k, v, width = max_key))
113        .collect::<Vec<_>>()
114        .join("\n")
115}
116
117/// Render a simple table with header and data rows.
118pub fn table(headers: &[&str], rows: &[Vec<String>]) -> String {
119    let col_count = headers.len();
120    let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
121    for row in rows {
122        for (i, cell) in row.iter().enumerate() {
123            if i < col_count {
124                widths[i] = widths[i].max(cell.len());
125            }
126        }
127    }
128
129    let header_line: String = headers
130        .iter()
131        .enumerate()
132        .map(|(i, h)| format!("{:width$}", h, width = widths[i]))
133        .collect::<Vec<_>>()
134        .join("  ");
135
136    let sep: String = widths
137        .iter()
138        .map(|w| "-".repeat(*w))
139        .collect::<Vec<_>>()
140        .join("  ");
141
142    let data_lines: Vec<String> = rows
143        .iter()
144        .map(|row| {
145            row.iter()
146                .enumerate()
147                .take(col_count)
148                .map(|(i, cell)| format!("{:width$}", cell, width = widths[i]))
149                .collect::<Vec<_>>()
150                .join("  ")
151        })
152        .collect();
153
154    let mut out = vec![header_line, sep];
155    out.extend(data_lines);
156    out.join("\n")
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn mask_credential_masks_long_values() {
165        assert_eq!(mask_credential("abcdefghijklmnop"), "abcdef…mnop");
166    }
167
168    #[test]
169    fn mask_credential_dots_short_values() {
170        assert_eq!(mask_credential("short"), "•••••");
171        assert_eq!(mask_credential(""), "");
172    }
173
174    #[test]
175    fn kv_block_aligns_values() {
176        let pairs = [("entity_id", "light.x".into()), ("state", "on".into())];
177        let out = kv_block(&pairs);
178        let lines: Vec<&str> = out.lines().collect();
179        let v1_pos = lines[0].find("light.x").unwrap();
180        let v2_pos = lines[1].find("on").unwrap();
181        assert_eq!(v1_pos, v2_pos);
182    }
183
184    #[test]
185    fn table_renders_header_separator_and_rows() {
186        let headers = ["ENTITY", "STATE"];
187        let rows = vec![
188            vec!["light.living_room".into(), "on".into()],
189            vec!["switch.fan".into(), "off".into()],
190        ];
191        let out = table(&headers, &rows);
192        let lines: Vec<&str> = out.lines().collect();
193        assert!(lines[0].contains("ENTITY") && lines[0].contains("STATE"));
194        assert!(lines[1].contains("---"));
195        assert!(lines[2].contains("light.living_room"));
196        assert!(lines[3].contains("switch.fan"));
197    }
198
199    #[test]
200    fn print_error_json_mode_emits_envelope_to_stdout() {
201        // Verify the envelope structure by exercising the serialization path directly.
202        let e = crate::api::HaError::NotFound("light.missing".into());
203        let envelope = serde_json::json!({
204            "ok": false,
205            "error": {
206                "code": e.error_code(),
207                "message": e.to_string()
208            }
209        });
210        assert_eq!(envelope["ok"], false);
211        assert_eq!(envelope["error"]["code"], "HA_NOT_FOUND");
212        assert!(
213            envelope["error"]["message"]
214                .as_str()
215                .unwrap()
216                .contains("light.missing")
217        );
218    }
219
220    #[test]
221    fn exit_code_for_auth_error_is_2() {
222        assert_eq!(
223            exit_codes::for_error(&crate::api::HaError::Auth("x".into())),
224            2
225        );
226    }
227
228    #[test]
229    fn exit_code_for_not_found_is_3() {
230        assert_eq!(
231            exit_codes::for_error(&crate::api::HaError::NotFound("x".into())),
232            3
233        );
234    }
235
236    #[test]
237    fn exit_code_for_connection_error_is_4() {
238        assert_eq!(
239            exit_codes::for_error(&crate::api::HaError::Connection("x".into())),
240            4
241        );
242    }
243}