Skip to main content

cfgd_core/output/
mod.rs

1// Centralized output system — all terminal interaction goes through Printer
2
3use 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
31// Default icons shared across all non-minimal presets
32const 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        // Apply hex color overrides
101        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        // Apply icon overrides
140        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
277/// Parse a hex color string (#rrggbb or rrggbb) into a console::Color.
278fn 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
289/// Map RGB to the nearest ANSI 256-color index.
290fn ansi256_from_rgb(r: u8, g: u8, b: u8) -> u8 {
291    // Check grayscale ramp (232-255) first
292    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    // Map to 6x6x6 color cube (indices 16-231)
302    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
321/// Apply a kubectl-compatible jsonpath expression to a JSON value.
322///
323/// Supports:
324/// - `{.field.nested}` — dot-path into objects
325/// - `{.items[0]}` — array index
326/// - `{.items[*].name}` — wildcard collect
327/// - `{.items[0:3]}` — array slice
328///
329/// Scalars return as raw text, objects/arrays as JSON.
330fn apply_jsonpath(value: &serde_json::Value, expr: &str) -> String {
331    // Strip optional { } wrapper
332    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            // Multiple results: output each on its own line (kubectl behavior)
347            results
348                .iter()
349                .map(|v| format_jsonpath_result(v))
350                .collect::<Vec<_>>()
351                .join("\n")
352        }
353    }
354}
355
356/// Extract a name-like identity field from a JSON value.
357/// Tries "name" first, then common identity fields as fallbacks.
358fn 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    // Try numeric identity fields
365    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    // Parse the next segment: either `key`, `key[index]`, `key[*]`, or `key[start:end]`
379    let (segment, rest) = split_jsonpath_segment(path);
380
381    // Check for array access on the segment
382    if let Some(bracket_pos) = segment.find('[') {
383        let key = &segment[..bracket_pos];
384        let bracket_expr = &segment[bracket_pos + 1..segment.len() - 1]; // strip [ ]
385
386        // Navigate to the key first (if non-empty)
387        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            // Wildcard: collect from all elements
403            arr.iter()
404                .flat_map(|elem| walk_jsonpath(elem, rest))
405                .collect()
406        } else if let Some(colon_pos) = bracket_expr.find(':') {
407            // Slice: [start:end]
408            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            // Numeric index
428            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        // Plain key access
439        match value.get(segment) {
440            Some(v) => walk_jsonpath(v, rest),
441            None => vec![],
442        }
443    }
444}
445
446/// Split a jsonpath into the first segment and the rest.
447/// Handles bracket notation: `items[0].name` → (`items[0]`, `name`)
448fn 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
463/// Format a jsonpath result value: scalars as raw text, objects/arrays as JSON.
464fn 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
481/// A line captured from a child process's stdout or stderr.
482enum CapturedLine {
483    Stdout(String),
484    Stderr(String),
485}
486
487/// Result of running a command with live output display.
488pub 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    /// Optional buffer for capturing output in tests. When set, all output
504    /// methods append plain text here regardless of verbosity level.
505    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    /// Disable color output globally. Wraps the `console` crate's color toggle
518    /// so that no other module needs to depend on `console` directly.
519    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        // Auto-quiet when structured output is active
530        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    /// Create a Printer that captures all output to a shared buffer.
551    /// Use in tests to verify output content regardless of verbosity.
552    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    /// Create a Printer with a specific output format that captures to a buffer.
568    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    /// Append a line to the test buffer (no-op when buffer is absent).
584    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, // no theme available — skip highlighting
695        };
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        // Header row
796        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        // Separator
806        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        // Data rows
815        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    /// Write a line to stdout (for machine-readable data output, not UI).
859    /// Used by commands like `config get` whose output may be captured by scripts.
860    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    /// Whether structured output mode is active (not table or wide).
867    pub fn is_structured(&self) -> bool {
868        !matches!(self.output_format, OutputFormat::Table | OutputFormat::Wide)
869    }
870
871    /// Returns `true` when `-o wide` was specified, enabling extra columns.
872    pub fn is_wide(&self) -> bool {
873        matches!(self.output_format, OutputFormat::Wide)
874    }
875
876    /// Write a serializable value as structured output to stdout.
877    /// Returns `true` if output was emitted (caller should skip human formatting).
878    /// Returns `false` if output format is Table/Wide (caller should do human formatting).
879    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    /// Run a command with live output display.
972    ///
973    /// TTY mode: shows a spinner with the last N lines of output in a bounded
974    /// region. On success, collapses to a summary line. On failure, shows full
975    /// stderr.
976    ///
977    /// Non-TTY / quiet mode: streams output lines as they arrive. Captures
978    /// stdout/stderr for the return value.
979    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    /// Spawn background threads to read stdout/stderr from a child process,
995    /// sending lines through the returned channel. The original sender is dropped
996    /// so the receiver disconnects once both reader threads finish.
997    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    /// TTY path: bounded scrolling output region with spinner.
1027    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        // Build themed spinner frames so the animation respects the active theme
1043        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(" "); // final "done" frame (unused with finish_and_clear)
1050        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, // multi-byte boundary; show full line
1088                            }
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    /// Non-TTY / quiet path: stream output lines as they arrive.
1125    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        // Errors go to stderr which can't easily be captured in unit tests,
1200        // so we verify it doesn't panic when called at Quiet verbosity.
1201        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        // Presets should produce distinct themes
1232        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    // --- OutputFormat and write_structured tests ---
1312
1313    #[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    // --- jsonpath tests ---
1337
1338    #[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        // Simple scalar lookups
1351        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        // Object/full-value results (need JSON parsing)
1376        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    // Smoke test: verify all printer methods execute without panic
1404    #[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        // Table with data and empty
1412        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        // Plan phase with items and empty
1421        printer.plan_phase("Packages", &["install brew: curl".to_string()]);
1422        printer.plan_phase("Files", &[]);
1423
1424        // Diff with changes and identical
1425        printer.diff("old content\nline2", "new content\nline2");
1426        printer.diff("same", "same");
1427
1428        // Syntax highlighting with known and unknown language
1429        printer.syntax_highlight("fn main() {}", "rs");
1430        printer.syntax_highlight("some text", "unknown_lang_xyz");
1431
1432        // write_structured in non-structured mode
1433        let data = serde_json::json!({"key": "value"});
1434        printer.write_structured(&data);
1435    }
1436
1437    // --- write_structured format variants ---
1438
1439    #[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        // name_from_value tries "name", then "context", "phase", "resourceType", "url", "applyId"
1460        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        // Should return true (structured output mode) but print error
1534        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    // --- name_from_value edge cases ---
1547
1548    #[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    // --- Theme construction edge cases ---
1572
1573    #[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        // Should not panic — all colors are applied
1617        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        // Should not panic — invalid color is silently ignored
1630        let _theme = Theme::from_config(Some(&config));
1631    }
1632
1633    // --- format_jsonpath_result ---
1634
1635    #[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    // --- Printer method behavior ---
1661
1662    #[test]
1663    fn printer_normal_verbosity_methods_do_not_panic() {
1664        // Unlike Quiet mode (which skips), Normal mode exercises the full rendering path
1665        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        // Table with real data
1678        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        // Plan phase
1687        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        // Diff
1697        printer.diff("line1\nline2\nline3", "line1\nmodified\nline3");
1698        printer.diff("identical", "identical");
1699
1700        // Syntax highlight
1701        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        // ProgressBar::hidden() is returned in quiet mode — verify it doesn't panic
1709        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    // --- jsonpath walk edge cases ---
1727
1728    #[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        // [0:2] should return first two
1761        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    // --- Printer::for_test capture behavior ---
1773
1774    #[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    // --- write_structured with for_test ---
1934
1935    #[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    // --- ansi256_from_rgb edge cases ---
2011
2012    #[test]
2013    fn ansi256_from_rgb_pure_black() {
2014        // r==g==b==0, r < 8 -> should return 16
2015        assert_eq!(ansi256_from_rgb(0, 0, 0), 16);
2016    }
2017
2018    #[test]
2019    fn ansi256_from_rgb_near_white() {
2020        // r==g==b==255, r > 248 -> should return 231
2021        assert_eq!(ansi256_from_rgb(255, 255, 255), 231);
2022    }
2023
2024    #[test]
2025    fn ansi256_from_rgb_grayscale_midrange() {
2026        // r==g==b, 8 <= r <= 248 -> grayscale ramp 232-255
2027        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        // r!=g or g!=b -> 6x6x6 color cube (16-231)
2037        let idx = ansi256_from_rgb(255, 0, 0); // pure red
2038        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        // Verify no panics and range validity for several colors
2047        let colors: &[(u8, u8, u8)] = &[
2048            (0, 255, 0),    // green
2049            (0, 0, 255),    // blue
2050            (128, 64, 0),   // brown
2051            (200, 200, 50), // yellow-ish (not all equal -> cube)
2052        ];
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    // --- Template rendering error paths ---
2063
2064    #[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    // --- OutputFormat variant coverage ---
2080
2081    #[test]
2082    fn output_format_auto_quiets_structured() {
2083        // When structured output is active, verbosity should be set to Quiet
2084        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    // --- name_from_value additional fields ---
2101
2102    #[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}