homeassistant_cli/
output.rs1use 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 pub fn print_data(&self, data: &str) {
36 println!("{data}");
37 }
38
39 pub fn print_message(&self, msg: &str) {
41 if !self.quiet {
42 eprintln!("{msg}");
43 }
44 }
45
46 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 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
98pub 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
107pub 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
117pub 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 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}