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                    _ => None,
97                };
98
99                if let Some(parser) = parser {
100                    if let Ok(Some(runner)) = parser.parse(path) {
101                        if !runner.tasks.is_empty() && tx.send(runner).is_err() {
102                            return WalkState::Quit;
103                        }
104                    }
105                }
106
107                WalkState::Continue
108            })
109        });
110    })
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use std::fs;
117    use tempfile::TempDir;
118
119    #[test]
120    fn test_scan_empty_dir() {
121        let dir = TempDir::new().unwrap();
122        let runners = scan(dir.path()).unwrap();
123        assert!(runners.is_empty());
124    }
125
126    #[test]
127    fn test_scan_respects_gitignore() {
128        use std::process::Command;
129
130        let dir = TempDir::new().unwrap();
131
132        // Initialize a git repo (required for .gitignore to work)
133        Command::new("git")
134            .args(["init"])
135            .current_dir(dir.path())
136            .output()
137            .ok();
138
139        // Create a .gitignore that ignores the ignored/ directory
140        fs::write(dir.path().join(".gitignore"), "ignored/\n").unwrap();
141
142        // Create a package.json in ignored/ (should be ignored)
143        let ignored_dir = dir.path().join("ignored");
144        fs::create_dir_all(&ignored_dir).unwrap();
145        fs::write(
146            ignored_dir.join("package.json"),
147            r#"{"scripts": {"test": "echo test"}}"#,
148        )
149        .unwrap();
150
151        // Create a package.json at root (should be found)
152        fs::write(
153            dir.path().join("package.json"),
154            r#"{"scripts": {"build": "echo build"}}"#,
155        )
156        .unwrap();
157
158        let runners = scan(dir.path()).unwrap();
159        assert_eq!(runners.len(), 1);
160        assert!(runners[0]
161            .config_path
162            .to_string_lossy()
163            .contains("package.json"));
164    }
165
166    #[test]
167    fn test_scan_no_ignore() {
168        let dir = TempDir::new().unwrap();
169
170        // Create a .gitignore that ignores the ignored/ directory
171        fs::write(dir.path().join(".gitignore"), "ignored/\n").unwrap();
172
173        // Create a package.json in ignored/
174        let ignored_dir = dir.path().join("ignored");
175        fs::create_dir_all(&ignored_dir).unwrap();
176        fs::write(
177            ignored_dir.join("package.json"),
178            r#"{"scripts": {"test": "echo test"}}"#,
179        )
180        .unwrap();
181
182        // Create a package.json at root
183        fs::write(
184            dir.path().join("package.json"),
185            r#"{"scripts": {"build": "echo build"}}"#,
186        )
187        .unwrap();
188
189        // With no_ignore, should find both
190        let options = ScanOptions {
191            no_ignore: true,
192            ..Default::default()
193        };
194        let runners = scan_with_options(dir.path(), options).unwrap();
195        assert_eq!(runners.len(), 2);
196    }
197}