Skip to main content

speccoder_runtime/collection/
static_cfg.rs

1//! The collection rules as the project configures them, read from static
2//! files only: Cargo manifests, pytest's ini/toml sections, `package.json`
3//! and JSON runner configs, `go.mod`. A configuration that is code (a
4//! `vitest.config.ts`) is not evaluated: the rule is unknown there and the
5//! runner's listing decides.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::path::Path;
9
10use speccoder_core::collection::{JsRule, JsRunner, PytestRule, RustPackage};
11
12/// The files of the checkout (workspace-relative, `/`-separated), build
13/// output and dependency directories left out.
14#[must_use]
15pub fn files(root: &Path) -> BTreeSet<String> {
16    walk(root).0
17}
18
19/// The most files walked: beyond it the list is incomplete.
20const MAX_FILES: usize = 50_000;
21
22/// [`files`], and whether the walk stopped at its limit (the list is then
23/// incomplete: nothing can be proved missing from it).
24#[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        // A symlinked file counts as the file it names.
50        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/// Every Cargo package of the checkout (a manifest with `[package]`).
64#[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                        // Without `path`: `tests/<name>.rs`, else `tests/<name>/main.rs`.
95                        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/// The package a path belongs to: the one with the longest root above it.
132#[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
140/// Splits an ini value list (spaces or new lines).
141fn words(v: &str) -> Vec<String> {
142    v.split_whitespace().map(str::to_owned).collect()
143}
144
145/// `key = value` lines of an ini `[section]`, continuation lines joined.
146fn 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/// Whether the project is a pytest project: a pytest configuration, a
182/// `conftest.py`, or a check that runs pytest.
183#[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/// pytest's rule, from the first configuration file that has a section.
196#[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/// A JS package: its directory, its runner, and its rule when static.
246#[derive(Debug, Clone)]
247pub struct JsPackage {
248    /// The directory of its `package.json` (`""` at the root).
249    pub root: String,
250    /// vitest or jest.
251    pub runner: JsRunner,
252    /// The rule, or why it is not known statically.
253    pub rule: Result<JsRule, String>,
254}
255
256/// Every `package.json` that uses vitest or jest (by its scripts or
257/// dependencies, or a check naming the runner).
258#[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                            // `<rootDir>/src` roots narrow every pattern to them.
353                            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/// The JS package a path belongs to.
404#[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/// A Go module: its directory and module path.
413#[derive(Debug, Clone)]
414pub struct GoModule {
415    /// The directory of its `go.mod`.
416    pub root: String,
417    /// `module …`.
418    pub path: String,
419}
420
421/// Every `go.mod` of the checkout.
422#[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/// The build tags a check sets (`-tags a,b` / `-tags=a,b`).
447#[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}