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 fn relative_time(iso: &str) -> String {
82 use std::time::{SystemTime, UNIX_EPOCH};
83
84 let now = SystemTime::now()
85 .duration_since(UNIX_EPOCH)
86 .map(|d| d.as_secs())
87 .unwrap_or(0);
88
89 match parse_unix_secs(iso) {
90 Some(ts) => {
91 let secs = now.saturating_sub(ts);
92 if secs < 60 {
93 format!("{secs}s ago")
94 } else if secs < 3600 {
95 format!("{}m ago", secs / 60)
96 } else if secs < 86400 {
97 format!("{}h ago", secs / 3600)
98 } else {
99 format!("{}d ago", secs / 86400)
100 }
101 }
102 None => iso.to_owned(),
103 }
104}
105
106fn parse_unix_secs(s: &str) -> Option<u64> {
109 if s.len() < 19 {
110 return None;
111 }
112 let year: i64 = s.get(0..4)?.parse().ok()?;
113 let month: i64 = s.get(5..7)?.parse().ok()?;
114 let day: i64 = s.get(8..10)?.parse().ok()?;
115 let hour: i64 = s.get(11..13)?.parse().ok()?;
116 let min: i64 = s.get(14..16)?.parse().ok()?;
117 let sec: i64 = s.get(17..19)?.parse().ok()?;
118
119 let rest = s.get(19..)?;
121 let rest = if rest.starts_with('.') {
122 let end = rest.find(['+', '-', 'Z']).unwrap_or(rest.len());
123 &rest[end..]
124 } else {
125 rest
126 };
127 let tz_secs: i64 = if rest.is_empty() || rest == "Z" {
128 0
129 } else {
130 let sign: i64 = if rest.starts_with('-') { -1 } else { 1 };
131 let tz = rest.get(1..)?;
132 let h: i64 = tz.get(0..2)?.parse().ok()?;
133 let m: i64 = tz.get(3..5)?.parse().ok()?;
134 sign * (h * 3600 + m * 60)
135 };
136
137 let y = year - i64::from(month <= 2);
139 let era = y.div_euclid(400);
140 let yoe = y - era * 400;
141 let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
142 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
143 let days = era * 146_097 + doe - 719_468;
144
145 let unix = days * 86_400 + hour * 3_600 + min * 60 + sec - tz_secs;
146 u64::try_from(unix).ok()
147}
148
149pub mod exit_codes {
150 use super::HaError;
151
152 pub const SUCCESS: i32 = 0;
153 pub const GENERAL_ERROR: i32 = 1;
154 pub const CONFIG_ERROR: i32 = 2;
155 pub const NOT_FOUND: i32 = 3;
156 pub const CONNECTION_ERROR: i32 = 4;
157
158 pub fn for_error(e: &HaError) -> i32 {
159 match e {
160 HaError::Auth(_) | HaError::InvalidInput(_) => CONFIG_ERROR,
161 HaError::NotFound(_) => NOT_FOUND,
162 HaError::Connection(_) => CONNECTION_ERROR,
163 _ => GENERAL_ERROR,
164 }
165 }
166}
167
168pub fn mask_credential(s: &str) -> String {
171 if s.len() <= 10 {
172 return "•".repeat(s.len());
173 }
174 format!("{}…{}", &s[..6], &s[s.len() - 4..])
175}
176
177pub fn kv_block(pairs: &[(&str, String)]) -> String {
179 let max_key = pairs.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
180 pairs
181 .iter()
182 .map(|(k, v)| format!("{:width$} {}", k, v, width = max_key))
183 .collect::<Vec<_>>()
184 .join("\n")
185}
186
187pub fn table(headers: &[&str], rows: &[Vec<String>]) -> String {
189 let col_count = headers.len();
190 let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
191 for row in rows {
192 for (i, cell) in row.iter().enumerate() {
193 if i < col_count {
194 widths[i] = widths[i].max(cell.len());
195 }
196 }
197 }
198
199 let header_line: String = headers
200 .iter()
201 .enumerate()
202 .map(|(i, h)| format!("{:width$}", h, width = widths[i]))
203 .collect::<Vec<_>>()
204 .join(" ");
205
206 let sep: String = widths
207 .iter()
208 .map(|w| "-".repeat(*w))
209 .collect::<Vec<_>>()
210 .join(" ");
211
212 let data_lines: Vec<String> = rows
213 .iter()
214 .map(|row| {
215 row.iter()
216 .enumerate()
217 .take(col_count)
218 .map(|(i, cell)| format!("{:width$}", cell, width = widths[i]))
219 .collect::<Vec<_>>()
220 .join(" ")
221 })
222 .collect();
223
224 let mut out = vec![header_line, sep];
225 out.extend(data_lines);
226 out.join("\n")
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn parse_unix_secs_handles_utc_z() {
235 assert_eq!(parse_unix_secs("1970-01-01T00:00:00Z"), Some(0));
237 }
238
239 #[test]
240 fn parse_unix_secs_handles_offset() {
241 assert_eq!(parse_unix_secs("1970-01-01T01:00:00+01:00"), Some(0));
243 }
244
245 #[test]
246 fn parse_unix_secs_handles_fractional_seconds() {
247 assert_eq!(parse_unix_secs("1970-01-01T00:00:01.999999+00:00"), Some(1));
248 }
249
250 #[test]
251 fn parse_unix_secs_rejects_short_input() {
252 assert_eq!(parse_unix_secs("2026-01"), None);
253 }
254
255 #[test]
256 fn relative_time_falls_back_on_invalid_input() {
257 assert_eq!(relative_time("not-a-date"), "not-a-date");
258 }
259
260 #[test]
261 fn mask_credential_masks_long_values() {
262 assert_eq!(mask_credential("abcdefghijklmnop"), "abcdef…mnop");
263 }
264
265 #[test]
266 fn mask_credential_dots_short_values() {
267 assert_eq!(mask_credential("short"), "•••••");
268 assert_eq!(mask_credential(""), "");
269 }
270
271 #[test]
272 fn kv_block_aligns_values() {
273 let pairs = [("entity_id", "light.x".into()), ("state", "on".into())];
274 let out = kv_block(&pairs);
275 let lines: Vec<&str> = out.lines().collect();
276 let v1_pos = lines[0].find("light.x").unwrap();
277 let v2_pos = lines[1].find("on").unwrap();
278 assert_eq!(v1_pos, v2_pos);
279 }
280
281 #[test]
282 fn table_renders_header_separator_and_rows() {
283 let headers = ["ENTITY", "STATE"];
284 let rows = vec![
285 vec!["light.living_room".into(), "on".into()],
286 vec!["switch.fan".into(), "off".into()],
287 ];
288 let out = table(&headers, &rows);
289 let lines: Vec<&str> = out.lines().collect();
290 assert!(lines[0].contains("ENTITY") && lines[0].contains("STATE"));
291 assert!(lines[1].contains("---"));
292 assert!(lines[2].contains("light.living_room"));
293 assert!(lines[3].contains("switch.fan"));
294 }
295
296 #[test]
297 fn print_error_json_mode_emits_envelope_to_stdout() {
298 let e = crate::api::HaError::NotFound("light.missing".into());
300 let envelope = serde_json::json!({
301 "ok": false,
302 "error": {
303 "code": e.error_code(),
304 "message": e.to_string()
305 }
306 });
307 assert_eq!(envelope["ok"], false);
308 assert_eq!(envelope["error"]["code"], "HA_NOT_FOUND");
309 assert!(
310 envelope["error"]["message"]
311 .as_str()
312 .unwrap()
313 .contains("light.missing")
314 );
315 }
316
317 #[test]
318 fn exit_code_for_auth_error_is_2() {
319 assert_eq!(
320 exit_codes::for_error(&crate::api::HaError::Auth("x".into())),
321 2
322 );
323 }
324
325 #[test]
326 fn exit_code_for_not_found_is_3() {
327 assert_eq!(
328 exit_codes::for_error(&crate::api::HaError::NotFound("x".into())),
329 3
330 );
331 }
332
333 #[test]
334 fn exit_code_for_connection_error_is_4() {
335 assert_eq!(
336 exit_codes::for_error(&crate::api::HaError::Connection("x".into())),
337 4
338 );
339 }
340}