Skip to main content

homeassistant_cli/
output.rs

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    /// Print data (tables, JSON, values) to stdout. Always shown.
37    pub fn print_data(&self, data: &str) {
38        println!("{data}");
39    }
40
41    /// Print informational message to stderr. Suppressed by --quiet.
42    pub fn print_message(&self, msg: &str) {
43        if !self.quiet {
44            eprintln!("{msg}");
45        }
46    }
47
48    /// Print an error. In JSON mode, emits the structured error envelope to stdout.
49    /// In human mode, prints to stderr.
50    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    /// Print a JSON result or human message depending on format.
69    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
81/// Color a Home Assistant state value for human display.
82pub 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
91/// Dim the domain prefix of an entity ID, leaving the name at normal brightness.
92/// `light.left_key_light` → `[dim]light.[/dim]left_key_light`
93pub 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
100/// Format an ISO 8601 timestamp as a human-friendly relative time ("2m ago").
101/// Falls back to the raw string if parsing fails.
102pub 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            // Dim timestamps older than 5 minutes.
123            if secs >= 300 {
124                s.dimmed().to_string()
125            } else {
126                s
127            }
128        }
129        None => iso.to_owned(),
130    }
131}
132
133/// Parse an ISO 8601 / RFC 3339 timestamp to Unix seconds.
134/// Handles `YYYY-MM-DDTHH:MM:SS[.frac][+HH:MM|Z]`.
135fn 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    // Skip fractional seconds, then parse timezone offset.
147    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    // Convert calendar date to days since Unix epoch using Hinnant's algorithm.
165    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    /// Batch operation where some items succeeded and some failed (e.g. `registry entity remove`).
185    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
197/// Mask a credential for safe display.
198/// Keeps first 6 and last 4 chars for long values; fully obscures short values.
199pub 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
206/// Render a two-column key/value block with aligned values.
207pub 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
216/// Strip ANSI escape codes to get the visible display length of a string.
217fn 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
234/// Pad a (potentially ANSI-colored) string to a given visible width.
235fn 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
241/// Return the current terminal width, or a sensible default.
242fn terminal_width() -> usize {
243    use std::io::IsTerminal;
244    if !std::io::stdout().is_terminal() {
245        return usize::MAX; // piped — no truncation needed
246    }
247    terminal_size::terminal_size()
248        .map(|(terminal_size::Width(w), _)| w as usize)
249        .unwrap_or(120)
250}
251
252/// Truncate a string (ignoring ANSI) to `max_visible` chars, appending `…` if cut.
253fn 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    // Re-build the string char by char, keeping ANSI escapes intact.
261    let mut out = String::new();
262    let mut visible = 0;
263    let mut in_escape = false;
264    let target = max_visible.saturating_sub(1); // reserve one for '…'
265    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    // Reset any open ANSI sequence before appending '…'.
282    out.push_str("\x1b[0m");
283    out.push('…');
284    out
285}
286
287/// Render a table with bold headers, dimmed separator, and ANSI-aware column alignment.
288/// Rows may contain pre-colored strings; alignment is based on visible width.
289/// Automatically shrinks the widest column(s) to fit within the terminal width.
290pub fn table(headers: &[&str], rows: &[Vec<String>]) -> String {
291    let col_count = headers.len();
292    // Compute natural column widths from visible (uncolored) content.
293    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    // Fit widths to terminal: separator between cols is 2 spaces.
303    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        // Shrink the widest columns first until everything fits.
309        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            // Find the second-largest width to know how much headroom to shrink.
319            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            // How much we can shrink all max-width cols before they meet the next level.
328            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            // Safety: don't shrink below a minimum of 4 chars.
344            if widths.iter().all(|&w| w <= 4) {
345                widths.fill(4);
346                break;
347            }
348        }
349        // Enforce per-column minimum so we always have something legible.
350        let min_col = budget / col_count;
351        for w in &mut widths {
352            *w = (*w).max(min_col.min(4));
353        }
354    }
355
356    // Render headers.
357    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        // 1970-01-01T00:00:00Z == 0
400        assert_eq!(parse_unix_secs("1970-01-01T00:00:00Z"), Some(0));
401    }
402
403    #[test]
404    fn parse_unix_secs_handles_offset() {
405        // 1970-01-01T01:00:00+01:00 == 0
406        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        // Verify the envelope structure by exercising the serialization path directly.
475        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}