1use std::collections::HashSet;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use crate::config::FolderSearchSettings;
6use crate::util;
7
8const SKIPPED_DIRECTORY_NAMES: &[&str] = &[
9 "node_modules",
10 "target",
11 "vendor",
12 "dist",
13 "build",
14 ".git",
15 ".direnv",
16 ".cache",
17];
18const SKIPPED_ROOT_CHILD_NAMES: &[&str] = &["Library"];
19
20#[derive(Debug, Clone, Default, Eq, PartialEq)]
21pub struct FolderSearchResult {
22 pub directories: Vec<String>,
23 pub warnings: Vec<FolderSearchWarning>,
24}
25
26#[derive(Debug, Clone, Eq, PartialEq)]
27pub struct FolderSearchWarning {
28 pub root: String,
29 pub message: String,
30}
31
32pub fn list_directories(settings: &FolderSearchSettings) -> FolderSearchResult {
33 let mut result = FolderSearchResult::default();
34 let mut seen = HashSet::new();
35
36 for root in &settings.roots {
37 let expanded = util::expand_tilde_path(Path::new(root));
38 let Ok(root_path) = expanded.canonicalize() else {
39 result.warnings.push(FolderSearchWarning {
40 root: root.clone(),
41 message: format!(
42 "failed to resolve folder search root {}",
43 expanded.display()
44 ),
45 });
46 continue;
47 };
48
49 walk_directory(
50 root,
51 &root_path,
52 0,
53 settings.max_depth,
54 settings.include_hidden,
55 &mut seen,
56 &mut result,
57 );
58 }
59
60 result.directories.sort();
61 result
62}
63
64fn walk_directory(
65 root_label: &str,
66 directory: &Path,
67 depth: usize,
68 max_depth: usize,
69 include_hidden: bool,
70 seen: &mut HashSet<PathBuf>,
71 result: &mut FolderSearchResult,
72) {
73 if !seen.insert(directory.to_path_buf()) {
74 return;
75 }
76
77 if let Ok(path) = util::path_to_string(directory) {
78 result.directories.push(path);
79 }
80
81 if depth >= max_depth {
82 return;
83 }
84
85 let entries = match fs::read_dir(directory) {
86 Ok(entries) => entries,
87 Err(error) => {
88 result.warnings.push(FolderSearchWarning {
89 root: root_label.to_owned(),
90 message: format!("failed to read {}: {error}", directory.display()),
91 });
92 return;
93 }
94 };
95
96 for entry in entries.flatten() {
97 let file_type = match entry.file_type() {
98 Ok(file_type) => file_type,
99 Err(_) => continue,
100 };
101 if !file_type.is_dir() {
102 continue;
103 }
104
105 let path = entry.path();
106 if should_skip_child(&path, depth, include_hidden) {
107 continue;
108 }
109
110 let Ok(path) = path.canonicalize() else {
111 continue;
112 };
113 walk_directory(
114 root_label,
115 &path,
116 depth + 1,
117 max_depth,
118 include_hidden,
119 seen,
120 result,
121 );
122 }
123}
124
125fn should_skip_child(path: &Path, parent_depth: usize, include_hidden: bool) -> bool {
126 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
127 return false;
128 };
129
130 (!include_hidden && name.starts_with('.'))
131 || SKIPPED_DIRECTORY_NAMES.contains(&name)
132 || (parent_depth == 0 && SKIPPED_ROOT_CHILD_NAMES.contains(&name))
133}
134
135#[cfg(test)]
136mod tests {
137 use std::fs;
138
139 use crate::config::FolderSearchSettings;
140
141 #[test]
142 fn finds_directories_under_configured_roots() {
143 let tempdir = tempfile::tempdir().expect("tempdir should be created");
144 fs::create_dir(tempdir.path().join("app")).expect("app dir should be created");
145 fs::create_dir_all(tempdir.path().join("app").join("src"))
146 .expect("nested dir should be created");
147
148 let result = super::list_directories(&FolderSearchSettings {
149 roots: vec![tempdir.path().display().to_string()],
150 max_depth: 1,
151 include_hidden: false,
152 });
153
154 assert!(result.warnings.is_empty());
155 assert!(
156 result
157 .directories
158 .contains(&tempdir.path().canonicalize().unwrap().display().to_string())
159 );
160 assert!(
161 result.directories.contains(
162 &tempdir
163 .path()
164 .join("app")
165 .canonicalize()
166 .unwrap()
167 .display()
168 .to_string()
169 )
170 );
171 assert!(
172 !result.directories.contains(
173 &tempdir
174 .path()
175 .join("app")
176 .join("src")
177 .canonicalize()
178 .unwrap()
179 .display()
180 .to_string()
181 )
182 );
183 }
184
185 #[test]
186 fn skips_hidden_directories_by_default() {
187 let tempdir = tempfile::tempdir().expect("tempdir should be created");
188 fs::create_dir(tempdir.path().join(".hidden")).expect("hidden dir should be created");
189
190 let result = super::list_directories(&FolderSearchSettings {
191 roots: vec![tempdir.path().display().to_string()],
192 max_depth: 1,
193 include_hidden: false,
194 });
195
196 assert!(
197 !result.directories.contains(
198 &tempdir
199 .path()
200 .join(".hidden")
201 .canonicalize()
202 .unwrap()
203 .display()
204 .to_string()
205 )
206 );
207 }
208
209 #[test]
210 fn skips_common_heavy_directories() {
211 let tempdir = tempfile::tempdir().expect("tempdir should be created");
212 fs::create_dir(tempdir.path().join("target")).expect("target dir should be created");
213
214 let result = super::list_directories(&FolderSearchSettings {
215 roots: vec![tempdir.path().display().to_string()],
216 max_depth: 1,
217 include_hidden: true,
218 });
219
220 assert!(
221 !result.directories.contains(
222 &tempdir
223 .path()
224 .join("target")
225 .canonicalize()
226 .unwrap()
227 .display()
228 .to_string()
229 )
230 );
231 }
232
233 #[test]
234 fn reports_missing_roots_without_failing() {
235 let tempdir = tempfile::tempdir().expect("tempdir should be created");
236 let missing = tempdir.path().join("missing");
237
238 let result = super::list_directories(&FolderSearchSettings {
239 roots: vec![missing.display().to_string()],
240 max_depth: 1,
241 include_hidden: false,
242 });
243
244 assert!(result.directories.is_empty());
245 assert_eq!(result.warnings.len(), 1);
246 }
247}