Skip to main content

iota_sdk_types/
tree_display.rs

1// Copyright (c) 2026 IOTA Stiftung
2// SPDX-License-Identifier: Apache-2.0
3
4/// Trait for types that render as tree sub-trees.
5///
6/// Types implementing this trait can render their fields as a tree structure
7/// with box-drawing characters (`├──`, `└──`, `│`).
8///
9/// Each `fmt_tree` impl must call [`TreeWriter::header`] before any other
10/// writer method, on every arm of an enum dispatch. [`TreeWriter::child`] and
11/// [`TreeWriter::inline_child`] leave state behind for the child's `header`
12/// call to consume; writing a leaf or branch first leaves it pending and
13/// applies it to some later, unrelated node. An impl that only delegates
14/// (`Self::V1(v1) => v1.fmt_tree(w)`) satisfies this through the type it
15/// delegates to.
16///
17/// Use [`impl_tree_display`] to generate the `Display` impl.
18pub(crate) trait TreeDisplay {
19    fn fmt_tree(&self, w: &mut TreeWriter<'_, '_>) -> std::fmt::Result;
20}
21
22/// Generates `Display` impls that delegate to [`TreeDisplay::fmt_tree`].
23macro_rules! impl_tree_display {
24    ($($ty:ty),* $(,)?) => {
25        $(
26        impl std::fmt::Display for $ty {
27            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28                let mut w = crate::TreeWriter::new(f);
29                crate::TreeDisplay::fmt_tree(self, &mut w)
30            }
31        }
32        )*
33    };
34}
35pub(crate) use impl_tree_display;
36
37/// A label a parent has written, waiting for its child's header.
38enum PendingLabel {
39    /// A field name, which already says what the node is.
40    Named(String),
41    /// An index, which names nothing.
42    Indexed,
43}
44
45/// A tree node writer that tracks depth and sibling position for rendering
46/// tree-structured output with box-drawing characters.
47pub(crate) struct TreeWriter<'f, 'a> {
48    f: &'f mut std::fmt::Formatter<'a>,
49    prefix: String,
50    needs_newline: bool,
51    inline_label: Option<PendingLabel>,
52    pending_enum: Option<String>,
53    skip_header: bool,
54}
55
56impl<'f, 'a> TreeWriter<'f, 'a> {
57    pub fn new(f: &'f mut std::fmt::Formatter<'a>) -> Self {
58        Self {
59            f,
60            prefix: String::new(),
61            needs_newline: false,
62            inline_label: None,
63            pending_enum: None,
64            skip_header: false,
65        }
66    }
67
68    /// Write the root label without connectors. When called as a child
69    /// (via [`child`](Self::child)), the parent already wrote the field
70    /// label, so the header is appended to it as `Label: Header` (or
71    /// omitted when it repeats the label).
72    pub fn header(&mut self, text: &str) -> std::fmt::Result {
73        let enum_name = self.pending_enum.take();
74        if std::mem::take(&mut self.skip_header) {
75            return Ok(());
76        }
77        if let Some(pending) = self.inline_label.take() {
78            match pending {
79                // The label already names the type; printing it twice adds nothing.
80                PendingLabel::Named(label) if label == text => {}
81                // A field label names the node, so the enum name is dropped.
82                PendingLabel::Named(_) => write!(self.f, ": {text}")?,
83                PendingLabel::Indexed => match &enum_name {
84                    Some(name) => write!(self.f, ": {name}: {text}")?,
85                    None => write!(self.f, ": {text}")?,
86                },
87            }
88            return Ok(());
89        }
90        if self.needs_newline {
91            writeln!(self.f)?;
92        }
93        match &enum_name {
94            Some(name) => write!(self.f, "{}{name}: {text}", self.prefix)?,
95            None => write!(self.f, "{}{}", self.prefix, text)?,
96        }
97        self.needs_newline = true;
98        Ok(())
99    }
100
101    /// Name the enum whose variant is about to write its header, so the node
102    /// reads `Enum Name: Variant`.
103    ///
104    /// The name is dropped where a field label already says what the node is,
105    /// and kept at a root or under an index, which name nothing. Nested enums
106    /// each add their name, so a variant holding another enum reads as the
107    /// whole chain down to the type that writes the header.
108    pub fn enum_name(&mut self, name: &str) {
109        match &mut self.pending_enum {
110            Some(pending) => {
111                pending.push_str(": ");
112                pending.push_str(name);
113            }
114            None => self.pending_enum = Some(name.to_owned()),
115        }
116    }
117
118    fn write_connector(&mut self, is_last: bool) -> std::fmt::Result {
119        if self.needs_newline {
120            writeln!(self.f)?;
121        }
122        let connector = if is_last { "└── " } else { "├── " };
123        write!(self.f, "{}{}", self.prefix, connector)?;
124        self.needs_newline = true;
125        Ok(())
126    }
127
128    /// Write a leaf node: `├── Label: value` or `└── Label: value`.
129    ///
130    /// A multi-line value keeps the tree readable: it starts on its own line
131    /// below the label, with all its lines indented equally.
132    pub fn leaf(
133        &mut self,
134        label: &str,
135        value: &dyn std::fmt::Display,
136        is_last: bool,
137    ) -> std::fmt::Result {
138        self.write_connector(is_last)?;
139        let value = value.to_string();
140        if value.contains('\n') {
141            write!(self.f, "{label}:")?;
142            let extension = if is_last { "    " } else { "│   " };
143            for line in value.lines() {
144                write!(self.f, "\n{}{extension}{line}", self.prefix)?;
145            }
146            Ok(())
147        } else {
148            write!(self.f, "{label}: {value}")
149        }
150    }
151
152    /// Write a branch with children rendered by a closure.
153    pub fn branch(
154        &mut self,
155        label: &str,
156        is_last: bool,
157        children: impl FnOnce(&mut Self) -> std::fmt::Result,
158    ) -> std::fmt::Result {
159        self.write_connector(is_last)?;
160        write!(self.f, "{label}")?;
161        let extension = if is_last { "    " } else { "│   " };
162        let old_len = self.prefix.len();
163        self.prefix.push_str(extension);
164        children(self)?;
165        self.prefix.truncate(old_len);
166        Ok(())
167    }
168
169    /// Write a [`TreeDisplay`] child as a sub-tree.
170    pub fn child(
171        &mut self,
172        label: &str,
173        child: &dyn TreeDisplay,
174        is_last: bool,
175    ) -> std::fmt::Result {
176        self.branch(label, is_last, |w| {
177            w.inline_label = Some(PendingLabel::Named(label.to_string()));
178            child.fmt_tree(w)
179        })
180    }
181
182    /// Write a [`TreeDisplay`] child under its index, which names nothing, so a
183    /// variant header keeps its enum name.
184    fn indexed_child(
185        &mut self,
186        index: usize,
187        child: &dyn TreeDisplay,
188        is_last: bool,
189    ) -> std::fmt::Result {
190        self.branch(&index.to_string(), is_last, |w| {
191            w.inline_label = Some(PendingLabel::Indexed);
192            child.fmt_tree(w)
193        })
194    }
195
196    /// Render a [`TreeDisplay`] child's fields under the header just written,
197    /// dropping the child's own header and nesting level.
198    pub fn inline_child(&mut self, child: &dyn TreeDisplay) -> std::fmt::Result {
199        self.skip_header = true;
200        child.fmt_tree(self)
201    }
202
203    /// Display a collection of `Display` items as indexed leaf nodes.
204    pub fn leaves<I>(&mut self, label: &str, items: I, is_last: bool) -> std::fmt::Result
205    where
206        I: IntoIterator,
207        I::Item: std::fmt::Display,
208        I::IntoIter: ExactSizeIterator,
209    {
210        let items = items.into_iter();
211        if items.len() == 0 {
212            self.leaf(label, &"[]", is_last)
213        } else {
214            self.branch(label, is_last, |w| {
215                let last_idx = items.len() - 1;
216                for (i, item) in items.enumerate() {
217                    w.leaf(&i.to_string(), &item, i == last_idx)?;
218                }
219                Ok(())
220            })
221        }
222    }
223
224    /// Display a slice of [`TreeDisplay`] items as indexed sub-trees.
225    pub fn children(
226        &mut self,
227        label: &str,
228        items: &[impl TreeDisplay],
229        is_last: bool,
230    ) -> std::fmt::Result {
231        if items.is_empty() {
232            self.leaf(label, &"[]", is_last)
233        } else {
234            self.branch(label, is_last, |w| {
235                let last_idx = items.len() - 1;
236                for (i, item) in items.iter().enumerate() {
237                    w.indexed_child(i, item, i == last_idx)?;
238                }
239                Ok(())
240            })
241        }
242    }
243
244    /// Display an `Option<impl Display>`, showing "None" for `None`.
245    pub fn option_leaf(
246        &mut self,
247        label: &str,
248        opt: &Option<impl std::fmt::Display>,
249        is_last: bool,
250    ) -> std::fmt::Result {
251        match opt {
252            Some(v) => self.leaf(label, v, is_last),
253            None => self.leaf(label, &"None", is_last),
254        }
255    }
256
257    /// Display an `Option<impl TreeDisplay>` as a sub-tree, showing "None" for
258    /// `None`.
259    pub fn option_child(
260        &mut self,
261        label: &str,
262        opt: &Option<impl TreeDisplay>,
263        is_last: bool,
264    ) -> std::fmt::Result {
265        match opt {
266            Some(v) => self.child(label, v, is_last),
267            None => self.leaf(label, &"None", is_last),
268        }
269    }
270
271    /// Display a `Vec<Vec<u8>>` as Base64-encoded indexed leaves.
272    pub fn base64_leaves(
273        &mut self,
274        label: &str,
275        items: &[Vec<u8>],
276        is_last: bool,
277    ) -> std::fmt::Result {
278        if items.is_empty() {
279            self.leaf(label, &"[]", is_last)
280        } else {
281            use base64ct::Encoding;
282            self.branch(label, is_last, |w| {
283                let last_idx = items.len() - 1;
284                for (i, bytes) in items.iter().enumerate() {
285                    w.leaf(
286                        &i.to_string(),
287                        &base64ct::Base64::encode_string(bytes),
288                        i == last_idx,
289                    )?;
290                }
291                Ok(())
292            })
293        }
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use std::fmt;
300
301    use super::*;
302
303    /// Helper that captures formatted output by implementing Display with a
304    /// closure.
305    struct FmtFn<F: Fn(&mut fmt::Formatter<'_>) -> fmt::Result>(F);
306
307    impl<F: Fn(&mut fmt::Formatter<'_>) -> fmt::Result> fmt::Display for FmtFn<F> {
308        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
309            (self.0)(f)
310        }
311    }
312
313    #[test]
314    fn writer_starts_without_a_leading_newline() {
315        let output = FmtFn(|f| {
316            let mut w = TreeWriter::new(f);
317            w.leaf("Key", &"value", true)
318        })
319        .to_string();
320
321        assert_eq!(output, "└── Key: value");
322    }
323
324    /// Renders the output a closure writes into a fresh [`TreeWriter`].
325    fn render(body: impl Fn(&mut TreeWriter<'_, '_>) -> fmt::Result) -> String {
326        FmtFn(move |f| body(&mut TreeWriter::new(f))).to_string()
327    }
328
329    /// Minimal [`TreeDisplay`] type for exercising sub-tree rendering.
330    struct Point {
331        x: u8,
332        y: u8,
333    }
334
335    impl TreeDisplay for Point {
336        fn fmt_tree(&self, w: &mut TreeWriter<'_, '_>) -> fmt::Result {
337            w.header("Point")?;
338            w.leaf("X", &self.x, false)?;
339            w.leaf("Y", &self.y, true)
340        }
341    }
342
343    impl_tree_display!(Point);
344
345    #[test]
346    fn header_writes_root_label_without_connector() {
347        assert_eq!(render(|w| w.header("Root")), "Root");
348
349        let expected = "
350Root
351└── Key: value";
352        assert_eq!(
353            render(|w| {
354                w.header("Root")?;
355                w.leaf("Key", &"value", true)
356            }),
357            expected.strip_prefix('\n').unwrap()
358        );
359    }
360
361    #[test]
362    fn leaf_connector_depends_on_sibling_position() {
363        let expected = "
364├── First: 1
365└── Last: 2";
366        assert_eq!(
367            render(|w| {
368                w.leaf("First", &1, false)?;
369                w.leaf("Last", &2, true)
370            }),
371            expected.strip_prefix('\n').unwrap()
372        );
373    }
374
375    #[test]
376    fn leaf_indents_multi_line_value_below_its_label() {
377        let expected = "
378└── Label:
379    line1
380    line2";
381        assert_eq!(
382            render(|w| w.leaf("Label", &"line1\nline2", true)),
383            expected.strip_prefix('\n').unwrap()
384        );
385
386        let expected = "
387├── Label:
388│   line1
389│   line2
390└── Last: x";
391        assert_eq!(
392            render(|w| {
393                w.leaf("Label", &"line1\nline2", false)?;
394                w.leaf("Last", &"x", true)
395            }),
396            expected.strip_prefix('\n').unwrap()
397        );
398    }
399
400    #[test]
401    fn branch_extends_prefix_for_children() {
402        let expected = "
403└── Parent
404    └── Child: v";
405        assert_eq!(
406            render(|w| w.branch("Parent", true, |w| w.leaf("Child", &"v", true))),
407            expected.strip_prefix('\n').unwrap()
408        );
409
410        let expected = "
411├── Parent
412│   └── Child: v
413└── Last: x";
414        assert_eq!(
415            render(|w| {
416                w.branch("Parent", false, |w| w.leaf("Child", &"v", true))?;
417                w.leaf("Last", &"x", true)
418            }),
419            expected.strip_prefix('\n').unwrap()
420        );
421    }
422
423    #[test]
424    fn child_appends_header_to_the_label_line() {
425        let expected = "
426└── Origin: Point
427    ├── X: 1
428    └── Y: 2";
429        assert_eq!(
430            render(|w| w.child("Origin", &Point { x: 1, y: 2 }, true)),
431            expected.strip_prefix('\n').unwrap()
432        );
433    }
434
435    #[test]
436    fn child_omits_header_equal_to_the_label() {
437        let expected = "
438└── Point
439    ├── X: 1
440    └── Y: 2";
441        assert_eq!(
442            render(|w| w.child("Point", &Point { x: 1, y: 2 }, true)),
443            expected.strip_prefix('\n').unwrap()
444        );
445    }
446
447    #[test]
448    fn inline_child_renders_fields_without_a_level() {
449        let expected = "
450Origin
451├── X: 1
452└── Y: 2";
453        assert_eq!(
454            render(|w| {
455                w.header("Origin")?;
456                w.inline_child(&Point { x: 1, y: 2 })
457            }),
458            expected.strip_prefix('\n').unwrap()
459        );
460    }
461
462    #[test]
463    fn leaves_writes_one_node_per_item() {
464        let empty: &[u8] = &[];
465        assert_eq!(render(|w| w.leaves("Items", empty, true)), "└── Items: []");
466        let expected = "
467└── Items
468    ├── 0: 10
469    └── 1: 20";
470        assert_eq!(
471            render(|w| w.leaves("Items", [10, 20], true)),
472            expected.strip_prefix('\n').unwrap()
473        );
474    }
475
476    #[test]
477    fn leaves_accepts_any_collection() {
478        let set = std::collections::BTreeSet::from([20, 10]);
479        assert_eq!(
480            render(|w| w.leaves("Items", std::collections::BTreeSet::<u8>::new(), true)),
481            "└── Items: []"
482        );
483        let expected = "
484└── Items
485    ├── 0: 10
486    └── 1: 20";
487        assert_eq!(
488            render(|w| w.leaves("Items", &set, true)),
489            expected.strip_prefix('\n').unwrap()
490        );
491    }
492
493    #[test]
494    fn children_renders_indexed_sub_trees() {
495        let empty: &[Point] = &[];
496        assert_eq!(
497            render(|w| w.children("Points", empty, true)),
498            "└── Points: []"
499        );
500        let expected = "
501└── Points
502    └── 0: Point
503        ├── X: 1
504        └── Y: 2";
505        assert_eq!(
506            render(|w| w.children("Points", &[Point { x: 1, y: 2 }], true)),
507            expected.strip_prefix('\n').unwrap()
508        );
509    }
510
511    #[test]
512    fn option_leaf_renders_value_or_none() {
513        assert_eq!(
514            render(|w| w.option_leaf("Opt", &Some(5), true)),
515            "└── Opt: 5"
516        );
517        assert_eq!(
518            render(|w| w.option_leaf("Opt", &None::<u8>, true)),
519            "└── Opt: None"
520        );
521    }
522
523    #[test]
524    fn option_child_renders_sub_tree_or_none() {
525        let expected = "
526└── Origin: Point
527    ├── X: 1
528    └── Y: 2";
529        assert_eq!(
530            render(|w| w.option_child("Origin", &Some(Point { x: 1, y: 2 }), true)),
531            expected.strip_prefix('\n').unwrap()
532        );
533        assert_eq!(
534            render(|w| w.option_child("Origin", &None::<Point>, true)),
535            "└── Origin: None"
536        );
537    }
538
539    #[test]
540    fn base64_leaves_encodes_each_entry() {
541        assert_eq!(
542            render(|w| w.base64_leaves("Data", &[], true)),
543            "└── Data: []"
544        );
545        let expected = "
546└── Data
547    └── 0: AQID";
548        assert_eq!(
549            render(|w| w.base64_leaves("Data", &[vec![1, 2, 3]], true)),
550            expected.strip_prefix('\n').unwrap()
551        );
552    }
553
554    #[test]
555    fn impl_tree_display_generates_display_from_fmt_tree() {
556        let expected = "
557Point
558├── X: 1
559└── Y: 2";
560        assert_eq!(
561            Point { x: 1, y: 2 }.to_string(),
562            expected.strip_prefix('\n').unwrap()
563        );
564    }
565
566    fn sample_transaction() -> crate::Transaction {
567        use crate::transaction::*;
568
569        Transaction::V1(TransactionV1 {
570            kind: TransactionKind::Programmable(ProgrammableTransaction {
571                inputs: vec![Input::Pure(vec![1, 2, 3])],
572                commands: vec![Command::SplitCoins(SplitCoins {
573                    coin: Argument::Gas,
574                    amounts: vec![Argument::Input(0)],
575                })],
576            }),
577            sender: crate::Address::ZERO,
578            gas_payment: GasPayment {
579                objects: vec![crate::ObjectReference::new(
580                    crate::ObjectId::ZERO,
581                    crate::Version::from_u64(42),
582                    crate::ObjectDigest::ZERO,
583                )],
584                owner: crate::Address::ZERO,
585                price: 1000,
586                budget: 5_000_000,
587            },
588            expiration: TransactionExpiration::None,
589        })
590    }
591
592    #[test]
593    fn transaction_renders_as_nested_tree() {
594        let expected = "
595Transaction: Transaction V1
596├── Kind: Programmable Transaction
597│   ├── Inputs
598│   │   └── 0: Input: Pure
599│   │       └── Value: 010203
600│   └── Commands
601│       └── 0: Command: Split Coins
602│           ├── Coin: Gas
603│           └── Amounts
604│               └── 0: Input(0)
605├── Sender: 0x0000000000000000000000000000000000000000000000000000000000000000
606├── Gas Payment
607│   ├── Objects
608│   │   └── 0: Object Reference
609│   │       ├── Object ID: 0x0000000000000000000000000000000000000000000000000000000000000000
610│   │       ├── Version: 42
611│   │       └── Digest: 11111111111111111111111111111111
612│   ├── Owner: 0x0000000000000000000000000000000000000000000000000000000000000000
613│   ├── Price: 1000
614│   └── Budget: 5000000
615└── Expiration: None";
616
617        assert_eq!(
618            sample_transaction().to_string(),
619            expected.strip_prefix('\n').unwrap()
620        );
621    }
622
623    #[test]
624    fn tree_typed_field_renders_as_indented_sub_tree() {
625        use crate::crypto::{
626            Ed25519PublicKey, Ed25519Signature, MultisigAggregatedSignature, MultisigCommittee,
627            MultisigMember, MultisigMemberSignature,
628        };
629
630        let committee = MultisigCommittee::new_unchecked(
631            vec![MultisigMember::new(Ed25519PublicKey::new([0; 32]), 1)],
632            1,
633        );
634        let signature = MultisigAggregatedSignature::new_unchecked(
635            vec![MultisigMemberSignature::Ed25519(Ed25519Signature::new(
636                [0; 64],
637            ))],
638            1,
639            committee,
640        );
641
642        let expected = "
643Multisig Aggregated Signature
644├── Committee: Multisig Committee
645│   ├── Members
646│   │   └── 0: Multisig Member
647│   │       ├── Public Key: Ed25519PublicKey(AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=)
648│   │       └── Weight: 1
649│   └── Threshold: 1
650├── Signatures
651│   └── 0: Multisig Member Signature: Ed25519Signature(AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==)
652└── Bitmap: 1";
653
654        assert_eq!(signature.to_string(), expected.strip_prefix('\n').unwrap());
655    }
656
657    #[test]
658    fn multi_line_leaf_value_is_indented_under_its_label() {
659        use crate::crypto::{Intent, IntentAppId, IntentMessage, IntentScope, IntentVersion};
660
661        let message = IntentMessage::new(
662            Intent::new(
663                IntentScope::TransactionData,
664                IntentVersion::V0,
665                IntentAppId::Iota,
666            ),
667            sample_transaction(),
668        );
669
670        let expected = "
671Intent Message
672├── Intent
673│   ├── Scope: TransactionData
674│   ├── Version: V0
675│   └── App ID: Iota
676└── Value:
677    Transaction: Transaction V1
678    ├── Kind: Programmable Transaction
679    │   ├── Inputs
680    │   │   └── 0: Input: Pure
681    │   │       └── Value: 010203
682    │   └── Commands
683    │       └── 0: Command: Split Coins
684    │           ├── Coin: Gas
685    │           └── Amounts
686    │               └── 0: Input(0)
687    ├── Sender: 0x0000000000000000000000000000000000000000000000000000000000000000
688    ├── Gas Payment
689    │   ├── Objects
690    │   │   └── 0: Object Reference
691    │   │       ├── Object ID: 0x0000000000000000000000000000000000000000000000000000000000000000
692    │   │       ├── Version: 42
693    │   │       └── Digest: 11111111111111111111111111111111
694    │   ├── Owner: 0x0000000000000000000000000000000000000000000000000000000000000000
695    │   ├── Price: 1000
696    │   └── Budget: 5000000
697    └── Expiration: None";
698
699        assert_eq!(message.to_string(), expected.strip_prefix('\n').unwrap());
700    }
701}