Skip to main content

smux/
folder_search.rs

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}