1use std::collections::BTreeSet;
2
3use neco_pathrel::{parent_path, path_matches_or_contains, PathPolicy};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum FileTreeNodeKind {
8 File,
9 Directory,
10}
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum DirectoryMaterialization {
15 Complete,
16 Partial,
17}
18
19#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct RevealPlan {
45 pub expand_paths: Vec<String>,
46 pub found: bool,
47}
48
49pub 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
66pub 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
131pub 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
143pub 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}