Skip to main content

homeassistant_cli/
output.rs

1use 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    /// Print data (tables, JSON, values) to stdout. Always shown.
35    pub fn print_data(&self, data: &str) {
36        println!("{data}");
37    }
38
39    /// Print informational message to stderr. Suppressed by --quiet.
40    pub fn print_message(&self, msg: &str) {
41        if !self.quiet {
42            eprintln!("{msg}");
43        }
44    }
45
46    /// Print an error. In JSON mode, emits the structured error envelope to stdout.
47    /// In human mode, prints to stderr.
48    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    /// Print a JSON result or human message depending on format.
67    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
79/// Format an ISO 8601 timestamp as a human-friendly relative time ("2m ago").
80/// Falls back to the raw string if parsing fails.
81pub 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
106/// Parse an ISO 8601 / RFC 3339 timestamp to Unix seconds.
107/// Handles `YYYY-MM-DDTHH:MM:SS[.frac][+HH:MM|Z]`.
108fn 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    // Skip fractional seconds, then parse timezone offset.
120    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    // Convert calendar date to days since Unix epoch using Hinnant's algorithm.
138    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
168/// Mask a credential for safe display.
169/// Keeps first 6 and last 4 chars for long values; fully obscures short values.
170pub 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
177/// Render a two-column key/value block with aligned values.
178pub 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
187/// Render a simple table with header and data rows.
188pub 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        // 1970-01-01T00:00:00Z == 0
236        assert_eq!(parse_unix_secs("1970-01-01T00:00:00Z"), Some(0));
237    }
238
239    #[test]
240    fn parse_unix_secs_handles_offset() {
241        // 1970-01-01T01:00:00+01:00 == 0
242        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        // Verify the envelope structure by exercising the serialization path directly.
299        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}