Skip to main content

neco_filetree/
lib.rs

1use std::collections::BTreeSet;
2
3use neco_pathrel::{parent_path, path_matches_or_contains, PathPolicy};
4
5/// Tree node kind used by runtime-facing file tree helpers.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum FileTreeNodeKind {
8    File,
9    Directory,
10}
11
12/// Whether a directory node is fully materialized or only partially known.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum DirectoryMaterialization {
15    Complete,
16    Partial,
17}
18
19/// Public runtime-facing file tree node shape.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct FileTreeNode {
22    pub name: String,
23    pub path: String,
24    pub kind: FileTreeNodeKind,
25    pub children: Vec<FileTreeNode>,
26    pub materialization: DirectoryMaterialization,
27    pub child_count: Option<usize>,
28}
29
30/// One flattened row derived from a file tree snapshot.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct FlatFileTreeRow {
33    pub depth: usize,
34    pub name: String,
35    pub path: String,
36    pub kind: FileTreeNodeKind,
37    pub is_collapsed: bool,
38    pub materialization: DirectoryMaterialization,
39    pub child_count: Option<usize>,
40}
41
42/// Expansion instructions needed to reveal a target path.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct RevealPlan {
45    pub expand_paths: Vec<String>,
46    pub found: bool,
47}
48
49/// Find one node by exact path under the given root.
50pub fn find_node_by_path<'a>(
51    root: &'a FileTreeNode,
52    path: &str,
53    policy: &PathPolicy,
54) -> Option<&'a FileTreeNode> {
55    if path_eq(&root.path, path, policy) {
56        return Some(root);
57    }
58    for child in &root.children {
59        if let Some(found) = find_node_by_path(child, path, policy) {
60            return Some(found);
61        }
62    }
63    None
64}
65
66/// Monotonic merge: recursively merge the matching subtree, preserving existing
67/// deeper children and sibling order. File nodes are replaced directly.
68pub fn merge_subtree(
69    root: &FileTreeNode,
70    subtree: FileTreeNode,
71    policy: &PathPolicy,
72) -> FileTreeNode {
73    if path_eq(&root.path, &subtree.path, policy) {
74        if subtree.kind == FileTreeNodeKind::File {
75            return subtree;
76        }
77
78        let mut matched_incoming = vec![false; subtree.children.len()];
79        let mut next_children = Vec::with_capacity(root.children.len() + subtree.children.len());
80
81        for child in &root.children {
82            if let Some((index, incoming_child)) = subtree
83                .children
84                .iter()
85                .enumerate()
86                .find(|(_, incoming_child)| path_eq(&child.path, &incoming_child.path, policy))
87            {
88                matched_incoming[index] = true;
89                next_children.push(merge_subtree(child, incoming_child.clone(), policy));
90            } else {
91                next_children.push(child.clone());
92            }
93        }
94
95        next_children.extend(
96            subtree
97                .children
98                .iter()
99                .enumerate()
100                .filter(|(index, _)| !matched_incoming[*index])
101                .map(|(_, child)| child.clone()),
102        );
103
104        let mut next = subtree;
105        next.children = next_children;
106        return next;
107    }
108
109    let mut changed = false;
110    let next_children = root
111        .children
112        .iter()
113        .map(|child| {
114            let next_child = merge_subtree(child, subtree.clone(), policy);
115            if next_child != *child {
116                changed = true;
117            }
118            next_child
119        })
120        .collect::<Vec<_>>();
121
122    if !changed {
123        return root.clone();
124    }
125
126    let mut next = root.clone();
127    next.children = next_children;
128    next
129}
130
131/// Flatten a tree into visible rows under the current collapsed set.
132pub fn flatten_file_tree(
133    root: &FileTreeNode,
134    collapsed_paths: &BTreeSet<String>,
135    include_root: bool,
136    policy: &PathPolicy,
137) -> Vec<FlatFileTreeRow> {
138    let mut rows = Vec::new();
139    flatten_into(root, collapsed_paths, include_root, 0, policy, &mut rows);
140    rows
141}
142
143/// Build the ancestor expansion plan needed to reveal one target path.
144pub fn reveal_plan_for_path(
145    root: &FileTreeNode,
146    target_path: &str,
147    policy: &PathPolicy,
148) -> RevealPlan {
149    if !path_matches_or_contains(target_path, &root.path, policy) {
150        return RevealPlan {
151            expand_paths: Vec::new(),
152            found: false,
153        };
154    }
155
156    if find_node_by_path(root, target_path, policy).is_none() {
157        return RevealPlan {
158            expand_paths: Vec::new(),
159            found: false,
160        };
161    }
162
163    let mut expand_paths = Vec::new();
164    let mut cursor = parent_path(target_path, policy);
165    while let Some(path) = cursor {
166        if path_eq(path, &root.path, policy) {
167            if root.kind == FileTreeNodeKind::Directory {
168                expand_paths.push(root.path.clone());
169            }
170            break;
171        }
172
173        if let Some(node) = find_node_by_path(root, path, policy) {
174            if node.kind == FileTreeNodeKind::Directory {
175                expand_paths.push(node.path.clone());
176            }
177        }
178        cursor = parent_path(path, policy);
179    }
180    expand_paths.reverse();
181    RevealPlan {
182        expand_paths,
183        found: true,
184    }
185}
186
187fn flatten_into(
188    node: &FileTreeNode,
189    collapsed_paths: &BTreeSet<String>,
190    include_self: bool,
191    depth: usize,
192    policy: &PathPolicy,
193    rows: &mut Vec<FlatFileTreeRow>,
194) {
195    let is_collapsed = collapsed_paths
196        .iter()
197        .any(|path| path_eq(path, &node.path, policy));
198
199    if include_self {
200        rows.push(FlatFileTreeRow {
201            depth,
202            name: node.name.clone(),
203            path: node.path.clone(),
204            kind: node.kind,
205            is_collapsed,
206            materialization: node.materialization,
207            child_count: node.child_count,
208        });
209    }
210
211    let next_depth = if include_self { depth + 1 } else { depth };
212    if include_self && is_collapsed {
213        return;
214    }
215
216    for child in &node.children {
217        flatten_into(child, collapsed_paths, true, next_depth, policy, rows);
218    }
219}
220
221fn path_eq(left: &str, right: &str, policy: &PathPolicy) -> bool {
222    path_matches_or_contains(left, right, policy) && path_matches_or_contains(right, left, policy)
223}
224
225#[cfg(test)]
226mod tests {
227    use std::collections::BTreeSet;
228
229    use neco_pathrel::{PathCaseSensitivity, PathPolicy};
230
231    use super::{
232        find_node_by_path, flatten_file_tree, merge_subtree, reveal_plan_for_path,
233        DirectoryMaterialization, FileTreeNode, FileTreeNodeKind,
234    };
235
236    fn posix() -> PathPolicy {
237        PathPolicy::posix()
238    }
239
240    fn insensitive() -> PathPolicy {
241        PathPolicy::new('/', PathCaseSensitivity::Insensitive)
242    }
243
244    fn file(name: &str, path: &str) -> FileTreeNode {
245        FileTreeNode {
246            name: name.to_string(),
247            path: path.to_string(),
248            kind: FileTreeNodeKind::File,
249            children: Vec::new(),
250            materialization: DirectoryMaterialization::Complete,
251            child_count: None,
252        }
253    }
254
255    fn dir(
256        name: &str,
257        path: &str,
258        materialization: DirectoryMaterialization,
259        child_count: Option<usize>,
260        children: Vec<FileTreeNode>,
261    ) -> FileTreeNode {
262        FileTreeNode {
263            name: name.to_string(),
264            path: path.to_string(),
265            kind: FileTreeNodeKind::Directory,
266            children,
267            materialization,
268            child_count,
269        }
270    }
271
272    fn sample_tree() -> FileTreeNode {
273        dir(
274            "workspace",
275            "/workspace",
276            DirectoryMaterialization::Complete,
277            Some(3),
278            vec![
279                dir(
280                    "src",
281                    "/workspace/src",
282                    DirectoryMaterialization::Complete,
283                    Some(2),
284                    vec![
285                        file("lib.rs", "/workspace/src/lib.rs"),
286                        file("main.rs", "/workspace/src/main.rs"),
287                    ],
288                ),
289                dir(
290                    "docs",
291                    "/workspace/docs",
292                    DirectoryMaterialization::Partial,
293                    Some(5),
294                    vec![file("guide.md", "/workspace/docs/guide.md")],
295                ),
296                file("Cargo.toml", "/workspace/Cargo.toml"),
297            ],
298        )
299    }
300
301    #[test]
302    fn find_node_by_path_returns_exact_match() {
303        let tree = sample_tree();
304        let found =
305            find_node_by_path(&tree, "/workspace/src/lib.rs", &posix()).expect("node should exist");
306        assert_eq!(found.name, "lib.rs");
307    }
308
309    #[test]
310    fn find_node_by_path_can_use_case_insensitive_policy() {
311        let tree = sample_tree();
312        let found = find_node_by_path(&tree, "/WORKSPACE/SRC/LIB.RS", &insensitive())
313            .expect("node should exist");
314        assert_eq!(found.path, "/workspace/src/lib.rs");
315    }
316
317    #[test]
318    fn merge_subtree_replaces_exact_match_path() {
319        let tree = sample_tree();
320        let replacement = dir(
321            "src",
322            "/workspace/src",
323            DirectoryMaterialization::Complete,
324            Some(1),
325            vec![file("mod.rs", "/workspace/src/mod.rs")],
326        );
327        let merged = merge_subtree(&tree, replacement, &posix());
328        let src = find_node_by_path(&merged, "/workspace/src", &posix()).expect("src should exist");
329        assert_eq!(src.children.len(), 3);
330        assert_eq!(src.children[0].name, "lib.rs");
331        assert_eq!(src.children[1].name, "main.rs");
332        assert_eq!(src.children[2].name, "mod.rs");
333    }
334
335    #[test]
336    fn merge_subtree_keeps_non_target_branches() {
337        let tree = sample_tree();
338        let replacement = dir(
339            "src",
340            "/workspace/src",
341            DirectoryMaterialization::Complete,
342            Some(1),
343            vec![file("mod.rs", "/workspace/src/mod.rs")],
344        );
345        let merged = merge_subtree(&tree, replacement, &posix());
346        let docs =
347            find_node_by_path(&merged, "/workspace/docs", &posix()).expect("docs should exist");
348        assert_eq!(docs.materialization, DirectoryMaterialization::Partial);
349    }
350
351    #[test]
352    fn merge_subtree_preserves_partial_metadata_from_subtree() {
353        let tree = sample_tree();
354        let replacement = dir(
355            "docs",
356            "/workspace/docs",
357            DirectoryMaterialization::Partial,
358            Some(9),
359            vec![file("reference.md", "/workspace/docs/reference.md")],
360        );
361        let merged = merge_subtree(&tree, replacement, &posix());
362        let docs =
363            find_node_by_path(&merged, "/workspace/docs", &posix()).expect("docs should exist");
364        assert_eq!(docs.child_count, Some(9));
365        assert_eq!(docs.children.len(), 2);
366        assert_eq!(docs.children[0].name, "guide.md");
367        assert_eq!(docs.children[1].name, "reference.md");
368    }
369
370    #[test]
371    fn merge_subtree_shallow_merge_preserves_deep_children() {
372        let tree = sample_tree();
373        let replacement = dir(
374            "src",
375            "/workspace/src",
376            DirectoryMaterialization::Partial,
377            Some(2),
378            Vec::new(),
379        );
380        let merged = merge_subtree(&tree, replacement, &posix());
381        let src = find_node_by_path(&merged, "/workspace/src", &posix()).expect("src should exist");
382        assert_eq!(src.materialization, DirectoryMaterialization::Partial);
383        assert_eq!(src.child_count, Some(2));
384        assert_eq!(src.children.len(), 2);
385        assert_eq!(src.children[0].name, "lib.rs");
386        assert_eq!(src.children[1].name, "main.rs");
387    }
388
389    #[test]
390    fn merge_subtree_file_node_is_replaced_directly() {
391        let tree = sample_tree();
392        let replacement = FileTreeNode {
393            name: "lib.rs".to_string(),
394            path: "/workspace/src/lib.rs".to_string(),
395            kind: FileTreeNodeKind::File,
396            children: Vec::new(),
397            materialization: DirectoryMaterialization::Partial,
398            child_count: Some(7),
399        };
400        let merged = merge_subtree(&tree, replacement.clone(), &posix());
401        let file = find_node_by_path(&merged, "/workspace/src/lib.rs", &posix())
402            .expect("file should exist");
403        assert_eq!(file, &replacement);
404    }
405
406    #[test]
407    fn merge_subtree_monotonic_keeps_unmatched_children() {
408        let tree = sample_tree();
409        let replacement = dir(
410            "src",
411            "/workspace/src",
412            DirectoryMaterialization::Complete,
413            Some(1),
414            vec![file("mod.rs", "/workspace/src/mod.rs")],
415        );
416        let merged = merge_subtree(&tree, replacement, &posix());
417        let src = find_node_by_path(&merged, "/workspace/src", &posix()).expect("src should exist");
418        assert_eq!(src.children.len(), 3);
419        assert!(src.children.iter().any(|child| child.name == "lib.rs"));
420        assert!(src.children.iter().any(|child| child.name == "main.rs"));
421        assert!(src.children.iter().any(|child| child.name == "mod.rs"));
422    }
423
424    #[test]
425    fn merge_subtree_preserves_existing_sibling_order() {
426        let tree = sample_tree();
427        let replacement = dir(
428            "src",
429            "/workspace/src",
430            DirectoryMaterialization::Complete,
431            Some(1),
432            vec![file("mod.rs", "/workspace/src/mod.rs")],
433        );
434        let merged = merge_subtree(&tree, replacement, &posix());
435        let src = find_node_by_path(&merged, "/workspace/src", &posix()).expect("src should exist");
436        let child_names = src
437            .children
438            .iter()
439            .map(|child| child.name.as_str())
440            .collect::<Vec<_>>();
441        assert_eq!(child_names, vec!["lib.rs", "main.rs", "mod.rs"]);
442    }
443
444    #[test]
445    fn merge_subtree_case_insensitive_matches_and_preserves_order() {
446        let tree = sample_tree();
447        let replacement = dir(
448            "SRC",
449            "/WORKSPACE/SRC",
450            DirectoryMaterialization::Complete,
451            Some(1),
452            vec![file("mod.rs", "/WORKSPACE/SRC/mod.rs")],
453        );
454        let merged = merge_subtree(&tree, replacement, &insensitive());
455        let src =
456            find_node_by_path(&merged, "/workspace/src", &insensitive()).expect("src should exist");
457        assert!(src.children.iter().any(|child| child.name == "lib.rs"));
458        assert!(src.children.iter().any(|child| child.name == "main.rs"));
459        assert!(src.children.iter().any(|child| child.name == "mod.rs"));
460        assert_eq!(src.children.len(), 3);
461        assert_eq!(src.children[0].name, "lib.rs");
462        assert_eq!(src.children[1].name, "main.rs");
463        assert_eq!(src.children[2].name, "mod.rs");
464    }
465
466    #[test]
467    fn flatten_file_tree_respects_collapsed_paths() {
468        let tree = sample_tree();
469        let mut collapsed = BTreeSet::new();
470        collapsed.insert("/workspace/src".to_string());
471        let rows = flatten_file_tree(&tree, &collapsed, true, &posix());
472        assert!(rows
473            .iter()
474            .any(|row| row.path == "/workspace/src" && row.is_collapsed));
475        assert!(!rows.iter().any(|row| row.path == "/workspace/src/lib.rs"));
476    }
477
478    #[test]
479    fn flatten_file_tree_can_skip_root_row() {
480        let tree = sample_tree();
481        let rows = flatten_file_tree(&tree, &BTreeSet::new(), false, &posix());
482        assert_eq!(
483            rows.first().map(|row| row.path.as_str()),
484            Some("/workspace/src")
485        );
486        assert!(rows.iter().all(|row| row.path != "/workspace"));
487    }
488
489    #[test]
490    fn flatten_file_tree_keeps_partial_directory_rows() {
491        let tree = sample_tree();
492        let rows = flatten_file_tree(&tree, &BTreeSet::new(), true, &posix());
493        let docs = rows
494            .iter()
495            .find(|row| row.path == "/workspace/docs")
496            .expect("docs row should exist");
497        assert_eq!(docs.materialization, DirectoryMaterialization::Partial);
498        assert_eq!(docs.child_count, Some(5));
499    }
500
501    #[test]
502    fn reveal_plan_returns_ancestor_directories_in_order() {
503        let tree = sample_tree();
504        let plan = reveal_plan_for_path(&tree, "/workspace/src/lib.rs", &posix());
505        assert!(plan.found);
506        assert_eq!(
507            plan.expand_paths,
508            vec!["/workspace".to_string(), "/workspace/src".to_string()]
509        );
510    }
511
512    #[test]
513    fn reveal_plan_is_empty_when_target_is_missing() {
514        let tree = sample_tree();
515        let plan = reveal_plan_for_path(&tree, "/workspace/missing.txt", &posix());
516        assert!(!plan.found);
517        assert!(plan.expand_paths.is_empty());
518    }
519
520    #[test]
521    fn reveal_plan_for_root_has_no_expansion_steps() {
522        let tree = sample_tree();
523        let plan = reveal_plan_for_path(&tree, "/workspace", &posix());
524        assert!(plan.found);
525        assert!(plan.expand_paths.is_empty());
526    }
527}