task_runner_detector/
scanner.rs1use 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#[derive(Debug, Clone, Default)]
14pub struct ScanOptions {
15 pub max_depth: Option<usize>,
17 pub no_ignore: bool,
19}
20
21pub fn scan(root: impl AsRef<Path>) -> ScanResult<Vec<TaskRunner>> {
23 scan_with_options(root, ScanOptions::default())
24}
25
26pub 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 let runners: Vec<TaskRunner> = rx.into_iter().collect();
41
42 handle.join().ok();
44
45 Ok(runners)
46}
47
48pub 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 Command::new("git")
141 .args(["init"])
142 .current_dir(dir.path())
143 .output()
144 .ok();
145
146 fs::write(dir.path().join(".gitignore"), "ignored/\n").unwrap();
148
149 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 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 fs::write(dir.path().join(".gitignore"), "ignored/\n").unwrap();
179
180 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 fs::write(
191 dir.path().join("package.json"),
192 r#"{"scripts": {"build": "echo build"}}"#,
193 )
194 .unwrap();
195
196 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}