speccoder_runtime/collection/
static_cfg.rs1use std::collections::{BTreeMap, BTreeSet};
8use std::path::Path;
9
10use speccoder_core::collection::{JsRule, JsRunner, PytestRule, RustPackage};
11
12#[must_use]
15pub fn files(root: &Path) -> BTreeSet<String> {
16 walk(root).0
17}
18
19const MAX_FILES: usize = 50_000;
21
22#[must_use]
25pub fn walk(root: &Path) -> (BTreeSet<String>, bool) {
26 let mut out = BTreeSet::new();
27 let walk = ignore::WalkBuilder::new(root)
28 .hidden(false)
29 .git_ignore(true)
30 .git_global(false)
31 .git_exclude(false)
32 .require_git(false)
33 .filter_entry(|e| {
34 let n = e.file_name().to_string_lossy();
35 !matches!(
36 n.as_ref(),
37 ".git" | "target" | "node_modules" | ".venv" | "vendor"
38 )
39 })
40 .build();
41 let mut seen = 0usize;
42 let mut truncated = false;
43 for e in walk.flatten() {
44 seen += 1;
45 if seen > MAX_FILES {
46 truncated = true;
47 break;
48 }
49 if e.path().is_file()
51 && let Ok(rel) = e.path().strip_prefix(root)
52 {
53 out.insert(rel.to_string_lossy().replace('\\', "/"));
54 }
55 }
56 (out, truncated)
57}
58
59fn read(root: &Path, rel: &str) -> Option<String> {
60 std::fs::read_to_string(root.join(rel)).ok()
61}
62
63#[must_use]
65pub fn rust_packages(root: &Path, files: &BTreeSet<String>) -> Vec<RustPackage> {
66 files
67 .iter()
68 .filter(|f| *f == "Cargo.toml" || f.ends_with("/Cargo.toml"))
69 .filter_map(|f| {
70 let table: toml::Table = toml::from_str(&read(root, f)?).ok()?;
71 let package = table.get("package")?.as_table()?;
72 let dir = f.trim_end_matches("Cargo.toml").trim_end_matches('/');
73 let at = |rel: &str| {
74 if dir.is_empty() {
75 rel.to_owned()
76 } else {
77 format!("{dir}/{rel}")
78 }
79 };
80 Some(RustPackage {
81 root: dir.to_owned(),
82 name: package.get("name")?.as_str()?.to_owned(),
83 autotests: package
84 .get("autotests")
85 .and_then(toml::Value::as_bool)
86 .unwrap_or(true),
87 tests: table
88 .get("test")
89 .and_then(toml::Value::as_array)
90 .into_iter()
91 .flatten()
92 .filter_map(|t| {
93 let name = t.get("name")?.as_str()?.to_owned();
94 let path = t.get("path").and_then(toml::Value::as_str).map_or_else(
96 || {
97 let main = format!("tests/{name}/main.rs");
98 if files.contains(&at(&main)) {
99 main
100 } else {
101 format!("tests/{name}.rs")
102 }
103 },
104 str::to_owned,
105 );
106 Some((name, path))
107 })
108 .collect(),
109 bins: table
110 .get("bin")
111 .and_then(toml::Value::as_array)
112 .into_iter()
113 .flatten()
114 .filter_map(|b| {
115 Some((
116 b.get("name")?.as_str()?.to_owned(),
117 b.get("path")?.as_str()?.to_owned(),
118 ))
119 })
120 .collect(),
121 lib: table
122 .get("lib")
123 .and_then(|l| l.get("path"))
124 .and_then(toml::Value::as_str)
125 .map(str::to_owned),
126 })
127 })
128 .collect()
129}
130
131#[must_use]
133pub fn owner<'a>(packages: &'a [RustPackage], path: &str) -> Option<&'a RustPackage> {
134 packages
135 .iter()
136 .filter(|p| p.root.is_empty() || path.starts_with(&format!("{}/", p.root)))
137 .max_by_key(|p| p.root.len())
138}
139
140fn words(v: &str) -> Vec<String> {
142 v.split_whitespace().map(str::to_owned).collect()
143}
144
145fn ini_section(text: &str, section: &str) -> Option<BTreeMap<String, String>> {
147 let mut out = BTreeMap::new();
148 let mut inside = false;
149 let mut found = false;
150 let mut last: Option<String> = None;
151 for line in text.lines() {
152 let t = line.trim();
153 if t.starts_with('[') && t.ends_with(']') {
154 inside = &t[1..t.len() - 1] == section;
155 found |= inside;
156 last = None;
157 continue;
158 }
159 if !inside || t.is_empty() || t.starts_with('#') || t.starts_with(';') {
160 continue;
161 }
162 if line.starts_with(char::is_whitespace)
163 && let Some(k) = &last
164 {
165 if let Some(v) = out.get_mut(k) {
166 let v: &mut String = v;
167 v.push(' ');
168 v.push_str(t);
169 }
170 continue;
171 }
172 if let Some((k, v)) = t.split_once('=') {
173 let k = k.trim().to_owned();
174 out.insert(k.clone(), v.trim().to_owned());
175 last = Some(k);
176 }
177 }
178 found.then_some(out)
179}
180
181#[must_use]
184pub fn uses_pytest(root: &Path, files: &BTreeSet<String>, check_argvs: &[Vec<String>]) -> bool {
185 check_argvs
186 .iter()
187 .any(|a| a.iter().any(|w| w.contains("pytest")))
188 || files.contains("pytest.ini")
189 || files.iter().any(|f| f.ends_with("conftest.py"))
190 || read(root, "pyproject.toml").is_some_and(|t| t.contains("[tool.pytest"))
191 || read(root, "setup.cfg").is_some_and(|t| t.contains("[tool:pytest]"))
192 || read(root, "tox.ini").is_some_and(|t| t.contains("[pytest]"))
193}
194
195#[must_use]
197pub fn pytest_rule(root: &Path) -> PytestRule {
198 let mut rule = PytestRule::default();
199 let from_ini = |file: &str, section: &str| {
200 read(root, file)
201 .and_then(|t| ini_section(&t, section))
202 .map(|m| (m, format!("{file} [{section}]")))
203 };
204 let found = from_ini("pytest.ini", "pytest")
205 .or_else(|| {
206 let text = read(root, "pyproject.toml")?;
207 let table: toml::Table = toml::from_str(&text).ok()?;
208 let opts = table
209 .get("tool")?
210 .get("pytest")?
211 .get("ini_options")?
212 .as_table()?;
213 let mut m = BTreeMap::new();
214 for (k, v) in opts {
215 let s = match v {
216 toml::Value::String(s) => s.clone(),
217 toml::Value::Array(a) => a
218 .iter()
219 .filter_map(toml::Value::as_str)
220 .collect::<Vec<_>>()
221 .join(" "),
222 _ => continue,
223 };
224 m.insert(k.clone(), s);
225 }
226 Some((m, "pyproject.toml [tool.pytest.ini_options]".to_owned()))
227 })
228 .or_else(|| from_ini("tox.ini", "pytest"))
229 .or_else(|| from_ini("setup.cfg", "tool:pytest"));
230 if let Some((m, source)) = found {
231 if let Some(v) = m.get("python_files") {
232 rule.python_files = words(v);
233 }
234 if let Some(v) = m.get("testpaths") {
235 rule.testpaths = words(v);
236 }
237 if let Some(v) = m.get("norecursedirs") {
238 rule.norecursedirs = words(v);
239 }
240 rule.source = source;
241 }
242 rule
243}
244
245#[derive(Debug, Clone)]
247pub struct JsPackage {
248 pub root: String,
250 pub runner: JsRunner,
252 pub rule: Result<JsRule, String>,
254}
255
256#[must_use]
259pub fn js_packages(
260 root: &Path,
261 files: &BTreeSet<String>,
262 check_argvs: &[Vec<String>],
263) -> Vec<JsPackage> {
264 let named = |r: &str| check_argvs.iter().any(|a| a.iter().any(|w| w.contains(r)));
265 files
266 .iter()
267 .filter(|f| *f == "package.json" || f.ends_with("/package.json"))
268 .filter_map(|f| {
269 let text = read(root, f)?;
270 let json: serde_json::Value = serde_json::from_str(&text).ok()?;
271 let dir = f
272 .trim_end_matches("package.json")
273 .trim_end_matches('/')
274 .to_owned();
275 let mentions = |r: &str| {
276 ["dependencies", "devDependencies"]
277 .iter()
278 .any(|k| json[k].get(r).is_some())
279 || json["scripts"].as_object().is_some_and(|s| {
280 s.values()
281 .any(|v| v.as_str().is_some_and(|v| v.contains(r)))
282 })
283 || json.get(r).is_some()
284 };
285 let at = |name: &str| {
286 if dir.is_empty() {
287 name.to_owned()
288 } else {
289 format!("{dir}/{name}")
290 }
291 };
292 let runner = if mentions("vitest") || (named("vitest") && !mentions("jest")) {
293 JsRunner::Vitest
294 } else if mentions("jest") || named("jest") {
295 JsRunner::Jest
296 } else {
297 return None;
298 };
299 let rule = match runner {
300 JsRunner::Vitest => {
301 let code = ["vitest.config", "vite.config", "vitest.workspace"]
302 .iter()
303 .flat_map(|b| {
304 ["ts", "js", "mjs", "cjs", "mts", "cts"]
305 .map(|e| at(&format!("{b}.{e}")))
306 })
307 .find(|c| files.contains(c));
308 match code {
309 Some(c) => Err(format!("`{c}` is code: its include is not read")),
310 None => Ok(JsRule {
311 runner,
312 include: runner.defaults(),
313 exclude: Vec::new(),
314 source: "vitest (default)".to_owned(),
315 }),
316 }
317 }
318 JsRunner::Jest => {
319 let code = ["ts", "js", "mjs", "cjs"]
320 .map(|e| at(&format!("jest.config.{e}")))
321 .into_iter()
322 .find(|c| files.contains(c));
323 let (cfg, source) = if let Some(c) = code {
324 return Some(JsPackage {
325 root: dir,
326 runner,
327 rule: Err(format!("`{c}` is code: its testMatch is not read")),
328 });
329 } else if let Some(j) = read(root, &at("jest.config.json"))
330 .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
331 {
332 (j, at("jest.config.json"))
333 } else {
334 (json["jest"].clone(), format!("{f} jest"))
335 };
336 if cfg.get("testRegex").is_some() {
337 Err(format!(
338 "{source} sets testRegex: speccoder reads testMatch only"
339 ))
340 } else {
341 let list = |k: &str| {
342 cfg[k].as_array().map(|a| {
343 a.iter()
344 .filter_map(|v| v.as_str().map(str::to_owned))
345 .collect::<Vec<_>>()
346 })
347 };
348 let include = list("testMatch");
349 let roots = list("roots").unwrap_or_default();
350 let mut include = include.clone().unwrap_or_else(|| runner.defaults());
351 if !roots.is_empty() {
352 let roots: Vec<String> = roots
354 .iter()
355 .map(|r| {
356 r.trim_start_matches("<rootDir>")
357 .trim_matches('/')
358 .to_owned()
359 })
360 .collect();
361 include = roots
362 .iter()
363 .flat_map(|r| {
364 include.iter().map(move |p| {
365 let p = p
366 .trim_start_matches("<rootDir>/")
367 .trim_start_matches("**/");
368 if r.is_empty() {
369 format!("**/{p}")
370 } else {
371 format!("{r}/**/{p}")
372 }
373 })
374 })
375 .collect();
376 }
377 let ignore = list("testPathIgnorePatterns").unwrap_or_default();
378 Ok(JsRule {
379 runner,
380 include,
381 exclude: ignore
382 .iter()
383 .map(|p| format!("**/{}/**", p.trim_matches('/')))
384 .collect(),
385 source: if cfg.get("testMatch").is_some() {
386 source
387 } else {
388 "jest (default)".to_owned()
389 },
390 })
391 }
392 }
393 };
394 Some(JsPackage {
395 root: dir,
396 runner,
397 rule,
398 })
399 })
400 .collect()
401}
402
403#[must_use]
405pub fn js_owner<'a>(packages: &'a [JsPackage], path: &str) -> Option<&'a JsPackage> {
406 packages
407 .iter()
408 .filter(|p| p.root.is_empty() || path.starts_with(&format!("{}/", p.root)))
409 .max_by_key(|p| p.root.len())
410}
411
412#[derive(Debug, Clone)]
414pub struct GoModule {
415 pub root: String,
417 pub path: String,
419}
420
421#[must_use]
423pub fn go_modules(root: &Path, files: &BTreeSet<String>) -> Vec<GoModule> {
424 files
425 .iter()
426 .filter(|f| *f == "go.mod" || f.ends_with("/go.mod"))
427 .filter_map(|f| {
428 let text = read(root, f)?;
429 let path = text
430 .lines()
431 .find_map(|l| l.trim().strip_prefix("module "))?
432 .trim()
433 .trim_matches('"')
434 .to_owned();
435 Some(GoModule {
436 root: f
437 .trim_end_matches("go.mod")
438 .trim_end_matches('/')
439 .to_owned(),
440 path,
441 })
442 })
443 .collect()
444}
445
446#[must_use]
448pub fn go_tags(check_argvs: &[Vec<String>]) -> BTreeSet<String> {
449 let mut out = BTreeSet::new();
450 for a in check_argvs {
451 for (i, w) in a.iter().enumerate() {
452 let v = w
453 .strip_prefix("-tags=")
454 .or_else(|| w.strip_prefix("--tags="))
455 .map(str::to_owned)
456 .or_else(|| {
457 (w == "-tags" || w == "--tags")
458 .then(|| a.get(i + 1).cloned())
459 .flatten()
460 });
461 for t in v.iter().flat_map(|v| v.split([',', ' '])) {
462 if !t.is_empty() {
463 out.insert(t.to_owned());
464 }
465 }
466 }
467 }
468 out
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474
475 #[test]
476 fn ini_sections_join_continuation_lines() {
477 let m = ini_section(
478 "[other]\nx = 1\n[pytest]\npython_files = check_*.py\n test_*.py\ntestpaths = tests\n",
479 "pytest",
480 )
481 .unwrap();
482 assert_eq!(words(&m["python_files"]), ["check_*.py", "test_*.py"]);
483 assert_eq!(m["testpaths"], "tests");
484 assert!(ini_section("[a]\n", "pytest").is_none());
485 }
486
487 #[test]
488 fn go_tags_are_read_in_both_forms() {
489 let t = go_tags(&[
490 vec!["go".into(), "test".into(), "-tags".into(), "a,b".into()],
491 vec!["go".into(), "test".into(), "-tags=c".into()],
492 ]);
493 assert_eq!(t.into_iter().collect::<Vec<_>>(), ["a", "b", "c"]);
494 }
495}