task_runner_detector/
scanner.rs1use std::path::Path;
4
5use ignore::WalkBuilder;
6
7use crate::parsers::{self, Parser};
8use crate::{ScanResult, TaskRunner};
9
10#[derive(Debug, Clone, Default)]
12pub struct ScanOptions {
13 pub max_depth: Option<usize>,
15 pub no_ignore: bool,
17}
18
19pub fn scan(root: impl AsRef<Path>) -> ScanResult<Vec<TaskRunner>> {
21 scan_with_options(root, ScanOptions::default())
22}
23
24pub 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 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 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 if !runner.tasks.is_empty() {
73 runners.push(runner);
74 }
75 }
76 Ok(None) => {
77 }
79 Err(e) => {
80 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 Command::new("git")
111 .args(["init"])
112 .current_dir(dir.path())
113 .output()
114 .ok();
115
116 fs::write(dir.path().join(".gitignore"), "ignored/\n").unwrap();
118
119 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 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 fs::write(dir.path().join(".gitignore"), "ignored/\n").unwrap();
149
150 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 fs::write(
161 dir.path().join("package.json"),
162 r#"{"scripts": {"build": "echo build"}}"#,
163 )
164 .unwrap();
165
166 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}