1use console::{Color, Style, Term};
4use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
5use serde::Serialize;
6use similar::{ChangeTag, TextDiff};
7use syntect::easy::HighlightLines;
8use syntect::highlighting::ThemeSet;
9use syntect::parsing::SyntaxSet;
10use syntect::util::as_24_bit_terminal_escaped;
11
12use std::collections::VecDeque;
13use std::io::BufRead;
14use std::sync::{Arc, Mutex, mpsc};
15use std::time::{Duration, Instant};
16
17use crate::config::ThemeConfig;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum OutputFormat {
21 Table,
22 Wide,
23 Json,
24 Yaml,
25 Name,
26 Jsonpath(String),
27 Template(String),
28 TemplateFile(std::path::PathBuf),
29}
30
31const ICON_SUCCESS: &str = "✓";
33const ICON_WARNING: &str = "⚠";
34const ICON_ERROR: &str = "✗";
35const ICON_INFO: &str = "⚙";
36const ICON_PENDING: &str = "○";
37const ICON_ARROW: &str = "→";
38
39pub struct Theme {
40 pub success: Style,
41 pub warning: Style,
42 pub error: Style,
43 pub info: Style,
44 pub muted: Style,
45
46 pub header: Style,
47 pub subheader: Style,
48 pub key: Style,
49 pub value: Style,
50
51 pub diff_add: Style,
52 pub diff_remove: Style,
53 pub diff_context: Style,
54
55 pub icon_success: String,
56 pub icon_warning: String,
57 pub icon_error: String,
58 pub icon_info: String,
59 pub icon_pending: String,
60 pub icon_arrow: String,
61}
62
63impl Default for Theme {
64 fn default() -> Self {
65 Self {
66 success: Style::new().green(),
67 warning: Style::new().yellow(),
68 error: Style::new().red().bold(),
69 info: Style::new().cyan(),
70 muted: Style::new().dim(),
71
72 header: Style::new().bold().cyan(),
73 subheader: Style::new().bold(),
74 key: Style::new().bold(),
75 value: Style::new(),
76
77 diff_add: Style::new().green(),
78 diff_remove: Style::new().red(),
79 diff_context: Style::new().dim(),
80
81 icon_success: ICON_SUCCESS.into(),
82 icon_warning: ICON_WARNING.into(),
83 icon_error: ICON_ERROR.into(),
84 icon_info: ICON_INFO.into(),
85 icon_pending: ICON_PENDING.into(),
86 icon_arrow: ICON_ARROW.into(),
87 }
88 }
89}
90
91impl Theme {
92 pub fn from_config(config: Option<&ThemeConfig>) -> Self {
93 let config = match config {
94 Some(c) => c,
95 None => return Self::default(),
96 };
97
98 let mut theme = Self::from_preset(&config.name);
99
100 let ov = &config.overrides;
102 if let Some(ref c) = ov.success {
103 apply_color(&mut theme.success, c);
104 }
105 if let Some(ref c) = ov.warning {
106 apply_color(&mut theme.warning, c);
107 }
108 if let Some(ref c) = ov.error {
109 apply_color(&mut theme.error, c);
110 }
111 if let Some(ref c) = ov.info {
112 apply_color(&mut theme.info, c);
113 }
114 if let Some(ref c) = ov.muted {
115 apply_color(&mut theme.muted, c);
116 }
117 if let Some(ref c) = ov.header {
118 apply_color(&mut theme.header, c);
119 }
120 if let Some(ref c) = ov.subheader {
121 apply_color(&mut theme.subheader, c);
122 }
123 if let Some(ref c) = ov.key {
124 apply_color(&mut theme.key, c);
125 }
126 if let Some(ref c) = ov.value {
127 apply_color(&mut theme.value, c);
128 }
129 if let Some(ref c) = ov.diff_add {
130 apply_color(&mut theme.diff_add, c);
131 }
132 if let Some(ref c) = ov.diff_remove {
133 apply_color(&mut theme.diff_remove, c);
134 }
135 if let Some(ref c) = ov.diff_context {
136 apply_color(&mut theme.diff_context, c);
137 }
138
139 if let Some(ref v) = ov.icon_success {
141 theme.icon_success = v.clone();
142 }
143 if let Some(ref v) = ov.icon_warning {
144 theme.icon_warning = v.clone();
145 }
146 if let Some(ref v) = ov.icon_error {
147 theme.icon_error = v.clone();
148 }
149 if let Some(ref v) = ov.icon_info {
150 theme.icon_info = v.clone();
151 }
152 if let Some(ref v) = ov.icon_pending {
153 theme.icon_pending = v.clone();
154 }
155 if let Some(ref v) = ov.icon_arrow {
156 theme.icon_arrow = v.clone();
157 }
158
159 theme
160 }
161
162 pub fn from_preset(name: &str) -> Self {
163 match name {
164 "dracula" => Self::dracula(),
165 "solarized-dark" => Self::solarized_dark(),
166 "solarized-light" => Self::solarized_light(),
167 "minimal" => Self::minimal(),
168 _ => Self::default(),
169 }
170 }
171
172 fn dracula() -> Self {
173 Self {
174 success: style_from_hex("#50fa7b"),
175 warning: style_from_hex("#f1fa8c"),
176 error: style_from_hex("#ff5555").bold(),
177 info: style_from_hex("#8be9fd"),
178 muted: style_from_hex("#6272a4"),
179
180 header: style_from_hex("#bd93f9").bold(),
181 subheader: Style::new().bold(),
182 key: style_from_hex("#ff79c6").bold(),
183 value: style_from_hex("#f8f8f2"),
184
185 diff_add: style_from_hex("#50fa7b"),
186 diff_remove: style_from_hex("#ff5555"),
187 diff_context: style_from_hex("#6272a4"),
188
189 icon_success: ICON_SUCCESS.into(),
190 icon_warning: ICON_WARNING.into(),
191 icon_error: ICON_ERROR.into(),
192 icon_info: ICON_INFO.into(),
193 icon_pending: ICON_PENDING.into(),
194 icon_arrow: ICON_ARROW.into(),
195 }
196 }
197
198 fn solarized_dark() -> Self {
199 Self {
200 success: style_from_hex("#859900"),
201 warning: style_from_hex("#b58900"),
202 error: style_from_hex("#dc322f").bold(),
203 info: style_from_hex("#268bd2"),
204 muted: style_from_hex("#586e75"),
205
206 header: style_from_hex("#268bd2").bold(),
207 subheader: Style::new().bold(),
208 key: style_from_hex("#2aa198").bold(),
209 value: style_from_hex("#839496"),
210
211 diff_add: style_from_hex("#859900"),
212 diff_remove: style_from_hex("#dc322f"),
213 diff_context: style_from_hex("#586e75"),
214
215 icon_success: ICON_SUCCESS.into(),
216 icon_warning: ICON_WARNING.into(),
217 icon_error: ICON_ERROR.into(),
218 icon_info: ICON_INFO.into(),
219 icon_pending: ICON_PENDING.into(),
220 icon_arrow: ICON_ARROW.into(),
221 }
222 }
223
224 fn solarized_light() -> Self {
225 Self {
226 success: style_from_hex("#859900"),
227 warning: style_from_hex("#b58900"),
228 error: style_from_hex("#dc322f").bold(),
229 info: style_from_hex("#268bd2"),
230 muted: style_from_hex("#93a1a1"),
231
232 header: style_from_hex("#268bd2").bold(),
233 subheader: Style::new().bold(),
234 key: style_from_hex("#2aa198").bold(),
235 value: style_from_hex("#657b83"),
236
237 diff_add: style_from_hex("#859900"),
238 diff_remove: style_from_hex("#dc322f"),
239 diff_context: style_from_hex("#93a1a1"),
240
241 icon_success: ICON_SUCCESS.into(),
242 icon_warning: ICON_WARNING.into(),
243 icon_error: ICON_ERROR.into(),
244 icon_info: ICON_INFO.into(),
245 icon_pending: ICON_PENDING.into(),
246 icon_arrow: ICON_ARROW.into(),
247 }
248 }
249
250 fn minimal() -> Self {
251 Self {
252 success: Style::new(),
253 warning: Style::new(),
254 error: Style::new().bold(),
255 info: Style::new(),
256 muted: Style::new().dim(),
257
258 header: Style::new().bold(),
259 subheader: Style::new().bold(),
260 key: Style::new().bold(),
261 value: Style::new(),
262
263 diff_add: Style::new(),
264 diff_remove: Style::new(),
265 diff_context: Style::new().dim(),
266
267 icon_success: "+".into(),
268 icon_warning: "!".into(),
269 icon_error: "x".into(),
270 icon_info: "-".into(),
271 icon_pending: " ".into(),
272 icon_arrow: ">".into(),
273 }
274 }
275}
276
277fn parse_hex_color(hex: &str) -> Option<Color> {
279 let hex = hex.strip_prefix('#').unwrap_or(hex);
280 if hex.len() != 6 {
281 return None;
282 }
283 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
284 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
285 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
286 Some(Color::Color256(ansi256_from_rgb(r, g, b)))
287}
288
289fn ansi256_from_rgb(r: u8, g: u8, b: u8) -> u8 {
291 if r == g && g == b {
293 if r < 8 {
294 return 16;
295 }
296 if r > 248 {
297 return 231;
298 }
299 return (((r as u16 - 8) * 24 / 247) as u8) + 232;
300 }
301 let ri = (r as u16 * 5 / 255) as u8;
303 let gi = (g as u16 * 5 / 255) as u8;
304 let bi = (b as u16 * 5 / 255) as u8;
305 16 + 36 * ri + 6 * gi + bi
306}
307
308fn style_from_hex(hex: &str) -> Style {
309 match parse_hex_color(hex) {
310 Some(color) => Style::new().fg(color),
311 None => Style::new(),
312 }
313}
314
315fn apply_color(style: &mut Style, hex: &str) {
316 if let Some(color) = parse_hex_color(hex) {
317 *style = Style::new().fg(color);
318 }
319}
320
321fn apply_jsonpath(value: &serde_json::Value, expr: &str) -> String {
331 let expr = expr.trim();
333 let expr = expr.strip_prefix('{').unwrap_or(expr);
334 let expr = expr.strip_suffix('}').unwrap_or(expr);
335 let expr = expr.strip_prefix('.').unwrap_or(expr);
336
337 if expr.is_empty() {
338 return format_jsonpath_result(value);
339 }
340
341 let results = walk_jsonpath(value, expr);
342 match results.len() {
343 0 => String::new(),
344 1 => format_jsonpath_result(results[0]),
345 _ => {
346 results
348 .iter()
349 .map(|v| format_jsonpath_result(v))
350 .collect::<Vec<_>>()
351 .join("\n")
352 }
353 }
354}
355
356fn name_from_value(value: &serde_json::Value) -> Option<String> {
359 for key in &["name", "context", "phase", "resourceType", "url"] {
360 if let Some(s) = value.get(key).and_then(|v| v.as_str()) {
361 return Some(s.to_string());
362 }
363 }
364 for key in &["applyId"] {
366 if let Some(n) = value.get(key).and_then(|v| v.as_i64()) {
367 return Some(n.to_string());
368 }
369 }
370 None
371}
372
373fn walk_jsonpath<'a>(value: &'a serde_json::Value, path: &str) -> Vec<&'a serde_json::Value> {
374 if path.is_empty() {
375 return vec![value];
376 }
377
378 let (segment, rest) = split_jsonpath_segment(path);
380
381 if let Some(bracket_pos) = segment.find('[') {
383 let key = &segment[..bracket_pos];
384 let bracket_expr = &segment[bracket_pos + 1..segment.len() - 1]; let target = if key.is_empty() {
388 value
389 } else {
390 match value.get(key) {
391 Some(v) => v,
392 None => return vec![],
393 }
394 };
395
396 let arr = match target.as_array() {
397 Some(a) => a,
398 None => return vec![],
399 };
400
401 if bracket_expr == "*" {
402 arr.iter()
404 .flat_map(|elem| walk_jsonpath(elem, rest))
405 .collect()
406 } else if let Some(colon_pos) = bracket_expr.find(':') {
407 let start = bracket_expr[..colon_pos]
409 .parse::<usize>()
410 .unwrap_or(0)
411 .min(arr.len());
412 let end = bracket_expr[colon_pos + 1..]
413 .parse::<usize>()
414 .unwrap_or(arr.len())
415 .min(arr.len());
416 if start >= end {
417 vec![]
418 } else if rest.is_empty() {
419 arr[start..end].iter().collect()
420 } else {
421 arr[start..end]
422 .iter()
423 .flat_map(|elem| walk_jsonpath(elem, rest))
424 .collect()
425 }
426 } else {
427 let idx: usize = match bracket_expr.parse() {
429 Ok(i) => i,
430 Err(_) => return vec![],
431 };
432 match arr.get(idx) {
433 Some(elem) => walk_jsonpath(elem, rest),
434 None => vec![],
435 }
436 }
437 } else {
438 match value.get(segment) {
440 Some(v) => walk_jsonpath(v, rest),
441 None => vec![],
442 }
443 }
444}
445
446fn split_jsonpath_segment(path: &str) -> (&str, &str) {
449 let mut in_bracket = false;
450 for (i, c) in path.char_indices() {
451 match c {
452 '[' => in_bracket = true,
453 ']' => in_bracket = false,
454 '.' if !in_bracket => {
455 return (&path[..i], &path[i + 1..]);
456 }
457 _ => {}
458 }
459 }
460 (path, "")
461}
462
463fn format_jsonpath_result(value: &serde_json::Value) -> String {
465 match value {
466 serde_json::Value::Null => String::new(),
467 serde_json::Value::String(s) => s.clone(),
468 serde_json::Value::Bool(b) => b.to_string(),
469 serde_json::Value::Number(n) => n.to_string(),
470 _ => serde_json::to_string_pretty(value).unwrap_or_default(),
471 }
472}
473
474#[derive(Debug, Clone, Copy, PartialEq, Eq)]
475pub enum Verbosity {
476 Quiet,
477 Normal,
478 Verbose,
479}
480
481enum CapturedLine {
483 Stdout(String),
484 Stderr(String),
485}
486
487pub struct CommandOutput {
489 pub status: std::process::ExitStatus,
490 pub stdout: String,
491 pub stderr: String,
492 pub duration: Duration,
493}
494
495pub struct Printer {
496 theme: Theme,
497 term: Term,
498 multi_progress: MultiProgress,
499 syntax_set: SyntaxSet,
500 theme_set: ThemeSet,
501 verbosity: Verbosity,
502 output_format: OutputFormat,
503 test_buf: Option<Arc<Mutex<String>>>,
506}
507
508impl Printer {
509 pub fn new(verbosity: Verbosity) -> Self {
510 Self::with_theme(verbosity, None)
511 }
512
513 pub fn with_theme(verbosity: Verbosity, theme_config: Option<&ThemeConfig>) -> Self {
514 Self::with_format(verbosity, theme_config, OutputFormat::Table)
515 }
516
517 pub fn disable_colors() {
520 console::set_colors_enabled(false);
521 console::set_colors_enabled_stderr(false);
522 }
523
524 pub fn with_format(
525 verbosity: Verbosity,
526 theme_config: Option<&ThemeConfig>,
527 output_format: OutputFormat,
528 ) -> Self {
529 let verbosity = match &output_format {
531 OutputFormat::Table | OutputFormat::Wide => verbosity,
532 _ => Verbosity::Quiet,
533 };
534 Self {
535 theme: Theme::from_config(theme_config),
536 term: Term::stderr(),
537 multi_progress: MultiProgress::new(),
538 syntax_set: SyntaxSet::load_defaults_newlines(),
539 theme_set: ThemeSet::load_defaults(),
540 verbosity,
541 output_format,
542 test_buf: None,
543 }
544 }
545
546 pub fn verbosity(&self) -> Verbosity {
547 self.verbosity
548 }
549
550 pub fn for_test() -> (Self, Arc<Mutex<String>>) {
553 let buf = Arc::new(Mutex::new(String::new()));
554 let printer = Self {
555 theme: Theme::from_config(None),
556 term: Term::stderr(),
557 multi_progress: MultiProgress::new(),
558 syntax_set: SyntaxSet::load_defaults_newlines(),
559 theme_set: ThemeSet::load_defaults(),
560 verbosity: Verbosity::Quiet,
561 output_format: OutputFormat::Table,
562 test_buf: Some(buf.clone()),
563 };
564 (printer, buf)
565 }
566
567 pub fn for_test_with_format(output_format: OutputFormat) -> (Self, Arc<Mutex<String>>) {
569 let buf = Arc::new(Mutex::new(String::new()));
570 let printer = Self {
571 theme: Theme::from_config(None),
572 term: Term::stderr(),
573 multi_progress: MultiProgress::new(),
574 syntax_set: SyntaxSet::load_defaults_newlines(),
575 theme_set: ThemeSet::load_defaults(),
576 verbosity: Verbosity::Quiet,
577 output_format,
578 test_buf: Some(buf.clone()),
579 };
580 (printer, buf)
581 }
582
583 fn capture(&self, text: &str) {
585 if let Some(ref buf) = self.test_buf {
586 let mut b = buf.lock().unwrap_or_else(|e| e.into_inner());
587 b.push_str(text);
588 b.push('\n');
589 }
590 }
591
592 pub fn header(&self, text: &str) {
593 self.capture(&format!("=== {} ===", text));
594 if self.verbosity == Verbosity::Quiet {
595 return;
596 }
597 let styled = self.theme.header.apply_to(format!("=== {} ===", text));
598 let _ = self.term.write_line(&styled.to_string());
599 }
600
601 pub fn subheader(&self, text: &str) {
602 self.capture(text);
603 if self.verbosity == Verbosity::Quiet {
604 return;
605 }
606 let styled = self.theme.subheader.apply_to(text);
607 let _ = self.term.write_line(&styled.to_string());
608 }
609
610 pub fn success(&self, text: &str) {
611 self.capture(text);
612 if self.verbosity == Verbosity::Quiet {
613 return;
614 }
615 let icon = self.theme.success.apply_to(&self.theme.icon_success);
616 let _ = self.term.write_line(&format!("{} {}", icon, text));
617 }
618
619 pub fn warning(&self, text: &str) {
620 self.capture(text);
621 if self.verbosity == Verbosity::Quiet {
622 return;
623 }
624 let icon = self.theme.warning.apply_to(&self.theme.icon_warning);
625 let styled_text = self.theme.warning.apply_to(text);
626 let _ = self.term.write_line(&format!("{} {}", icon, styled_text));
627 }
628
629 pub fn error(&self, text: &str) {
630 self.capture(text);
631 let icon = self.theme.error.apply_to(&self.theme.icon_error);
632 let styled_text = self.theme.error.apply_to(text);
633 let _ = self.term.write_line(&format!("{} {}", icon, styled_text));
634 }
635
636 pub fn info(&self, text: &str) {
637 self.capture(text);
638 if self.verbosity == Verbosity::Quiet {
639 return;
640 }
641 let icon = self.theme.info.apply_to(&self.theme.icon_info);
642 let styled_text = self.theme.info.apply_to(text);
643 let _ = self.term.write_line(&format!("{} {}", icon, styled_text));
644 }
645
646 pub fn key_value(&self, key: &str, value: &str) {
647 self.capture(&format!("{}: {}", key, value));
648 if self.verbosity == Verbosity::Quiet {
649 return;
650 }
651 let k = self.theme.key.apply_to(format!("{:>16}", key));
652 let arrow = self.theme.muted.apply_to(&self.theme.icon_arrow);
653 let v = self.theme.value.apply_to(value);
654 let _ = self.term.write_line(&format!("{} {} {}", k, arrow, v));
655 }
656
657 pub fn diff(&self, old: &str, new: &str) {
658 if self.test_buf.is_some() {
659 let diff = TextDiff::from_lines(old, new);
660 for change in diff.iter_all_changes() {
661 let sign = match change.tag() {
662 ChangeTag::Delete => "-",
663 ChangeTag::Insert => "+",
664 ChangeTag::Equal => " ",
665 };
666 self.capture(&format!("{}{}", sign, change.to_string().trim_end()));
667 }
668 }
669 if self.verbosity == Verbosity::Quiet {
670 return;
671 }
672 let diff = TextDiff::from_lines(old, new);
673 for change in diff.iter_all_changes() {
674 let (sign, style) = match change.tag() {
675 ChangeTag::Delete => ("-", &self.theme.diff_remove),
676 ChangeTag::Insert => ("+", &self.theme.diff_add),
677 ChangeTag::Equal => (" ", &self.theme.diff_context),
678 };
679 let line = style.apply_to(format!("{}{}", sign, change));
680 let _ = self.term.write_str(&line.to_string());
681 }
682 }
683
684 pub fn syntax_highlight(&self, code: &str, language: &str) {
685 if self.verbosity == Verbosity::Quiet {
686 return;
687 }
688 let syntax = self
689 .syntax_set
690 .find_syntax_by_token(language)
691 .unwrap_or_else(|| self.syntax_set.find_syntax_plain_text());
692 let theme = match self.theme_set.themes.get("base16-ocean.dark") {
693 Some(t) => t,
694 None => return, };
696 let mut highlighter = HighlightLines::new(syntax, theme);
697
698 for line in code.lines() {
699 match highlighter.highlight_line(line, &self.syntax_set) {
700 Ok(ranges) => {
701 let escaped = as_24_bit_terminal_escaped(&ranges[..], false);
702 let _ = self.term.write_line(&format!("{}\x1b[0m", escaped));
703 }
704 Err(_) => {
705 let _ = self.term.write_line(line);
706 }
707 }
708 }
709 }
710
711 pub fn progress_bar(&self, total: u64, message: &str) -> ProgressBar {
712 let pb = self.multi_progress.add(ProgressBar::new(total));
713 pb.set_style(
714 ProgressStyle::with_template("{spinner:.cyan} [{bar:30.cyan/dim}] {pos}/{len} {msg}")
715 .unwrap_or_else(|_| ProgressStyle::default_bar())
716 .progress_chars("━╸─"),
717 );
718 pb.set_message(message.to_string());
719 pb
720 }
721
722 pub fn spinner(&self, message: &str) -> ProgressBar {
723 if self.verbosity == Verbosity::Quiet {
724 return ProgressBar::hidden();
725 }
726 let pb = self.multi_progress.add(ProgressBar::new_spinner());
727 let frames_raw = [
728 "\u{28fb}", "\u{28d9}", "\u{2839}", "\u{2838}", "\u{283c}", "\u{2834}", "\u{2826}",
729 "\u{2827}", "\u{2807}", "\u{280f}",
730 ];
731 let styled_frames: Vec<String> = frames_raw
732 .iter()
733 .map(|f| self.theme.info.apply_to(f).to_string())
734 .collect();
735 let mut tick_refs: Vec<&str> = styled_frames.iter().map(|s| s.as_str()).collect();
736 tick_refs.push(" ");
737 pb.set_style(
738 ProgressStyle::with_template("{spinner} {msg}")
739 .unwrap_or_else(|_| ProgressStyle::default_spinner())
740 .tick_strings(&tick_refs),
741 );
742 pb.set_message(message.to_string());
743 pb.enable_steady_tick(Duration::from_millis(80));
744 pb
745 }
746
747 pub fn multi_progress(&self) -> &MultiProgress {
748 &self.multi_progress
749 }
750
751 pub fn plan_phase(&self, name: &str, items: &[String]) {
752 self.capture(&format!("Phase: {}", name));
753 for item in items {
754 self.capture(&format!(" {}", item));
755 }
756 if self.verbosity == Verbosity::Quiet {
757 return;
758 }
759 let header = self.theme.subheader.apply_to(format!("Phase: {}", name));
760 let _ = self.term.write_line(&header.to_string());
761 if items.is_empty() {
762 let muted = self.theme.muted.apply_to(" (nothing to do)");
763 let _ = self.term.write_line(&muted.to_string());
764 } else {
765 for item in items {
766 let icon = self.theme.muted.apply_to(&self.theme.icon_pending);
767 let muted_text = self.theme.muted.apply_to(item.as_str());
768 let _ = self.term.write_line(&format!(" {} {}", icon, muted_text));
769 }
770 }
771 }
772
773 pub fn table(&self, headers: &[&str], rows: &[Vec<String>]) {
774 if self.test_buf.is_some() {
775 self.capture(&headers.join("\t"));
776 for row in rows {
777 self.capture(&row.join("\t"));
778 }
779 }
780 if self.verbosity == Verbosity::Quiet {
781 return;
782 }
783
784 let col_count = headers.len();
785 let mut widths = vec![0usize; col_count];
786 for (i, h) in headers.iter().enumerate() {
787 widths[i] = h.len();
788 }
789 for row in rows {
790 for (i, cell) in row.iter().enumerate().take(col_count) {
791 widths[i] = widths[i].max(cell.len());
792 }
793 }
794
795 let header_line: String = headers
797 .iter()
798 .enumerate()
799 .map(|(i, h)| format!("{:<width$}", h, width = widths[i]))
800 .collect::<Vec<_>>()
801 .join(" ");
802 let styled_header = self.theme.subheader.apply_to(&header_line);
803 let _ = self.term.write_line(&styled_header.to_string());
804
805 let sep: String = widths
807 .iter()
808 .map(|w| "─".repeat(*w))
809 .collect::<Vec<_>>()
810 .join("──");
811 let styled_sep = self.theme.muted.apply_to(&sep);
812 let _ = self.term.write_line(&styled_sep.to_string());
813
814 for row in rows {
816 let line: String = row
817 .iter()
818 .enumerate()
819 .take(col_count)
820 .map(|(i, cell)| format!("{:<width$}", cell, width = widths[i]))
821 .collect::<Vec<_>>()
822 .join(" ");
823 let _ = self.term.write_line(&line);
824 }
825 }
826
827 pub fn prompt_confirm(
828 &self,
829 message: &str,
830 ) -> std::result::Result<bool, inquire::InquireError> {
831 inquire::Confirm::new(message).with_default(false).prompt()
832 }
833
834 pub fn prompt_select<'a>(
835 &self,
836 message: &str,
837 options: &'a [String],
838 ) -> std::result::Result<&'a String, inquire::InquireError> {
839 if options.is_empty() {
840 return Err(inquire::InquireError::Custom("no options available".into()));
841 }
842 let idx = inquire::Select::new(message, options.to_vec()).prompt()?;
843 Ok(options.iter().find(|o| **o == idx).unwrap_or(&options[0]))
844 }
845
846 pub fn prompt_text(
847 &self,
848 message: &str,
849 default: &str,
850 ) -> std::result::Result<String, inquire::InquireError> {
851 inquire::Text::new(message).with_default(default).prompt()
852 }
853
854 pub fn newline(&self) {
855 let _ = self.term.write_line("");
856 }
857
858 pub fn stdout_line(&self, text: &str) {
861 self.capture(text);
862 let stdout = Term::stdout();
863 let _ = stdout.write_line(text);
864 }
865
866 pub fn is_structured(&self) -> bool {
868 !matches!(self.output_format, OutputFormat::Table | OutputFormat::Wide)
869 }
870
871 pub fn is_wide(&self) -> bool {
873 matches!(self.output_format, OutputFormat::Wide)
874 }
875
876 pub fn write_structured<T: Serialize>(&self, value: &T) -> bool {
880 match &self.output_format {
881 OutputFormat::Table | OutputFormat::Wide => false,
882 OutputFormat::Json => {
883 let json_value = serde_json::to_value(value).unwrap_or(serde_json::Value::Null);
884 let text = serde_json::to_string_pretty(&json_value).unwrap_or_default();
885 self.stdout_line(&text);
886 true
887 }
888 OutputFormat::Yaml => {
889 let yaml = serde_yaml::to_string(value).unwrap_or_default();
890 let trimmed = yaml.strip_prefix("---\n").unwrap_or(&yaml);
891 self.stdout_line(trimmed.trim_end());
892 true
893 }
894 OutputFormat::Name => {
895 let json_value = serde_json::to_value(value).unwrap_or(serde_json::Value::Null);
896 match &json_value {
897 serde_json::Value::Array(arr) => {
898 for item in arr {
899 if let Some(name) = name_from_value(item) {
900 self.stdout_line(&name);
901 }
902 }
903 }
904 obj => {
905 if let Some(name) = name_from_value(obj) {
906 self.stdout_line(&name);
907 }
908 }
909 }
910 true
911 }
912 OutputFormat::Jsonpath(expr) => {
913 let json_value = serde_json::to_value(value).unwrap_or(serde_json::Value::Null);
914 let text = apply_jsonpath(&json_value, expr);
915 self.stdout_line(&text);
916 true
917 }
918 OutputFormat::Template(tmpl) => {
919 self.render_template(value, tmpl);
920 true
921 }
922 OutputFormat::TemplateFile(path) => {
923 let path = crate::expand_tilde(path);
924 match std::fs::read_to_string(&path) {
925 Ok(tmpl) => {
926 self.render_template(value, &tmpl);
927 }
928 Err(e) => {
929 self.error(&format!(
930 "failed to read template file '{}': {}",
931 path.display(),
932 e
933 ));
934 }
935 }
936 true
937 }
938 }
939 }
940
941 fn render_template<T: Serialize>(&self, value: &T, template: &str) {
942 let json_value = serde_json::to_value(value).unwrap_or(serde_json::Value::Null);
943 let mut tera = tera::Tera::default();
944 let tmpl_name = "__inline__";
945 if let Err(e) = tera.add_raw_template(tmpl_name, template) {
946 self.error(&format!("invalid template: {}", e));
947 return;
948 }
949 match &json_value {
950 serde_json::Value::Array(arr) => {
951 for item in arr {
952 match tera::Context::from_value(item.clone()) {
953 Ok(ctx) => match tera.render(tmpl_name, &ctx) {
954 Ok(rendered) => self.stdout_line(&rendered),
955 Err(e) => self.error(&format!("template render error: {}", e)),
956 },
957 Err(e) => self.error(&format!("template context error: {}", e)),
958 }
959 }
960 }
961 other => match tera::Context::from_value(other.clone()) {
962 Ok(ctx) => match tera.render(tmpl_name, &ctx) {
963 Ok(rendered) => self.stdout_line(&rendered),
964 Err(e) => self.error(&format!("template render error: {}", e)),
965 },
966 Err(e) => self.error(&format!("template context error: {}", e)),
967 },
968 }
969 }
970
971 pub fn run_with_output(
980 &self,
981 cmd: &mut std::process::Command,
982 label: &str,
983 ) -> std::io::Result<CommandOutput> {
984 let start = Instant::now();
985 cmd.stdin(std::process::Stdio::null());
986
987 if self.term.is_term() && self.verbosity != Verbosity::Quiet {
988 self.run_with_progress(cmd, label, start)
989 } else {
990 self.run_streaming(cmd, label, start)
991 }
992 }
993
994 fn spawn_output_readers(child: &mut std::process::Child) -> mpsc::Receiver<CapturedLine> {
998 let (tx, rx) = mpsc::channel();
999
1000 if let Some(stdout) = child.stdout.take() {
1001 let tx = tx.clone();
1002 std::thread::spawn(move || {
1003 for line in std::io::BufReader::new(stdout)
1004 .lines()
1005 .map_while(Result::ok)
1006 {
1007 let _ = tx.send(CapturedLine::Stdout(line));
1008 }
1009 });
1010 }
1011 if let Some(stderr) = child.stderr.take() {
1012 let tx = tx.clone();
1013 std::thread::spawn(move || {
1014 for line in std::io::BufReader::new(stderr)
1015 .lines()
1016 .map_while(Result::ok)
1017 {
1018 let _ = tx.send(CapturedLine::Stderr(line));
1019 }
1020 });
1021 }
1022 drop(tx);
1023 rx
1024 }
1025
1026 fn run_with_progress(
1028 &self,
1029 cmd: &mut std::process::Command,
1030 label: &str,
1031 start: Instant,
1032 ) -> std::io::Result<CommandOutput> {
1033 const VISIBLE_LINES: usize = 5;
1034
1035 let mut child = cmd
1036 .stdout(std::process::Stdio::piped())
1037 .stderr(std::process::Stdio::piped())
1038 .spawn()?;
1039
1040 let pb = self.multi_progress.add(ProgressBar::new_spinner());
1041
1042 let frames_raw = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1044 let styled_frames: Vec<String> = frames_raw
1045 .iter()
1046 .map(|f| self.theme.info.apply_to(f).to_string())
1047 .collect();
1048 let mut tick_refs: Vec<&str> = styled_frames.iter().map(|s| s.as_str()).collect();
1049 tick_refs.push(" "); pb.set_style(
1051 ProgressStyle::with_template("{spinner} {msg}")
1052 .unwrap_or_else(|_| ProgressStyle::default_spinner())
1053 .tick_strings(&tick_refs),
1054 );
1055 pb.set_message(label.to_string());
1056 pb.enable_steady_tick(Duration::from_millis(80));
1057
1058 let rx = Self::spawn_output_readers(&mut child);
1059
1060 let mut ring: VecDeque<String> = VecDeque::with_capacity(VISIBLE_LINES);
1061 let mut all_stdout = Vec::new();
1062 let mut all_stderr = Vec::new();
1063
1064 loop {
1065 match rx.recv_timeout(Duration::from_millis(100)) {
1066 Ok(line) => {
1067 let text = match &line {
1068 CapturedLine::Stdout(s) => {
1069 all_stdout.push(s.clone());
1070 s
1071 }
1072 CapturedLine::Stderr(s) => {
1073 all_stderr.push(s.clone());
1074 s
1075 }
1076 };
1077 if ring.len() >= VISIBLE_LINES {
1078 ring.pop_front();
1079 }
1080 ring.push_back(text.clone());
1081
1082 let mut msg = label.to_string();
1083 for l in &ring {
1084 let display = if l.len() > 120 {
1085 match l.get(..120) {
1086 Some(s) => s,
1087 None => l, }
1089 } else {
1090 l
1091 };
1092 msg.push_str(&format!("\n {}", self.theme.muted.apply_to(display)));
1093 }
1094 pb.set_message(msg);
1095 }
1096 Err(mpsc::RecvTimeoutError::Timeout) => continue,
1097 Err(mpsc::RecvTimeoutError::Disconnected) => break,
1098 }
1099 }
1100
1101 let status = child.wait()?;
1102 let duration = start.elapsed();
1103 pb.finish_and_clear();
1104
1105 if status.success() {
1106 self.success(&format!("{} ({}s)", label, duration.as_secs()));
1107 } else {
1108 self.error(&format!("{} — failed ({}s)", label, duration.as_secs()));
1109 for line in &all_stderr {
1110 let _ = self
1111 .term
1112 .write_line(&format!(" {}", self.theme.muted.apply_to(line)));
1113 }
1114 }
1115
1116 Ok(CommandOutput {
1117 status,
1118 stdout: all_stdout.join("\n"),
1119 stderr: all_stderr.join("\n"),
1120 duration,
1121 })
1122 }
1123
1124 fn run_streaming(
1126 &self,
1127 cmd: &mut std::process::Command,
1128 label: &str,
1129 start: Instant,
1130 ) -> std::io::Result<CommandOutput> {
1131 let mut child = cmd
1132 .stdout(std::process::Stdio::piped())
1133 .stderr(std::process::Stdio::piped())
1134 .spawn()?;
1135
1136 if self.verbosity != Verbosity::Quiet {
1137 self.info(label);
1138 }
1139
1140 let rx = Self::spawn_output_readers(&mut child);
1141
1142 let mut all_stdout = Vec::new();
1143 let mut all_stderr = Vec::new();
1144
1145 for line in rx {
1146 match &line {
1147 CapturedLine::Stdout(s) => {
1148 if self.verbosity != Verbosity::Quiet {
1149 let _ = self.term.write_line(s);
1150 }
1151 all_stdout.push(s.clone());
1152 }
1153 CapturedLine::Stderr(s) => {
1154 if self.verbosity != Verbosity::Quiet {
1155 let _ = self.term.write_line(s);
1156 }
1157 all_stderr.push(s.clone());
1158 }
1159 }
1160 }
1161
1162 let status = child.wait()?;
1163 let duration = start.elapsed();
1164
1165 if status.success() {
1166 if self.verbosity != Verbosity::Quiet {
1167 self.success(&format!("{} ({}s)", label, duration.as_secs()));
1168 }
1169 } else {
1170 self.error(&format!("{} — failed ({}s)", label, duration.as_secs()));
1171 }
1172
1173 Ok(CommandOutput {
1174 status,
1175 stdout: all_stdout.join("\n"),
1176 stderr: all_stderr.join("\n"),
1177 duration,
1178 })
1179 }
1180}
1181
1182#[cfg(test)]
1183mod tests {
1184 use super::*;
1185
1186 #[test]
1187 fn printer_respects_quiet_verbosity() {
1188 let printer = Printer::new(Verbosity::Quiet);
1189 assert_eq!(printer.verbosity(), Verbosity::Quiet);
1190 printer.header("test");
1191 printer.success("test");
1192 printer.warning("test");
1193 printer.info("test");
1194 printer.key_value("key", "value");
1195 }
1196
1197 #[test]
1198 fn printer_error_always_prints() {
1199 let printer = Printer::new(Verbosity::Quiet);
1202 printer.error("this is an error");
1203 }
1204
1205 #[test]
1206 fn parse_hex_valid() {
1207 let color = parse_hex_color("#ff5555");
1208 assert!(color.is_some());
1209 }
1210
1211 #[test]
1212 fn parse_hex_no_hash() {
1213 let color = parse_hex_color("50fa7b");
1214 assert!(color.is_some());
1215 }
1216
1217 #[test]
1218 fn parse_hex_invalid() {
1219 assert!(parse_hex_color("xyz").is_none());
1220 assert!(parse_hex_color("#gg0000").is_none());
1221 assert!(parse_hex_color("#ff").is_none());
1222 }
1223
1224 #[test]
1225 fn named_presets_exist() {
1226 let default = Theme::from_preset("default");
1227 let dracula = Theme::from_preset("dracula");
1228 let solarized_dark = Theme::from_preset("solarized-dark");
1229 let solarized_light = Theme::from_preset("solarized-light");
1230 let minimal = Theme::from_preset("minimal");
1231 assert_ne!(default.icon_success, minimal.icon_success);
1233 assert_ne!(dracula.icon_success, minimal.icon_success);
1234 assert_ne!(solarized_dark.icon_success, minimal.icon_success);
1235 assert_ne!(solarized_light.icon_success, minimal.icon_success);
1236 }
1237
1238 #[test]
1239 fn from_config_with_overrides() {
1240 let config = ThemeConfig {
1241 name: "default".into(),
1242 overrides: crate::config::ThemeOverrides {
1243 icon_success: Some("OK".into()),
1244 success: Some("#50fa7b".into()),
1245 ..Default::default()
1246 },
1247 };
1248 let theme = Theme::from_config(Some(&config));
1249 assert_eq!(theme.icon_success, "OK");
1250 }
1251
1252 #[test]
1253 fn run_with_output_captures_stdout() {
1254 let printer = Printer::new(Verbosity::Quiet);
1255 let output = printer
1256 .run_with_output(std::process::Command::new("echo").arg("hello"), "test echo")
1257 .unwrap();
1258 assert!(output.status.success());
1259 assert!(output.stdout.contains("hello"));
1260 }
1261
1262 #[test]
1263 #[cfg(unix)]
1264 fn run_with_output_captures_stderr() {
1265 let printer = Printer::new(Verbosity::Quiet);
1266 let output = printer
1267 .run_with_output(
1268 std::process::Command::new("sh")
1269 .arg("-c")
1270 .arg("echo error >&2"),
1271 "test stderr",
1272 )
1273 .unwrap();
1274 assert!(output.status.success());
1275 assert!(output.stderr.contains("error"));
1276 }
1277
1278 #[test]
1279 #[cfg(unix)]
1280 fn run_with_output_reports_failure() {
1281 let printer = Printer::new(Verbosity::Quiet);
1282 let output = printer
1283 .run_with_output(&mut std::process::Command::new("false"), "test failure")
1284 .unwrap();
1285 assert!(!output.status.success());
1286 }
1287
1288 #[test]
1289 #[cfg(unix)]
1290 fn run_with_output_tracks_duration() {
1291 let printer = Printer::new(Verbosity::Quiet);
1292 let output = printer
1293 .run_with_output(&mut std::process::Command::new("true"), "test duration")
1294 .unwrap();
1295 assert!(output.duration.as_secs() < 5);
1296 }
1297
1298 #[test]
1299 fn run_with_output_spawn_error() {
1300 let printer = Printer::new(Verbosity::Quiet);
1301 let result = printer.run_with_output(
1302 &mut std::process::Command::new("/nonexistent/binary"),
1303 "test spawn error",
1304 );
1305 match result {
1306 Err(err) => assert_eq!(err.kind(), std::io::ErrorKind::NotFound),
1307 Ok(_) => panic!("expected spawn error for nonexistent binary"),
1308 }
1309 }
1310
1311 #[test]
1314 fn output_format_table_returns_false() {
1315 let printer = Printer::new(Verbosity::Normal);
1316 assert!(!printer.is_structured());
1317 let val = serde_json::json!({"key": "value"});
1318 assert!(!printer.write_structured(&val));
1319 }
1320
1321 #[test]
1322 fn output_format_json_returns_true() {
1323 let printer = Printer::with_format(Verbosity::Normal, None, OutputFormat::Json);
1324 assert!(printer.is_structured());
1325 let val = serde_json::json!({"key": "value"});
1326 assert!(printer.write_structured(&val));
1327 }
1328
1329 #[test]
1330 fn output_format_yaml_returns_true() {
1331 let printer = Printer::with_format(Verbosity::Normal, None, OutputFormat::Yaml);
1332 assert!(printer.is_structured());
1333 assert!(printer.write_structured(&"hello"));
1334 }
1335
1336 #[test]
1339 fn apply_jsonpath_cases() {
1340 let obj = serde_json::json!({"name": "cfgd", "version": "1.0"});
1341 let nested = serde_json::json!({"status": {"phase": "running", "ready": true}});
1342 let arr = serde_json::json!({"items": ["a", "b", "c"]});
1343 let arr_obj = serde_json::json!({"items": [{"name": "a"}, {"name": "b"}]});
1344 let arr_num = serde_json::json!({"items": [1, 2, 3, 4, 5]});
1345 let arr_short = serde_json::json!({"items": [1, 2]});
1346 let arr3 = serde_json::json!({"items": [1, 2, 3]});
1347 let null_val = serde_json::json!({"key": null});
1348 let small = serde_json::json!({"a": 1});
1349
1350 let cases: &[(&serde_json::Value, &str, &str)] = &[
1352 (&obj, "{.name}", "cfgd"),
1353 (&obj, "{.version}", "1.0"),
1354 (&nested, "{.status.phase}", "running"),
1355 (&nested, "{.status.ready}", "true"),
1356 (&arr, "{.items[0]}", "a"),
1357 (&arr, "{.items[2]}", "c"),
1358 (&arr_obj, "{.items[*].name}", "a\nb"),
1359 (&arr_num, "{.items[1:3]}", "2\n3"),
1360 (&obj, "{.missing}", ""),
1361 (&obj, ".name", "cfgd"),
1362 (&arr_short, "{.items[5]}", ""),
1363 (&arr3, "{.items[10:20]}", ""),
1364 (&arr3, "{.items[5:2]}", ""),
1365 (&null_val, "{.key}", ""),
1366 ];
1367 for (val, expr, expected) in cases {
1368 assert_eq!(
1369 apply_jsonpath(val, expr),
1370 *expected,
1371 "failed for expr {expr:?}"
1372 );
1373 }
1374
1375 let status_json = apply_jsonpath(&nested, "{.status}");
1377 let parsed: serde_json::Value = serde_json::from_str(&status_json).unwrap();
1378 assert_eq!(parsed["phase"], "running");
1379
1380 let full = apply_jsonpath(&small, "{}");
1381 let parsed: serde_json::Value = serde_json::from_str(&full).unwrap();
1382 assert_eq!(parsed["a"], 1);
1383 }
1384
1385 #[test]
1386 fn split_jsonpath_segment_simple() {
1387 assert_eq!(split_jsonpath_segment("foo.bar"), ("foo", "bar"));
1388 assert_eq!(split_jsonpath_segment("foo"), ("foo", ""));
1389 }
1390
1391 #[test]
1392 fn split_jsonpath_segment_with_bracket() {
1393 assert_eq!(
1394 split_jsonpath_segment("items[0].name"),
1395 ("items[0]", "name")
1396 );
1397 assert_eq!(
1398 split_jsonpath_segment("items[*].name"),
1399 ("items[*]", "name")
1400 );
1401 }
1402
1403 #[test]
1405 fn printer_methods_smoke_test() {
1406 let printer = Printer::new(Verbosity::Quiet);
1407 printer.subheader("Test Section");
1408 printer.newline();
1409 printer.stdout_line("output line");
1410
1411 let rows = vec![
1413 vec!["a".to_string(), "b".to_string()],
1414 vec!["c".to_string(), "d".to_string()],
1415 ];
1416 printer.table(&["Col1", "Col2"], &rows);
1417 let empty_rows: Vec<Vec<String>> = vec![];
1418 printer.table(&["Col1"], &empty_rows);
1419
1420 printer.plan_phase("Packages", &["install brew: curl".to_string()]);
1422 printer.plan_phase("Files", &[]);
1423
1424 printer.diff("old content\nline2", "new content\nline2");
1426 printer.diff("same", "same");
1427
1428 printer.syntax_highlight("fn main() {}", "rs");
1430 printer.syntax_highlight("some text", "unknown_lang_xyz");
1431
1432 let data = serde_json::json!({"key": "value"});
1434 printer.write_structured(&data);
1435 }
1436
1437 #[test]
1440 fn write_structured_name_single_object() {
1441 let printer = Printer::with_format(Verbosity::Normal, None, OutputFormat::Name);
1442 assert!(printer.is_structured());
1443 let val = serde_json::json!({"name": "my-profile"});
1444 assert!(printer.write_structured(&val));
1445 }
1446
1447 #[test]
1448 fn write_structured_name_array() {
1449 let printer = Printer::with_format(Verbosity::Normal, None, OutputFormat::Name);
1450 let val = serde_json::json!([
1451 {"name": "profile-a"},
1452 {"name": "profile-b"}
1453 ]);
1454 assert!(printer.write_structured(&val));
1455 }
1456
1457 #[test]
1458 fn write_structured_name_fallback_fields() {
1459 let printer = Printer::with_format(Verbosity::Normal, None, OutputFormat::Name);
1461
1462 let context_val = serde_json::json!({"context": "production"});
1463 assert!(printer.write_structured(&context_val));
1464
1465 let phase_val = serde_json::json!({"phase": "Packages"});
1466 assert!(printer.write_structured(&phase_val));
1467
1468 let apply_id_val = serde_json::json!({"applyId": 42});
1469 assert!(printer.write_structured(&apply_id_val));
1470 }
1471
1472 #[test]
1473 fn write_structured_jsonpath() {
1474 let printer = Printer::with_format(
1475 Verbosity::Normal,
1476 None,
1477 OutputFormat::Jsonpath("{.status.phase}".to_string()),
1478 );
1479 assert!(printer.is_structured());
1480 let val = serde_json::json!({"status": {"phase": "ready"}});
1481 assert!(printer.write_structured(&val));
1482 }
1483
1484 #[test]
1485 fn write_structured_template() {
1486 let printer = Printer::with_format(
1487 Verbosity::Normal,
1488 None,
1489 OutputFormat::Template("Name: {{ name }}".to_string()),
1490 );
1491 assert!(printer.is_structured());
1492 let val = serde_json::json!({"name": "my-config"});
1493 assert!(printer.write_structured(&val));
1494 }
1495
1496 #[test]
1497 fn write_structured_template_array() {
1498 let printer = Printer::with_format(
1499 Verbosity::Normal,
1500 None,
1501 OutputFormat::Template("- {{ name }}".to_string()),
1502 );
1503 let val = serde_json::json!([
1504 {"name": "a"},
1505 {"name": "b"}
1506 ]);
1507 assert!(printer.write_structured(&val));
1508 }
1509
1510 #[test]
1511 fn write_structured_template_file() {
1512 let dir = tempfile::tempdir().unwrap();
1513 let tmpl_path = dir.path().join("output.tmpl");
1514 std::fs::write(&tmpl_path, "Name={{ name }} Version={{ version }}").unwrap();
1515
1516 let printer = Printer::with_format(
1517 Verbosity::Normal,
1518 None,
1519 OutputFormat::TemplateFile(tmpl_path),
1520 );
1521 let val = serde_json::json!({"name": "cfgd", "version": "1.0"});
1522 assert!(printer.write_structured(&val));
1523 }
1524
1525 #[test]
1526 fn write_structured_template_file_missing() {
1527 let printer = Printer::with_format(
1528 Verbosity::Normal,
1529 None,
1530 OutputFormat::TemplateFile("/nonexistent/template.tmpl".into()),
1531 );
1532 let val = serde_json::json!({"key": "value"});
1533 assert!(printer.write_structured(&val));
1535 }
1536
1537 #[test]
1538 fn write_structured_wide_returns_false() {
1539 let printer = Printer::with_format(Verbosity::Normal, None, OutputFormat::Wide);
1540 assert!(!printer.is_structured());
1541 assert!(printer.is_wide());
1542 let val = serde_json::json!({"key": "value"});
1543 assert!(!printer.write_structured(&val));
1544 }
1545
1546 #[test]
1549 fn name_from_value_no_match_returns_none() {
1550 let val = serde_json::json!({"unknown_field": "value"});
1551 assert!(name_from_value(&val).is_none());
1552 }
1553
1554 #[test]
1555 fn name_from_value_prefers_name_over_others() {
1556 let val = serde_json::json!({"name": "primary", "context": "secondary"});
1557 assert_eq!(name_from_value(&val), Some("primary".to_string()));
1558 }
1559
1560 #[test]
1561 fn name_from_value_numeric_apply_id() {
1562 let val = serde_json::json!({"applyId": 123});
1563 assert_eq!(name_from_value(&val), Some("123".to_string()));
1564 }
1565
1566 #[test]
1567 fn name_from_value_null_returns_none() {
1568 assert!(name_from_value(&serde_json::Value::Null).is_none());
1569 }
1570
1571 #[test]
1574 fn theme_from_config_all_icon_overrides() {
1575 let config = ThemeConfig {
1576 name: "default".into(),
1577 overrides: crate::config::ThemeOverrides {
1578 icon_success: Some("OK".into()),
1579 icon_warning: Some("!!".into()),
1580 icon_error: Some("ERR".into()),
1581 icon_info: Some("ii".into()),
1582 icon_pending: Some("..".into()),
1583 icon_arrow: Some(">>".into()),
1584 ..Default::default()
1585 },
1586 };
1587 let theme = Theme::from_config(Some(&config));
1588 assert_eq!(theme.icon_success, "OK");
1589 assert_eq!(theme.icon_warning, "!!");
1590 assert_eq!(theme.icon_error, "ERR");
1591 assert_eq!(theme.icon_info, "ii");
1592 assert_eq!(theme.icon_pending, "..");
1593 assert_eq!(theme.icon_arrow, ">>");
1594 }
1595
1596 #[test]
1597 fn theme_from_config_all_color_overrides() {
1598 let config = ThemeConfig {
1599 name: "default".into(),
1600 overrides: crate::config::ThemeOverrides {
1601 success: Some("#00ff00".into()),
1602 warning: Some("#ffff00".into()),
1603 error: Some("#ff0000".into()),
1604 info: Some("#00ffff".into()),
1605 muted: Some("#888888".into()),
1606 header: Some("#0000ff".into()),
1607 subheader: Some("#ff00ff".into()),
1608 key: Some("#ff79c6".into()),
1609 value: Some("#f8f8f2".into()),
1610 diff_add: Some("#50fa7b".into()),
1611 diff_remove: Some("#ff5555".into()),
1612 diff_context: Some("#6272a4".into()),
1613 ..Default::default()
1614 },
1615 };
1616 let _theme = Theme::from_config(Some(&config));
1618 }
1619
1620 #[test]
1621 fn theme_from_config_invalid_color_ignored() {
1622 let config = ThemeConfig {
1623 name: "default".into(),
1624 overrides: crate::config::ThemeOverrides {
1625 success: Some("not-a-color".into()),
1626 ..Default::default()
1627 },
1628 };
1629 let _theme = Theme::from_config(Some(&config));
1631 }
1632
1633 #[test]
1636 fn format_jsonpath_result_bool() {
1637 assert_eq!(
1638 format_jsonpath_result(&serde_json::Value::Bool(true)),
1639 "true"
1640 );
1641 assert_eq!(
1642 format_jsonpath_result(&serde_json::Value::Bool(false)),
1643 "false"
1644 );
1645 }
1646
1647 #[test]
1648 fn format_jsonpath_result_number() {
1649 let num = serde_json::json!(42);
1650 assert_eq!(format_jsonpath_result(&num), "42");
1651 let float = serde_json::json!(1.234);
1652 assert_eq!(format_jsonpath_result(&float), "1.234");
1653 }
1654
1655 #[test]
1656 fn format_jsonpath_result_null_empty() {
1657 assert_eq!(format_jsonpath_result(&serde_json::Value::Null), "");
1658 }
1659
1660 #[test]
1663 fn printer_normal_verbosity_methods_do_not_panic() {
1664 let printer = Printer::new(Verbosity::Normal);
1666
1667 printer.header("Test Header");
1668 printer.subheader("Test Subheader");
1669 printer.success("Operation succeeded");
1670 printer.warning("Something might be wrong");
1671 printer.error("Something failed");
1672 printer.info("Some information");
1673 printer.key_value("Profile", "developer");
1674 printer.key_value("Config Dir", "~/.config/cfgd");
1675 printer.newline();
1676
1677 printer.table(
1679 &["NAME", "STATUS", "AGE"],
1680 &[
1681 vec!["nvim".into(), "installed".into(), "2d".into()],
1682 vec!["tmux".into(), "missing".into(), "-".into()],
1683 ],
1684 );
1685
1686 printer.plan_phase(
1688 "Packages",
1689 &[
1690 "install brew: neovim".to_string(),
1691 "install apt: ripgrep".to_string(),
1692 ],
1693 );
1694 printer.plan_phase("Files", &[]);
1695
1696 printer.diff("line1\nline2\nline3", "line1\nmodified\nline3");
1698 printer.diff("identical", "identical");
1699
1700 printer.syntax_highlight("fn main() { println!(\"hello\"); }", "rs");
1702 }
1703
1704 #[test]
1705 fn spinner_hidden_in_quiet_mode() {
1706 let printer = Printer::new(Verbosity::Quiet);
1707 let spinner = printer.spinner("loading...");
1708 spinner.finish_and_clear();
1710 }
1711
1712 #[test]
1713 fn progress_bar_creates_valid_bar() {
1714 let printer = Printer::new(Verbosity::Normal);
1715 let pb = printer.progress_bar(100, "processing");
1716 pb.inc(50);
1717 assert_eq!(pb.position(), 50);
1718 pb.finish();
1719 }
1720
1721 #[test]
1722 fn disable_colors_does_not_panic() {
1723 Printer::disable_colors();
1724 }
1725
1726 #[test]
1729 fn jsonpath_non_array_bracket_access_returns_empty() {
1730 let val = serde_json::json!({"items": "not-an-array"});
1731 assert_eq!(apply_jsonpath(&val, "{.items[0]}"), "");
1732 }
1733
1734 #[test]
1735 fn jsonpath_non_numeric_bracket_returns_empty() {
1736 let val = serde_json::json!({"items": [1, 2, 3]});
1737 assert_eq!(apply_jsonpath(&val, "{.items[abc]}"), "");
1738 }
1739
1740 #[test]
1741 fn jsonpath_nested_array_wildcard() {
1742 let val = serde_json::json!({
1743 "groups": [
1744 {"members": [{"name": "alice"}, {"name": "bob"}]},
1745 {"members": [{"name": "charlie"}]}
1746 ]
1747 });
1748 let result = apply_jsonpath(&val, "{.groups[*].members[*].name}");
1749 assert!(result.contains("alice"), "should contain alice: {result}");
1750 assert!(result.contains("bob"), "should contain bob: {result}");
1751 assert!(
1752 result.contains("charlie"),
1753 "should contain charlie: {result}"
1754 );
1755 }
1756
1757 #[test]
1758 fn jsonpath_slice_from_start() {
1759 let val = serde_json::json!({"items": ["a", "b", "c", "d"]});
1760 assert_eq!(apply_jsonpath(&val, "{.items[0:2]}"), "a\nb");
1762 }
1763
1764 #[test]
1765 fn jsonpath_empty_array() {
1766 let val = serde_json::json!({"items": []});
1767 assert_eq!(apply_jsonpath(&val, "{.items[0]}"), "");
1768 assert_eq!(apply_jsonpath(&val, "{.items[*]}"), "");
1769 assert_eq!(apply_jsonpath(&val, "{.items[0:5]}"), "");
1770 }
1771
1772 #[test]
1775 fn for_test_captures_header_text() {
1776 let (printer, buf) = Printer::for_test();
1777 printer.header("Test Header");
1778 let output = buf.lock().unwrap();
1779 assert!(output.contains("Test Header"), "should capture header text");
1780 }
1781
1782 #[test]
1783 fn for_test_captures_success_text() {
1784 let (printer, buf) = Printer::for_test();
1785 printer.success("Operation passed");
1786 let output = buf.lock().unwrap();
1787 assert!(
1788 output.contains("Operation passed"),
1789 "should capture success text"
1790 );
1791 }
1792
1793 #[test]
1794 fn for_test_captures_warning_text() {
1795 let (printer, buf) = Printer::for_test();
1796 printer.warning("Careful now");
1797 let output = buf.lock().unwrap();
1798 assert!(
1799 output.contains("Careful now"),
1800 "should capture warning text"
1801 );
1802 }
1803
1804 #[test]
1805 fn for_test_captures_error_text() {
1806 let (printer, buf) = Printer::for_test();
1807 printer.error("Something broke");
1808 let output = buf.lock().unwrap();
1809 assert!(
1810 output.contains("Something broke"),
1811 "should capture error text"
1812 );
1813 }
1814
1815 #[test]
1816 fn for_test_captures_info_text() {
1817 let (printer, buf) = Printer::for_test();
1818 printer.info("FYI message");
1819 let output = buf.lock().unwrap();
1820 assert!(output.contains("FYI message"), "should capture info text");
1821 }
1822
1823 #[test]
1824 fn for_test_captures_key_value() {
1825 let (printer, buf) = Printer::for_test();
1826 printer.key_value("Profile", "developer");
1827 let output = buf.lock().unwrap();
1828 assert!(
1829 output.contains("Profile") && output.contains("developer"),
1830 "should capture key and value"
1831 );
1832 }
1833
1834 #[test]
1835 fn for_test_captures_subheader() {
1836 let (printer, buf) = Printer::for_test();
1837 printer.subheader("Subsection");
1838 let output = buf.lock().unwrap();
1839 assert!(
1840 output.contains("Subsection"),
1841 "should capture subheader text"
1842 );
1843 }
1844
1845 #[test]
1846 fn for_test_captures_plan_phase() {
1847 let (printer, buf) = Printer::for_test();
1848 printer.plan_phase("Install", &["neovim".to_string(), "tmux".to_string()]);
1849 let output = buf.lock().unwrap();
1850 assert!(
1851 output.contains("Phase: Install"),
1852 "should capture phase name"
1853 );
1854 assert!(
1855 output.contains("neovim") && output.contains("tmux"),
1856 "should capture phase items"
1857 );
1858 }
1859
1860 #[test]
1861 fn for_test_captures_plan_phase_empty() {
1862 let (printer, buf) = Printer::for_test();
1863 printer.plan_phase("Cleanup", &[]);
1864 let output = buf.lock().unwrap();
1865 assert!(
1866 output.contains("Phase: Cleanup"),
1867 "should capture empty phase name"
1868 );
1869 }
1870
1871 #[test]
1872 fn for_test_captures_table() {
1873 let (printer, buf) = Printer::for_test();
1874 let rows = vec![
1875 vec!["nvim".to_string(), "installed".to_string()],
1876 vec!["tmux".to_string(), "missing".to_string()],
1877 ];
1878 printer.table(&["NAME", "STATUS"], &rows);
1879 let output = buf.lock().unwrap();
1880 assert!(
1881 output.contains("NAME") && output.contains("STATUS"),
1882 "should capture table headers"
1883 );
1884 assert!(
1885 output.contains("nvim") && output.contains("installed"),
1886 "should capture table rows"
1887 );
1888 assert!(
1889 output.contains("tmux") && output.contains("missing"),
1890 "should capture second row"
1891 );
1892 }
1893
1894 #[test]
1895 fn for_test_captures_diff() {
1896 let (printer, buf) = Printer::for_test();
1897 printer.diff("line1\nold\nline3\n", "line1\nnew\nline3\n");
1898 let output = buf.lock().unwrap();
1899 assert!(output.contains("-old"), "should capture removed line");
1900 assert!(output.contains("+new"), "should capture added line");
1901 assert!(
1902 output.contains(" line1"),
1903 "should capture unchanged context"
1904 );
1905 }
1906
1907 #[test]
1908 fn for_test_captures_diff_identical() {
1909 let (printer, buf) = Printer::for_test();
1910 printer.diff("same\n", "same\n");
1911 let output = buf.lock().unwrap();
1912 assert!(
1913 output.contains(" same"),
1914 "identical diff should show context line"
1915 );
1916 assert!(
1917 !output.contains("-same") && !output.contains("+same"),
1918 "identical diff should have no add/remove markers"
1919 );
1920 }
1921
1922 #[test]
1923 fn for_test_captures_stdout_line() {
1924 let (printer, buf) = Printer::for_test();
1925 printer.stdout_line("data output");
1926 let output = buf.lock().unwrap();
1927 assert!(
1928 output.contains("data output"),
1929 "should capture stdout_line text"
1930 );
1931 }
1932
1933 #[test]
1936 fn for_test_write_structured_json() {
1937 let (printer, buf) = Printer::for_test_with_format(OutputFormat::Json);
1938 let val = serde_json::json!({"key": "value"});
1939 let wrote = printer.write_structured(&val);
1940 assert!(wrote, "should return true for JSON format");
1941 let output = buf.lock().unwrap();
1942 assert!(
1943 output.contains("key") && output.contains("value"),
1944 "should capture JSON output"
1945 );
1946 }
1947
1948 #[test]
1949 fn for_test_write_structured_yaml() {
1950 let (printer, buf) = Printer::for_test_with_format(OutputFormat::Yaml);
1951 let val = serde_json::json!({"name": "test"});
1952 let wrote = printer.write_structured(&val);
1953 assert!(wrote, "should return true for YAML format");
1954 let output = buf.lock().unwrap();
1955 assert!(
1956 output.contains("name") && output.contains("test"),
1957 "should capture YAML output"
1958 );
1959 }
1960
1961 #[test]
1962 fn for_test_write_structured_name() {
1963 let (printer, buf) = Printer::for_test_with_format(OutputFormat::Name);
1964 let val = serde_json::json!({"name": "my-profile"});
1965 let wrote = printer.write_structured(&val);
1966 assert!(wrote, "should return true for Name format");
1967 let output = buf.lock().unwrap();
1968 assert!(output.contains("my-profile"), "should capture name output");
1969 }
1970
1971 #[test]
1972 fn for_test_write_structured_name_array_items() {
1973 let (printer, buf) = Printer::for_test_with_format(OutputFormat::Name);
1974 let val = serde_json::json!([
1975 {"name": "alpha"},
1976 {"name": "beta"}
1977 ]);
1978 let wrote = printer.write_structured(&val);
1979 assert!(wrote);
1980 let output = buf.lock().unwrap();
1981 assert!(output.contains("alpha"), "should capture first name");
1982 assert!(output.contains("beta"), "should capture second name");
1983 }
1984
1985 #[test]
1986 fn for_test_write_structured_jsonpath_captures() {
1987 let (printer, buf) =
1988 Printer::for_test_with_format(OutputFormat::Jsonpath("{.name}".to_string()));
1989 let val = serde_json::json!({"name": "cfgd", "version": "2.0"});
1990 let wrote = printer.write_structured(&val);
1991 assert!(wrote);
1992 let output = buf.lock().unwrap();
1993 assert!(output.contains("cfgd"), "should capture jsonpath result");
1994 }
1995
1996 #[test]
1997 fn for_test_write_structured_template_captures() {
1998 let (printer, buf) =
1999 Printer::for_test_with_format(OutputFormat::Template("Hello {{ name }}!".to_string()));
2000 let val = serde_json::json!({"name": "world"});
2001 let wrote = printer.write_structured(&val);
2002 assert!(wrote);
2003 let output = buf.lock().unwrap();
2004 assert!(
2005 output.contains("Hello world!"),
2006 "should capture rendered template"
2007 );
2008 }
2009
2010 #[test]
2013 fn ansi256_from_rgb_pure_black() {
2014 assert_eq!(ansi256_from_rgb(0, 0, 0), 16);
2016 }
2017
2018 #[test]
2019 fn ansi256_from_rgb_near_white() {
2020 assert_eq!(ansi256_from_rgb(255, 255, 255), 231);
2022 }
2023
2024 #[test]
2025 fn ansi256_from_rgb_grayscale_midrange() {
2026 let idx = ansi256_from_rgb(128, 128, 128);
2028 assert!(
2029 idx >= 232,
2030 "midrange gray should be in grayscale ramp: {idx}"
2031 );
2032 }
2033
2034 #[test]
2035 fn ansi256_from_rgb_color_cube() {
2036 let idx = ansi256_from_rgb(255, 0, 0); assert!(
2039 (16..=231).contains(&idx),
2040 "pure red should map to color cube: {idx}"
2041 );
2042 }
2043
2044 #[test]
2045 fn ansi256_from_rgb_various_colors() {
2046 let colors: &[(u8, u8, u8)] = &[
2048 (0, 255, 0), (0, 0, 255), (128, 64, 0), (200, 200, 50), ];
2053 for (r, g, b) in colors {
2054 let idx = ansi256_from_rgb(*r, *g, *b);
2055 assert!(
2056 (16..=231).contains(&idx),
2057 "color ({r},{g},{b}) should map to cube or grayscale: {idx}"
2058 );
2059 }
2060 }
2061
2062 #[test]
2065 fn write_structured_invalid_template_syntax() {
2066 let (printer, buf) = Printer::for_test_with_format(OutputFormat::Template(
2067 "{{ invalid {% endfor }".to_string(),
2068 ));
2069 let val = serde_json::json!({"name": "test"});
2070 let wrote = printer.write_structured(&val);
2071 assert!(wrote, "should still return true");
2072 let output = buf.lock().unwrap();
2073 assert!(
2074 output.contains("invalid template"),
2075 "should capture template error message, got: {output}"
2076 );
2077 }
2078
2079 #[test]
2082 fn output_format_auto_quiets_structured() {
2083 let printer = Printer::with_format(Verbosity::Normal, None, OutputFormat::Json);
2085 assert_eq!(printer.verbosity(), Verbosity::Quiet);
2086 }
2087
2088 #[test]
2089 fn output_format_table_preserves_verbosity() {
2090 let printer = Printer::with_format(Verbosity::Normal, None, OutputFormat::Table);
2091 assert_eq!(printer.verbosity(), Verbosity::Normal);
2092 }
2093
2094 #[test]
2095 fn output_format_wide_preserves_verbosity() {
2096 let printer = Printer::with_format(Verbosity::Verbose, None, OutputFormat::Wide);
2097 assert_eq!(printer.verbosity(), Verbosity::Verbose);
2098 }
2099
2100 #[test]
2103 fn name_from_value_url_field() {
2104 let val = serde_json::json!({"url": "https://example.com"});
2105 assert_eq!(
2106 name_from_value(&val),
2107 Some("https://example.com".to_string())
2108 );
2109 }
2110
2111 #[test]
2112 fn name_from_value_resource_type_field() {
2113 let val = serde_json::json!({"resourceType": "MachineConfig"});
2114 assert_eq!(name_from_value(&val), Some("MachineConfig".to_string()));
2115 }
2116}