Skip to main content

homeassistant_cli/
output.rs

1use std::io::IsTerminal;
2
3use owo_colors::OwoColorize;
4
5use crate::api::HaError;
6
7/// Output format selector. `Auto` (the default) chooses JSON when stdout is not
8/// a TTY and table when it is. An explicit value always wins over TTY detection.
9#[derive(Clone, Copy, Debug, PartialEq, clap::ValueEnum)]
10pub enum OutputFormat {
11    /// Auto-detect: JSON when piped, table in a terminal.
12    Auto,
13    /// Human-friendly table/text, regardless of TTY.
14    Text,
15    /// JSON output, regardless of TTY.
16    Json,
17    /// Plain text (alias for text, kept for backward compatibility).
18    #[value(hide = true)]
19    Plain,
20    /// Table (alias for text, kept for backward compatibility).
21    #[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    /// Print data (tables, JSON, values) to stdout. Always shown.
46    pub fn print_data(&self, data: &str) {
47        println!("{data}");
48    }
49
50    /// Print informational message to stderr. Suppressed by --quiet.
51    pub fn print_message(&self, msg: &str) {
52        if !self.quiet {
53            eprintln!("{msg}");
54        }
55    }
56
57    /// Print the structured error envelope to stderr as a single JSON line
58    /// (the last line of stderr, per the spec). Always emits to stderr.
59    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    /// Print a JSON result or human message depending on format.
71    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
83/// Color a Home Assistant state value for human display.
84pub 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
93/// Dim the domain prefix of an entity ID, leaving the name at normal brightness.
94/// `light.left_key_light` → `[dim]light.[/dim]left_key_light`
95pub 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
102/// Format an ISO 8601 timestamp as a human-friendly relative time ("2m ago").
103/// Falls back to the raw string if parsing fails.
104pub 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            // Dim timestamps older than 5 minutes.
125            if secs >= 300 {
126                s.dimmed().to_string()
127            } else {
128                s
129            }
130        }
131        None => iso.to_owned(),
132    }
133}
134
135/// Parse an ISO 8601 / RFC 3339 timestamp to Unix seconds.
136/// Handles `YYYY-MM-DDTHH:MM:SS[.frac][+HH:MM|Z]`.
137fn 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    // Skip fractional seconds, then parse timezone offset.
149    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    // Convert calendar date to days since Unix epoch using Hinnant's algorithm.
167    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    /// Config/auth error (kind: auth, config_error).
184    pub const CONFIG_ERROR: i32 = 2;
185    /// Resource not found (kind: not_found).
186    pub const NOT_FOUND: i32 = 3;
187    /// Network/connection error (kind: connection).
188    pub const CONNECTION_ERROR: i32 = 4;
189    /// Batch operation where some items succeeded and some failed (kind: partial_failure).
190    pub const PARTIAL_FAILURE: i32 = 5;
191    /// Confirmation required in non-interactive mode (kind: confirmation_required).
192    pub const CONFIRMATION_REQUIRED: i32 = 6;
193    /// Conflict: resource exists with different configuration (kind: conflict).
194    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
209/// Mask a credential for safe display.
210/// Keeps first 6 and last 4 chars for long values; fully obscures short values.
211pub 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
218/// Render a two-column key/value block with aligned values.
219pub 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
228/// Strip ANSI escape codes to get the visible display length of a string.
229fn 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
246/// Pad a (potentially ANSI-colored) string to a given visible width.
247fn 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
253/// Return the current terminal width, or a sensible default.
254fn terminal_width() -> usize {
255    use std::io::IsTerminal;
256    if !std::io::stdout().is_terminal() {
257        return usize::MAX; // piped — no truncation needed
258    }
259    terminal_size::terminal_size()
260        .map(|(terminal_size::Width(w), _)| w as usize)
261        .unwrap_or(120)
262}
263
264/// Truncate a string (ignoring ANSI) to `max_visible` chars, appending `…` if cut.
265fn 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    // Re-build the string char by char, keeping ANSI escapes intact.
273    let mut out = String::new();
274    let mut visible = 0;
275    let mut in_escape = false;
276    let target = max_visible.saturating_sub(1); // reserve one for '…'
277    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    // Reset any open ANSI sequence before appending '…'.
294    out.push_str("\x1b[0m");
295    out.push('…');
296    out
297}
298
299/// Render a table with bold headers, dimmed separator, and ANSI-aware column alignment.
300/// Rows may contain pre-colored strings; alignment is based on visible width.
301/// Automatically shrinks the widest column(s) to fit within the terminal width.
302pub fn table(headers: &[&str], rows: &[Vec<String>]) -> String {
303    let col_count = headers.len();
304    // Compute natural column widths from visible (uncolored) content.
305    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    // Fit widths to terminal: separator between cols is 2 spaces.
315    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        // Shrink the widest columns first until everything fits.
321        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            // Find the second-largest width to know how much headroom to shrink.
331            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            // How much we can shrink all max-width cols before they meet the next level.
340            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            // Safety: don't shrink below a minimum of 4 chars.
356            if widths.iter().all(|&w| w <= 4) {
357                widths.fill(4);
358                break;
359            }
360        }
361        // Enforce per-column minimum so we always have something legible.
362        let min_col = budget / col_count;
363        for w in &mut widths {
364            *w = (*w).max(min_col.min(4));
365        }
366    }
367
368    // Render headers.
369    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        // 1970-01-01T00:00:00Z == 0
412        assert_eq!(parse_unix_secs("1970-01-01T00:00:00Z"), Some(0));
413    }
414
415    #[test]
416    fn parse_unix_secs_handles_offset() {
417        // 1970-01-01T01:00:00+01:00 == 0
418        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        // Verify the spec-required envelope structure: error.kind (not error.code).
487        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        // The spec says the envelope is written as a single line of JSON.
507        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        // In tests, stdout is not a TTY, so auto should select JSON.
564        let cfg = OutputConfig::new(None, false);
565        // stdout in test context is piped, so is_json() returns true for Auto.
566        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        // Explicit text wins over TTY detection.
575        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    // Backward-compat: --output table and --output plain must still be accepted
589    // and must produce text (non-JSON) output regardless of TTY state.
590    #[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    // Verify the backward-compat values are accepted by clap's value parser.
609    // This exercises the production code path (clap ValueEnum derive) so a
610    // regression in the hidden alias wiring will fail here, not just at runtime.
611    #[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}