1pub mod junit_map;
12pub mod listing;
13pub mod runner;
14pub mod static_cfg;
15
16use std::collections::BTreeMap;
17use std::path::Path;
18use std::time::Duration;
19
20use speccoder_core::collection::{
21 COMPILED_OUT, Collection, CollectionRule, DecidedBy, FileCollection, JsRunner, Toolchain,
22 collected, defines_tests, go_breaks, go_declared_tests, go_rule, is_build_or_dependency,
23 not_collected, rust_fixes, rust_reachable, rust_rule, rust_targets, toolchain_of, unknown,
24};
25use speccoder_core::verify::{CheckResult, TestOutcome};
26
27use crate::verify::config::Check;
28use crate::verify::process::CommandRunner;
29use junit_map::Place;
30
31pub struct Input<'a> {
33 pub root: &'a Path,
35 pub changes: &'a [(String, String)],
37 pub checks: &'a [Check],
39 pub results: &'a [CheckResult],
41 pub runner: &'a dyn CommandRunner,
43 pub listings: bool,
45}
46
47#[must_use]
51pub fn judged_files(root: &Path, changes: &[(String, String)]) -> Vec<(String, Toolchain)> {
52 let mut out: Vec<(String, Toolchain)> = changes
53 .iter()
54 .filter(|(_, kind)| kind != "deleted")
55 .filter(|(p, _)| {
56 (crate::tools::paths::is_test_path(p) || test_named(p)) && !is_build_or_dependency(p)
57 })
58 .filter(|(p, _)| !p.split('/').any(|c| c == "benches" || c == "examples"))
59 .filter_map(|(p, _)| {
60 let t = toolchain_of(p)?;
61 let text = std::fs::read_to_string(root.join(p)).ok()?;
62 defines_tests(t, &text).then(|| (p.clone(), t))
63 })
64 .collect();
65 out.sort();
66 out.dedup();
67 out
68}
69
70fn test_named(path: &str) -> bool {
73 let name = path.rsplit('/').next().unwrap_or(path);
74 let stem = name.rsplit_once('.').map_or(name, |(s, _)| s);
75 stem.split(['.', '_', '-']).any(|w| {
76 matches!(
77 w.to_ascii_lowercase().trim_end_matches('s'),
78 "test" | "spec"
79 )
80 })
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
85struct Need {
86 argv: Vec<String>,
87 cwd: String,
88 kind: ListKind,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
92enum ListKind {
93 Nextest,
94 Libtest,
95 Pytest,
96 Vitest,
97 Jest,
98 Go,
99 LibtestIgnored,
100 Custom,
101}
102
103struct Pending {
105 path: String,
106 place: Place,
107 rule: Option<CollectionRule>,
108 needs: Vec<(Need, Option<String>)>,
109}
110
111#[must_use]
113pub fn judge(input: &Input<'_>) -> Option<Collection> {
114 let judged = judged_files(input.root, input.changes);
115 if judged.is_empty() {
116 return None;
117 }
118 let (files, truncated) = static_cfg::walk(input.root);
119 let argvs: Vec<Vec<String>> = input.checks.iter().map(|c| c.argv.clone()).collect();
120 let timeout = input
121 .checks
122 .iter()
123 .map(|c| c.timeout)
124 .max()
125 .unwrap_or(Duration::from_secs(600));
126 let custom = input.checks.iter().find_map(|c| c.list.clone());
127 let custom_timeout = input
129 .checks
130 .iter()
131 .find(|c| c.list.is_some())
132 .map_or(timeout, |c| c.timeout);
133 let packages = static_cfg::rust_packages(input.root, &files);
134 let js = static_cfg::js_packages(input.root, &files, &argvs);
135 let go = static_cfg::go_modules(input.root, &files);
136 let pytest = static_cfg::uses_pytest(input.root, &files, &argvs)
137 .then(|| static_cfg::pytest_rule(input.root));
138 let read = |p: &str| std::fs::read_to_string(input.root.join(p)).ok();
139
140 let mut out: Vec<FileCollection> = Vec::new();
141 let mut pending: Vec<Pending> = Vec::new();
142 for (path, toolchain) in &judged {
143 let source = read(path).unwrap_or_default();
144 let (place, rule, needs) = match toolchain {
146 Toolchain::Rust => {
147 let Some(pkg) = static_cfg::owner(&packages, path) else {
148 unrecognised(
149 &mut out,
150 &mut pending,
151 custom.as_ref(),
152 path,
153 Place::Rust(BTreeMap::new()),
154 "no Cargo.toml governs it",
155 );
156 continue;
157 };
158 let targets = rust_targets(pkg, &files);
159 let reach = rust_reachable(&targets, &|p| read(p));
160 let rule = rust_rule(pkg);
161 let Some(places) = reach.get(path) else {
162 let stem = path
165 .rsplit('/')
166 .next()
167 .unwrap_or(path)
168 .trim_end_matches(".rs");
169 let named = input
170 .results
171 .iter()
172 .filter(|r| runner::runs(*toolchain, &r.argv))
173 .flat_map(|r| r.tests.iter().flatten())
174 .any(|t| {
175 t.id.contains(&format!("::{stem}::"))
176 || t.id.starts_with(&format!("{}::{stem}::", pkg.name))
177 });
178 if truncated || named {
179 out.push(unknown(
180 path,
181 if truncated {
182 "the checkout has more files than speccoder reads, so its rule cannot prove the file is never built"
183 } else {
184 "Cargo's rule says nothing builds it, but the run's report names tests of that name"
185 },
186 ));
187 continue;
188 }
189 let tests =
190 speccoder_core::collection::join(&pkg.root, "tests").unwrap_or_default();
191 let why = if pkg.autotests && path.starts_with(&format!("{tests}/")) {
192 "this file is neither"
193 } else {
194 "no target or `mod` declaration reaches it"
195 };
196 out.push(not_collected(
197 path,
198 DecidedBy::Static,
199 rule,
200 why,
201 rust_fixes(&pkg.root, path),
202 ));
203 continue;
204 };
205 let virtual_root =
207 files.contains("Cargo.toml") && !packages.iter().any(|p| p.root.is_empty());
208 let mut needs: Vec<(Need, Option<String>)> = Vec::new();
209 for c in input
210 .checks
211 .iter()
212 .filter(|c| runner::runs(*toolchain, &c.argv))
213 {
214 if let Some(argv) = runner::nextest_list(&c.argv) {
215 needs.push((
216 Need {
217 argv,
218 cwd: check_dir(c),
219 kind: ListKind::Nextest,
220 },
221 None,
222 ));
223 continue;
224 }
225 let root_package =
226 pkg.root.is_empty() || virtual_root || c.cwd.trim_matches('/') == pkg.root;
227 for (binary, _) in places {
228 let kind = match binary.strip_prefix(&format!("{}::", pkg.name)) {
229 None => ("lib".to_owned(), String::new()),
230 Some(b) => match b.strip_prefix("bin/") {
231 Some(bin) => ("bin".to_owned(), bin.to_owned()),
232 None => ("test".to_owned(), b.to_owned()),
233 },
234 };
235 if !runner::cargo_runs(&c.argv, &pkg.name, root_package, &kind) {
236 continue;
237 }
238 let (list, ignored) = runner::cargo_list(&c.argv, &pkg.name, &kind);
239 needs.push((
240 Need {
241 argv: list,
242 cwd: check_dir(c),
243 kind: ListKind::Libtest,
244 },
245 Some(binary.clone()),
246 ));
247 if let Some(ig) = ignored {
248 needs.push((
249 Need {
250 argv: ig,
251 cwd: check_dir(c),
252 kind: ListKind::LibtestIgnored,
253 },
254 Some(binary.clone()),
255 ));
256 }
257 }
258 }
259 (Place::Rust(reach), rule, needs)
260 }
261 Toolchain::Python => {
262 let Some(rule) = pytest.as_ref() else {
263 unrecognised(
264 &mut out,
265 &mut pending,
266 custom.as_ref(),
267 path,
268 Place::Python,
269 "no pytest configuration or check: speccoder knows pytest's collection only",
270 );
271 continue;
272 };
273 if let Some(why) = rule.breaks(path) {
274 out.push(not_collected(
275 path,
276 DecidedBy::Static,
277 rule.rule(),
278 &why,
279 rule.fixes(path),
280 ));
281 continue;
282 }
283 let needs = input
284 .checks
285 .iter()
286 .filter(|c| runner::runs(*toolchain, &c.argv))
287 .map(|c| {
288 (
289 Need {
290 argv: runner::pytest_list(&c.argv),
291 cwd: check_dir(c),
292 kind: ListKind::Pytest,
293 },
294 None,
295 )
296 })
297 .collect();
298 (Place::Python, rule.rule(), needs)
299 }
300 Toolchain::JavaScript => {
301 let Some(pkg) = static_cfg::js_owner(&js, path) else {
302 unrecognised(
303 &mut out,
304 &mut pending,
305 custom.as_ref(),
306 path,
307 Place::Js(path.clone()),
308 "no package.json uses vitest or jest: speccoder knows their collection only",
309 );
310 continue;
311 };
312 let rel = if pkg.root.is_empty() {
313 path.clone()
314 } else {
315 path.trim_start_matches(&format!("{}/", pkg.root))
316 .to_owned()
317 };
318 let rule = match &pkg.rule {
319 Ok(r) => {
320 if let Some(why) = r.breaks(&rel) {
321 out.push(not_collected(
322 path,
323 DecidedBy::Static,
324 r.rule(),
325 &why,
326 r.fixes(&rel),
327 ));
328 continue;
329 }
330 r.rule()
331 }
332 Err(_) => CollectionRule {
333 toolchain: Toolchain::JavaScript,
334 text: format!(
335 "{} collects the files its configuration includes",
336 pkg.runner.name()
337 ),
338 source: format!("{}/package.json", pkg.root)
339 .trim_start_matches('/')
340 .to_owned(),
341 },
342 };
343 let needs = input
344 .checks
345 .iter()
346 .filter(|c| runner::runs(*toolchain, &c.argv))
347 .filter_map(|c| {
348 let (argv, kind) = match pkg.runner {
349 JsRunner::Vitest => (runner::vitest_list(&c.argv)?, ListKind::Vitest),
350 JsRunner::Jest => (runner::jest_list(&c.argv), ListKind::Jest),
351 };
352 Some((
353 Need {
354 argv,
355 cwd: pkg.root.clone(),
356 kind,
357 },
358 None,
359 ))
360 })
361 .collect();
362 (Place::Js(rel), rule, needs)
363 }
364 Toolchain::Go => {
365 let Some(module) = go
366 .iter()
367 .filter(|m| m.root.is_empty() || path.starts_with(&format!("{}/", m.root)))
368 .max_by_key(|m| m.root.len())
369 else {
370 unrecognised(
371 &mut out,
372 &mut pending,
373 custom.as_ref(),
374 path,
375 Place::Go(String::new(), Vec::new()),
376 "no go.mod governs it",
377 );
378 continue;
379 };
380 let tags = static_cfg::go_tags(&argvs);
381 if let Some((why, fixes)) = go_breaks(path, &source, &tags) {
382 out.push(not_collected(
383 path,
384 DecidedBy::Static,
385 go_rule(),
386 &why,
387 fixes,
388 ));
389 continue;
390 }
391 let dir = path.rsplit_once('/').map_or("", |(d, _)| d);
392 let rel_dir = dir
393 .strip_prefix(module.root.as_str())
394 .unwrap_or(dir)
395 .trim_start_matches('/')
396 .to_owned();
397 let import = if rel_dir.is_empty() {
398 module.path.clone()
399 } else {
400 format!("{}/{rel_dir}", module.path)
401 };
402 let needs = input
403 .checks
404 .iter()
405 .filter(|c| runner::runs(*toolchain, &c.argv))
406 .map(|c| {
407 (
408 Need {
409 argv: runner::go_list(&c.argv),
410 cwd: module.root.clone(),
411 kind: ListKind::Go,
412 },
413 None,
414 )
415 })
416 .collect();
417 (
418 Place::Go(import, go_declared_tests(&source)),
419 go_rule(),
420 needs,
421 )
422 }
423 };
424 let reports: Vec<&CheckResult> = input
426 .results
427 .iter()
428 .filter(|r| r.tests.is_some() && runner::runs(*toolchain, &r.argv))
429 .collect();
430 if !reports.is_empty() {
431 let ids: Vec<String> = reports
432 .iter()
433 .flat_map(|r| r.tests.iter().flatten())
434 .filter(|t| !matches!(t.outcome, TestOutcome::Skipped))
435 .map(|t| t.id.clone())
436 .collect();
437 let mine = junit_map::tests_of(&place, path, &ids);
438 out.push(if mine.is_empty() {
439 not_collected(
440 path,
441 DecidedBy::Junit,
442 rule,
443 COMPILED_OUT,
444 compiled_out_fixes(*toolchain),
445 )
446 } else {
447 collected(path, DecidedBy::Junit, &mine)
448 });
449 continue;
450 }
451 let needs = match &custom {
453 Some(argv) => vec![(
454 Need {
455 argv: argv.clone(),
456 cwd: String::new(),
457 kind: ListKind::Custom,
458 },
459 None,
460 )],
461 None => needs,
462 };
463 if needs.is_empty() {
465 out.push(unknown(
466 path,
467 &format!(
468 "no fast check would run it: none runs these {} tests",
469 toolchain.title()
470 ),
471 ));
472 continue;
473 }
474 if runner::skips_unseen_by_listing(*toolchain, &source) {
476 out.push(unknown(
477 path,
478 "it may skip its tests, which a listing cannot tell; a JUnit report can",
479 ));
480 continue;
481 }
482 pending.push(Pending {
483 path: path.clone(),
484 place,
485 rule: Some(rule),
486 needs,
487 });
488 }
489
490 let mut runs = Vec::new();
491 if !pending.is_empty() && input.listings {
492 let mut cache: BTreeMap<Need, Result<String, String>> = BTreeMap::new();
493 for p in &pending {
494 let mut ids: Vec<String> = Vec::new();
495 let mut ignored: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
496 let mut failed: Option<String> = None;
497 for (need, binary) in &p.needs {
498 let got = cache.entry(need.clone()).or_insert_with(|| {
499 let cwd = input.root.join(&need.cwd);
500 let deadline = if need.kind == ListKind::Custom {
501 custom_timeout
502 } else {
503 timeout
504 };
505 let (r, run) = listing::run(input.runner, &cwd, &need.argv, deadline);
506 runs.push(run);
507 r.map(|l| l.stdout)
508 });
509 match got {
510 Err(why) => failed = Some(why.clone()),
511 Ok(stdout) => {
512 let root = input.root.join(&need.cwd);
513 let listed: Option<Vec<String>> = match need.kind {
514 ListKind::Nextest => listing::nextest_ids(stdout),
515 ListKind::Libtest => Some(
516 listing::libtest_names(stdout)
517 .into_iter()
518 .map(|n| {
519 format!("{}::{n}", binary.as_deref().unwrap_or_default())
520 })
521 .collect(),
522 ),
523 ListKind::LibtestIgnored => {
524 ignored.extend(listing::libtest_names(stdout).into_iter().map(
525 |n| format!("{}::{n}", binary.as_deref().unwrap_or_default()),
526 ));
527 Some(Vec::new())
528 }
529 ListKind::Pytest => Some(listing::pytest_ids(stdout)),
530 ListKind::Vitest => listing::vitest_files(stdout, &root),
531 ListKind::Jest => Some(listing::jest_files(stdout, &root)),
532 ListKind::Go => Some(listing::go_ids(stdout)),
533 ListKind::Custom => Some(listing::lines(stdout, input.root)),
534 };
535 match listed {
536 Some(l) => ids.extend(l),
537 None => {
538 failed = Some(format!(
539 "the output of `{}` could not be read",
540 need.argv.join(" ")
541 ))
542 }
543 }
544 }
545 }
546 }
547 ids.retain(|id| !ignored.contains(id));
548 let custom = p.needs.iter().any(|(n, _)| n.kind == ListKind::Custom);
549 let mine = if custom {
550 junit_map::listed(&p.place, &p.path, &ids)
551 } else {
552 match &p.place {
553 Place::Js(rel) => ids
554 .iter()
555 .filter(|f| *f == rel || *f == &p.path)
556 .cloned()
557 .collect(),
558 place => junit_map::tests_of(place, &p.path, &ids),
559 }
560 };
561 let toolchain = p
562 .rule
563 .as_ref()
564 .map(|r| r.toolchain)
565 .or_else(|| toolchain_of(&p.path))
566 .unwrap_or(Toolchain::Rust);
567 if let (true, Some(why)) = (mine.is_empty(), failed.as_ref()) {
569 out.push(unknown(&p.path, why));
570 continue;
571 }
572 out.push(match (mine.is_empty(), &p.rule) {
573 (false, _) => collected(&p.path, DecidedBy::Listing, &mine),
574 (true, Some(rule)) => not_collected(
575 &p.path,
576 DecidedBy::Listing,
577 rule.clone(),
578 COMPILED_OUT,
579 compiled_out_fixes(toolchain),
580 ),
581 (true, None) => not_collected(
582 &p.path,
583 DecidedBy::Listing,
584 CollectionRule {
585 toolchain,
586 text: "the check's `list` command names the tests it runs".to_owned(),
587 source: "speccoder.toml [checks] list".to_owned(),
588 },
589 "it names none from this file",
590 vec!["put the tests where the check runs them".to_owned()],
591 ),
592 });
593 }
594 } else {
595 for p in &pending {
596 out.push(unknown(
597 &p.path,
598 "no JUnit report names its tests and no listing ran",
599 ));
600 }
601 }
602 out.sort_by(|a, b| a.path.cmp(&b.path));
603 Some(Collection {
604 files: out,
605 listing: runs,
606 })
607}
608
609#[must_use]
613pub fn statically_refused(root: &Path, path: &str, content: &str) -> Option<String> {
614 let mut files = static_cfg::files(root);
615 files.insert(path.to_owned());
616 let read = |p: &str| {
617 if p == path {
618 Some(content.to_owned())
619 } else {
620 std::fs::read_to_string(root.join(p)).ok()
621 }
622 };
623 match toolchain_of(path)? {
624 Toolchain::Rust => {
625 let packages = static_cfg::rust_packages(root, &files);
626 let pkg = static_cfg::owner(&packages, path)?;
627 let reach = rust_reachable(&rust_targets(pkg, &files), &read);
628 (!reach.contains_key(path)).then(|| rust_rule(pkg).text)
629 }
630 Toolchain::Python => {
631 let rule = static_cfg::pytest_rule(root);
632 rule.breaks(path).map(|_| rule.rule().text)
633 }
634 Toolchain::JavaScript => {
635 let js = static_cfg::js_packages(root, &files, &[]);
636 let pkg = static_cfg::js_owner(&js, path)?;
637 let r = pkg.rule.as_ref().ok()?;
638 let rel = if pkg.root.is_empty() {
639 path.to_owned()
640 } else {
641 path.trim_start_matches(&format!("{}/", pkg.root))
642 .to_owned()
643 };
644 r.breaks(&rel).map(|_| r.rule().text)
645 }
646 Toolchain::Go => {
647 go_breaks(path, content, &std::collections::BTreeSet::new()).map(|_| go_rule().text)
648 }
649 }
650}
651
652fn unrecognised(
655 out: &mut Vec<FileCollection>,
656 pending: &mut Vec<Pending>,
657 custom: Option<&Vec<String>>,
658 path: &str,
659 place: Place,
660 why: &str,
661) {
662 match custom {
663 Some(argv) => pending.push(Pending {
664 path: path.to_owned(),
665 place,
666 rule: None,
667 needs: vec![(
668 Need {
669 argv: argv.clone(),
670 cwd: String::new(),
671 kind: ListKind::Custom,
672 },
673 None,
674 )],
675 }),
676 None => out.push(unknown(path, why)),
677 }
678}
679
680fn check_dir(c: &Check) -> String {
682 let d = c.cwd.trim_matches('/');
683 if d == "." {
684 String::new()
685 } else {
686 d.trim_start_matches("./").to_owned()
687 }
688}
689
690fn compiled_out_fixes(t: Toolchain) -> Vec<String> {
691 vec![
692 match t {
693 Toolchain::Rust => {
694 "make the check run them (a `cfg` feature they need, an `#[ignore]` they carry)"
695 }
696 Toolchain::Python => {
697 "make the check collect them (a marker or `-k` that deselects them, a skip)"
698 }
699 Toolchain::JavaScript => {
700 "make the check include them (a project or filter that leaves them out, a `.skip`)"
701 }
702 Toolchain::Go => {
703 "make the check run them (a `-run` filter or build tag that leaves them out)"
704 }
705 }
706 .to_owned(),
707 ]
708}