1use std::io::IsTerminal;
2
3use owo_colors::OwoColorize;
4
5use crate::api::HaError;
6
7#[derive(Clone, Copy, Debug, PartialEq, clap::ValueEnum)]
8pub enum OutputFormat {
9 Json,
10 Table,
11 Plain,
12}
13
14#[derive(Clone, Copy)]
15pub struct OutputConfig {
16 pub format: OutputFormat,
17 pub quiet: bool,
18}
19
20impl OutputConfig {
21 pub fn new(format_arg: Option<OutputFormat>, quiet: bool) -> Self {
22 let format = format_arg.unwrap_or_else(|| {
23 if std::io::stdout().is_terminal() {
24 OutputFormat::Table
25 } else {
26 OutputFormat::Json
27 }
28 });
29 Self { format, quiet }
30 }
31
32 pub fn is_json(&self) -> bool {
33 matches!(self.format, OutputFormat::Json)
34 }
35
36 pub fn print_data(&self, data: &str) {
38 println!("{data}");
39 }
40
41 pub fn print_message(&self, msg: &str) {
43 if !self.quiet {
44 eprintln!("{msg}");
45 }
46 }
47
48 pub fn print_error(&self, e: &HaError) {
51 if self.is_json() {
52 let envelope = serde_json::json!({
53 "ok": false,
54 "error": {
55 "code": e.error_code(),
56 "message": e.to_string()
57 }
58 });
59 println!(
60 "{}",
61 serde_json::to_string_pretty(&envelope).expect("serialize")
62 );
63 } else {
64 eprintln!("{e}");
65 }
66 }
67
68 pub fn print_result(&self, json_value: &serde_json::Value, human_message: &str) {
70 if self.is_json() {
71 println!(
72 "{}",
73 serde_json::to_string_pretty(json_value).expect("serialize")
74 );
75 } else {
76 println!("{human_message}");
77 }
78 }
79}
80
81pub fn colored_state(state: &str) -> String {
83 match state {
84 "on" | "open" | "home" | "active" | "playing" => state.green().to_string(),
85 "off" | "closed" | "not_home" | "idle" | "paused" => state.dimmed().to_string(),
86 "unavailable" | "unknown" => state.yellow().to_string(),
87 _ => state.to_owned(),
88 }
89}
90
91pub fn colored_entity_id(entity_id: &str) -> String {
94 match entity_id.split_once('.') {
95 Some((domain, name)) => format!("{}.{}", domain.dimmed(), name),
96 None => entity_id.to_owned(),
97 }
98}
99
100pub fn relative_time(iso: &str) -> String {
103 use std::time::{SystemTime, UNIX_EPOCH};
104
105 let now = SystemTime::now()
106 .duration_since(UNIX_EPOCH)
107 .map(|d| d.as_secs())
108 .unwrap_or(0);
109
110 match parse_unix_secs(iso) {
111 Some(ts) => {
112 let secs = now.saturating_sub(ts);
113 let s = if secs < 60 {
114 format!("{secs}s ago")
115 } else if secs < 3600 {
116 format!("{}m ago", secs / 60)
117 } else if secs < 86400 {
118 format!("{}h ago", secs / 3600)
119 } else {
120 format!("{}d ago", secs / 86400)
121 };
122 if secs >= 300 {
124 s.dimmed().to_string()
125 } else {
126 s
127 }
128 }
129 None => iso.to_owned(),
130 }
131}
132
133fn parse_unix_secs(s: &str) -> Option<u64> {
136 if s.len() < 19 {
137 return None;
138 }
139 let year: i64 = s.get(0..4)?.parse().ok()?;
140 let month: i64 = s.get(5..7)?.parse().ok()?;
141 let day: i64 = s.get(8..10)?.parse().ok()?;
142 let hour: i64 = s.get(11..13)?.parse().ok()?;
143 let min: i64 = s.get(14..16)?.parse().ok()?;
144 let sec: i64 = s.get(17..19)?.parse().ok()?;
145
146 let rest = s.get(19..)?;
148 let rest = if rest.starts_with('.') {
149 let end = rest.find(['+', '-', 'Z']).unwrap_or(rest.len());
150 &rest[end..]
151 } else {
152 rest
153 };
154 let tz_secs: i64 = if rest.is_empty() || rest == "Z" {
155 0
156 } else {
157 let sign: i64 = if rest.starts_with('-') { -1 } else { 1 };
158 let tz = rest.get(1..)?;
159 let h: i64 = tz.get(0..2)?.parse().ok()?;
160 let m: i64 = tz.get(3..5)?.parse().ok()?;
161 sign * (h * 3600 + m * 60)
162 };
163
164 let y = year - i64::from(month <= 2);
166 let era = y.div_euclid(400);
167 let yoe = y - era * 400;
168 let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
169 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
170 let days = era * 146_097 + doe - 719_468;
171
172 let unix = days * 86_400 + hour * 3_600 + min * 60 + sec - tz_secs;
173 u64::try_from(unix).ok()
174}
175
176pub mod exit_codes {
177 use super::HaError;
178
179 pub const SUCCESS: i32 = 0;
180 pub const GENERAL_ERROR: i32 = 1;
181 pub const CONFIG_ERROR: i32 = 2;
182 pub const NOT_FOUND: i32 = 3;
183 pub const CONNECTION_ERROR: i32 = 4;
184 pub const PARTIAL_FAILURE: i32 = 5;
186
187 pub fn for_error(e: &HaError) -> i32 {
188 match e {
189 HaError::Auth(_) | HaError::InvalidInput(_) => CONFIG_ERROR,
190 HaError::NotFound(_) => NOT_FOUND,
191 HaError::Connection(_) => CONNECTION_ERROR,
192 _ => GENERAL_ERROR,
193 }
194 }
195}
196
197pub fn mask_credential(s: &str) -> String {
200 if s.len() <= 10 {
201 return "•".repeat(s.len());
202 }
203 format!("{}…{}", &s[..6], &s[s.len() - 4..])
204}
205
206pub fn kv_block(pairs: &[(&str, String)]) -> String {
208 let max_key = pairs.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
209 pairs
210 .iter()
211 .map(|(k, v)| format!("{:width$} {}", k, v, width = max_key))
212 .collect::<Vec<_>>()
213 .join("\n")
214}
215
216fn visible_len(s: &str) -> usize {
218 let mut len = 0;
219 let mut in_escape = false;
220 for c in s.chars() {
221 if c == '\x1b' {
222 in_escape = true;
223 } else if in_escape {
224 if c == 'm' {
225 in_escape = false;
226 }
227 } else {
228 len += 1;
229 }
230 }
231 len
232}
233
234fn pad_cell(s: &str, width: usize) -> String {
236 let vlen = visible_len(s);
237 let padding = width.saturating_sub(vlen);
238 format!("{}{}", s, " ".repeat(padding))
239}
240
241fn terminal_width() -> usize {
243 use std::io::IsTerminal;
244 if !std::io::stdout().is_terminal() {
245 return usize::MAX; }
247 terminal_size::terminal_size()
248 .map(|(terminal_size::Width(w), _)| w as usize)
249 .unwrap_or(120)
250}
251
252fn truncate_cell(s: &str, max_visible: usize) -> String {
254 if max_visible == 0 {
255 return String::new();
256 }
257 if visible_len(s) <= max_visible {
258 return s.to_owned();
259 }
260 let mut out = String::new();
262 let mut visible = 0;
263 let mut in_escape = false;
264 let target = max_visible.saturating_sub(1); for c in s.chars() {
266 if c == '\x1b' {
267 in_escape = true;
268 out.push(c);
269 } else if in_escape {
270 out.push(c);
271 if c == 'm' {
272 in_escape = false;
273 }
274 } else if visible < target {
275 out.push(c);
276 visible += 1;
277 } else {
278 break;
279 }
280 }
281 out.push_str("\x1b[0m");
283 out.push('…');
284 out
285}
286
287pub fn table(headers: &[&str], rows: &[Vec<String>]) -> String {
291 let col_count = headers.len();
292 let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
294 for row in rows {
295 for (i, cell) in row.iter().enumerate() {
296 if i < col_count {
297 widths[i] = widths[i].max(visible_len(cell));
298 }
299 }
300 }
301
302 let term_w = terminal_width();
304 let separators = col_count.saturating_sub(1) * 2;
305 let total: usize = widths.iter().sum::<usize>() + separators;
306 if total > term_w {
307 let budget = term_w.saturating_sub(separators);
308 loop {
310 let current: usize = widths.iter().sum();
311 if current <= budget {
312 break;
313 }
314 let max_w = *widths.iter().max().unwrap_or(&0);
315 if max_w == 0 {
316 break;
317 }
318 let second = widths
320 .iter()
321 .filter(|&&w| w < max_w)
322 .copied()
323 .max()
324 .unwrap_or(0);
325 let n_max = widths.iter().filter(|&&w| w == max_w).count();
326 let excess = current - budget;
327 let headroom = (max_w - second) * n_max;
329 if headroom >= excess {
330 let cut = excess.div_ceil(n_max);
331 for w in &mut widths {
332 if *w == max_w {
333 *w = max_w.saturating_sub(cut);
334 }
335 }
336 } else {
337 for w in &mut widths {
338 if *w == max_w {
339 *w = second;
340 }
341 }
342 }
343 if widths.iter().all(|&w| w <= 4) {
345 widths.fill(4);
346 break;
347 }
348 }
349 let min_col = budget / col_count;
351 for w in &mut widths {
352 *w = (*w).max(min_col.min(4));
353 }
354 }
355
356 let header_line: String = headers
358 .iter()
359 .enumerate()
360 .map(|(i, h)| {
361 let truncated = truncate_cell(h, widths[i]);
362 pad_cell(&truncated.bold().to_string(), widths[i])
363 })
364 .collect::<Vec<_>>()
365 .join(" ");
366
367 let sep: String = widths
368 .iter()
369 .map(|w| "─".repeat(*w).dimmed().to_string())
370 .collect::<Vec<_>>()
371 .join(" ");
372
373 let data_lines: Vec<String> = rows
374 .iter()
375 .map(|row| {
376 row.iter()
377 .enumerate()
378 .take(col_count)
379 .map(|(i, cell)| {
380 let truncated = truncate_cell(cell, widths[i]);
381 pad_cell(&truncated, widths[i])
382 })
383 .collect::<Vec<_>>()
384 .join(" ")
385 })
386 .collect();
387
388 let mut out = vec![header_line, sep];
389 out.extend(data_lines);
390 out.join("\n")
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
398 fn parse_unix_secs_handles_utc_z() {
399 assert_eq!(parse_unix_secs("1970-01-01T00:00:00Z"), Some(0));
401 }
402
403 #[test]
404 fn parse_unix_secs_handles_offset() {
405 assert_eq!(parse_unix_secs("1970-01-01T01:00:00+01:00"), Some(0));
407 }
408
409 #[test]
410 fn parse_unix_secs_handles_fractional_seconds() {
411 assert_eq!(parse_unix_secs("1970-01-01T00:00:01.999999+00:00"), Some(1));
412 }
413
414 #[test]
415 fn parse_unix_secs_rejects_short_input() {
416 assert_eq!(parse_unix_secs("2026-01"), None);
417 }
418
419 #[test]
420 fn relative_time_falls_back_on_invalid_input() {
421 assert_eq!(relative_time("not-a-date"), "not-a-date");
422 }
423
424 #[test]
425 fn mask_credential_masks_long_values() {
426 assert_eq!(mask_credential("abcdefghijklmnop"), "abcdef…mnop");
427 }
428
429 #[test]
430 fn mask_credential_dots_short_values() {
431 assert_eq!(mask_credential("short"), "•••••");
432 assert_eq!(mask_credential(""), "");
433 }
434
435 #[test]
436 fn kv_block_aligns_values() {
437 let pairs = [("entity_id", "light.x".into()), ("state", "on".into())];
438 let out = kv_block(&pairs);
439 let lines: Vec<&str> = out.lines().collect();
440 let v1_pos = lines[0].find("light.x").unwrap();
441 let v2_pos = lines[1].find("on").unwrap();
442 assert_eq!(v1_pos, v2_pos);
443 }
444
445 #[test]
446 fn truncate_cell_shortens_plain_string() {
447 let result = truncate_cell("hello world", 7);
448 assert!(visible_len(&result) <= 7);
449 assert!(result.contains('…'));
450 }
451
452 #[test]
453 fn truncate_cell_leaves_short_string_intact() {
454 assert_eq!(truncate_cell("hi", 10), "hi");
455 }
456
457 #[test]
458 fn table_renders_header_separator_and_rows() {
459 let headers = ["ENTITY", "STATE"];
460 let rows = vec![
461 vec!["light.living_room".into(), "on".into()],
462 vec!["switch.fan".into(), "off".into()],
463 ];
464 let out = table(&headers, &rows);
465 let lines: Vec<&str> = out.lines().collect();
466 assert!(lines[0].contains("ENTITY") && lines[0].contains("STATE"));
467 assert!(lines[1].contains("─"));
468 assert!(lines[2].contains("light.living_room"));
469 assert!(lines[3].contains("switch.fan"));
470 }
471
472 #[test]
473 fn print_error_json_mode_emits_envelope_to_stdout() {
474 let e = crate::api::HaError::NotFound("light.missing".into());
476 let envelope = serde_json::json!({
477 "ok": false,
478 "error": {
479 "code": e.error_code(),
480 "message": e.to_string()
481 }
482 });
483 assert_eq!(envelope["ok"], false);
484 assert_eq!(envelope["error"]["code"], "HA_NOT_FOUND");
485 assert!(
486 envelope["error"]["message"]
487 .as_str()
488 .unwrap()
489 .contains("light.missing")
490 );
491 }
492
493 #[test]
494 fn exit_code_for_auth_error_is_2() {
495 assert_eq!(
496 exit_codes::for_error(&crate::api::HaError::Auth("x".into())),
497 2
498 );
499 }
500
501 #[test]
502 fn exit_code_for_not_found_is_3() {
503 assert_eq!(
504 exit_codes::for_error(&crate::api::HaError::NotFound("x".into())),
505 3
506 );
507 }
508
509 #[test]
510 fn exit_code_for_connection_error_is_4() {
511 assert_eq!(
512 exit_codes::for_error(&crate::api::HaError::Connection("x".into())),
513 4
514 );
515 }
516}