Skip to main content

sniff/
walker.rs

1use crate::config::ResolvedConfig;
2use ignore::WalkBuilder;
3use std::path::Path;
4
5#[path = "walker_filters.rs"]
6mod filters;
7
8fn walk_directory(
9    root: &Path,
10    config: &ResolvedConfig,
11    file_paths: &mut Vec<String>,
12    include_surface_files: bool,
13) -> Result<(), String> {
14    let ignored = config.ignore.clone();
15    let mut builder = WalkBuilder::new(root);
16    builder.standard_filters(true);
17    builder.follow_links(false);
18    builder.filter_entry(move |entry| filters::should_descend(entry, &ignored));
19
20    for result in builder.build() {
21        match result {
22            Ok(entry) => {
23                let path = entry.path();
24                if path.is_file() && filters::should_keep_file(path, config, include_surface_files)
25                {
26                    file_paths.push(path.to_string_lossy().replace("\\\\?\\", ""));
27                }
28            }
29            Err(err) => {
30                return Err(format!("failed to read directory entry: {err}"));
31            }
32        }
33    }
34
35    Ok(())
36}
37
38pub fn walk(root_path: &str, config: &ResolvedConfig) -> Result<Vec<String>, String> {
39    let mut file_paths = Vec::new();
40    let root_buf = std::fs::canonicalize(root_path)
41        .map_err(|err| format!("failed to resolve scan target {root_path}: {err}"))?;
42    let root = root_buf.as_path();
43
44    if !root.exists() {
45        return Err(format!("scan target does not exist: {root_path}"));
46    }
47
48    if root.is_file() {
49        if filters::should_keep_file(root, config, true) {
50            file_paths.push(root.to_string_lossy().replace("\\\\?\\", ""));
51        }
52        return Ok(file_paths);
53    }
54
55    walk_directory(
56        root,
57        config,
58        &mut file_paths,
59        filters::is_explicit_surface_root(root),
60    )?;
61
62    file_paths.sort();
63    Ok(file_paths)
64}
65
66#[cfg(test)]
67mod tests {
68    use super::walk;
69    use crate::config::ResolvedConfig;
70    use std::fs;
71    use std::time::{SystemTime, UNIX_EPOCH};
72
73    #[test]
74    fn ignored_directory_names_are_pruned_without_substring_overmatch() {
75        let unique = SystemTime::now()
76            .duration_since(UNIX_EPOCH)
77            .unwrap()
78            .as_nanos();
79        let root = std::env::temp_dir().join(format!("sniff-walker-test-{unique}"));
80        let output_dir = root.join("output");
81        let next_dir = root.join(".next");
82        let src_dir = root.join("src");
83        fs::create_dir_all(&output_dir).unwrap();
84        fs::create_dir_all(&next_dir).unwrap();
85        fs::create_dir_all(&src_dir).unwrap();
86
87        fs::write(output_dir.join("main.ts"), "export function ignored() {}\n").unwrap();
88        fs::write(next_dir.join("chunk.ts"), "export function ignored() {}\n").unwrap();
89        fs::write(
90            src_dir.join("output_parser.ts"),
91            "export function kept() {}\n",
92        )
93        .unwrap();
94
95        let files = walk(root.to_str().unwrap(), &ResolvedConfig::default()).unwrap();
96        assert!(
97            files.iter().any(|path| path.contains("output_parser.ts")),
98            "expected non-ignored file to remain visible: {files:?}"
99        );
100        assert!(
101            !files.iter().any(|path| path.contains("output\\main.ts")),
102            "expected ignored output directory to be pruned: {files:?}"
103        );
104        assert!(
105            !files.iter().any(|path| path.contains(".next\\chunk.ts")),
106            "expected ignored .next directory to be pruned: {files:?}"
107        );
108
109        let _ = fs::remove_dir_all(&root);
110    }
111
112    #[test]
113    fn gold_fixture_corpus_paths_are_pruned_from_walk_results() {
114        let unique = SystemTime::now()
115            .duration_since(UNIX_EPOCH)
116            .unwrap()
117            .as_nanos();
118        let root = std::env::temp_dir().join(format!("sniff-fixture-walk-{unique}"));
119        let fixtures_dir = root.join("gold_fixtures").join("repo").join("go");
120        fs::create_dir_all(&fixtures_dir).unwrap();
121        fs::write(
122            fixtures_dir.join("math.go"),
123            "package main\n\nfunc ProcessData(values []string) []string { return values }\n",
124        )
125        .unwrap();
126
127        let files = walk(root.to_str().unwrap(), &ResolvedConfig::default()).unwrap();
128        assert!(
129            files.is_empty(),
130            "expected fixture corpus files to be skipped from walks: {files:?}"
131        );
132
133        let _ = fs::remove_dir_all(&root);
134    }
135
136    #[test]
137    fn explicit_fixture_directory_is_scannable() {
138        let unique = SystemTime::now()
139            .duration_since(UNIX_EPOCH)
140            .unwrap()
141            .as_nanos();
142        let root = std::env::temp_dir().join(format!("sniff-explicit-fixture-{unique}"));
143        let fixture = root.join("tests").join("fixtures");
144        fs::create_dir_all(&fixture).unwrap();
145        fs::write(
146            fixture.join("example.py"),
147            "def example(value):\n    return value\n",
148        )
149        .unwrap();
150
151        let files = walk(fixture.to_str().unwrap(), &ResolvedConfig::default()).unwrap();
152        assert_eq!(files.len(), 1, "explicit fixture target should be scanned");
153        assert!(files[0].ends_with("example.py"));
154
155        let _ = fs::remove_dir_all(&root);
156    }
157
158    #[test]
159    fn missing_scan_targets_return_an_error() {
160        let unique = SystemTime::now()
161            .duration_since(UNIX_EPOCH)
162            .unwrap()
163            .as_nanos();
164        let root = std::env::temp_dir().join(format!("sniff-missing-walk-{unique}"));
165
166        let err = walk(root.to_str().unwrap(), &ResolvedConfig::default())
167            .expect_err("missing scan target should fail explicitly");
168        assert!(err.contains("failed to resolve scan target"), "{err}");
169    }
170}