Skip to main content

inspect_format/
tree.rs

1//! Tree formatter for inspected values.
2
3use std::{collections::HashSet, fmt::Write, io::IsTerminal};
4
5use inspect_core::{Children, Kind, Sensitivity, ValueRef};
6
7use crate::{color_choice::ColorChoice, role::StyleRole, theme::Theme};
8
9/// Configuration for tree formatting.
10#[derive(Debug, Clone)]
11pub struct TreeConfig {
12    /// Show type names alongside values.
13    pub show_types: bool,
14
15    /// Indent string (default: "    ").
16    pub indent: String,
17
18    /// Tree branch characters (Unicode vs ASCII).
19    pub use_unicode: bool,
20
21    /// Redact secret values.
22    pub redact_secrets: bool,
23
24    /// Show truncation information.
25    pub show_truncation: bool,
26
27    /// Maximum children items to render per container (None = unlimited).
28    pub max_items: Option<usize>,
29
30    /// Maximum tree depth to inspect/render (None = unlimited).
31    pub max_depth: Option<usize>,
32
33    /// Color configuration choice (Auto, Always, Never).
34    pub color_choice: ColorChoice,
35
36    /// Theme mapping semantic roles to terminal styles.
37    pub theme: Theme,
38}
39
40impl Default for TreeConfig {
41    fn default() -> Self {
42        Self {
43            show_types: true,
44            indent: "    ".to_string(),
45            use_unicode: true,
46            redact_secrets: true,
47            show_truncation: true,
48            max_items: None,
49            max_depth: None,
50            color_choice: ColorChoice::Auto,
51            theme: Theme::default(),
52        }
53    }
54}
55
56impl TreeConfig {
57    /// Create a compact configuration.
58    pub fn compact() -> Self {
59        Self { show_types: false, show_truncation: false, ..Default::default() }
60    }
61
62    /// Create a configuration with ANSI colors strictly disabled.
63    pub fn plain() -> Self {
64        Self { color_choice: ColorChoice::Never, ..Default::default() }
65    }
66
67    /// Create a configuration with ANSI colors explicitly forced on.
68    pub fn colored() -> Self {
69        Self { color_choice: ColorChoice::Always, ..Default::default() }
70    }
71
72    /// Builder to configure the theme.
73    pub fn with_theme(mut self, theme: Theme) -> Self {
74        self.theme = theme;
75        self
76    }
77
78    /// Builder to configure color choice.
79    pub fn with_color_choice(mut self, choice: ColorChoice) -> Self {
80        self.color_choice = choice;
81        self
82    }
83
84    /// Builder to toggle color on or off explicitly.
85    pub fn with_color(mut self, enabled: bool) -> Self {
86        self.color_choice = if enabled { ColorChoice::Always } else { ColorChoice::Never };
87        self
88    }
89
90    /// Builder to set maximum items to display per container.
91    pub fn with_max_items(mut self, max: usize) -> Self {
92        self.max_items = Some(max);
93        self
94    }
95
96    /// Builder to set maximum recursion depth.
97    pub fn with_max_depth(mut self, depth: usize) -> Self {
98        self.max_depth = Some(depth);
99        self
100    }
101}
102
103/// Zero-allocation styled writer adapter.
104struct StyledWriter<'w, W> {
105    writer: &'w mut W,
106    color_enabled: bool,
107}
108
109impl<'w, W: Write> StyledWriter<'w, W> {
110    #[inline(always)]
111    fn write_plain(&mut self, s: &str) -> std::fmt::Result {
112        self.writer.write_str(s)
113    }
114
115    #[inline(always)]
116    fn write_styled(&mut self, text: &str, role: StyleRole, theme: &Theme) -> std::fmt::Result {
117        if !self.color_enabled {
118            self.writer.write_str(text)
119        } else {
120            let style = theme.style(role);
121            write!(self.writer, "{}{}{}", style.render(), text, style.render_reset())
122        }
123    }
124
125    #[inline(always)]
126    fn write_styled_fmt(
127        &mut self,
128        args: std::fmt::Arguments<'_>,
129        role: StyleRole,
130        theme: &Theme,
131    ) -> std::fmt::Result {
132        if !self.color_enabled {
133            self.writer.write_fmt(args)
134        } else {
135            let style = theme.style(role);
136            write!(self.writer, "{}", style.render())?;
137            self.writer.write_fmt(args)?;
138            write!(self.writer, "{}", style.render_reset())
139        }
140    }
141}
142
143/// Formats inspected values as tree structures.
144pub struct TreeFormatter {
145    config: TreeConfig,
146    visited: HashSet<usize>,
147}
148
149impl TreeFormatter {
150    /// Create a new tree formatter with default configuration.
151    pub fn new() -> Self {
152        Self::with_config(TreeConfig::default())
153    }
154
155    /// Create a tree formatter with specific configuration.
156    pub fn with_config(config: TreeConfig) -> Self {
157        Self { config, visited: HashSet::new() }
158    }
159
160    /// Format an inspected value to a `String`, automatically resolving color choice
161    /// based on stdout TTY and environment variables.
162    pub fn format(&mut self, value: &ValueRef<'_>) -> String {
163        let is_term = std::io::stdout().is_terminal();
164        let color_enabled = self.config.color_choice.should_color(is_term);
165        let mut output = String::new();
166        self.format_to_styled(value, &mut output, color_enabled).ok();
167        output
168    }
169
170    /// Format an inspected value with ANSI colors explicitly enabled.
171    pub fn format_colored(&mut self, value: &ValueRef<'_>) -> String {
172        let mut output = String::new();
173        self.format_to_styled(value, &mut output, true).ok();
174        output
175    }
176
177    /// Format an inspected value with ANSI colors explicitly disabled.
178    pub fn format_plain(&mut self, value: &ValueRef<'_>) -> String {
179        let mut output = String::new();
180        self.format_to_styled(value, &mut output, false).ok();
181        output
182    }
183
184    /// Format an inspected value directly to a `std::fmt::Write` destination.
185    pub fn format_to<W: Write>(
186        &mut self,
187        value: &ValueRef<'_>,
188        writer: &mut W,
189    ) -> std::fmt::Result {
190        let is_term = std::io::stdout().is_terminal();
191        let color_enabled = self.config.color_choice.should_color(is_term);
192        self.format_to_styled(value, writer, color_enabled)
193    }
194
195    /// Format an inspected value to a `std::fmt::Write` destination with explicit color flag.
196    pub fn format_to_styled<W: Write>(
197        &mut self,
198        value: &ValueRef<'_>,
199        writer: &mut W,
200        color_enabled: bool,
201    ) -> std::fmt::Result {
202        self.visited.clear();
203        let mut styled_writer = StyledWriter { writer, color_enabled };
204        self.format_value(value, &mut styled_writer, "", true, 0)
205    }
206
207    /// Format an inspected value directly to a `std::io::Write` destination.
208    pub fn format_io<W: std::io::Write>(
209        &mut self,
210        value: &ValueRef<'_>,
211        mut writer: W,
212    ) -> std::io::Result<()> {
213        let mut buf = String::new();
214        self.format_to(value, &mut buf).map_err(std::io::Error::other)?;
215        writer.write_all(buf.as_bytes())
216    }
217
218    fn format_value<W: Write>(
219        &mut self,
220        value: &ValueRef<'_>,
221        out: &mut StyledWriter<'_, W>,
222        prefix: &str,
223        is_last: bool,
224        current_depth: usize,
225    ) -> std::fmt::Result {
226        // Sensitivity: Hidden values are completely omitted
227        if value.sensitivity() == Sensitivity::Hidden {
228            return Ok(());
229        }
230
231        let (branch, continuation) = if prefix.is_empty() {
232            ("", "")
233        } else if self.config.use_unicode {
234            if is_last { ("└── ", "    ") } else { ("├── ", "│   ") }
235        } else {
236            if is_last { ("+-- ", "    ") } else { ("|-- ", "|   ") }
237        };
238
239        if !prefix.is_empty() {
240            out.write_styled(prefix, StyleRole::Punctuation, &self.config.theme)?;
241            out.write_styled(branch, StyleRole::Punctuation, &self.config.theme)?;
242        }
243
244        // Sensitivity: Secret values are redacted before any display
245        if value.sensitivity() == Sensitivity::Secret && self.config.redact_secrets {
246            out.write_styled("[REDACTED]", StyleRole::Sensitive, &self.config.theme)?;
247            out.write_plain("\n")?;
248            return Ok(());
249        }
250
251        // Max depth check
252        if let Some(max_depth) = self.config.max_depth {
253            if current_depth >= max_depth {
254                out.write_styled(
255                    "… <max depth reached>",
256                    StyleRole::Truncated,
257                    &self.config.theme,
258                )?;
259                out.write_plain("\n")?;
260                return Ok(());
261            }
262        }
263
264        // Format root node header
265        match value.kind() {
266            Kind::Unit => {
267                out.write_styled("()", StyleRole::Null, &self.config.theme)?;
268            }
269            Kind::Bool(b) => {
270                out.write_styled_fmt(format_args!("{b}"), StyleRole::Boolean, &self.config.theme)?;
271            }
272            Kind::Char(c) => {
273                out.write_styled("'", StyleRole::Punctuation, &self.config.theme)?;
274                let mut esc_buf = String::new();
275                for esc in c.escape_debug() {
276                    esc_buf.push(esc);
277                }
278                out.write_styled(&esc_buf, StyleRole::String, &self.config.theme)?;
279                out.write_styled("'", StyleRole::Punctuation, &self.config.theme)?;
280            }
281            Kind::I8(n) => {
282                out.write_styled_fmt(format_args!("{n}"), StyleRole::Number, &self.config.theme)?
283            }
284            Kind::I16(n) => {
285                out.write_styled_fmt(format_args!("{n}"), StyleRole::Number, &self.config.theme)?
286            }
287            Kind::I32(n) => {
288                out.write_styled_fmt(format_args!("{n}"), StyleRole::Number, &self.config.theme)?
289            }
290            Kind::I64(n) => {
291                out.write_styled_fmt(format_args!("{n}"), StyleRole::Number, &self.config.theme)?
292            }
293            Kind::I128(n) => {
294                out.write_styled_fmt(format_args!("{n}"), StyleRole::Number, &self.config.theme)?
295            }
296            Kind::Isize(n) => {
297                out.write_styled_fmt(format_args!("{n}"), StyleRole::Number, &self.config.theme)?
298            }
299            Kind::U8(n) => {
300                out.write_styled_fmt(format_args!("{n}"), StyleRole::Number, &self.config.theme)?
301            }
302            Kind::U16(n) => {
303                out.write_styled_fmt(format_args!("{n}"), StyleRole::Number, &self.config.theme)?
304            }
305            Kind::U32(n) => {
306                out.write_styled_fmt(format_args!("{n}"), StyleRole::Number, &self.config.theme)?
307            }
308            Kind::U64(n) => {
309                out.write_styled_fmt(format_args!("{n}"), StyleRole::Number, &self.config.theme)?
310            }
311            Kind::U128(n) => {
312                out.write_styled_fmt(format_args!("{n}"), StyleRole::Number, &self.config.theme)?
313            }
314            Kind::Usize(n) => {
315                out.write_styled_fmt(format_args!("{n}"), StyleRole::Number, &self.config.theme)?
316            }
317            Kind::F32(f) => {
318                out.write_styled_fmt(format_args!("{f}"), StyleRole::Number, &self.config.theme)?
319            }
320            Kind::F64(f) => {
321                out.write_styled_fmt(format_args!("{f}"), StyleRole::Number, &self.config.theme)?
322            }
323            Kind::Str(s) => {
324                out.write_styled("\"", StyleRole::Punctuation, &self.config.theme)?;
325                let mut esc_buf = String::new();
326                for c in s.chars() {
327                    for esc in c.escape_debug() {
328                        esc_buf.push(esc);
329                    }
330                }
331                out.write_styled(&esc_buf, StyleRole::String, &self.config.theme)?;
332                out.write_styled("\"", StyleRole::Punctuation, &self.config.theme)?;
333            }
334            Kind::Bytes(b) => {
335                out.write_styled("[", StyleRole::Punctuation, &self.config.theme)?;
336                out.write_styled_fmt(
337                    format_args!("{} bytes", b.len()),
338                    StyleRole::Metadata,
339                    &self.config.theme,
340                )?;
341                out.write_styled("]", StyleRole::Punctuation, &self.config.theme)?;
342            }
343            _ => {
344                let type_name = value.type_info().name();
345                if self.config.show_types {
346                    out.write_styled(type_name, StyleRole::Type, &self.config.theme)?;
347                }
348
349                if let Some(variant) = value.variant() {
350                    if self.config.show_types {
351                        out.write_styled("::", StyleRole::Punctuation, &self.config.theme)?;
352                    }
353                    out.write_styled(variant.name(), StyleRole::Variant, &self.config.theme)?;
354                }
355            }
356        }
357
358        out.write_plain("\n")?;
359
360        // Format children
361        if let Some(children) = value.children() {
362            let next_prefix = if prefix.is_empty() {
363                "".to_string()
364            } else {
365                format!("{}{}", prefix, continuation)
366            };
367            self.format_children(children, out, &next_prefix, value.kind(), current_depth + 1)?;
368        }
369
370        Ok(())
371    }
372
373    fn format_children<W: Write>(
374        &mut self,
375        children: &Children<'_>,
376        out: &mut StyledWriter<'_, W>,
377        prefix: &str,
378        parent_kind: &Kind<'_>,
379        current_depth: usize,
380    ) -> std::fmt::Result {
381        match children {
382            Children::Direct(fields) => {
383                let total_count = fields.len();
384                let limit = self.config.max_items.unwrap_or(total_count).min(total_count);
385                let is_truncated = limit < total_count;
386
387                for (idx, (field, child_value)) in fields.iter().take(limit).enumerate() {
388                    let is_last = (idx == limit - 1) && !is_truncated;
389
390                    let (branch, continuation) = if self.config.use_unicode {
391                        if is_last { ("└── ", "    ") } else { ("├── ", "│   ") }
392                    } else {
393                        if is_last { ("+-- ", "    ") } else { ("|-- ", "|   ") }
394                    };
395
396                    // Handle Hidden
397                    if field.sensitivity() == Sensitivity::Hidden
398                        || child_value.sensitivity() == Sensitivity::Hidden
399                    {
400                        continue;
401                    }
402
403                    out.write_styled(prefix, StyleRole::Punctuation, &self.config.theme)?;
404                    out.write_styled(branch, StyleRole::Punctuation, &self.config.theme)?;
405
406                    // Field name / key / index
407                    if let Some(name) = field.name() {
408                        let role = if matches!(parent_kind, Kind::Map) {
409                            StyleRole::Key
410                        } else {
411                            StyleRole::Field
412                        };
413                        out.write_styled(name, role, &self.config.theme)?;
414                        out.write_styled(": ", StyleRole::Punctuation, &self.config.theme)?;
415                    } else {
416                        out.write_styled("[", StyleRole::Punctuation, &self.config.theme)?;
417                        out.write_styled_fmt(
418                            format_args!("{}", field.index()),
419                            StyleRole::Index,
420                            &self.config.theme,
421                        )?;
422                        out.write_styled("]: ", StyleRole::Punctuation, &self.config.theme)?;
423                    }
424
425                    // Handle Secret field
426                    if (field.sensitivity() == Sensitivity::Secret
427                        || child_value.sensitivity() == Sensitivity::Secret)
428                        && self.config.redact_secrets
429                    {
430                        out.write_styled("[REDACTED]", StyleRole::Sensitive, &self.config.theme)?;
431                        out.write_plain("\n")?;
432                        continue;
433                    }
434
435                    if child_value.kind().is_scalar() {
436                        self.format_scalar_inline(child_value, out)?;
437                        out.write_plain("\n")?;
438                    } else {
439                        if let Some(max_depth) = self.config.max_depth {
440                            if current_depth >= max_depth {
441                                out.write_styled(
442                                    "… <max depth reached>",
443                                    StyleRole::Truncated,
444                                    &self.config.theme,
445                                )?;
446                                out.write_plain("\n")?;
447                                continue;
448                            }
449                        }
450
451                        // Structured child
452                        let type_name = child_value.type_info().name();
453                        if self.config.show_types {
454                            out.write_styled(type_name, StyleRole::Type, &self.config.theme)?;
455                        }
456                        if let Some(variant) = child_value.variant() {
457                            if self.config.show_types {
458                                out.write_styled("::", StyleRole::Punctuation, &self.config.theme)?;
459                            }
460                            out.write_styled(
461                                variant.name(),
462                                StyleRole::Variant,
463                                &self.config.theme,
464                            )?;
465                        }
466                        out.write_plain("\n")?;
467
468                        if let Some(sub_children) = child_value.children() {
469                            let next_prefix = format!("{}{}", prefix, continuation);
470                            self.format_children(
471                                sub_children,
472                                out,
473                                &next_prefix,
474                                child_value.kind(),
475                                current_depth + 1,
476                            )?;
477                        }
478                    }
479                }
480
481                // Truncation notice
482                if is_truncated && self.config.show_truncation {
483                    let remaining = total_count - limit;
484                    let branch = if self.config.use_unicode { "└── " } else { "\\-- " };
485                    out.write_styled(prefix, StyleRole::Punctuation, &self.config.theme)?;
486                    out.write_styled(branch, StyleRole::Punctuation, &self.config.theme)?;
487                    let notice = format!("… {remaining} more");
488                    out.write_styled(&notice, StyleRole::Truncated, &self.config.theme)?;
489                    out.write_plain("\n")?;
490                }
491            }
492        }
493
494        Ok(())
495    }
496
497    fn format_scalar_inline<W: Write>(
498        &self,
499        value: &ValueRef<'_>,
500        out: &mut StyledWriter<'_, W>,
501    ) -> std::fmt::Result {
502        match value.kind() {
503            Kind::Unit => out.write_styled("()", StyleRole::Null, &self.config.theme),
504            Kind::Bool(b) => {
505                out.write_styled_fmt(format_args!("{b}"), StyleRole::Boolean, &self.config.theme)
506            }
507            Kind::Char(c) => {
508                out.write_styled("'", StyleRole::Punctuation, &self.config.theme)?;
509                let mut esc_buf = String::new();
510                for esc in c.escape_debug() {
511                    esc_buf.push(esc);
512                }
513                out.write_styled(&esc_buf, StyleRole::String, &self.config.theme)?;
514                out.write_styled("'", StyleRole::Punctuation, &self.config.theme)
515            }
516            Kind::I8(n) => {
517                out.write_styled_fmt(format_args!("{n}"), StyleRole::Number, &self.config.theme)
518            }
519            Kind::I16(n) => {
520                out.write_styled_fmt(format_args!("{n}"), StyleRole::Number, &self.config.theme)
521            }
522            Kind::I32(n) => {
523                out.write_styled_fmt(format_args!("{n}"), StyleRole::Number, &self.config.theme)
524            }
525            Kind::I64(n) => {
526                out.write_styled_fmt(format_args!("{n}"), StyleRole::Number, &self.config.theme)
527            }
528            Kind::I128(n) => {
529                out.write_styled_fmt(format_args!("{n}"), StyleRole::Number, &self.config.theme)
530            }
531            Kind::Isize(n) => {
532                out.write_styled_fmt(format_args!("{n}"), StyleRole::Number, &self.config.theme)
533            }
534            Kind::U8(n) => {
535                out.write_styled_fmt(format_args!("{n}"), StyleRole::Number, &self.config.theme)
536            }
537            Kind::U16(n) => {
538                out.write_styled_fmt(format_args!("{n}"), StyleRole::Number, &self.config.theme)
539            }
540            Kind::U32(n) => {
541                out.write_styled_fmt(format_args!("{n}"), StyleRole::Number, &self.config.theme)
542            }
543            Kind::U64(n) => {
544                out.write_styled_fmt(format_args!("{n}"), StyleRole::Number, &self.config.theme)
545            }
546            Kind::U128(n) => {
547                out.write_styled_fmt(format_args!("{n}"), StyleRole::Number, &self.config.theme)
548            }
549            Kind::Usize(n) => {
550                out.write_styled_fmt(format_args!("{n}"), StyleRole::Number, &self.config.theme)
551            }
552            Kind::F32(f) => {
553                out.write_styled_fmt(format_args!("{f}"), StyleRole::Number, &self.config.theme)
554            }
555            Kind::F64(f) => {
556                out.write_styled_fmt(format_args!("{f}"), StyleRole::Number, &self.config.theme)
557            }
558            Kind::Str(s) => {
559                out.write_styled("\"", StyleRole::Punctuation, &self.config.theme)?;
560                let mut esc_buf = String::new();
561                for c in s.chars() {
562                    for esc in c.escape_debug() {
563                        esc_buf.push(esc);
564                    }
565                }
566                out.write_styled(&esc_buf, StyleRole::String, &self.config.theme)?;
567                out.write_styled("\"", StyleRole::Punctuation, &self.config.theme)
568            }
569            Kind::Bytes(b) => {
570                out.write_styled("[", StyleRole::Punctuation, &self.config.theme)?;
571                out.write_styled_fmt(
572                    format_args!("{} bytes", b.len()),
573                    StyleRole::Metadata,
574                    &self.config.theme,
575                )?;
576                out.write_styled("]", StyleRole::Punctuation, &self.config.theme)
577            }
578            _ => out.write_styled(value.type_info().name(), StyleRole::Type, &self.config.theme),
579        }
580    }
581}
582
583impl Default for TreeFormatter {
584    fn default() -> Self {
585        Self::new()
586    }
587}