1use std::ffi::OsStr;
12use std::path::{Path, PathBuf};
13
14use crate::tree::{NodeId, Tree};
15
16pub 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
30pub(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
39fn 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; }
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 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 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 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 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 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}