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 _ => 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 Command::new("git")
134 .args(["init"])
135 .current_dir(dir.path())
136 .output()
137 .ok();
138
139 fs::write(dir.path().join(".gitignore"), "ignored/\n").unwrap();
141
142 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 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 fs::write(dir.path().join(".gitignore"), "ignored/\n").unwrap();
172
173 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 fs::write(
184 dir.path().join("package.json"),
185 r#"{"scripts": {"build": "echo build"}}"#,
186 )
187 .unwrap();
188
189 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}