task_runner_detector/
scanner.rs

1//! Directory scanner for task runner config files
2
3use std::path::Path;
4
5use ignore::WalkBuilder;
6
7use crate::parsers::{self, Parser};
8use crate::{ScanResult, TaskRunner};
9
10/// Options for customizing the scan behavior
11#[derive(Debug, Clone, Default)]
12pub struct ScanOptions {
13    /// Maximum depth to traverse (None = unlimited)
14    pub max_depth: Option<usize>,
15    /// If true, ignore .gitignore and scan all files
16    pub no_ignore: bool,
17}
18
19/// Scan a directory tree for task runners using default options
20pub fn scan(root: impl AsRef<Path>) -> ScanResult<Vec<TaskRunner>> {
21    scan_with_options(root, ScanOptions::default())
22}
23
24/// Scan a directory tree for task runners with custom options
25pub fn scan_with_options(
26    root: impl AsRef<Path>,
27    options: ScanOptions,
28) -> ScanResult<Vec<TaskRunner>> {
29    let root = root.as_ref();
30    let mut runners = Vec::new();
31
32    let mut builder = WalkBuilder::new(root);
33    builder.follow_links(false);
34    builder.standard_filters(!options.no_ignore);
35
36    if let Some(max_depth) = options.max_depth {
37        builder.max_depth(Some(max_depth));
38    }
39
40    for result in builder.build() {
41        let entry = result?;
42
43        // Only process files
44        let is_file = entry.file_type().map(|ft| ft.is_file()).unwrap_or(false);
45        if !is_file {
46            continue;
47        }
48
49        let path = entry.path();
50        let file_name = match path.file_name() {
51            Some(name) => name.to_string_lossy(),
52            None => continue,
53        };
54
55        // Match config files and parse them
56        let parser: Option<Box<dyn Parser>> = match file_name.as_ref() {
57            "package.json" => Some(Box::new(parsers::PackageJsonParser)),
58            "Makefile" | "makefile" | "GNUmakefile" => Some(Box::new(parsers::MakefileParser)),
59            "Cargo.toml" => Some(Box::new(parsers::CargoTomlParser)),
60            "pubspec.yaml" => Some(Box::new(parsers::PubspecYamlParser)),
61            "turbo.json" => Some(Box::new(parsers::TurboJsonParser)),
62            "pyproject.toml" => Some(Box::new(parsers::PyprojectTomlParser)),
63            "justfile" | "Justfile" | ".justfile" => Some(Box::new(parsers::JustfileParser)),
64            "deno.json" | "deno.jsonc" => Some(Box::new(parsers::DenoJsonParser)),
65            _ => None,
66        };
67
68        if let Some(parser) = parser {
69            match parser.parse(path) {
70                Ok(Some(runner)) => {
71                    // Only add if there are tasks
72                    if !runner.tasks.is_empty() {
73                        runners.push(runner);
74                    }
75                }
76                Ok(None) => {
77                    // Parser decided this file doesn't have relevant tasks
78                }
79                Err(e) => {
80                    // Log but don't fail on parse errors - continue scanning
81                    eprintln!("Warning: Failed to parse {}: {}", path.display(), e);
82                }
83            }
84        }
85    }
86
87    Ok(runners)
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use std::fs;
94    use tempfile::TempDir;
95
96    #[test]
97    fn test_scan_empty_dir() {
98        let dir = TempDir::new().unwrap();
99        let runners = scan(dir.path()).unwrap();
100        assert!(runners.is_empty());
101    }
102
103    #[test]
104    fn test_scan_respects_gitignore() {
105        use std::process::Command;
106
107        let dir = TempDir::new().unwrap();
108
109        // Initialize a git repo (required for .gitignore to work)
110        Command::new("git")
111            .args(["init"])
112            .current_dir(dir.path())
113            .output()
114            .ok();
115
116        // Create a .gitignore that ignores the ignored/ directory
117        fs::write(dir.path().join(".gitignore"), "ignored/\n").unwrap();
118
119        // Create a package.json in ignored/ (should be ignored)
120        let ignored_dir = dir.path().join("ignored");
121        fs::create_dir_all(&ignored_dir).unwrap();
122        fs::write(
123            ignored_dir.join("package.json"),
124            r#"{"scripts": {"test": "echo test"}}"#,
125        )
126        .unwrap();
127
128        // Create a package.json at root (should be found)
129        fs::write(
130            dir.path().join("package.json"),
131            r#"{"scripts": {"build": "echo build"}}"#,
132        )
133        .unwrap();
134
135        let runners = scan(dir.path()).unwrap();
136        assert_eq!(runners.len(), 1);
137        assert!(runners[0]
138            .config_path
139            .to_string_lossy()
140            .contains("package.json"));
141    }
142
143    #[test]
144    fn test_scan_no_ignore() {
145        let dir = TempDir::new().unwrap();
146
147        // Create a .gitignore that ignores the ignored/ directory
148        fs::write(dir.path().join(".gitignore"), "ignored/\n").unwrap();
149
150        // Create a package.json in ignored/
151        let ignored_dir = dir.path().join("ignored");
152        fs::create_dir_all(&ignored_dir).unwrap();
153        fs::write(
154            ignored_dir.join("package.json"),
155            r#"{"scripts": {"test": "echo test"}}"#,
156        )
157        .unwrap();
158
159        // Create a package.json at root
160        fs::write(
161            dir.path().join("package.json"),
162            r#"{"scripts": {"build": "echo build"}}"#,
163        )
164        .unwrap();
165
166        // With no_ignore, should find both
167        let options = ScanOptions {
168            no_ignore: true,
169            ..Default::default()
170        };
171        let runners = scan_with_options(dir.path(), options).unwrap();
172        assert_eq!(runners.len(), 2);
173    }
174}