task_runner_detector/
scanner.rs

1//! Directory scanner for task runner config files
2
3use std::path::{Path, PathBuf};
4use std::sync::mpsc::Sender;
5use std::thread::{self, JoinHandle};
6
7use ignore::{WalkBuilder, WalkState};
8
9use crate::parsers::{self, Parser};
10use crate::{ScanResult, TaskRunner};
11
12/// Options for customizing the scan behavior
13#[derive(Debug, Clone, Default)]
14pub struct ScanOptions {
15    /// Maximum depth to traverse (None = unlimited)
16    pub max_depth: Option<usize>,
17    /// If true, ignore .gitignore and scan all files
18    pub no_ignore: bool,
19}
20
21/// Scan a directory tree for task runners using default options
22pub fn scan(root: impl AsRef<Path>) -> ScanResult<Vec<TaskRunner>> {
23    scan_with_options(root, ScanOptions::default())
24}
25
26/// Scan a directory tree for task runners with custom options.
27/// Uses scan_streaming internally and collects results.
28pub fn scan_with_options(
29    root: impl AsRef<Path>,
30    options: ScanOptions,
31) -> ScanResult<Vec<TaskRunner>> {
32    use std::sync::mpsc;
33
34    let root = root.as_ref().to_path_buf();
35    let (tx, rx) = mpsc::channel();
36
37    let handle = scan_streaming(root, options, tx);
38
39    // Collect all results
40    let runners: Vec<TaskRunner> = rx.into_iter().collect();
41
42    // Wait for scanner to finish
43    handle.join().ok();
44
45    Ok(runners)
46}
47
48/// Scan a directory tree for task runners, streaming results through a channel.
49/// Uses parallel walking for better performance on large directories.
50/// Returns a JoinHandle that completes when scanning is done.
51pub fn scan_streaming(
52    root: PathBuf,
53    options: ScanOptions,
54    tx: Sender<TaskRunner>,
55) -> JoinHandle<()> {
56    thread::spawn(move || {
57        let mut builder = WalkBuilder::new(&root);
58        builder.follow_links(false);
59        builder.standard_filters(!options.no_ignore);
60
61        if let Some(max_depth) = options.max_depth {
62            builder.max_depth(Some(max_depth));
63        }
64
65        builder.build_parallel().run(|| {
66            let tx = tx.clone();
67            Box::new(move |result| {
68                let entry = match result {
69                    Ok(e) => e,
70                    Err(_) => return WalkState::Continue,
71                };
72
73                if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
74                    return WalkState::Continue;
75                }
76
77                let path = entry.path();
78                let file_name = match path.file_name() {
79                    Some(name) => name.to_string_lossy(),
80                    None => return WalkState::Continue,
81                };
82
83                let parser: Option<Box<dyn Parser>> = match file_name.as_ref() {
84                    "package.json" => Some(Box::new(parsers::PackageJsonParser)),
85                    "Makefile" | "makefile" | "GNUmakefile" => {
86                        Some(Box::new(parsers::MakefileParser))
87                    }
88                    "Cargo.toml" => Some(Box::new(parsers::CargoTomlParser)),
89                    "pubspec.yaml" => Some(Box::new(parsers::PubspecYamlParser)),
90                    "turbo.json" => Some(Box::new(parsers::TurboJsonParser)),
91                    "pyproject.toml" => Some(Box::new(parsers::PyprojectTomlParser)),
92                    "justfile" | "Justfile" | ".justfile" => {
93                        Some(Box::new(parsers::JustfileParser))
94                    }
95                    "deno.json" | "deno.jsonc" => Some(Box::new(parsers::DenoJsonParser)),
96                    "pom.xml" => Some(Box::new(parsers::PomXmlParser)),
97                    name if name.ends_with(".csproj")
98                        || name.ends_with(".fsproj")
99                        || name.ends_with(".vbproj") =>
100                    {
101                        Some(Box::new(parsers::CsprojParser))
102                    }
103                    _ => None,
104                };
105
106                if let Some(parser) = parser {
107                    if let Ok(Some(runner)) = parser.parse(path) {
108                        if !runner.tasks.is_empty() && tx.send(runner).is_err() {
109                            return WalkState::Quit;
110                        }
111                    }
112                }
113
114                WalkState::Continue
115            })
116        });
117    })
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use std::fs;
124    use tempfile::TempDir;
125
126    #[test]
127    fn test_scan_empty_dir() {
128        let dir = TempDir::new().unwrap();
129        let runners = scan(dir.path()).unwrap();
130        assert!(runners.is_empty());
131    }
132
133    #[test]
134    fn test_scan_respects_gitignore() {
135        use std::process::Command;
136
137        let dir = TempDir::new().unwrap();
138
139        // Initialize a git repo (required for .gitignore to work)
140        Command::new("git")
141            .args(["init"])
142            .current_dir(dir.path())
143            .output()
144            .ok();
145
146        // Create a .gitignore that ignores the ignored/ directory
147        fs::write(dir.path().join(".gitignore"), "ignored/\n").unwrap();
148
149        // Create a package.json in ignored/ (should be ignored)
150        let ignored_dir = dir.path().join("ignored");
151        fs::create_dir_all(&ignored_dir).unwrap();
152        fs::write(
153            ignored_dir.join("package.json"),
154            r#"{"scripts": {"test": "echo test"}}"#,
155        )
156        .unwrap();
157
158        // Create a package.json at root (should be found)
159        fs::write(
160            dir.path().join("package.json"),
161            r#"{"scripts": {"build": "echo build"}}"#,
162        )
163        .unwrap();
164
165        let runners = scan(dir.path()).unwrap();
166        assert_eq!(runners.len(), 1);
167        assert!(runners[0]
168            .config_path
169            .to_string_lossy()
170            .contains("package.json"));
171    }
172
173    #[test]
174    fn test_scan_no_ignore() {
175        let dir = TempDir::new().unwrap();
176
177        // Create a .gitignore that ignores the ignored/ directory
178        fs::write(dir.path().join(".gitignore"), "ignored/\n").unwrap();
179
180        // Create a package.json in ignored/
181        let ignored_dir = dir.path().join("ignored");
182        fs::create_dir_all(&ignored_dir).unwrap();
183        fs::write(
184            ignored_dir.join("package.json"),
185            r#"{"scripts": {"test": "echo test"}}"#,
186        )
187        .unwrap();
188
189        // Create a package.json at root
190        fs::write(
191            dir.path().join("package.json"),
192            r#"{"scripts": {"build": "echo build"}}"#,
193        )
194        .unwrap();
195
196        // With no_ignore, should find both
197        let options = ScanOptions {
198            no_ignore: true,
199            ..Default::default()
200        };
201        let runners = scan_with_options(dir.path(), options).unwrap();
202        assert_eq!(runners.len(), 2);
203    }
204}