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