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
185 pub fn for_error(e: &HaError) -> i32 {
186 match e {
187 HaError::Auth(_) | HaError::InvalidInput(_) => CONFIG_ERROR,
188 HaError::NotFound(_) => NOT_FOUND,
189 HaError::Connection(_) => CONNECTION_ERROR,
190 _ => GENERAL_ERROR,
191 }
192 }
193}
194
195pub fn mask_credential(s: &str) -> String {
198 if s.len() <= 10 {
199 return "•".repeat(s.len());
200 }
201 format!("{}…{}", &s[..6], &s[s.len() - 4..])
202}
203
204pub fn kv_block(pairs: &[(&str, String)]) -> String {
206 let max_key = pairs.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
207 pairs
208 .iter()
209 .map(|(k, v)| format!("{:width$} {}", k, v, width = max_key))
210 .collect::<Vec<_>>()
211 .join("\n")
212}
213
214fn visible_len(s: &str) -> usize {
216 let mut len = 0;
217 let mut in_escape = false;
218 for c in s.chars() {
219 if c == '\x1b' {
220 in_escape = true;
221 } else if in_escape {
222 if c == 'm' {
223 in_escape = false;
224 }
225 } else {
226 len += 1;
227 }
228 }
229 len
230}
231
232fn pad_cell(s: &str, width: usize) -> String {
234 let vlen = visible_len(s);
235 let padding = width.saturating_sub(vlen);
236 format!("{}{}", s, " ".repeat(padding))
237}
238
239fn terminal_width() -> usize {
241 use std::io::IsTerminal;
242 if !std::io::stdout().is_terminal() {
243 return usize::MAX; }
245 terminal_size::terminal_size()
246 .map(|(terminal_size::Width(w), _)| w as usize)
247 .unwrap_or(120)
248}
249
250fn truncate_cell(s: &str, max_visible: usize) -> String {
252 if max_visible == 0 {
253 return String::new();
254 }
255 if visible_len(s) <= max_visible {
256 return s.to_owned();
257 }
258 let mut out = String::new();
260 let mut visible = 0;
261 let mut in_escape = false;
262 let target = max_visible.saturating_sub(1); for c in s.chars() {
264 if c == '\x1b' {
265 in_escape = true;
266 out.push(c);
267 } else if in_escape {
268 out.push(c);
269 if c == 'm' {
270 in_escape = false;
271 }
272 } else if visible < target {
273 out.push(c);
274 visible += 1;
275 } else {
276 break;
277 }
278 }
279 out.push_str("\x1b[0m");
281 out.push('…');
282 out
283}
284
285pub fn table(headers: &[&str], rows: &[Vec<String>]) -> String {
289 let col_count = headers.len();
290 let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
292 for row in rows {
293 for (i, cell) in row.iter().enumerate() {
294 if i < col_count {
295 widths[i] = widths[i].max(visible_len(cell));
296 }
297 }
298 }
299
300 let term_w = terminal_width();
302 let separators = col_count.saturating_sub(1) * 2;
303 let total: usize = widths.iter().sum::<usize>() + separators;
304 if total > term_w {
305 let budget = term_w.saturating_sub(separators);
306 loop {
308 let current: usize = widths.iter().sum();
309 if current <= budget {
310 break;
311 }
312 let max_w = *widths.iter().max().unwrap_or(&0);
313 if max_w == 0 {
314 break;
315 }
316 let second = widths
318 .iter()
319 .filter(|&&w| w < max_w)
320 .copied()
321 .max()
322 .unwrap_or(0);
323 let n_max = widths.iter().filter(|&&w| w == max_w).count();
324 let excess = current - budget;
325 let headroom = (max_w - second) * n_max;
327 if headroom >= excess {
328 let cut = excess.div_ceil(n_max);
329 for w in &mut widths {
330 if *w == max_w {
331 *w = max_w.saturating_sub(cut);
332 }
333 }
334 } else {
335 for w in &mut widths {
336 if *w == max_w {
337 *w = second;
338 }
339 }
340 }
341 if widths.iter().all(|&w| w <= 4) {
343 widths.fill(4);
344 break;
345 }
346 }
347 let min_col = budget / col_count;
349 for w in &mut widths {
350 *w = (*w).max(min_col.min(4));
351 }
352 }
353
354 let header_line: String = headers
356 .iter()
357 .enumerate()
358 .map(|(i, h)| {
359 let truncated = truncate_cell(h, widths[i]);
360 pad_cell(&truncated.bold().to_string(), widths[i])
361 })
362 .collect::<Vec<_>>()
363 .join(" ");
364
365 let sep: String = widths
366 .iter()
367 .map(|w| "─".repeat(*w).dimmed().to_string())
368 .collect::<Vec<_>>()
369 .join(" ");
370
371 let data_lines: Vec<String> = rows
372 .iter()
373 .map(|row| {
374 row.iter()
375 .enumerate()
376 .take(col_count)
377 .map(|(i, cell)| {
378 let truncated = truncate_cell(cell, widths[i]);
379 pad_cell(&truncated, widths[i])
380 })
381 .collect::<Vec<_>>()
382 .join(" ")
383 })
384 .collect();
385
386 let mut out = vec![header_line, sep];
387 out.extend(data_lines);
388 out.join("\n")
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn parse_unix_secs_handles_utc_z() {
397 assert_eq!(parse_unix_secs("1970-01-01T00:00:00Z"), Some(0));
399 }
400
401 #[test]
402 fn parse_unix_secs_handles_offset() {
403 assert_eq!(parse_unix_secs("1970-01-01T01:00:00+01:00"), Some(0));
405 }
406
407 #[test]
408 fn parse_unix_secs_handles_fractional_seconds() {
409 assert_eq!(parse_unix_secs("1970-01-01T00:00:01.999999+00:00"), Some(1));
410 }
411
412 #[test]
413 fn parse_unix_secs_rejects_short_input() {
414 assert_eq!(parse_unix_secs("2026-01"), None);
415 }
416
417 #[test]
418 fn relative_time_falls_back_on_invalid_input() {
419 assert_eq!(relative_time("not-a-date"), "not-a-date");
420 }
421
422 #[test]
423 fn mask_credential_masks_long_values() {
424 assert_eq!(mask_credential("abcdefghijklmnop"), "abcdef…mnop");
425 }
426
427 #[test]
428 fn mask_credential_dots_short_values() {
429 assert_eq!(mask_credential("short"), "•••••");
430 assert_eq!(mask_credential(""), "");
431 }
432
433 #[test]
434 fn kv_block_aligns_values() {
435 let pairs = [("entity_id", "light.x".into()), ("state", "on".into())];
436 let out = kv_block(&pairs);
437 let lines: Vec<&str> = out.lines().collect();
438 let v1_pos = lines[0].find("light.x").unwrap();
439 let v2_pos = lines[1].find("on").unwrap();
440 assert_eq!(v1_pos, v2_pos);
441 }
442
443 #[test]
444 fn truncate_cell_shortens_plain_string() {
445 let result = truncate_cell("hello world", 7);
446 assert!(visible_len(&result) <= 7);
447 assert!(result.contains('…'));
448 }
449
450 #[test]
451 fn truncate_cell_leaves_short_string_intact() {
452 assert_eq!(truncate_cell("hi", 10), "hi");
453 }
454
455 #[test]
456 fn table_renders_header_separator_and_rows() {
457 let headers = ["ENTITY", "STATE"];
458 let rows = vec![
459 vec!["light.living_room".into(), "on".into()],
460 vec!["switch.fan".into(), "off".into()],
461 ];
462 let out = table(&headers, &rows);
463 let lines: Vec<&str> = out.lines().collect();
464 assert!(lines[0].contains("ENTITY") && lines[0].contains("STATE"));
465 assert!(lines[1].contains("─"));
466 assert!(lines[2].contains("light.living_room"));
467 assert!(lines[3].contains("switch.fan"));
468 }
469
470 #[test]
471 fn print_error_json_mode_emits_envelope_to_stdout() {
472 let e = crate::api::HaError::NotFound("light.missing".into());
474 let envelope = serde_json::json!({
475 "ok": false,
476 "error": {
477 "code": e.error_code(),
478 "message": e.to_string()
479 }
480 });
481 assert_eq!(envelope["ok"], false);
482 assert_eq!(envelope["error"]["code"], "HA_NOT_FOUND");
483 assert!(
484 envelope["error"]["message"]
485 .as_str()
486 .unwrap()
487 .contains("light.missing")
488 );
489 }
490
491 #[test]
492 fn exit_code_for_auth_error_is_2() {
493 assert_eq!(
494 exit_codes::for_error(&crate::api::HaError::Auth("x".into())),
495 2
496 );
497 }
498
499 #[test]
500 fn exit_code_for_not_found_is_3() {
501 assert_eq!(
502 exit_codes::for_error(&crate::api::HaError::NotFound("x".into())),
503 3
504 );
505 }
506
507 #[test]
508 fn exit_code_for_connection_error_is_4() {
509 assert_eq!(
510 exit_codes::for_error(&crate::api::HaError::Connection("x".into())),
511 4
512 );
513 }
514}