Skip to main content

ite_cli/
fstree.rs

1//! Filesystem scanner that transforms a directory into source-neutral tree
2//! data, lazily: `scan` walks only the top level, and each directory's
3//! contents come from a targeted depth-1 walk when it is expanded or when the
4//! app's background sweep reaches it. The targeted walk keeps full ignore
5//! semantics — `ignore::WalkBuilder`'s standard filters read ignore files in
6//! parent directories, so a lazily walked subdirectory honors the same rules
7//! the eager scan did. Unreadable directories are reported through the
8//! tree's error list (banner + stderr on exit) instead of being skipped
9//! silently.
10
11use std::path::{Path, PathBuf};
12
13use crate::tree::{NodeId, Tree};
14
15/// Scan `dir`'s top level, honoring ignore files unless `no_ignore` is set.
16///
17/// Dotfiles are always included. Deeper directories materialize on demand.
18pub fn scan(dir: &Path, no_ignore: bool) -> std::io::Result<Tree> {
19    let _span = crate::profile::span("fstree::scan");
20    let root_dir = dir.canonicalize()?;
21    let mut tree = Tree::new_fs(root_dir.clone(), no_ignore);
22    walk_into(&mut tree, None, &root_dir, no_ignore);
23    tree.containers_first();
24    Ok(tree)
25}
26
27/// Materialize one directory's children with a targeted depth-1 walk.
28pub(crate) fn materialize(tree: &mut Tree, id: NodeId) {
29    let path = PathBuf::from(tree.path(id));
30    let no_ignore = tree.fs_no_ignore();
31    walk_into(tree, Some(id), &path, no_ignore);
32    tree.containers_first_children(id);
33    tree.mark_children_loaded(id);
34}
35
36/// Push `path`'s immediate entries under `parent`, sorted case-insensitively
37/// (directories are reordered first by the callers).
38fn walk_into(tree: &mut Tree, parent: Option<NodeId>, path: &Path, no_ignore: bool) {
39    let walk = ignore::WalkBuilder::new(path)
40        .standard_filters(!no_ignore)
41        .hidden(false)
42        .max_depth(Some(1))
43        .sort_by_file_name(|a, b| a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase()))
44        .build();
45    for entry in walk {
46        match entry {
47            Ok(entry) => {
48                if entry.depth() == 0 {
49                    continue; // the walked directory itself
50                }
51                let is_dir = entry.file_type().is_some_and(|t| t.is_dir());
52                tree.push_fs(parent, entry.file_name(), is_dir);
53            }
54            Err(error) => tree.record_error(error.to_string()),
55        }
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62    use tui_treelistview::{TreeChildren, TreeModel};
63
64    /// Builds:
65    ///   root/
66    ///     .hidden-file
67    ///     b-dir/
68    ///       inner.txt
69    ///     empty-dir/
70    ///     a-file.txt
71    ///     z-file.txt
72    ///     ignored.log     (matched by .ignore)
73    fn fixture() -> tempfile::TempDir {
74        let dir = tempfile::tempdir().unwrap();
75        let p = dir.path();
76        std::fs::write(p.join(".hidden-file"), "").unwrap();
77        std::fs::create_dir(p.join("b-dir")).unwrap();
78        std::fs::write(p.join("b-dir/inner.txt"), "").unwrap();
79        std::fs::create_dir(p.join("empty-dir")).unwrap();
80        std::fs::write(p.join("a-file.txt"), "").unwrap();
81        std::fs::write(p.join("z-file.txt"), "").unwrap();
82        std::fs::write(p.join("ignored.log"), "").unwrap();
83        std::fs::write(p.join(".ignore"), "*.log\n").unwrap();
84        dir
85    }
86
87    /// Scan and run the sweep to completion, as the app does moments after
88    /// startup — structure-shape tests want the whole tree present.
89    fn scan_all(dir: &Path) -> Tree {
90        let mut tree = scan(dir, false).unwrap();
91        tree.index_all();
92        tree
93    }
94
95    fn root_names(tree: &Tree) -> Vec<String> {
96        tree.root_ids().iter().map(|&id| tree.name(id)).collect()
97    }
98
99    #[test]
100    fn default_scan_shows_dotfiles_but_honors_ignore_files() {
101        let dir = fixture();
102        let tree = scan(dir.path(), false).unwrap();
103        // Dirs come first, then files, each sorted case-insensitively.
104        assert_eq!(
105            root_names(&tree),
106            [
107                "b-dir",
108                "empty-dir",
109                ".hidden-file",
110                ".ignore",
111                "a-file.txt",
112                "z-file.txt",
113            ]
114        );
115        assert!(!root_names(&tree).contains(&"ignored.log".to_string()));
116    }
117
118    #[test]
119    fn no_ignore_reveals_ignored_files() {
120        let dir = fixture();
121        let tree = scan(dir.path(), true).unwrap();
122        let names = root_names(&tree);
123        assert!(names.contains(&".hidden-file".to_string()));
124        assert!(names.contains(&"ignored.log".to_string()));
125    }
126
127    #[test]
128    fn scan_builds_only_the_top_level_and_dirs_materialize_on_demand() {
129        let dir = fixture();
130        let mut tree = scan(dir.path(), false).unwrap();
131        let b_dir = tree.root_ids()[0];
132
133        assert!(tree.children_of(b_dir).is_empty());
134        assert!(!tree.is_leaf(b_dir), "an unwalked dir must stay expandable");
135        assert!(!tree.fully_indexed());
136
137        assert!(tree.ensure_children(b_dir));
138        assert_eq!(tree.name(tree.children_of(b_dir)[0]), "inner.txt");
139
140        tree.index_all();
141        assert!(tree.fully_indexed());
142        assert!(tree.errors().is_empty());
143    }
144
145    #[test]
146    fn ancestor_ignore_rules_apply_to_lazily_walked_subdirectories() {
147        let dir = fixture();
148        std::fs::write(dir.path().join("b-dir/nested.log"), "").unwrap();
149        let mut tree = scan(dir.path(), false).unwrap();
150        let b_dir = tree.root_ids()[0];
151
152        tree.ensure_children(b_dir);
153
154        let names: Vec<String> = tree
155            .children_of(b_dir)
156            .iter()
157            .map(|&id| tree.name(id))
158            .collect();
159        assert!(!names.contains(&"nested.log".to_string()), "{names:?}");
160        assert!(names.contains(&"inner.txt".to_string()), "{names:?}");
161    }
162
163    #[cfg(unix)]
164    #[test]
165    fn unreadable_directories_record_an_error_instead_of_vanishing() {
166        use std::os::unix::fs::PermissionsExt;
167        let dir = tempfile::tempdir().unwrap();
168        let locked = dir.path().join("locked");
169        std::fs::create_dir(&locked).unwrap();
170        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
171
172        let mut tree = scan(dir.path(), false).unwrap();
173        tree.index_all();
174        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
175
176        assert!(tree.fully_indexed());
177        assert!(!tree.errors().is_empty());
178    }
179
180    #[test]
181    fn children_and_depth() {
182        let dir = fixture();
183        let tree = scan_all(dir.path());
184        let b_dir = tree.root_ids()[0];
185        assert_eq!(tree.name(b_dir), "b-dir");
186        assert_eq!(tree.depth(b_dir), 0);
187        let kids = tree.children_of(b_dir).to_vec();
188        assert_eq!(kids.len(), 1);
189        assert_eq!(tree.name(kids[0]), "inner.txt");
190        assert_eq!(tree.depth(kids[0]), 1);
191        assert_eq!(tree.parent(kids[0]), Some(b_dir));
192    }
193
194    #[test]
195    fn paths_are_absolute_and_relative() {
196        let dir = fixture();
197        let tree = scan_all(dir.path());
198        let b_dir = tree.root_ids()[0];
199        let inner = tree.children_of(b_dir)[0];
200        assert!(Path::new(&tree.path(inner)).is_absolute());
201        assert!(Path::new(&tree.path(inner)).ends_with("b-dir/inner.txt"));
202        assert_eq!(
203            tree.relpath(inner),
204            Path::new("b-dir/inner.txt").as_os_str()
205        );
206        assert_eq!(
207            tree.alternate_output(inner),
208            std::ffi::OsStr::new("inner.txt")
209        );
210    }
211
212    #[test]
213    fn leaf_classification() {
214        let dir = fixture();
215        let tree = scan_all(dir.path());
216        let by_name = |name: &str| {
217            tree.root_ids()
218                .iter()
219                .copied()
220                .find(|&id| tree.name(id) == name)
221                .unwrap()
222        };
223        assert!(!tree.is_leaf(by_name("b-dir")));
224        // An empty directory is still a directory: a branch that opens to
225        // nothing, never a leaf.
226        assert!(!tree.is_leaf(by_name("empty-dir")));
227        assert!(tree.is_leaf(by_name("a-file.txt")));
228    }
229
230    #[test]
231    fn tree_model_children_match_nodes() {
232        let dir = fixture();
233        let tree = scan_all(dir.path());
234        let b_dir = tree.root_ids()[0];
235        match tree.children(b_dir) {
236            TreeChildren::Loaded(kids) => assert_eq!(kids, tree.children_of(b_dir)),
237            other => panic!("expected Loaded, got {other:?}"),
238        }
239        // A walked-empty directory stays a branch; `Unloaded` (not an empty
240        // slice) is how the model keeps that fact visible to the widget.
241        let empty = tree.root_ids()[1];
242        assert_eq!(tree.children(empty), TreeChildren::Unloaded);
243    }
244
245    #[test]
246    fn unwalked_directories_report_unloaded_children() {
247        let dir = fixture();
248        let tree = scan(dir.path(), false).unwrap();
249        let b_dir = tree.root_ids()[0];
250        assert_eq!(tree.children(b_dir), TreeChildren::Unloaded);
251    }
252
253    #[test]
254    fn branches_lists_expandable_dirs() {
255        let dir = fixture();
256        let tree = scan_all(dir.path());
257        let names: Vec<String> = tree.branches().map(|(id, _)| tree.name(id)).collect();
258        assert_eq!(names, ["b-dir", "empty-dir"]);
259    }
260}