Skip to main content

ite_cli/
tree.rs

1//! Source-neutral tree model at the center of the application. Filesystem and
2//! JSON adapters build its flat node arena; `app` navigates it and `ui` renders
3//! it through the `tui_treelistview::TreeModel` implementation.
4//!
5//! Consumers read nodes only through `Tree` accessors. The stored representation
6//! is private so source-specific payloads—including filesystem names and spans
7//! into retained JSON—can change without touching the app, renderer, or picker.
8//! Accessors return owned values because those values may be derived on demand.
9
10use std::ffi::OsString;
11use std::ops::Range;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14
15use tui_treelistview::{TreeChildren, TreeModel, TreeRevision};
16
17pub type NodeId = usize;
18
19/// Values used when the focused node is accepted or passed to a shell binding.
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct ActionValues {
22    /// Text written to stdout when the node is accepted.
23    pub output: OsString,
24    /// Text written to stdout by the alternate accept action.
25    pub alternate_output: OsString,
26    /// Value exported to shell bindings as `$path`.
27    pub path: OsString,
28    /// Value exported to shell bindings as `$relpath`.
29    pub relpath: OsString,
30}
31
32impl ActionValues {
33    pub fn new(
34        output: impl Into<OsString>,
35        path: impl Into<OsString>,
36        relpath: impl Into<OsString>,
37    ) -> Self {
38        let output = output.into();
39        Self {
40            alternate_output: output.clone(),
41            output,
42            path: path.into(),
43            relpath: relpath.into(),
44        }
45    }
46
47    pub fn with_alternate_output(mut self, output: impl Into<OsString>) -> Self {
48        self.alternate_output = output.into();
49        self
50    }
51}
52
53/// Display and action values stored verbatim.
54#[derive(Debug)]
55struct Explicit {
56    name: String,
57    detail: Option<String>,
58    action: ActionValues,
59}
60
61/// How a JSON node is addressed within its parent.
62#[derive(Clone, Debug)]
63pub(crate) enum JsonKey {
64    /// The synthetic `$` root of a single-rooted document.
65    Root,
66    /// The synthetic root of a JSONL document: the virtual array of records.
67    JsonlRoot,
68    /// An object member; the span covers the raw key token, quotes included.
69    Member { key_span: Range<u32> },
70    /// An array element.
71    Index(u32),
72}
73
74/// Per-node data that differs by source.
75#[derive(Debug)]
76enum Payload {
77    /// Stored verbatim (tests, transitional). Boxed so the variant does not
78    /// inflate every filesystem or JSON node.
79    Explicit(Box<Explicit>),
80    /// A filesystem entry's final path component. Kept as raw `OsString` so
81    /// non-UTF-8 names survive into the derived paths handed to shell
82    /// bindings; only the displayed name goes through `to_string_lossy`.
83    Fs { file_name: OsString },
84    /// A JSON value: its byte span in the retained input plus how it is
85    /// addressed within its parent. Everything else is derived.
86    /// `child_count` is the immediate-child count discovered when the parent
87    /// was scanned; `None` until known (unvalidated JSONL records).
88    Json {
89        span: Range<u32>,
90        key: JsonKey,
91        child_count: Option<u32>,
92    },
93    /// A span that failed to scan (a corrupt JSONL record): kept selectable,
94    /// rendered as an error leaf whose raw text is the alternate output.
95    JsonError { span: Range<u32>, key: JsonKey },
96}
97
98#[derive(Debug)]
99struct Node {
100    parent: Option<NodeId>,
101    children: Vec<NodeId>,
102    is_container: bool,
103    depth: usize,
104    /// False while the node's children (or, for a pending scalar record, its
105    /// validation) have not been computed yet.
106    children_loaded: bool,
107    payload: Payload,
108}
109
110#[derive(Debug, Default)]
111pub struct Tree {
112    nodes: Vec<Node>,
113    roots: Vec<NodeId>,
114    view_root: Option<NodeId>,
115    revision: TreeRevision,
116    /// The scanned directory (canonicalized) filesystem paths derive from.
117    fs_root: Option<PathBuf>,
118    /// Whether the scan ignores ignore-files; lazy walks must match.
119    fs_no_ignore: bool,
120    /// The raw JSON input document that `Payload::Json` spans index into.
121    /// Shared so materialization can read it while appending nodes.
122    json_source: Option<Arc<Vec<u8>>>,
123    /// Nodes whose children/validation are still pending.
124    unloaded: usize,
125    /// Arena-order sweep position for [`Tree::index_some`].
126    cursor: NodeId,
127    /// Messages for spans that failed to scan, for the banner and exit report.
128    errors: Vec<String>,
129}
130
131impl Tree {
132    pub fn new() -> Self {
133        Self::default()
134    }
135
136    /// A tree over a directory scan rooted at `root` (canonicalized);
137    /// filesystem nodes derive their paths from it and lazy directory walks
138    /// reuse the scan's ignore setting.
139    pub(crate) fn new_fs(root: PathBuf, no_ignore: bool) -> Self {
140        Self {
141            fs_root: Some(root),
142            fs_no_ignore: no_ignore,
143            ..Self::default()
144        }
145    }
146
147    pub(crate) fn fs_no_ignore(&self) -> bool {
148        self.fs_no_ignore
149    }
150
151    pub fn push(
152        &mut self,
153        parent: Option<NodeId>,
154        name: impl Into<String>,
155        is_container: bool,
156        action: ActionValues,
157    ) -> NodeId {
158        self.push_with_detail(parent, name, None, is_container, action)
159    }
160
161    pub fn push_with_detail(
162        &mut self,
163        parent: Option<NodeId>,
164        name: impl Into<String>,
165        detail: Option<String>,
166        is_container: bool,
167        action: ActionValues,
168    ) -> NodeId {
169        self.push_node(
170            parent,
171            is_container,
172            true,
173            Payload::Explicit(Box::new(Explicit {
174                name: name.into(),
175                detail,
176                action,
177            })),
178        )
179    }
180
181    /// Append a filesystem entry; its action values are derived on demand.
182    /// Directories start unloaded — their contents come from a lazy walk.
183    pub(crate) fn push_fs(
184        &mut self,
185        parent: Option<NodeId>,
186        file_name: impl Into<OsString>,
187        is_container: bool,
188    ) -> NodeId {
189        self.push_node(
190            parent,
191            is_container,
192            !is_container,
193            Payload::Fs {
194                file_name: file_name.into(),
195            },
196        )
197    }
198
199    /// Append a JSON value node; its display and action values are derived on
200    /// demand from the retained input bytes.
201    pub(crate) fn push_json(
202        &mut self,
203        parent: Option<NodeId>,
204        span: Range<u32>,
205        key: JsonKey,
206        is_container: bool,
207        child_count: Option<u32>,
208        children_loaded: bool,
209    ) -> NodeId {
210        self.push_node(
211            parent,
212            is_container,
213            children_loaded,
214            Payload::Json {
215                span,
216                key,
217                child_count,
218            },
219        )
220    }
221
222    /// Attach the input document the JSON spans index into.
223    pub(crate) fn set_json_source(&mut self, bytes: Arc<Vec<u8>>) {
224        self.json_source = Some(bytes);
225    }
226
227    /// Drop every node with id >= `len` (discarding a partially scanned JSONL
228    /// record); nodes below `len` and their order are untouched.
229    pub(crate) fn truncate(&mut self, len: usize) {
230        for node in &self.nodes[len.min(self.nodes.len())..] {
231            if !node.children_loaded {
232                self.unloaded -= 1;
233            }
234        }
235        self.nodes.truncate(len);
236        self.roots.retain(|&id| id < len);
237        for node in &mut self.nodes {
238            node.children.retain(|&id| id < len);
239        }
240        self.cursor = self.cursor.min(len);
241    }
242
243    fn json_bytes(&self) -> &[u8] {
244        self.json_source
245            .as_ref()
246            .map_or(&[], |bytes| bytes.as_slice())
247    }
248
249    /// The shared input document, for materialization to read while pushing.
250    pub(crate) fn json_source_arc(&self) -> Option<Arc<Vec<u8>>> {
251        self.json_source.clone()
252    }
253
254    /// The byte span of a JSON (or error) node.
255    pub(crate) fn json_span(&self, id: NodeId) -> Option<Range<u32>> {
256        match &self.node(id).payload {
257            Payload::Json { span, .. } | Payload::JsonError { span, .. } => Some(span.clone()),
258            _ => None,
259        }
260    }
261
262    fn push_node(
263        &mut self,
264        parent: Option<NodeId>,
265        is_container: bool,
266        children_loaded: bool,
267        payload: Payload,
268    ) -> NodeId {
269        let id = self.nodes.len();
270        let depth = parent.map_or(0, |id| self.nodes[id].depth + 1);
271        self.nodes.push(Node {
272            parent,
273            children: Vec::new(),
274            is_container,
275            depth,
276            children_loaded,
277            payload,
278        });
279        if !children_loaded {
280            self.unloaded += 1;
281        }
282        match parent {
283            Some(parent) => self.nodes[parent].children.push(id),
284            None => self.roots.push(id),
285        }
286        id
287    }
288
289    /// Materialize the node's pending children (or validate a pending scalar
290    /// record). Returns true when work was actually done.
291    pub(crate) fn ensure_children(&mut self, id: NodeId) -> bool {
292        if self.nodes[id].children_loaded {
293            return false;
294        }
295        match self.nodes[id].payload {
296            Payload::Json { .. } => crate::json_tree::materialize(self, id),
297            Payload::Fs { .. } => crate::fstree::materialize(self, id),
298            _ => self.mark_children_loaded(id),
299        }
300        self.revision.advance();
301        true
302    }
303
304    /// Flip a node to loaded, keeping the pending-work counter accurate.
305    pub(crate) fn mark_children_loaded(&mut self, id: NodeId) {
306        if !self.nodes[id].children_loaded {
307            self.nodes[id].children_loaded = true;
308            self.unloaded -= 1;
309        }
310    }
311
312    /// Convert a node whose span failed to scan into a selectable error leaf
313    /// and record the message for the banner and the exit report.
314    pub(crate) fn set_json_error(&mut self, id: NodeId, message: String) {
315        let (span, key) = match &self.nodes[id].payload {
316            Payload::Json { span, key, .. } => (span.clone(), key.clone()),
317            _ => return,
318        };
319        self.nodes[id].payload = Payload::JsonError { span, key };
320        self.nodes[id].is_container = false;
321        self.mark_children_loaded(id);
322        self.errors.push(message);
323        self.revision.advance();
324    }
325
326    /// Advance the arena-order sweep, loading up to `budget` pending nodes.
327    /// Returns true when any work was done. The sweep together with
328    /// on-demand loading eventually visits every node, so a fully swept tree
329    /// has validated every byte of the input.
330    pub(crate) fn index_some(&mut self, budget: usize) -> bool {
331        let mut done = 0;
332        while done < budget && self.cursor < self.nodes.len() {
333            if self.nodes[self.cursor].children_loaded {
334                self.cursor += 1;
335            } else {
336                self.ensure_children(self.cursor);
337                done += 1;
338            }
339        }
340        done > 0
341    }
342
343    /// Run the sweep to completion (startup `--expand`, and tests).
344    pub(crate) fn index_all(&mut self) {
345        while self.index_some(usize::MAX) {}
346    }
347
348    /// True once every node's children are loaded and every span validated;
349    /// the jump picker requires this.
350    pub fn fully_indexed(&self) -> bool {
351        self.unloaded == 0
352    }
353
354    /// Nodes whose children/validation are still pending (progress display).
355    pub fn pending(&self) -> usize {
356        self.unloaded
357    }
358
359    /// Messages for spans that failed to scan, in discovery order.
360    pub fn errors(&self) -> &[String] {
361        &self.errors
362    }
363
364    /// Record a non-fatal source error (an unreadable directory) for the
365    /// banner and the exit report.
366    pub(crate) fn record_error(&mut self, message: String) {
367        self.errors.push(message);
368        self.revision.advance();
369    }
370
371    fn node(&self, id: NodeId) -> &Node {
372        &self.nodes[id]
373    }
374
375    /// The node's display name.
376    pub fn name(&self, id: NodeId) -> String {
377        let node = self.node(id);
378        match &node.payload {
379            Payload::Explicit(explicit) => explicit.name.clone(),
380            Payload::Fs { file_name } => file_name.to_string_lossy().into_owned(),
381            Payload::Json {
382                span,
383                key,
384                child_count,
385            } => {
386                let bytes = self.json_bytes();
387                let prefix = match key {
388                    JsonKey::Root | JsonKey::JsonlRoot => "$".to_owned(),
389                    JsonKey::Member { key_span } => crate::json_tree::key_text(bytes, key_span),
390                    JsonKey::Index(index) => format!("[{index}]"),
391                };
392                if node.is_container {
393                    // Loaded containers count their children; unloaded ones
394                    // fall back to the count discovered when their parent was
395                    // scanned, and show `…` when even that is pending
396                    // (unvalidated JSONL records).
397                    let count = if node.children_loaded {
398                        Some(node.children.len() as u32)
399                    } else {
400                        *child_count
401                    };
402                    // The JSONL root is a virtual array whatever its first
403                    // record's first byte happens to be.
404                    let object =
405                        !matches!(key, JsonKey::JsonlRoot) && bytes[span.start as usize] == b'{';
406                    match (object, count) {
407                        (true, Some(0)) => format!("{prefix} {{}}"),
408                        (true, Some(count)) => format!("{prefix} {{{count}}}"),
409                        (true, None) => format!("{prefix} {{…}}"),
410                        (false, Some(0)) => format!("{prefix} []"),
411                        (false, Some(count)) => format!("{prefix} [{count}]"),
412                        (false, None) => format!("{prefix} […]"),
413                    }
414                } else {
415                    format!("{prefix}: {}", crate::json_tree::value_text(bytes, span))
416                }
417            }
418            Payload::JsonError { key, .. } => {
419                let prefix = match key {
420                    JsonKey::Root | JsonKey::JsonlRoot => "$".to_owned(),
421                    JsonKey::Member { key_span } => {
422                        crate::json_tree::key_text(self.json_bytes(), key_span)
423                    }
424                    JsonKey::Index(index) => format!("[{index}]"),
425                };
426                format!("{prefix} ⚠")
427            }
428        }
429    }
430
431    /// Optional secondary text rendered after the name.
432    pub fn detail(&self, id: NodeId) -> Option<String> {
433        let node = self.node(id);
434        match &node.payload {
435            Payload::Explicit(explicit) => explicit.detail.clone(),
436            Payload::Fs { .. } => None,
437            Payload::Json { span, .. } => {
438                let bytes = self.json_bytes();
439                if !node.is_container || bytes.get(span.start as usize) != Some(&b'{') {
440                    return None;
441                }
442                let members = node.children.iter().map(|&child| {
443                    let child = self.node(child);
444                    match &child.payload {
445                        Payload::Json {
446                            span,
447                            key: JsonKey::Member { key_span },
448                            ..
449                        } if !child.is_container => Some((key_span.clone(), span.clone())),
450                        _ => None,
451                    }
452                });
453                crate::json_tree::object_preview(bytes, members)
454            }
455            Payload::JsonError { span, .. } => {
456                let mut text = crate::json_tree::raw_text(self.json_bytes(), span);
457                if text.len() > 512 {
458                    let mut end = 512;
459                    while !text.is_char_boundary(end) {
460                        end -= 1;
461                    }
462                    text.truncate(end);
463                }
464                Some(text)
465            }
466        }
467    }
468
469    pub fn parent(&self, id: NodeId) -> Option<NodeId> {
470        self.node(id).parent
471    }
472
473    /// 0 for roots.
474    pub fn depth(&self, id: NodeId) -> usize {
475        self.node(id).depth
476    }
477
478    /// Whether the node represents a container, including an empty one.
479    pub fn is_container(&self, id: NodeId) -> bool {
480        self.node(id).is_container
481    }
482
483    /// The node's children, in display order.
484    pub fn children_of(&self, id: NodeId) -> &[NodeId] {
485        &self.node(id).children
486    }
487
488    /// Text written to stdout when the node is accepted.
489    pub fn output(&self, id: NodeId) -> OsString {
490        match &self.node(id).payload {
491            Payload::Explicit(explicit) => explicit.action.output.clone(),
492            Payload::Fs { .. } => self.fs_path(id),
493            Payload::Json { .. } | Payload::JsonError { .. } => {
494                self.json_pointer_from(id, false).into()
495            }
496        }
497    }
498
499    /// Text written to stdout by the alternate accept action.
500    pub fn alternate_output(&self, id: NodeId) -> OsString {
501        match &self.node(id).payload {
502            Payload::Explicit(explicit) => explicit.action.alternate_output.clone(),
503            Payload::Fs { file_name } => file_name.clone(),
504            Payload::Json { span, .. } => {
505                crate::json_tree::value_text(self.json_bytes(), span).into()
506            }
507            Payload::JsonError { span, .. } => {
508                crate::json_tree::raw_text(self.json_bytes(), span).into()
509            }
510        }
511    }
512
513    /// Value exported to shell bindings as `$path`.
514    pub fn path(&self, id: NodeId) -> OsString {
515        match &self.node(id).payload {
516            Payload::Explicit(explicit) => explicit.action.path.clone(),
517            Payload::Fs { .. } => self.fs_path(id),
518            Payload::Json { .. } | Payload::JsonError { .. } => {
519                self.json_pointer_from(id, false).into()
520            }
521        }
522    }
523
524    /// The node's location in the host filesystem, when it has one. JSON nodes
525    /// are addressed by pointer within a document, so nothing outside ite can
526    /// open them.
527    pub fn filesystem_path(&self, id: NodeId) -> Option<OsString> {
528        match &self.node(id).payload {
529            Payload::Explicit(explicit) => Some(explicit.action.path.clone()),
530            Payload::Fs { .. } => Some(self.fs_path(id)),
531            Payload::Json { .. } | Payload::JsonError { .. } => None,
532        }
533    }
534
535    /// Value exported to shell bindings as `$relpath`: the address within the
536    /// source's natural unit — the scan root for directories, the document
537    /// for JSON, the containing record for JSONL (the record itself is "").
538    pub fn relpath(&self, id: NodeId) -> OsString {
539        match &self.node(id).payload {
540            Payload::Explicit(explicit) => explicit.action.relpath.clone(),
541            Payload::Fs { .. } => self.fs_relpath(id).into_os_string(),
542            Payload::Json { .. } | Payload::JsonError { .. } => {
543                self.json_pointer_from(id, true).into()
544            }
545        }
546    }
547
548    /// The text the jump picker matches and displays: the node's
549    /// document-global address. Coincides with `relpath` for directory scans
550    /// and JSON documents; JSONL diverges, since a record-relative `relpath`
551    /// repeats across records.
552    pub fn jump_key(&self, id: NodeId) -> String {
553        match &self.node(id).payload {
554            Payload::Json { .. } | Payload::JsonError { .. } => self.json_pointer_from(id, false),
555            _ => self.relpath(id).to_string_lossy().into_owned(),
556        }
557    }
558
559    /// Root-relative path of a filesystem node: ancestor file names joined.
560    fn fs_relpath(&self, id: NodeId) -> PathBuf {
561        let mut components = Vec::new();
562        let mut cursor = Some(id);
563        while let Some(current) = cursor {
564            let node = self.node(current);
565            if let Payload::Fs { file_name } = &node.payload {
566                components.push(file_name.as_os_str());
567            }
568            cursor = node.parent;
569        }
570        components.iter().rev().collect()
571    }
572
573    /// Absolute path of a filesystem node: the scan root plus [`Self::fs_relpath`].
574    fn fs_path(&self, id: NodeId) -> OsString {
575        let root = self.fs_root.as_deref().unwrap_or(Path::new(""));
576        root.join(self.fs_relpath(id)).into_os_string()
577    }
578
579    /// Canonical JSON Pointer of a JSON node, assembled from its ancestor
580    /// keys. Synthetic roots contribute no token, so a single-rooted
581    /// document's root is the empty (whole-document) pointer. With
582    /// `within_record` the walk stops at the containing JSONL record, whose
583    /// own token is excluded — the record-relative address.
584    fn json_pointer_from(&self, id: NodeId, within_record: bool) -> String {
585        let mut tokens = Vec::new();
586        let mut cursor = Some(id);
587        while let Some(current) = cursor {
588            let node = self.node(current);
589            let (Payload::Json { key, .. } | Payload::JsonError { key, .. }) = &node.payload else {
590                break;
591            };
592            if within_record && self.is_jsonl_record(current) {
593                break;
594            }
595            match key {
596                JsonKey::Root | JsonKey::JsonlRoot => {}
597                JsonKey::Member { key_span } => {
598                    tokens.push(crate::json_tree::key_text(self.json_bytes(), key_span));
599                }
600                JsonKey::Index(index) => tokens.push(index.to_string()),
601            }
602            cursor = node.parent;
603        }
604        let mut pointer = String::new();
605        for token in tokens.iter().rev() {
606            pointer = crate::json_tree::append_pointer(&pointer, token);
607        }
608        pointer
609    }
610
611    /// Whether the node is a JSONL record: a direct child of the virtual
612    /// array root.
613    fn is_jsonl_record(&self, id: NodeId) -> bool {
614        self.node(id).parent.is_some_and(|parent| {
615            matches!(
616                &self.node(parent).payload,
617                Payload::Json {
618                    key: JsonKey::JsonlRoot,
619                    ..
620                }
621            )
622        })
623    }
624
625    pub fn len(&self) -> usize {
626        self.nodes.len()
627    }
628
629    pub fn is_empty(&self) -> bool {
630        self.nodes.is_empty()
631    }
632
633    pub fn root_ids(&self) -> &[NodeId] {
634        &self.roots
635    }
636
637    /// The temporary root used by the tree view, if it has been narrowed.
638    pub(crate) fn view_root(&self) -> Option<NodeId> {
639        self.view_root
640    }
641
642    /// Narrow the tree model to one subtree, or restore the original forest.
643    pub(crate) fn set_view_root(&mut self, root: Option<NodeId>) {
644        if self.view_root != root {
645            self.view_root = root;
646            self.revision.advance();
647        }
648    }
649
650    /// The node's parent in the current view. A temporary root has no parent.
651    pub(crate) fn view_parent(&self, id: NodeId) -> Option<NodeId> {
652        if self.view_root == Some(id) {
653            None
654        } else {
655            self.nodes[id].parent
656        }
657    }
658
659    /// Whether a node belongs to the subtree exposed by the current view.
660    pub(crate) fn is_in_view(&self, id: NodeId) -> bool {
661        let Some(root) = self.view_root else {
662            return true;
663        };
664        let mut cursor = Some(id);
665        while let Some(current) = cursor {
666            if current == root {
667                return true;
668            }
669            cursor = self.nodes[current].parent;
670        }
671        false
672    }
673
674    /// True when the node cannot be expanded. Containers are never leaves:
675    /// an empty directory is still a directory and `{}`/`[]` are still
676    /// containers — they simply open to nothing.
677    pub fn is_leaf(&self, id: NodeId) -> bool {
678        !self.node(id).is_container
679    }
680
681    /// All expandable nodes as `(id, parent)` pairs, in tree order.
682    pub fn branches(&self) -> impl Iterator<Item = (NodeId, Option<NodeId>)> + '_ {
683        self.nodes
684            .iter()
685            .enumerate()
686            .filter(|(id, _)| !self.is_leaf(*id))
687            .map(|(id, node)| (id, node.parent))
688    }
689
690    /// Reorder every sibling list (roots included) to put containers first,
691    /// keeping the existing relative order within each group.
692    pub(crate) fn containers_first(&mut self) {
693        let containers_first = |nodes: &[Node], ids: &mut Vec<NodeId>| {
694            ids.sort_by_key(|&id| !nodes[id].is_container);
695        };
696        let mut roots = std::mem::take(&mut self.roots);
697        containers_first(&self.nodes, &mut roots);
698        self.roots = roots;
699        for id in 0..self.nodes.len() {
700            let mut children = std::mem::take(&mut self.nodes[id].children);
701            containers_first(&self.nodes, &mut children);
702            self.nodes[id].children = children;
703        }
704    }
705
706    /// Reorder one node's children to put containers first (lazy directory
707    /// walks sort each sibling list as it materializes).
708    pub(crate) fn containers_first_children(&mut self, id: NodeId) {
709        let mut children = std::mem::take(&mut self.nodes[id].children);
710        children.sort_by_key(|&child| !self.nodes[child].is_container);
711        self.nodes[id].children = children;
712    }
713}
714
715impl TreeModel for Tree {
716    type Id = NodeId;
717
718    fn roots(&self) -> impl Iterator<Item = NodeId> + '_ {
719        self.roots
720            .iter()
721            .copied()
722            .filter(|_| self.view_root.is_none())
723            .chain(self.view_root)
724    }
725
726    fn children(&self, id: NodeId) -> TreeChildren<'_, NodeId> {
727        let node = &self.nodes[id];
728        if node.is_container && node.children.is_empty() {
729            // `Unloaded` rather than an empty slice (which the widget
730            // normalizes to `Leaf`): a childless container — unwalked,
731            // unvalidated, or genuinely empty — must stay a branch.
732            TreeChildren::Unloaded
733        } else {
734            TreeChildren::loaded(&node.children)
735        }
736    }
737
738    fn revision(&self) -> TreeRevision {
739        self.revision
740    }
741
742    fn size_hint(&self) -> usize {
743        self.nodes.len()
744    }
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750    use std::ffi::OsStr;
751
752    fn sample() -> (Tree, NodeId, NodeId, NodeId) {
753        let mut tree = Tree::new();
754        let dir = tree.push(
755            None,
756            "dir",
757            true,
758            ActionValues::new("dir", "/abs/dir", "dir"),
759        );
760        let file = tree.push_with_detail(
761            Some(dir),
762            "file",
763            Some("hint".to_owned()),
764            false,
765            ActionValues::new("dir/file", "/abs/dir/file", "dir/file")
766                .with_alternate_output("file"),
767        );
768        let leaf = tree.push(
769            None,
770            "leaf",
771            false,
772            ActionValues::new("leaf", "/abs/leaf", "leaf"),
773        );
774        (tree, dir, file, leaf)
775    }
776
777    #[test]
778    fn accessors_expose_hierarchy_and_display_data() {
779        let (tree, dir, file, leaf) = sample();
780        assert_eq!(tree.name(dir), "dir");
781        assert_eq!(tree.detail(dir), None);
782        assert_eq!(tree.detail(file).as_deref(), Some("hint"));
783        assert_eq!(tree.parent(dir), None);
784        assert_eq!(tree.parent(file), Some(dir));
785        assert_eq!(tree.depth(dir), 0);
786        assert_eq!(tree.depth(file), 1);
787        assert!(tree.is_container(dir));
788        assert!(!tree.is_container(leaf));
789        assert_eq!(tree.children_of(dir), [file]);
790        assert!(tree.children_of(leaf).is_empty());
791    }
792
793    #[test]
794    fn accessors_expose_action_values() {
795        let (tree, _, file, _) = sample();
796        assert_eq!(tree.output(file), OsStr::new("dir/file"));
797        assert_eq!(tree.alternate_output(file), OsStr::new("file"));
798        assert_eq!(tree.path(file), OsStr::new("/abs/dir/file"));
799        assert_eq!(tree.relpath(file), OsStr::new("dir/file"));
800    }
801
802    #[test]
803    fn jump_key_is_the_relpath_text_today() {
804        let (tree, dir, file, _) = sample();
805        assert_eq!(tree.jump_key(dir), "dir");
806        assert_eq!(tree.jump_key(file), "dir/file");
807    }
808
809    #[test]
810    fn fs_nodes_derive_action_values_from_the_hierarchy() {
811        let mut tree = Tree::new_fs("/scan/root".into(), false);
812        let dir = tree.push_fs(None, "b-dir", true);
813        let file = tree.push_fs(Some(dir), "inner.txt", false);
814
815        assert_eq!(tree.name(file), "inner.txt");
816        assert_eq!(tree.detail(file), None);
817        assert_eq!(tree.path(dir), OsStr::new("/scan/root/b-dir"));
818        assert_eq!(tree.path(file), OsStr::new("/scan/root/b-dir/inner.txt"));
819        assert_eq!(tree.relpath(file), OsStr::new("b-dir/inner.txt"));
820        assert_eq!(tree.output(file), tree.path(file));
821        assert_eq!(tree.alternate_output(file), OsStr::new("inner.txt"));
822        assert_eq!(tree.jump_key(file), "b-dir/inner.txt");
823        assert!(tree.is_container(dir));
824        assert!(!tree.is_container(file));
825    }
826
827    #[test]
828    fn only_filesystem_backed_nodes_have_a_filesystem_path() {
829        let mut tree = Tree::new_fs("/scan/root".into(), false);
830        let dir = tree.push_fs(None, "b-dir", true);
831        let file = tree.push_fs(Some(dir), "inner.txt", false);
832        assert_eq!(
833            tree.filesystem_path(file),
834            Some(OsString::from("/scan/root/b-dir/inner.txt"))
835        );
836
837        let json = crate::json_tree::from_reader(r#"{"a": 1}"#.as_bytes()).unwrap();
838        let member = json.root_ids()[0];
839        // A JSON node is addressed by pointer, not by a path anything can open.
840        assert!(!json.path(member).is_empty());
841        assert_eq!(json.filesystem_path(member), None);
842    }
843
844    #[test]
845    fn containers_first_reorders_every_sibling_list_stably() {
846        let mut tree = Tree::new();
847        let a = tree.push(None, "a-file", false, ActionValues::new("", "", ""));
848        let b = tree.push(None, "b-dir", true, ActionValues::new("", "", ""));
849        let c = tree.push(None, "c-file", false, ActionValues::new("", "", ""));
850        let d = tree.push(None, "d-dir", true, ActionValues::new("", "", ""));
851        let inner_file = tree.push(Some(b), "x-file", false, ActionValues::new("", "", ""));
852        let inner_dir = tree.push(Some(b), "y-dir", true, ActionValues::new("", "", ""));
853
854        tree.containers_first();
855
856        assert_eq!(tree.root_ids(), [b, d, a, c]);
857        assert_eq!(tree.children_of(b), [inner_dir, inner_file]);
858    }
859}