Skip to main content

ite_cli/
fstree.rs

1//! Filesystem scanner that transforms a directory into source-neutral tree data.
2
3use std::path::{Path, PathBuf};
4
5use crate::tree::{ActionValues, Node, NodeId, Tree};
6
7/// Scan `dir`, honoring ignore files unless `no_ignore` is set.
8///
9/// Dotfiles are always included.
10pub fn scan(dir: &Path, no_ignore: bool) -> std::io::Result<Tree> {
11    let _span = crate::profile::span("fstree::scan");
12    let root_dir = dir.canonicalize()?;
13    let mut tree = Tree::new();
14    let mut ids_by_path: std::collections::HashMap<PathBuf, NodeId> =
15        std::collections::HashMap::new();
16
17    let walk = ignore::WalkBuilder::new(&root_dir)
18        .standard_filters(!no_ignore)
19        .hidden(false)
20        .sort_by_file_name(|a, b| a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase()))
21        .build();
22    for entry in walk {
23        let Ok(entry) = entry else { continue };
24        if entry.depth() == 0 {
25            continue; // the scanned directory itself
26        }
27        let path = entry.path().to_path_buf();
28        let is_dir = entry.file_type().is_some_and(|t| t.is_dir());
29        let parent = path.parent().and_then(|p| ids_by_path.get(p)).copied();
30        let relpath = path.strip_prefix(&root_dir).unwrap_or(&path);
31        let action = ActionValues::new(path.as_os_str(), path.as_os_str(), relpath.as_os_str())
32            .with_alternate_output(entry.file_name());
33        let id = tree.push(parent, entry.file_name().to_string_lossy(), is_dir, action);
34        if is_dir {
35            ids_by_path.insert(path, id);
36        }
37    }
38
39    // The walker sorts alphabetically; reorder each sibling list to put
40    // directories first.
41    let dirs_first = |nodes: &[Node], ids: &mut Vec<NodeId>| {
42        ids.sort_by_key(|&id| !nodes[id].is_container);
43    };
44    let mut roots = std::mem::take(&mut tree.roots);
45    dirs_first(&tree.nodes, &mut roots);
46    tree.roots = roots;
47    for id in 0..tree.nodes.len() {
48        let mut children = std::mem::take(&mut tree.nodes[id].children);
49        dirs_first(&tree.nodes, &mut children);
50        tree.nodes[id].children = children;
51    }
52    Ok(tree)
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58    use tui_treelistview::{TreeChildren, TreeModel};
59
60    /// Builds:
61    ///   root/
62    ///     .hidden-file
63    ///     b-dir/
64    ///       inner.txt
65    ///     empty-dir/
66    ///     a-file.txt
67    ///     z-file.txt
68    ///     ignored.log     (matched by .ignore)
69    fn fixture() -> tempfile::TempDir {
70        let dir = tempfile::tempdir().unwrap();
71        let p = dir.path();
72        std::fs::write(p.join(".hidden-file"), "").unwrap();
73        std::fs::create_dir(p.join("b-dir")).unwrap();
74        std::fs::write(p.join("b-dir/inner.txt"), "").unwrap();
75        std::fs::create_dir(p.join("empty-dir")).unwrap();
76        std::fs::write(p.join("a-file.txt"), "").unwrap();
77        std::fs::write(p.join("z-file.txt"), "").unwrap();
78        std::fs::write(p.join("ignored.log"), "").unwrap();
79        std::fs::write(p.join(".ignore"), "*.log\n").unwrap();
80        dir
81    }
82
83    fn root_names(tree: &Tree) -> Vec<String> {
84        tree.root_ids()
85            .iter()
86            .map(|&id| tree.node(id).name.clone())
87            .collect()
88    }
89
90    #[test]
91    fn default_scan_shows_dotfiles_but_honors_ignore_files() {
92        let dir = fixture();
93        let tree = scan(dir.path(), false).unwrap();
94        // Dirs come first, then files, each sorted case-insensitively.
95        assert_eq!(
96            root_names(&tree),
97            [
98                "b-dir",
99                "empty-dir",
100                ".hidden-file",
101                ".ignore",
102                "a-file.txt",
103                "z-file.txt",
104            ]
105        );
106        assert!(!root_names(&tree).contains(&"ignored.log".to_string()));
107    }
108
109    #[test]
110    fn no_ignore_reveals_ignored_files() {
111        let dir = fixture();
112        let tree = scan(dir.path(), true).unwrap();
113        let names = root_names(&tree);
114        assert!(names.contains(&".hidden-file".to_string()));
115        assert!(names.contains(&"ignored.log".to_string()));
116    }
117
118    #[test]
119    fn children_and_depth() {
120        let dir = fixture();
121        let tree = scan(dir.path(), false).unwrap();
122        let b_dir = tree.root_ids()[0];
123        assert_eq!(tree.node(b_dir).name, "b-dir");
124        assert_eq!(tree.node(b_dir).depth, 0);
125        let kids = tree.node(b_dir).children.clone();
126        assert_eq!(kids.len(), 1);
127        assert_eq!(tree.node(kids[0]).name, "inner.txt");
128        assert_eq!(tree.node(kids[0]).depth, 1);
129        assert_eq!(tree.node(kids[0]).parent, Some(b_dir));
130    }
131
132    #[test]
133    fn paths_are_absolute_and_relative() {
134        let dir = fixture();
135        let tree = scan(dir.path(), false).unwrap();
136        let b_dir = tree.root_ids()[0];
137        let inner = tree.node(b_dir).children[0];
138        assert!(Path::new(&tree.node(inner).action.path).is_absolute());
139        assert!(Path::new(&tree.node(inner).action.path).ends_with("b-dir/inner.txt"));
140        assert_eq!(
141            tree.node(inner).action.relpath,
142            Path::new("b-dir/inner.txt").as_os_str()
143        );
144        assert_eq!(
145            tree.node(inner).action.alternate_output,
146            std::ffi::OsStr::new("inner.txt")
147        );
148    }
149
150    #[test]
151    fn leaf_classification() {
152        let dir = fixture();
153        let tree = scan(dir.path(), false).unwrap();
154        let by_name = |name: &str| {
155            tree.root_ids()
156                .iter()
157                .copied()
158                .find(|&id| tree.node(id).name == name)
159                .unwrap()
160        };
161        assert!(!tree.is_leaf(by_name("b-dir")));
162        // An empty directory cannot be expanded, so it is a leaf.
163        assert!(tree.is_leaf(by_name("empty-dir")));
164        assert!(tree.is_leaf(by_name("a-file.txt")));
165    }
166
167    #[test]
168    fn tree_model_children_match_nodes() {
169        let dir = fixture();
170        let tree = scan(dir.path(), false).unwrap();
171        let b_dir = tree.root_ids()[0];
172        match tree.children(b_dir) {
173            TreeChildren::Loaded(kids) => assert_eq!(kids, tree.node(b_dir).children.as_slice()),
174            other => panic!("expected Loaded, got {other:?}"),
175        }
176        let empty = tree.root_ids()[1];
177        assert_eq!(tree.children(empty), TreeChildren::Leaf);
178    }
179
180    #[test]
181    fn branches_lists_expandable_dirs() {
182        let dir = fixture();
183        let tree = scan(dir.path(), false).unwrap();
184        let names: Vec<&str> = tree
185            .branches()
186            .map(|(id, _)| tree.node(id).name.as_str())
187            .collect();
188        assert_eq!(names, ["b-dir"]);
189    }
190}