1use std::io::IsTerminal;
2
3use owo_colors::OwoColorize;
4
5use crate::api::HaError;
6
7#[derive(Clone, Copy, Debug, PartialEq, clap::ValueEnum)]
10pub enum OutputFormat {
11 Auto,
13 Text,
15 Json,
17 #[value(hide = true)]
19 Plain,
20 #[value(hide = true)]
22 Table,
23}
24
25#[derive(Clone, Copy)]
26pub struct OutputConfig {
27 pub format: OutputFormat,
28 pub quiet: bool,
29}
30
31impl OutputConfig {
32 pub fn new(format_arg: Option<OutputFormat>, quiet: bool) -> Self {
33 let format = format_arg.unwrap_or(OutputFormat::Auto);
34 Self { format, quiet }
35 }
36
37 pub fn is_json(&self) -> bool {
38 match self.format {
39 OutputFormat::Json => true,
40 OutputFormat::Text | OutputFormat::Plain | OutputFormat::Table => false,
41 OutputFormat::Auto => !std::io::stdout().is_terminal(),
42 }
43 }
44
45 pub fn print_data(&self, data: &str) {
47 println!("{data}");
48 }
49
50 pub fn print_message(&self, msg: &str) {
52 if !self.quiet {
53 eprintln!("{msg}");
54 }
55 }
56
57 pub fn print_error(&self, e: &HaError) {
60 let envelope = serde_json::json!({
61 "error": {
62 "kind": e.error_kind(),
63 "message": e.to_string(),
64 "hint": e.error_hint(),
65 }
66 });
67 eprintln!("{}", serde_json::to_string(&envelope).expect("serialize"));
68 }
69
70 pub fn print_result(&self, json_value: &serde_json::Value, human_message: &str) {
72 if self.is_json() {
73 println!(
74 "{}",
75 serde_json::to_string_pretty(json_value).expect("serialize")
76 );
77 } else {
78 println!("{human_message}");
79 }
80 }
81}
82
83pub fn colored_state(state: &str) -> String {
85 match state {
86 "on" | "open" | "home" | "active" | "playing" => state.green().to_string(),
87 "off" | "closed" | "not_home" | "idle" | "paused" => state.dimmed().to_string(),
88 "unavailable" | "unknown" => state.yellow().to_string(),
89 _ => state.to_owned(),
90 }
91}
92
93pub fn colored_entity_id(entity_id: &str) -> String {
96 match entity_id.split_once('.') {
97 Some((domain, name)) => format!("{}.{}", domain.dimmed(), name),
98 None => entity_id.to_owned(),
99 }
100}
101
102pub fn relative_time(iso: &str) -> String {
105 use std::time::{SystemTime, UNIX_EPOCH};
106
107 let now = SystemTime::now()
108 .duration_since(UNIX_EPOCH)
109 .map(|d| d.as_secs())
110 .unwrap_or(0);
111
112 match parse_unix_secs(iso) {
113 Some(ts) => {
114 let secs = now.saturating_sub(ts);
115 let s = if secs < 60 {
116 format!("{secs}s ago")
117 } else if secs < 3600 {
118 format!("{}m ago", secs / 60)
119 } else if secs < 86400 {
120 format!("{}h ago", secs / 3600)
121 } else {
122 format!("{}d ago", secs / 86400)
123 };
124 if secs >= 300 {
126 s.dimmed().to_string()
127 } else {
128 s
129 }
130 }
131 None => iso.to_owned(),
132 }
133}
134
135fn parse_unix_secs(s: &str) -> Option<u64> {
138 if s.len() < 19 {
139 return None;
140 }
141 let year: i64 = s.get(0..4)?.parse().ok()?;
142 let month: i64 = s.get(5..7)?.parse().ok()?;
143 let day: i64 = s.get(8..10)?.parse().ok()?;
144 let hour: i64 = s.get(11..13)?.parse().ok()?;
145 let min: i64 = s.get(14..16)?.parse().ok()?;
146 let sec: i64 = s.get(17..19)?.parse().ok()?;
147
148 let rest = s.get(19..)?;
150 let rest = if rest.starts_with('.') {
151 let end = rest.find(['+', '-', 'Z']).unwrap_or(rest.len());
152 &rest[end..]
153 } else {
154 rest
155 };
156 let tz_secs: i64 = if rest.is_empty() || rest == "Z" {
157 0
158 } else {
159 let sign: i64 = if rest.starts_with('-') { -1 } else { 1 };
160 let tz = rest.get(1..)?;
161 let h: i64 = tz.get(0..2)?.parse().ok()?;
162 let m: i64 = tz.get(3..5)?.parse().ok()?;
163 sign * (h * 3600 + m * 60)
164 };
165
166 let y = year - i64::from(month <= 2);
168 let era = y.div_euclid(400);
169 let yoe = y - era * 400;
170 let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
171 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
172 let days = era * 146_097 + doe - 719_468;
173
174 let unix = days * 86_400 + hour * 3_600 + min * 60 + sec - tz_secs;
175 u64::try_from(unix).ok()
176}
177
178pub mod exit_codes {
179 use super::HaError;
180
181 pub const SUCCESS: i32 = 0;
182 pub const GENERAL_ERROR: i32 = 1;
183 pub const CONFIG_ERROR: i32 = 2;
185 pub const NOT_FOUND: i32 = 3;
187 pub const CONNECTION_ERROR: i32 = 4;
189 pub const PARTIAL_FAILURE: i32 = 5;
191 pub const CONFIRMATION_REQUIRED: i32 = 6;
193 pub const CONFLICT: i32 = 7;
195
196 pub fn for_error(e: &HaError) -> i32 {
197 match e {
198 HaError::Auth(_) => CONFIG_ERROR,
199 HaError::InvalidInput(_) => GENERAL_ERROR,
200 HaError::NotFound(_) => NOT_FOUND,
201 HaError::Connection(_) => CONNECTION_ERROR,
202 HaError::ConfirmationRequired(_) => CONFIRMATION_REQUIRED,
203 HaError::Conflict(_) => CONFLICT,
204 _ => GENERAL_ERROR,
205 }
206 }
207}
208
209pub fn mask_credential(s: &str) -> String {
212 if s.len() <= 10 {
213 return "•".repeat(s.len());
214 }
215 format!("{}…{}", &s[..6], &s[s.len() - 4..])
216}
217
218pub fn kv_block(pairs: &[(&str, String)]) -> String {
220 let max_key = pairs.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
221 pairs
222 .iter()
223 .map(|(k, v)| format!("{:width$} {}", k, v, width = max_key))
224 .collect::<Vec<_>>()
225 .join("\n")
226}
227
228fn visible_len(s: &str) -> usize {
230 let mut len = 0;
231 let mut in_escape = false;
232 for c in s.chars() {
233 if c == '\x1b' {
234 in_escape = true;
235 } else if in_escape {
236 if c == 'm' {
237 in_escape = false;
238 }
239 } else {
240 len += 1;
241 }
242 }
243 len
244}
245
246fn pad_cell(s: &str, width: usize) -> String {
248 let vlen = visible_len(s);
249 let padding = width.saturating_sub(vlen);
250 format!("{}{}", s, " ".repeat(padding))
251}
252
253fn terminal_width() -> usize {
255 use std::io::IsTerminal;
256 if !std::io::stdout().is_terminal() {
257 return usize::MAX; }
259 terminal_size::terminal_size()
260 .map(|(terminal_size::Width(w), _)| w as usize)
261 .unwrap_or(120)
262}
263
264fn truncate_cell(s: &str, max_visible: usize) -> String {
266 if max_visible == 0 {
267 return String::new();
268 }
269 if visible_len(s) <= max_visible {
270 return s.to_owned();
271 }
272 let mut out = String::new();
274 let mut visible = 0;
275 let mut in_escape = false;
276 let target = max_visible.saturating_sub(1); for c in s.chars() {
278 if c == '\x1b' {
279 in_escape = true;
280 out.push(c);
281 } else if in_escape {
282 out.push(c);
283 if c == 'm' {
284 in_escape = false;
285 }
286 } else if visible < target {
287 out.push(c);
288 visible += 1;
289 } else {
290 break;
291 }
292 }
293 out.push_str("\x1b[0m");
295 out.push('…');
296 out
297}
298
299pub fn table(headers: &[&str], rows: &[Vec<String>]) -> String {
303 let col_count = headers.len();
304 let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
306 for row in rows {
307 for (i, cell) in row.iter().enumerate() {
308 if i < col_count {
309 widths[i] = widths[i].max(visible_len(cell));
310 }
311 }
312 }
313
314 let term_w = terminal_width();
316 let separators = col_count.saturating_sub(1) * 2;
317 let total: usize = widths.iter().sum::<usize>() + separators;
318 if total > term_w {
319 let budget = term_w.saturating_sub(separators);
320 loop {
322 let current: usize = widths.iter().sum();
323 if current <= budget {
324 break;
325 }
326 let max_w = *widths.iter().max().unwrap_or(&0);
327 if max_w == 0 {
328 break;
329 }
330 let second = widths
332 .iter()
333 .filter(|&&w| w < max_w)
334 .copied()
335 .max()
336 .unwrap_or(0);
337 let n_max = widths.iter().filter(|&&w| w == max_w).count();
338 let excess = current - budget;
339 let headroom = (max_w - second) * n_max;
341 if headroom >= excess {
342 let cut = excess.div_ceil(n_max);
343 for w in &mut widths {
344 if *w == max_w {
345 *w = max_w.saturating_sub(cut);
346 }
347 }
348 } else {
349 for w in &mut widths {
350 if *w == max_w {
351 *w = second;
352 }
353 }
354 }
355 if widths.iter().all(|&w| w <= 4) {
357 widths.fill(4);
358 break;
359 }
360 }
361 let min_col = budget / col_count;
363 for w in &mut widths {
364 *w = (*w).max(min_col.min(4));
365 }
366 }
367
368 let header_line: String = headers
370 .iter()
371 .enumerate()
372 .map(|(i, h)| {
373 let truncated = truncate_cell(h, widths[i]);
374 pad_cell(&truncated.bold().to_string(), widths[i])
375 })
376 .collect::<Vec<_>>()
377 .join(" ");
378
379 let sep: String = widths
380 .iter()
381 .map(|w| "─".repeat(*w).dimmed().to_string())
382 .collect::<Vec<_>>()
383 .join(" ");
384
385 let data_lines: Vec<String> = rows
386 .iter()
387 .map(|row| {
388 row.iter()
389 .enumerate()
390 .take(col_count)
391 .map(|(i, cell)| {
392 let truncated = truncate_cell(cell, widths[i]);
393 pad_cell(&truncated, widths[i])
394 })
395 .collect::<Vec<_>>()
396 .join(" ")
397 })
398 .collect();
399
400 let mut out = vec![header_line, sep];
401 out.extend(data_lines);
402 out.join("\n")
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 #[test]
410 fn parse_unix_secs_handles_utc_z() {
411 assert_eq!(parse_unix_secs("1970-01-01T00:00:00Z"), Some(0));
413 }
414
415 #[test]
416 fn parse_unix_secs_handles_offset() {
417 assert_eq!(parse_unix_secs("1970-01-01T01:00:00+01:00"), Some(0));
419 }
420
421 #[test]
422 fn parse_unix_secs_handles_fractional_seconds() {
423 assert_eq!(parse_unix_secs("1970-01-01T00:00:01.999999+00:00"), Some(1));
424 }
425
426 #[test]
427 fn parse_unix_secs_rejects_short_input() {
428 assert_eq!(parse_unix_secs("2026-01"), None);
429 }
430
431 #[test]
432 fn relative_time_falls_back_on_invalid_input() {
433 assert_eq!(relative_time("not-a-date"), "not-a-date");
434 }
435
436 #[test]
437 fn mask_credential_masks_long_values() {
438 assert_eq!(mask_credential("abcdefghijklmnop"), "abcdef…mnop");
439 }
440
441 #[test]
442 fn mask_credential_dots_short_values() {
443 assert_eq!(mask_credential("short"), "•••••");
444 assert_eq!(mask_credential(""), "");
445 }
446
447 #[test]
448 fn kv_block_aligns_values() {
449 let pairs = [("entity_id", "light.x".into()), ("state", "on".into())];
450 let out = kv_block(&pairs);
451 let lines: Vec<&str> = out.lines().collect();
452 let v1_pos = lines[0].find("light.x").unwrap();
453 let v2_pos = lines[1].find("on").unwrap();
454 assert_eq!(v1_pos, v2_pos);
455 }
456
457 #[test]
458 fn truncate_cell_shortens_plain_string() {
459 let result = truncate_cell("hello world", 7);
460 assert!(visible_len(&result) <= 7);
461 assert!(result.contains('…'));
462 }
463
464 #[test]
465 fn truncate_cell_leaves_short_string_intact() {
466 assert_eq!(truncate_cell("hi", 10), "hi");
467 }
468
469 #[test]
470 fn table_renders_header_separator_and_rows() {
471 let headers = ["ENTITY", "STATE"];
472 let rows = vec![
473 vec!["light.living_room".into(), "on".into()],
474 vec!["switch.fan".into(), "off".into()],
475 ];
476 let out = table(&headers, &rows);
477 let lines: Vec<&str> = out.lines().collect();
478 assert!(lines[0].contains("ENTITY") && lines[0].contains("STATE"));
479 assert!(lines[1].contains("─"));
480 assert!(lines[2].contains("light.living_room"));
481 assert!(lines[3].contains("switch.fan"));
482 }
483
484 #[test]
485 fn error_envelope_uses_kind_field() {
486 let e = crate::api::HaError::NotFound("light.missing".into());
488 let envelope = serde_json::json!({
489 "error": {
490 "kind": e.error_kind(),
491 "message": e.to_string(),
492 "hint": e.error_hint(),
493 }
494 });
495 assert_eq!(envelope["error"]["kind"], "not_found");
496 assert!(
497 envelope["error"]["message"]
498 .as_str()
499 .unwrap()
500 .contains("light.missing")
501 );
502 }
503
504 #[test]
505 fn error_envelope_is_valid_single_line_json() {
506 let e = crate::api::HaError::Auth("expired".into());
508 let envelope = serde_json::json!({
509 "error": {
510 "kind": e.error_kind(),
511 "message": e.to_string(),
512 "hint": e.error_hint(),
513 }
514 });
515 let line = serde_json::to_string(&envelope).unwrap();
516 assert!(!line.contains('\n'), "envelope must be single-line");
517 let parsed: serde_json::Value = serde_json::from_str(&line).unwrap();
518 assert_eq!(parsed["error"]["kind"], "auth");
519 }
520
521 #[test]
522 fn exit_code_for_auth_error_is_2() {
523 assert_eq!(
524 exit_codes::for_error(&crate::api::HaError::Auth("x".into())),
525 2
526 );
527 }
528
529 #[test]
530 fn exit_code_for_not_found_is_3() {
531 assert_eq!(
532 exit_codes::for_error(&crate::api::HaError::NotFound("x".into())),
533 3
534 );
535 }
536
537 #[test]
538 fn exit_code_for_connection_error_is_4() {
539 assert_eq!(
540 exit_codes::for_error(&crate::api::HaError::Connection("x".into())),
541 4
542 );
543 }
544
545 #[test]
546 fn exit_code_for_confirmation_required_is_6() {
547 assert_eq!(
548 exit_codes::for_error(&crate::api::HaError::ConfirmationRequired("x".into())),
549 6
550 );
551 }
552
553 #[test]
554 fn exit_code_for_conflict_is_7() {
555 assert_eq!(
556 exit_codes::for_error(&crate::api::HaError::Conflict("x".into())),
557 7
558 );
559 }
560
561 #[test]
562 fn output_format_auto_is_json_when_not_tty() {
563 let cfg = OutputConfig::new(None, false);
565 assert!(
567 cfg.is_json(),
568 "Auto format should be JSON when stdout is not a TTY"
569 );
570 }
571
572 #[test]
573 fn output_format_text_is_not_json_even_when_piped() {
574 let cfg = OutputConfig::new(Some(OutputFormat::Text), false);
576 assert!(
577 !cfg.is_json(),
578 "Text format must not be JSON even when piped"
579 );
580 }
581
582 #[test]
583 fn output_format_json_is_always_json() {
584 let cfg = OutputConfig::new(Some(OutputFormat::Json), false);
585 assert!(cfg.is_json());
586 }
587
588 #[test]
591 fn output_format_table_is_not_json() {
592 let cfg = OutputConfig::new(Some(OutputFormat::Table), false);
593 assert!(
594 !cfg.is_json(),
595 "--output table must produce text output, not JSON"
596 );
597 }
598
599 #[test]
600 fn output_format_plain_is_not_json() {
601 let cfg = OutputConfig::new(Some(OutputFormat::Plain), false);
602 assert!(
603 !cfg.is_json(),
604 "--output plain must produce text output, not JSON"
605 );
606 }
607
608 #[test]
612 fn clap_parses_table_as_output_format() {
613 use clap::ValueEnum;
614 let val = OutputFormat::from_str("table", true)
615 .expect("--output table must be a valid clap value");
616 assert_eq!(val, OutputFormat::Table);
617 }
618
619 #[test]
620 fn clap_parses_plain_as_output_format() {
621 use clap::ValueEnum;
622 let val = OutputFormat::from_str("plain", true)
623 .expect("--output plain must be a valid clap value");
624 assert_eq!(val, OutputFormat::Plain);
625 }
626}