Skip to main content

speccoder_core/
collection.rs

1//! Which test files a run actually collected: a test that never ran is
2//! evidence of nothing.
3//!
4//! Pure: the runtime reads manifests and configuration, runs listings, and
5//! hands their facts here. Per toolchain this module knows the static
6//! collection rule (what proves a file is *not* collected), how a test id in
7//! a JUnit report or a listing maps back to a file, the markers that make a
8//! file define tests, and the one-sentence rule and fixes a person and the
9//! retry read.
10
11use std::collections::{BTreeMap, BTreeSet};
12
13use serde::{Deserialize, Serialize};
14
15use crate::verify::CheckStatus;
16
17/// A test toolchain speccoder knows how to ask.
18#[derive(
19    Debug,
20    Clone,
21    Copy,
22    PartialEq,
23    Eq,
24    PartialOrd,
25    Ord,
26    Hash,
27    Serialize,
28    Deserialize,
29    schemars::JsonSchema,
30)]
31#[serde(rename_all = "snake_case")]
32pub enum Toolchain {
33    /// Cargo (`cargo test`, `cargo nextest`).
34    Rust,
35    /// pytest.
36    Python,
37    /// vitest or jest.
38    #[serde(rename = "javascript")]
39    JavaScript,
40    /// `go test`.
41    Go,
42}
43
44impl Toolchain {
45    /// As people read it.
46    #[must_use]
47    pub const fn title(self) -> &'static str {
48        match self {
49            Self::Rust => "Rust",
50            Self::Python => "Python",
51            Self::JavaScript => "JavaScript/TypeScript",
52            Self::Go => "Go",
53        }
54    }
55}
56
57/// A runner's collection rule: one sentence a person reads, and where it
58/// came from.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
60pub struct CollectionRule {
61    /// The toolchain.
62    pub toolchain: Toolchain,
63    /// The rule (``Cargo builds `tests/*.rs` and `tests/<dir>/main.rs` (and the modules they declare)``).
64    pub text: String,
65    /// Where it came from (`Cargo.toml (default)`, `pyproject.toml [tool.pytest.ini_options]`).
66    pub source: String,
67}
68
69/// Whether the run collected a test from a file.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
71#[serde(rename_all = "snake_case")]
72pub enum FileState {
73    /// At least one test from it ran (passed or failed).
74    Collected,
75    /// None did.
76    NotCollected,
77    /// speccoder cannot tell.
78    Unknown,
79}
80
81/// What decided a file's state.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
83#[serde(rename_all = "snake_case")]
84pub enum DecidedBy {
85    /// The toolchain's static rule.
86    Static,
87    /// The check's fresh JUnit report.
88    Junit,
89    /// The runner's own listing.
90    Listing,
91}
92
93/// One judged test file.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
95pub struct FileCollection {
96    /// Workspace-relative path.
97    pub path: String,
98    /// Collected, not collected, or unknown.
99    pub state: FileState,
100    /// What decided it; `null` only when unknown.
101    pub decided_by: Option<DecidedBy>,
102    /// Tests from it that ran (skipped and ignored ones excluded).
103    pub tests: usize,
104    /// The first 20 of their ids.
105    pub test_ids: Vec<String>,
106    /// The runner's rule (not collected).
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub rule: Option<CollectionRule>,
109    /// Not collected: which part of the rule the file breaks; unknown: why
110    /// speccoder cannot tell.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub why: Option<String>,
113    /// The ways out, for this toolchain and file.
114    #[serde(default, skip_serializing_if = "Vec::is_empty")]
115    pub fixes: Vec<String>,
116}
117
118/// A listing speccoder ran because the earlier sources left a file undecided.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
120pub struct ListingRun {
121    /// The command.
122    pub argv: Vec<String>,
123    /// How long it took.
124    pub duration_ms: u64,
125    /// How it ended.
126    pub status: CheckStatus,
127}
128
129/// The attempt's changed test files and whether the run collected them.
130#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
131pub struct Collection {
132    /// Every judged file, by path.
133    pub files: Vec<FileCollection>,
134    /// The listings run, in order.
135    #[serde(default, skip_serializing_if = "Vec::is_empty")]
136    pub listing: Vec<ListingRun>,
137}
138
139impl Collection {
140    /// The files no test ran from.
141    pub fn not_collected(&self) -> impl Iterator<Item = &FileCollection> {
142        self.files
143            .iter()
144            .filter(|f| f.state == FileState::NotCollected)
145    }
146
147    /// The files speccoder cannot tell about.
148    pub fn unknown(&self) -> impl Iterator<Item = &FileCollection> {
149        self.files.iter().filter(|f| f.state == FileState::Unknown)
150    }
151
152    /// The verdict reasons: one per file that never ran.
153    #[must_use]
154    pub fn reasons(&self) -> Vec<String> {
155        self.not_collected().map(reason).collect()
156    }
157
158    /// The retry lines: one per file that never ran.
159    #[must_use]
160    pub fn retry_lines(&self) -> Vec<String> {
161        self.not_collected().map(retry_line).collect()
162    }
163
164    /// The sorted paths that never ran (the retry fingerprint's part).
165    #[must_use]
166    pub fn uncollected_paths(&self) -> Vec<String> {
167        let mut p: Vec<String> = self.not_collected().map(|f| f.path.clone()).collect();
168        p.sort();
169        p
170    }
171}
172
173/// The prefix of every verdict reason a never-run file gives.
174pub const REASON_PREFIX: &str = "tests not collected: ";
175
176/// ``tests not collected: `<path>` — <rule>; <why>``.
177#[must_use]
178pub fn reason(f: &FileCollection) -> String {
179    let rule = f.rule.as_ref().map_or("", |r| r.text.as_str());
180    match f.why.as_deref() {
181        Some(why) => format!("{REASON_PREFIX}`{}` — {rule}; {why}", f.path),
182        None => format!("{REASON_PREFIX}`{}` — {rule}", f.path),
183    }
184}
185
186/// `` `<path>` never ran: <short rule>. <Fixes>.`` — the retry digest's line.
187#[must_use]
188pub fn retry_line(f: &FileCollection) -> String {
189    let short = f.rule.as_ref().map_or_else(String::new, short_rule);
190    let fixes = capitalise(&f.fixes.join(", or "));
191    let mut line = format!("`{}` never ran", f.path);
192    if !short.is_empty() {
193        line.push_str(": ");
194        line.push_str(&short);
195    }
196    line.push('.');
197    if !fixes.is_empty() {
198        line.push(' ');
199        line.push_str(&fixes);
200        line.push('.');
201    }
202    line
203}
204
205fn short_rule(r: &CollectionRule) -> String {
206    r.text
207        .split(" (and the modules")
208        .next()
209        .unwrap_or(&r.text)
210        .to_owned()
211}
212
213fn capitalise(s: &str) -> String {
214    let mut c = s.chars();
215    c.next()
216        .map(|f| f.to_uppercase().collect::<String>() + c.as_str())
217        .unwrap_or_default()
218}
219
220/// The toolchain a file's extension belongs to.
221#[must_use]
222pub fn toolchain_of(path: &str) -> Option<Toolchain> {
223    let ext = path.rsplit_once('.').map(|(_, e)| e)?;
224    match ext {
225        "rs" => Some(Toolchain::Rust),
226        "py" => Some(Toolchain::Python),
227        "js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs" | "mts" | "cts" => Some(Toolchain::JavaScript),
228        "go" => Some(Toolchain::Go),
229        _ => None,
230    }
231}
232
233/// Directories nothing judges: build output and dependencies.
234#[must_use]
235pub fn is_build_or_dependency(path: &str) -> bool {
236    path.split('/').any(|c| {
237        matches!(
238            c,
239            "target"
240                | "node_modules"
241                | "vendor"
242                | ".venv"
243                | "venv"
244                | "dist"
245                | "build"
246                | "__pycache__"
247        )
248    })
249}
250
251/// Whether `source` defines at least one test by the toolchain's markers:
252/// a helper without test definitions is never judged.
253#[must_use]
254pub fn defines_tests(toolchain: Toolchain, source: &str) -> bool {
255    match toolchain {
256        Toolchain::Rust => {
257            let code: String = strip_comments(source).split_whitespace().collect();
258            // `#[test]`, and any `#[<crate>::test]` / `#[<crate>::test(…)]`
259            // attribute (tokio, async-std, test-log, sqlx, googletest, …).
260            code.contains("#[test]")
261                || ["#[rstest", "#[test_case", "#[quickcheck", "#[proptest"]
262                    .iter()
263                    .any(|m| code.contains(m))
264                || code.match_indices("#[").any(|(i, _)| {
265                    let end = code[i..].find(']').map_or(code.len(), |e| i + e + 1);
266                    let attr = &code[i..end];
267                    attr.contains("::test]") || attr.contains("::test(")
268                })
269        }
270        Toolchain::Python => source.lines().any(|l| {
271            let t = l.trim_start();
272            t.starts_with("def test")
273                || t.starts_with("async def test")
274                || t.starts_with("class Test")
275        }),
276        Toolchain::JavaScript => {
277            let code = strip_comments(source);
278            [
279                "it(",
280                "test(",
281                "describe(",
282                "it.each",
283                "test.each",
284                "it.only(",
285                "test.only(",
286            ]
287            .iter()
288            .any(|m| {
289                code.match_indices(m).any(|(i, _)| {
290                    i == 0
291                        || !code[..i]
292                            .chars()
293                            .next_back()
294                            .is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '.')
295                })
296            })
297        }
298        Toolchain::Go => !go_test_like(source).is_empty(),
299    }
300}
301
302/// How many tests `source` declares by the toolchain's markers (a lower
303/// bound for what a run of it must collect).
304#[must_use]
305pub fn declared_tests(toolchain: Toolchain, source: &str) -> usize {
306    let code = strip_comments(source);
307    match toolchain {
308        Toolchain::Rust => {
309            let flat: String = code.split_whitespace().collect();
310            flat.match_indices("#[")
311                .filter(|(i, _)| {
312                    let end = flat[*i..].find(']').map_or(flat.len(), |e| i + e + 1);
313                    let attr = &flat[*i..end];
314                    attr == "#[test]"
315                        || attr.contains("::test]")
316                        || attr.contains("::test(")
317                        || attr.starts_with("#[rstest")
318                        || attr.starts_with("#[test_case")
319                })
320                .count()
321        }
322        Toolchain::Python => code
323            .lines()
324            .map(str::trim_start)
325            .filter(|l| l.starts_with("def test") || l.starts_with("async def test"))
326            .count(),
327        Toolchain::JavaScript => ["it(", "test("]
328            .iter()
329            .map(|m| {
330                code.match_indices(m)
331                    .filter(|(i, _)| {
332                        *i == 0
333                            || !code[..*i]
334                                .chars()
335                                .next_back()
336                                .is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '.')
337                    })
338                    .count()
339            })
340            .sum(),
341        Toolchain::Go => go_declared_tests(source).len(),
342    }
343}
344
345/// Strips `//` and `/* */` comments, keeping line breaks (strings are left
346/// as they are: a comment marker inside one is rare and only hides text).
347#[must_use]
348pub fn strip_comments(src: &str) -> String {
349    let mut out = String::with_capacity(src.len());
350    let b = src.as_bytes();
351    let mut i = 0;
352    let mut in_str = false;
353    while i < b.len() {
354        let c = b[i];
355        if in_str {
356            out.push(c as char);
357            if c == b'\\' && i + 1 < b.len() {
358                out.push(b[i + 1] as char);
359                i += 2;
360                continue;
361            }
362            if c == b'"' {
363                in_str = false;
364            }
365            i += 1;
366        } else if c == b'"' {
367            in_str = true;
368            out.push('"');
369            i += 1;
370        } else if c == b'/' && b.get(i + 1) == Some(&b'/') {
371            while i < b.len() && b[i] != b'\n' {
372                i += 1;
373            }
374        } else if c == b'/' && b.get(i + 1) == Some(&b'*') {
375            i += 2;
376            let mut depth = 1;
377            while i < b.len() && depth > 0 {
378                if b[i] == b'/' && b.get(i + 1) == Some(&b'*') {
379                    depth += 1;
380                    i += 2;
381                } else if b[i] == b'*' && b.get(i + 1) == Some(&b'/') {
382                    depth -= 1;
383                    i += 2;
384                } else {
385                    if b[i] == b'\n' {
386                        out.push('\n');
387                    }
388                    i += 1;
389                }
390            }
391        } else {
392            // Keep multi-byte characters whole.
393            let ch = src[i..].chars().next().unwrap_or(' ');
394            out.push(ch);
395            i += ch.len_utf8();
396        }
397    }
398    out
399}
400
401/// Joins `dir` and `rel` and normalises `.`/`..`; `None` when the result
402/// leaves the workspace root.
403#[must_use]
404pub fn join(dir: &str, rel: &str) -> Option<String> {
405    let mut parts: Vec<&str> = Vec::new();
406    for c in dir.split('/').chain(rel.split('/')) {
407        match c {
408            "" | "." => {}
409            ".." => {
410                parts.pop()?;
411            }
412            c => parts.push(c),
413        }
414    }
415    Some(parts.join("/"))
416}
417
418fn parent(path: &str) -> &str {
419    path.rsplit_once('/').map_or("", |(d, _)| d)
420}
421
422fn file_name(path: &str) -> &str {
423    path.rsplit_once('/').map_or(path, |(_, f)| f)
424}
425
426fn stem(path: &str) -> &str {
427    let f = file_name(path);
428    f.split_once('.').map_or(f, |(s, _)| s)
429}
430
431// ---------------------------------------------------------------- Rust
432
433/// One Cargo package, as its manifest says.
434#[derive(Debug, Clone, PartialEq, Eq, Default)]
435pub struct RustPackage {
436    /// The package's directory, workspace-relative (`""` for the root).
437    pub root: String,
438    /// `[package] name`.
439    pub name: String,
440    /// `autotests` (default `true`).
441    pub autotests: bool,
442    /// `[[test]]` targets: name and path (package-relative).
443    pub tests: Vec<(String, String)>,
444    /// `[lib] path`, when set.
445    pub lib: Option<String>,
446    /// `[[bin]]` targets with a `path`: name and path (package-relative).
447    pub bins: Vec<(String, String)>,
448}
449
450/// A compiled test binary and its crate root.
451#[derive(Debug, Clone, PartialEq, Eq)]
452pub struct RustTarget {
453    /// nextest's binary id: `greet`, `greet::greet_name`, `greet::bin/cli`.
454    pub binary_id: String,
455    /// The crate root, workspace-relative.
456    pub root_file: String,
457}
458
459/// The test-carrying targets of `pkg`, from its manifest and the files
460/// present (`files`: workspace-relative paths).
461#[must_use]
462pub fn rust_targets(pkg: &RustPackage, files: &BTreeSet<String>) -> Vec<RustTarget> {
463    let at = |rel: &str| join(&pkg.root, rel).unwrap_or_default();
464    let mut out = Vec::new();
465    let lib = at(pkg.lib.as_deref().unwrap_or("src/lib.rs"));
466    if files.contains(&lib) {
467        out.push(RustTarget {
468            binary_id: pkg.name.clone(),
469            root_file: lib,
470        });
471    }
472    let main = at("src/main.rs");
473    if files.contains(&main) {
474        out.push(RustTarget {
475            binary_id: format!("{}::bin/{}", pkg.name, pkg.name),
476            root_file: main,
477        });
478    }
479    for (name, path) in &pkg.bins {
480        let root_file = at(path);
481        if files.contains(&root_file) && !out.iter().any(|t| t.root_file == root_file) {
482            out.push(RustTarget {
483                binary_id: format!("{}::bin/{name}", pkg.name),
484                root_file,
485            });
486        }
487    }
488    let bin_dir = at("src/bin");
489    for f in files {
490        let Some(rest) = f.strip_prefix(&format!("{bin_dir}/")) else {
491            continue;
492        };
493        let name = match rest.split('/').collect::<Vec<_>>()[..] {
494            [one] if one.ends_with(".rs") => one.trim_end_matches(".rs"),
495            [dir, "main.rs"] => dir,
496            _ => continue,
497        };
498        out.push(RustTarget {
499            binary_id: format!("{}::bin/{name}", pkg.name),
500            root_file: f.clone(),
501        });
502    }
503    let mut explicit: BTreeSet<String> = BTreeSet::new();
504    for (name, path) in &pkg.tests {
505        let root_file = at(path);
506        explicit.insert(root_file.clone());
507        out.push(RustTarget {
508            binary_id: format!("{}::{name}", pkg.name),
509            root_file,
510        });
511    }
512    if pkg.autotests {
513        let tests_dir = at("tests");
514        for f in files {
515            let Some(rest) = f.strip_prefix(&format!("{tests_dir}/")) else {
516                continue;
517            };
518            let name = match rest.split('/').collect::<Vec<_>>()[..] {
519                [one] if one.ends_with(".rs") => one.trim_end_matches(".rs"),
520                [dir, "main.rs"] => dir,
521                _ => continue,
522            };
523            if explicit.contains(f) || pkg.tests.iter().any(|(n, _)| n == name) {
524                continue;
525            }
526            out.push(RustTarget {
527                binary_id: format!("{}::{name}", pkg.name),
528                root_file: f.clone(),
529            });
530        }
531    }
532    out
533}
534
535/// A `mod` declaration found in a source file.
536#[derive(Debug, Clone, PartialEq, Eq)]
537pub struct ModDecl {
538    /// The module's name.
539    pub name: String,
540    /// Its `#[path = "…"]`, when set.
541    pub path: Option<String>,
542    /// The inline modules (`mod a { … }`) it sits in, outermost first, each
543    /// with its own `#[path]` when set.
544    pub inline: Vec<(String, Option<String>)>,
545}
546
547#[derive(Debug, Clone, PartialEq, Eq)]
548enum Tok {
549    Ident(String),
550    Str(String),
551    Punct(char),
552}
553
554fn lex(src: &str) -> Vec<Tok> {
555    let code = strip_comments(src);
556    let chars: Vec<char> = code.chars().collect();
557    let mut out = Vec::new();
558    let mut i = 0;
559    while i < chars.len() {
560        let c = chars[i];
561        if c.is_whitespace() {
562            i += 1;
563        } else if c.is_alphabetic() || c == '_' {
564            let s = i;
565            while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
566                i += 1;
567            }
568            let word: String = chars[s..i].iter().collect();
569            // A raw string: r"…" or r#"…"#.
570            if word == "r" && i < chars.len() && (chars[i] == '"' || chars[i] == '#') {
571                let mut hashes = 0;
572                while i < chars.len() && chars[i] == '#' {
573                    hashes += 1;
574                    i += 1;
575                }
576                if i < chars.len() && chars[i] == '"' {
577                    i += 1;
578                    let s = i;
579                    loop {
580                        if i >= chars.len() {
581                            break;
582                        }
583                        if chars[i] == '"'
584                            && (0..hashes).all(|k| chars.get(i + 1 + k) == Some(&'#'))
585                        {
586                            break;
587                        }
588                        i += 1;
589                    }
590                    out.push(Tok::Str(chars[s..i.min(chars.len())].iter().collect()));
591                    i += 1 + hashes;
592                }
593                continue;
594            }
595            out.push(Tok::Ident(word));
596        } else if c == '"' {
597            i += 1;
598            let mut s = String::new();
599            while i < chars.len() && chars[i] != '"' {
600                if chars[i] == '\\' && i + 1 < chars.len() {
601                    s.push(chars[i + 1]);
602                    i += 2;
603                } else {
604                    s.push(chars[i]);
605                    i += 1;
606                }
607            }
608            out.push(Tok::Str(s));
609            i += 1;
610        } else if c == '\'' {
611            // A char literal ('x', '\n') or a lifetime ('a).
612            if chars.get(i + 2) == Some(&'\'') {
613                i += 3;
614            } else if chars.get(i + 1) == Some(&'\\') {
615                i += 2;
616                while i < chars.len() && chars[i] != '\'' {
617                    i += 1;
618                }
619                i += 1;
620            } else {
621                i += 1;
622            }
623        } else {
624            out.push(Tok::Punct(c));
625            i += 1;
626        }
627    }
628    out
629}
630
631/// The `mod` declarations of a Rust source file (comments ignored), file
632/// modules (`mod x;`) only, each with the inline modules around it.
633#[must_use]
634pub fn rust_mod_decls(src: &str) -> Vec<ModDecl> {
635    let toks = lex(src);
636    let mut out = Vec::new();
637    // Inline modules open: (name, path, brace depth inside it).
638    let mut stack: Vec<(String, Option<String>, usize)> = Vec::new();
639    let mut depth = 0usize;
640    let mut pending_path: Option<String> = None;
641    let mut i = 0;
642    while i < toks.len() {
643        match &toks[i] {
644            Tok::Punct('#') if toks.get(i + 1) == Some(&Tok::Punct('[')) => {
645                // An attribute: #[path = "…"] or anything else, skipped.
646                let mut j = i + 2;
647                let mut nest = 1;
648                let is_path = toks.get(j) == Some(&Tok::Ident("path".to_owned()))
649                    && toks.get(j + 1) == Some(&Tok::Punct('='));
650                if is_path && let Some(Tok::Str(p)) = toks.get(j + 2) {
651                    pending_path = Some(p.clone());
652                }
653                while j < toks.len() && nest > 0 {
654                    match toks[j] {
655                        Tok::Punct('[') => nest += 1,
656                        Tok::Punct(']') => nest -= 1,
657                        _ => {}
658                    }
659                    j += 1;
660                }
661                i = j;
662                continue;
663            }
664            Tok::Ident(w) if w == "mod" => {
665                if let Some(Tok::Ident(name)) = toks.get(i + 1) {
666                    match toks.get(i + 2) {
667                        Some(Tok::Punct(';')) => {
668                            out.push(ModDecl {
669                                name: name.clone(),
670                                path: pending_path.take(),
671                                inline: stack
672                                    .iter()
673                                    .map(|(n, p, _)| (n.clone(), p.clone()))
674                                    .collect(),
675                            });
676                            i += 3;
677                            continue;
678                        }
679                        Some(Tok::Punct('{')) => {
680                            depth += 1;
681                            stack.push((name.clone(), pending_path.take(), depth));
682                            i += 3;
683                            continue;
684                        }
685                        _ => {}
686                    }
687                }
688            }
689            Tok::Ident(w) if matches!(w.as_str(), "pub" | "crate" | "super" | "self" | "in") => {}
690            Tok::Punct('(' | ')') => {}
691            Tok::Punct('{') => {
692                depth += 1;
693                pending_path = None;
694            }
695            Tok::Punct('}') => {
696                if stack.last().is_some_and(|(_, _, d)| *d == depth) {
697                    stack.pop();
698                }
699                depth = depth.saturating_sub(1);
700                pending_path = None;
701            }
702            _ => pending_path = None,
703        }
704        i += 1;
705    }
706    out
707}
708
709/// Where each file reachable from `targets` sits: the targets' binary ids
710/// with the file's module path (`""` for a crate root). `read` gives a
711/// file's text (workspace-relative path); a file it cannot read ends the
712/// walk there. `#[path]` is followed; a path leaving the root is ignored.
713#[must_use]
714pub fn rust_reachable(
715    targets: &[RustTarget],
716    read: &dyn Fn(&str) -> Option<String>,
717) -> BTreeMap<String, Vec<(String, String)>> {
718    let mut out: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
719    for t in targets {
720        // (file, module path, mod-rs-like: children live beside it)
721        let mut queue: Vec<(String, String, bool)> =
722            vec![(t.root_file.clone(), String::new(), true)];
723        let mut seen: BTreeSet<String> = BTreeSet::new();
724        while let Some((file, module, mod_rs)) = queue.pop() {
725            if !seen.insert(file.clone()) {
726                continue;
727            }
728            let Some(src) = read(&file) else { continue };
729            out.entry(file.clone())
730                .or_default()
731                .push((t.binary_id.clone(), module.clone()));
732            let dir = parent(&file).to_owned();
733            // A non-mod-rs file `a/x.rs` holds its children in `a/x/`.
734            let base = if mod_rs {
735                dir.clone()
736            } else {
737                join(&dir, stem(&file)).unwrap_or_default()
738            };
739            for d in rust_mod_decls(&src) {
740                let mut inline_dir = base.clone();
741                let mut module_path: Vec<String> = module
742                    .split("::")
743                    .filter(|s| !s.is_empty())
744                    .map(str::to_owned)
745                    .collect();
746                for (n, p) in &d.inline {
747                    inline_dir = join(&inline_dir, p.as_deref().unwrap_or(n)).unwrap_or_default();
748                    module_path.push(n.clone());
749                }
750                module_path.push(d.name.clone());
751                let child_module = module_path.join("::");
752                let candidates: Vec<(Option<String>, bool)> = match &d.path {
753                    // Outside inline blocks, #[path] is relative to the file's directory.
754                    Some(p) if d.inline.is_empty() => vec![(join(&dir, p), true)],
755                    Some(p) => vec![(join(&inline_dir, p), true)],
756                    None => vec![
757                        (join(&inline_dir, &format!("{}.rs", d.name)), false),
758                        (join(&inline_dir, &format!("{}/mod.rs", d.name)), true),
759                    ],
760                };
761                for (c, rs) in candidates {
762                    let Some(c) = c else { continue };
763                    if read(&c).is_some() {
764                        queue.push((c, child_module.clone(), rs));
765                        break;
766                    }
767                }
768            }
769        }
770    }
771    out
772}
773
774/// The Rust rule sentence.
775pub const RUST_RULE: &str =
776    "Cargo builds `tests/*.rs` and `tests/<dir>/main.rs` (and the modules they declare)";
777
778/// The Rust rule as configured: `autotests = false` leaves only `[[test]]` targets.
779#[must_use]
780pub fn rust_rule(pkg: &RustPackage) -> CollectionRule {
781    let manifest = join(&pkg.root, "Cargo.toml").unwrap_or_else(|| "Cargo.toml".to_owned());
782    if pkg.autotests {
783        CollectionRule {
784            toolchain: Toolchain::Rust,
785            text: RUST_RULE.to_owned(),
786            source: if pkg.tests.is_empty() {
787                format!("{manifest} (default)")
788            } else {
789                format!("{manifest} [[test]]")
790            },
791        }
792    } else {
793        let listed: Vec<String> = pkg.tests.iter().map(|(_, p)| format!("`{p}`")).collect();
794        CollectionRule {
795            toolchain: Toolchain::Rust,
796            text: if listed.is_empty() {
797                "Cargo builds no integration test (`autotests = false` and no `[[test]]`), only inline test modules (and the modules they declare)".to_owned()
798            } else {
799                format!(
800                    "Cargo builds only the `[[test]]` targets {} (`autotests = false`) and inline test modules (and the modules they declare)",
801                    listed.join(", ")
802                )
803            },
804            source: format!("{manifest} autotests"),
805        }
806    }
807}
808
809/// The ways out for an unreachable Rust file.
810#[must_use]
811pub fn rust_fixes(pkg_root: &str, path: &str) -> Vec<String> {
812    let s = stem(path);
813    let tests = join(pkg_root, "tests").unwrap_or_else(|| "tests".to_owned());
814    match path.strip_prefix(&format!("{tests}/")) {
815        Some(rest) if rest.contains('/') => {
816            let dir = rest.split('/').next().unwrap_or_default();
817            vec![
818                format!("move the tests to `{tests}/{s}.rs`"),
819                format!("add `{tests}/{dir}/main.rs` with `mod {s};`"),
820            ]
821        }
822        _ => vec![
823            format!("declare it with `mod {s};` from its parent module"),
824            format!("move the tests to `{tests}/{s}.rs`"),
825        ],
826    }
827}
828
829/// Which file each test id belongs to: the longest binary id that prefixes
830/// it, then the longest module path of a file of that binary. `files`:
831/// path → (binary id, module path) as [`rust_reachable`] gives them.
832#[must_use]
833pub fn rust_tests_by_file(
834    files: &BTreeMap<String, Vec<(String, String)>>,
835    ids: &[String],
836) -> BTreeMap<String, Vec<String>> {
837    let mut by_binary: BTreeMap<&str, Vec<(&str, &str)>> = BTreeMap::new();
838    for (path, places) in files {
839        for (b, m) in places {
840            by_binary
841                .entry(b.as_str())
842                .or_default()
843                .push((m.as_str(), path.as_str()));
844        }
845    }
846    let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
847    for id in ids {
848        let Some((binary, rest)) = by_binary
849            .keys()
850            .filter_map(|b| {
851                id.strip_prefix(*b)
852                    .and_then(|r| r.strip_prefix("::"))
853                    .map(|r| (*b, r))
854            })
855            .max_by_key(|(b, _)| b.len())
856        else {
857            continue;
858        };
859        let owner = by_binary[binary]
860            .iter()
861            .filter(|(m, _)| m.is_empty() || rest.starts_with(&format!("{m}::")))
862            .max_by_key(|(m, _)| m.len());
863        if let Some((_, path)) = owner {
864            out.entry((*path).to_owned()).or_default().push(id.clone());
865        }
866    }
867    out
868}
869
870// ---------------------------------------------------------------- globs
871
872/// Whether `path` matches a glob: `**`, `*`, `?`, `[…]`, `{a,b}` and the
873/// extglobs `?(…)`, `@(…)`, `+(…)`, `*(…)` (vitest and jest defaults use them).
874#[must_use]
875pub fn glob_match(pattern: &str, path: &str) -> bool {
876    expand(pattern).iter().any(|p| {
877        match_segments(
878            &p.split('/').collect::<Vec<_>>(),
879            &path.split('/').collect::<Vec<_>>(),
880        )
881    })
882}
883
884/// Brace and extglob alternatives expanded to plain globs.
885fn expand(pattern: &str) -> Vec<String> {
886    let chars: Vec<char> = pattern.chars().collect();
887    // The first group: `{` or an extglob `X(`.
888    let mut i = 0;
889    while i < chars.len() {
890        let ext = matches!(chars[i], '?' | '@' | '+' | '*' | '!') && chars.get(i + 1) == Some(&'(');
891        if chars[i] == '{' || ext {
892            let open = if ext { i + 1 } else { i };
893            let close_ch = if ext { ')' } else { '}' };
894            let open_ch = chars[open];
895            let mut depth = 0;
896            let mut j = open;
897            let mut parts = Vec::new();
898            let mut start = open + 1;
899            while j < chars.len() {
900                let c = chars[j];
901                if c == open_ch || (c == '{' && open_ch != '{') || (c == '(' && open_ch != '(') {
902                    depth += 1;
903                } else if c == close_ch
904                    || (c == '}' && close_ch != '}')
905                    || (c == ')' && close_ch != ')')
906                {
907                    depth -= 1;
908                    if depth == 0 {
909                        parts.push(chars[start..j].iter().collect::<String>());
910                        break;
911                    }
912                } else if depth == 1 && (c == ',' && !ext || c == '|' && ext) {
913                    parts.push(chars[start..j].iter().collect::<String>());
914                    start = j + 1;
915                }
916                j += 1;
917            }
918            if j >= chars.len() {
919                break;
920            }
921            let head: String = chars[..i].iter().collect();
922            let tail: String = chars[j + 1..].iter().collect();
923            let mut alts = parts;
924            if ext && matches!(chars[i], '?' | '*') {
925                alts.insert(0, String::new());
926            }
927            if ext && chars[i] == '!' {
928                // A negation is not expanded: treat as any.
929                alts = vec!["*".to_owned()];
930            }
931            return alts
932                .into_iter()
933                .flat_map(|a| expand(&format!("{head}{a}{tail}")))
934                .collect();
935        }
936        i += 1;
937    }
938    vec![pattern.to_owned()]
939}
940
941fn match_segments(p: &[&str], s: &[&str]) -> bool {
942    match (p.first(), s.first()) {
943        (None, None) => true,
944        (Some(&"**"), _) => {
945            match_segments(&p[1..], s) || (!s.is_empty() && match_segments(p, &s[1..]))
946        }
947        (Some(ps), Some(ss)) => match_name(ps, ss) && match_segments(&p[1..], &s[1..]),
948        _ => false,
949    }
950}
951
952/// fnmatch over one path segment: `*`, `?`, `[…]`.
953#[must_use]
954pub fn match_name(pattern: &str, name: &str) -> bool {
955    let p: Vec<char> = pattern.chars().collect();
956    let n: Vec<char> = name.chars().collect();
957    fn go(p: &[char], n: &[char]) -> bool {
958        match p.first() {
959            None => n.is_empty(),
960            Some('*') => (0..=n.len()).any(|k| go(&p[1..], &n[k..])),
961            Some('?') => !n.is_empty() && go(&p[1..], &n[1..]),
962            Some('[') => {
963                let Some(end) = p.iter().position(|c| *c == ']') else {
964                    return n.first() == Some(&'[') && go(&p[1..], &n[1..]);
965                };
966                let Some(c) = n.first() else { return false };
967                let class = &p[1..end];
968                let (neg, class) = match class.first() {
969                    Some('!' | '^') => (true, &class[1..]),
970                    _ => (false, class),
971                };
972                let mut hit = false;
973                let mut k = 0;
974                while k < class.len() {
975                    if k + 2 < class.len() && class[k + 1] == '-' {
976                        hit |= class[k] <= *c && *c <= class[k + 2];
977                        k += 3;
978                    } else {
979                        hit |= class[k] == *c;
980                        k += 1;
981                    }
982                }
983                hit != neg && go(&p[end + 1..], &n[1..])
984            }
985            Some(c) => n.first() == Some(c) && go(&p[1..], &n[1..]),
986        }
987    }
988    go(&p, &n)
989}
990
991// ---------------------------------------------------------------- Python
992
993/// pytest's collection settings, as configured or by default.
994#[derive(Debug, Clone, PartialEq, Eq)]
995pub struct PytestRule {
996    /// `python_files` (default `test_*.py`, `*_test.py`).
997    pub python_files: Vec<String>,
998    /// `testpaths` (default: none — the rootdir).
999    pub testpaths: Vec<String>,
1000    /// `norecursedirs` (default pytest's).
1001    pub norecursedirs: Vec<String>,
1002    /// Where the settings came from.
1003    pub source: String,
1004}
1005
1006impl Default for PytestRule {
1007    fn default() -> Self {
1008        Self {
1009            python_files: vec!["test_*.py".to_owned(), "*_test.py".to_owned()],
1010            testpaths: Vec::new(),
1011            norecursedirs: [
1012                "*.egg",
1013                ".*",
1014                "_darcs",
1015                "build",
1016                "CVS",
1017                "dist",
1018                "node_modules",
1019                "venv",
1020                "{arch}",
1021            ]
1022            .map(str::to_owned)
1023            .to_vec(),
1024            source: "pytest defaults".to_owned(),
1025        }
1026    }
1027}
1028
1029impl PytestRule {
1030    /// The rule sentence.
1031    #[must_use]
1032    pub fn rule(&self) -> CollectionRule {
1033        let files: Vec<String> = self.python_files.iter().map(|p| format!("`{p}`")).collect();
1034        let under = if self.testpaths.is_empty() {
1035            "the rootdir".to_owned()
1036        } else {
1037            self.testpaths
1038                .iter()
1039                .map(|p| format!("`{p}`"))
1040                .collect::<Vec<_>>()
1041                .join(", ")
1042        };
1043        CollectionRule {
1044            toolchain: Toolchain::Python,
1045            text: format!(
1046                "pytest collects {} (python_files) under {} (testpaths)",
1047                files.join(" and "),
1048                under
1049            ),
1050            source: self.source.clone(),
1051        }
1052    }
1053
1054    /// Why `path` is never collected by the static rule, if it is not.
1055    #[must_use]
1056    pub fn breaks(&self, path: &str) -> Option<String> {
1057        let name = file_name(path);
1058        if !self.python_files.iter().any(|p| match_name(p, name)) {
1059            return Some("this file matches neither".to_owned());
1060        }
1061        if !self.testpaths.is_empty()
1062            && !self.testpaths.iter().any(|t| {
1063                let t = t.trim_end_matches('/');
1064                t == "." || path.starts_with(&format!("{t}/"))
1065            })
1066        {
1067            return Some("this file is outside testpaths".to_owned());
1068        }
1069        let dir = parent(path);
1070        if let Some(d) = dir
1071            .split('/')
1072            .filter(|d| !d.is_empty())
1073            .find(|d| self.norecursedirs.iter().any(|p| match_name(p, d)))
1074        {
1075            return Some(format!("it is under `{d}/`, which norecursedirs skips"));
1076        }
1077        None
1078    }
1079
1080    /// The ways out.
1081    #[must_use]
1082    pub fn fixes(&self, path: &str) -> Vec<String> {
1083        let s = stem(path);
1084        let s = s.strip_prefix("test_").unwrap_or(s);
1085        let mut out = vec![format!("rename to `test_{s}.py`")];
1086        if let Some(t) = self.testpaths.first() {
1087            out.push(format!(
1088                "move under `{}/` (testpaths)",
1089                t.trim_end_matches('/')
1090            ));
1091        }
1092        out
1093    }
1094}
1095
1096/// A pytest id's file: `tests/test_x.py::test_a` (listing) or the JUnit
1097/// `classname::name` form `tests.test_x::test_a` / `tests.test_x.TestC::t`.
1098#[must_use]
1099pub fn python_id_matches(path: &str, id: &str) -> bool {
1100    if id.starts_with(&format!("{path}::")) || id == path {
1101        return true;
1102    }
1103    let dotted = path.trim_end_matches(".py").replace('/', ".");
1104    id.starts_with(&format!("{dotted}::")) || id.starts_with(&format!("{dotted}."))
1105}
1106
1107// ---------------------------------------------------------------- JS / TS
1108
1109/// The JS runners.
1110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1111pub enum JsRunner {
1112    /// vitest.
1113    Vitest,
1114    /// jest.
1115    Jest,
1116}
1117
1118impl JsRunner {
1119    /// Its command name.
1120    #[must_use]
1121    pub const fn name(self) -> &'static str {
1122        match self {
1123            Self::Vitest => "vitest",
1124            Self::Jest => "jest",
1125        }
1126    }
1127
1128    /// The setting that selects test files.
1129    #[must_use]
1130    pub const fn key(self) -> &'static str {
1131        match self {
1132            Self::Vitest => "include",
1133            Self::Jest => "testMatch",
1134        }
1135    }
1136
1137    /// Its default patterns.
1138    #[must_use]
1139    pub fn defaults(self) -> Vec<String> {
1140        match self {
1141            Self::Vitest => vec!["**/*.{test,spec}.?(c|m)[jt]s?(x)".to_owned()],
1142            Self::Jest => vec![
1143                "**/__tests__/**/*.?([mc])[jt]s?(x)".to_owned(),
1144                "**/?(*.)+(spec|test).?([mc])[jt]s?(x)".to_owned(),
1145            ],
1146        }
1147    }
1148}
1149
1150/// A JS runner's include rule, when it is known statically.
1151#[derive(Debug, Clone, PartialEq, Eq)]
1152pub struct JsRule {
1153    /// Which runner.
1154    pub runner: JsRunner,
1155    /// Include patterns.
1156    pub include: Vec<String>,
1157    /// Exclude patterns (`node_modules` always).
1158    pub exclude: Vec<String>,
1159    /// Where they came from.
1160    pub source: String,
1161}
1162
1163impl JsRule {
1164    /// The rule sentence.
1165    #[must_use]
1166    pub fn rule(&self) -> CollectionRule {
1167        let pats: Vec<String> = self.include.iter().map(|p| format!("`{p}`")).collect();
1168        CollectionRule {
1169            toolchain: Toolchain::JavaScript,
1170            text: format!(
1171                "{} collects files matching {} ({})",
1172                self.runner.name(),
1173                pats.join(" or "),
1174                self.runner.key()
1175            ),
1176            source: self.source.clone(),
1177        }
1178    }
1179
1180    /// Why `path` (relative to the runner's root) is never collected, if it is not.
1181    #[must_use]
1182    pub fn breaks(&self, path: &str) -> Option<String> {
1183        if path.split('/').any(|c| c == "node_modules")
1184            || self.exclude.iter().any(|p| glob_match(p, path))
1185        {
1186            return Some("this file is excluded".to_owned());
1187        }
1188        (!self.include.iter().any(|p| glob_match(p, path)))
1189            .then(|| "this file does not match".to_owned())
1190    }
1191
1192    /// The ways out.
1193    #[must_use]
1194    pub fn fixes(&self, path: &str) -> Vec<String> {
1195        let name = file_name(path);
1196        let (s, ext) = name.split_once('.').unwrap_or((name, "ts"));
1197        let ext = ext.rsplit('.').next().unwrap_or("ts");
1198        vec![format!(
1199            "rename to `{s}.test.{ext}` ({}: `{}`)",
1200            self.runner.key(),
1201            self.include.first().map_or("", String::as_str)
1202        )]
1203    }
1204}
1205
1206// ---------------------------------------------------------------- Go
1207
1208/// The Go rule sentence.
1209pub const GO_RULE: &str = "go test runs `*_test.go` files in a package directory (not `testdata/` or a `_`/`.`-prefixed directory) whose functions are named `Test…`, `Example…` or `Fuzz…` followed by a non-lowercase character";
1210
1211/// The Go rule.
1212#[must_use]
1213pub fn go_rule() -> CollectionRule {
1214    CollectionRule {
1215        toolchain: Toolchain::Go,
1216        text: GO_RULE.to_owned(),
1217        source: "go test (default)".to_owned(),
1218    }
1219}
1220
1221/// Functions that look like tests (`func Test…`/`func test…`, …): the
1222/// markers that make a file judged.
1223fn go_test_like(source: &str) -> Vec<String> {
1224    let code = strip_comments(source);
1225    code.lines()
1226        .filter_map(|l| {
1227            let rest = l.trim_start().strip_prefix("func ")?;
1228            let name: String = rest
1229                .chars()
1230                .take_while(|c| c.is_alphanumeric() || *c == '_')
1231                .collect();
1232            let lower = name.to_ascii_lowercase();
1233            (["test", "example", "fuzz"]
1234                .iter()
1235                .any(|p| lower.starts_with(p))
1236                && rest[name.len()..].trim_start().starts_with('('))
1237            .then_some(name)
1238        })
1239        .collect()
1240}
1241
1242/// The test functions `go test` runs from a file: `Test`, `Example`,
1243/// `Fuzz` followed by end or a non-lowercase character.
1244#[must_use]
1245pub fn go_declared_tests(source: &str) -> Vec<String> {
1246    go_test_like(source)
1247        .into_iter()
1248        .filter(|n| {
1249            ["Test", "Example", "Fuzz"].iter().any(|p| {
1250                n.strip_prefix(p)
1251                    .is_some_and(|r| r.chars().next().is_none_or(|c| !c.is_lowercase()))
1252            })
1253        })
1254        .collect()
1255}
1256
1257/// The file's `//go:build` expression, when it has one.
1258#[must_use]
1259pub fn go_build_constraint(source: &str) -> Option<String> {
1260    source
1261        .lines()
1262        .take_while(|l| !l.trim_start().starts_with("package "))
1263        .find_map(|l| {
1264            l.trim()
1265                .strip_prefix("//go:build ")
1266                .map(|e| e.trim().to_owned())
1267        })
1268}
1269
1270/// Evaluates a `//go:build` expression with `tags` set (plus the OS,
1271/// architecture, `unix` and release tags `go1.N`, which are always on).
1272#[must_use]
1273pub fn go_build_satisfied(expr: &str, tags: &BTreeSet<String>) -> bool {
1274    let toks: Vec<String> = {
1275        let mut t = Vec::new();
1276        let mut cur = String::new();
1277        for c in expr.chars() {
1278            match c {
1279                '(' | ')' | '!' => {
1280                    if !cur.is_empty() {
1281                        t.push(std::mem::take(&mut cur));
1282                    }
1283                    t.push(c.to_string());
1284                }
1285                '&' | '|' => {
1286                    if !cur.is_empty() && !cur.starts_with(c) {
1287                        t.push(std::mem::take(&mut cur));
1288                    }
1289                    cur.push(c);
1290                    if cur.len() == 2 {
1291                        t.push(std::mem::take(&mut cur));
1292                    }
1293                }
1294                c if c.is_whitespace() => {
1295                    if !cur.is_empty() {
1296                        t.push(std::mem::take(&mut cur));
1297                    }
1298                }
1299                c => cur.push(c),
1300            }
1301        }
1302        if !cur.is_empty() {
1303            t.push(cur);
1304        }
1305        t
1306    };
1307    let on = |tag: &str| {
1308        tags.contains(tag)
1309            || tag.starts_with("go1.")
1310            || matches!(
1311                tag,
1312                "unix" | "linux" | "darwin" | "amd64" | "arm64" | "gc" | "cgo"
1313            )
1314    };
1315    fn or(t: &[String], i: &mut usize, on: &dyn Fn(&str) -> bool) -> bool {
1316        let mut v = and(t, i, on);
1317        while t.get(*i).is_some_and(|x| x == "||") {
1318            *i += 1;
1319            v |= and(t, i, on);
1320        }
1321        v
1322    }
1323    fn and(t: &[String], i: &mut usize, on: &dyn Fn(&str) -> bool) -> bool {
1324        let mut v = not(t, i, on);
1325        while t.get(*i).is_some_and(|x| x == "&&") {
1326            *i += 1;
1327            v &= not(t, i, on);
1328        }
1329        v
1330    }
1331    fn not(t: &[String], i: &mut usize, on: &dyn Fn(&str) -> bool) -> bool {
1332        match t.get(*i).map(String::as_str) {
1333            Some("!") => {
1334                *i += 1;
1335                !not(t, i, on)
1336            }
1337            Some("(") => {
1338                *i += 1;
1339                let v = or(t, i, on);
1340                *i += 1;
1341                v
1342            }
1343            Some(x) => {
1344                *i += 1;
1345                on(x)
1346            }
1347            None => false,
1348        }
1349    }
1350    let mut i = 0;
1351    or(&toks, &mut i, &on)
1352}
1353
1354/// Why a Go test file is never run by the static rule, if it is not, with
1355/// the fixes. `tags`: the build tags the check sets.
1356#[must_use]
1357pub fn go_breaks(
1358    path: &str,
1359    source: &str,
1360    tags: &BTreeSet<String>,
1361) -> Option<(String, Vec<String>)> {
1362    let s = stem(path);
1363    if let Some(d) = parent(path)
1364        .split('/')
1365        .find(|d| *d == "testdata" || d.starts_with('_') || d.starts_with('.'))
1366    {
1367        return Some((
1368            format!("it is under `{d}/`, which go ignores"),
1369            vec![format!("move it out of `{d}/`")],
1370        ));
1371    }
1372    if !file_name(path).ends_with("_test.go") {
1373        return Some((
1374            "its name does not end in `_test.go`".to_owned(),
1375            vec![format!("rename to `{s}_test.go`")],
1376        ));
1377    }
1378    if let Some(expr) = go_build_constraint(source)
1379        && !go_build_satisfied(&expr, tags)
1380    {
1381        return Some((
1382            format!("its build constraint `{expr}` is not set by the check"),
1383            vec![format!(
1384                "the file needs build tag `{expr}`, which the check does not set: add `-tags` to the check, or drop the constraint"
1385            )],
1386        ));
1387    }
1388    let like = go_test_like(source);
1389    if go_declared_tests(source).is_empty() && !like.is_empty() {
1390        return Some((
1391            format!(
1392                "no function is named `Test[A-Z_]…` ({})",
1393                like.iter()
1394                    .map(|n| format!("`{n}`"))
1395                    .collect::<Vec<_>>()
1396                    .join(", ")
1397            ),
1398            vec!["rename the functions to `Test<Name>` (capital after `Test`)".to_owned()],
1399        ));
1400    }
1401    None
1402}
1403
1404/// A Go test id's match: JUnit `importpath::TestX` or a listing name, for
1405/// a file whose package is `import_path` and that declares `names`.
1406#[must_use]
1407pub fn go_id_matches(import_path: &str, names: &[String], id: &str) -> bool {
1408    let (pkg, name) = id.rsplit_once("::").unwrap_or(("", id));
1409    let base = name.split('/').next().unwrap_or(name);
1410    (pkg.is_empty() || pkg == import_path) && names.iter().any(|n| n == base)
1411}
1412
1413// ---------------------------------------------------------------- decision
1414
1415/// A decided file, built from parts.
1416#[must_use]
1417pub fn collected(path: &str, by: DecidedBy, ids: &[String]) -> FileCollection {
1418    FileCollection {
1419        path: path.to_owned(),
1420        state: FileState::Collected,
1421        decided_by: Some(by),
1422        tests: ids.len(),
1423        test_ids: ids.iter().take(20).cloned().collect(),
1424        rule: None,
1425        why: None,
1426        fixes: Vec::new(),
1427    }
1428}
1429
1430/// A file no test ran from.
1431#[must_use]
1432pub fn not_collected(
1433    path: &str,
1434    by: DecidedBy,
1435    rule: CollectionRule,
1436    why: &str,
1437    fixes: Vec<String>,
1438) -> FileCollection {
1439    FileCollection {
1440        path: path.to_owned(),
1441        state: FileState::NotCollected,
1442        decided_by: Some(by),
1443        tests: 0,
1444        test_ids: Vec::new(),
1445        rule: Some(rule),
1446        why: Some(why.to_owned()),
1447        fixes,
1448    }
1449}
1450
1451/// A file speccoder cannot tell about.
1452#[must_use]
1453pub fn unknown(path: &str, why: &str) -> FileCollection {
1454    FileCollection {
1455        path: path.to_owned(),
1456        state: FileState::Unknown,
1457        decided_by: None,
1458        tests: 0,
1459        test_ids: Vec::new(),
1460        rule: None,
1461        why: Some(why.to_owned()),
1462        fixes: Vec::new(),
1463    }
1464}
1465
1466/// The reason for a file reachable by the rule whose tests did not run.
1467pub const COMPILED_OUT: &str =
1468    "the runner collected no test from it (its tests are compiled out or ignored)";
1469
1470#[cfg(test)]
1471mod tests {
1472    use super::*;
1473    use proptest::prelude::*;
1474
1475    fn set(files: &[&str]) -> BTreeSet<String> {
1476        files.iter().map(|s| (*s).to_owned()).collect()
1477    }
1478
1479    fn pkg() -> RustPackage {
1480        RustPackage {
1481            root: String::new(),
1482            name: "greet".to_owned(),
1483            autotests: true,
1484            ..RustPackage::default()
1485        }
1486    }
1487
1488    fn reach(files: &[(&str, &str)]) -> BTreeMap<String, Vec<(String, String)>> {
1489        let map: BTreeMap<String, String> = files
1490            .iter()
1491            .map(|(p, t)| ((*p).to_owned(), (*t).to_owned()))
1492            .collect();
1493        let names: BTreeSet<String> = map.keys().cloned().collect();
1494        let targets = rust_targets(&pkg(), &names);
1495        rust_reachable(&targets, &|p| map.get(p).cloned())
1496    }
1497
1498    #[test]
1499    fn the_poc_files_are_unreachable_and_tests_rs_files_are_targets() {
1500        let r = reach(&[
1501            (
1502                "src/lib.rs",
1503                "pub fn greet() {}\n#[cfg(test)]\nmod tests { #[test] fn it_works() {} }\n",
1504            ),
1505            ("tests/unit/greet_name.rs", "#[test] fn a() {}\n"),
1506            ("tests/greet_name.rs", "#[test] fn a() {}\n"),
1507        ]);
1508        assert!(!r.contains_key("tests/unit/greet_name.rs"));
1509        assert_eq!(
1510            r["tests/greet_name.rs"],
1511            [("greet::greet_name".to_owned(), String::new())]
1512        );
1513        assert_eq!(r["src/lib.rs"], [("greet".to_owned(), String::new())]);
1514    }
1515
1516    #[test]
1517    fn a_main_rs_declares_its_modules_and_path_attributes_are_followed() {
1518        let r = reach(&[
1519            (
1520                "tests/unit/main.rs",
1521                "mod greet_name;\n// mod commented;\n#[path = \"../shared/x.rs\"]\nmod shared;\n",
1522            ),
1523            ("tests/unit/greet_name.rs", "#[test] fn a() {}\n"),
1524            ("tests/unit/commented.rs", "#[test] fn a() {}\n"),
1525            ("tests/shared/x.rs", "#[test] fn a() {}\n"),
1526            (
1527                "src/lib.rs",
1528                "#[cfg(feature = \"slow\")]\nmod slow;\nmod outer { mod inner; }\n#[path = \"../../escape.rs\"] mod escape;\n",
1529            ),
1530            ("src/slow.rs", "#[test] fn s() {}\n"),
1531            ("src/outer/inner.rs", "#[test] fn s() {}\n"),
1532        ]);
1533        assert_eq!(
1534            r["tests/unit/greet_name.rs"],
1535            [("greet::unit".to_owned(), "greet_name".to_owned())]
1536        );
1537        assert!(!r.contains_key("tests/unit/commented.rs"));
1538        assert_eq!(
1539            r["tests/shared/x.rs"],
1540            [("greet::unit".to_owned(), "shared".to_owned())]
1541        );
1542        assert_eq!(r["src/slow.rs"], [("greet".to_owned(), "slow".to_owned())]);
1543        assert_eq!(
1544            r["src/outer/inner.rs"],
1545            [("greet".to_owned(), "outer::inner".to_owned())]
1546        );
1547    }
1548
1549    #[test]
1550    fn autotests_off_leaves_only_listed_targets() {
1551        let p = RustPackage {
1552            autotests: false,
1553            tests: vec![("it".to_owned(), "tests/it.rs".to_owned())],
1554            ..pkg()
1555        };
1556        let t = rust_targets(&p, &set(&["tests/it.rs", "tests/other.rs", "src/lib.rs"]));
1557        let roots: Vec<&str> = t.iter().map(|t| t.root_file.as_str()).collect();
1558        assert_eq!(roots, ["src/lib.rs", "tests/it.rs"]);
1559        assert!(rust_rule(&p).text.contains("`tests/it.rs`"));
1560    }
1561
1562    #[test]
1563    fn test_ids_map_to_the_file_with_the_longest_module() {
1564        let r = reach(&[
1565            (
1566                "src/lib.rs",
1567                "mod a;\n#[cfg(test)] mod tests { #[test] fn t() {} }\n",
1568            ),
1569            ("src/a.rs", "#[test] fn x() {}\n"),
1570            ("tests/unit/main.rs", "mod greet_name;\n"),
1571            ("tests/unit/greet_name.rs", "#[test] fn a() {}\n"),
1572        ]);
1573        let ids = [
1574            "greet::tests::t",
1575            "greet::a::x",
1576            "greet::a::tests::y",
1577            "greet::unit::greet_name::a",
1578            "other::z",
1579        ]
1580        .map(str::to_owned);
1581        let m = rust_tests_by_file(&r, &ids);
1582        assert_eq!(m["src/lib.rs"], ["greet::tests::t"]);
1583        assert_eq!(m["src/a.rs"], ["greet::a::x", "greet::a::tests::y"]);
1584        assert_eq!(
1585            m["tests/unit/greet_name.rs"],
1586            ["greet::unit::greet_name::a"]
1587        );
1588        assert!(!m.contains_key("tests/unit/main.rs"));
1589    }
1590
1591    #[test]
1592    fn markers_tell_tests_from_helpers() {
1593        assert!(defines_tests(Toolchain::Rust, "#[test]\nfn a() {}"));
1594        assert!(defines_tests(
1595            Toolchain::Rust,
1596            "#[tokio::test]\nasync fn a() {}"
1597        ));
1598        assert!(defines_tests(
1599            Toolchain::Rust,
1600            "#[test_log::test]\nfn a() {}"
1601        ));
1602        assert!(defines_tests(
1603            Toolchain::Rust,
1604            "#[sqlx::test(migrations = \"m\")]\nasync fn a() {}"
1605        ));
1606        assert!(defines_tests(Toolchain::Rust, "# [test]\nfn a() {}"));
1607        assert!(!defines_tests(
1608            Toolchain::Rust,
1609            "pub fn setup() {}\n// #[test]\n"
1610        ));
1611        assert!(defines_tests(
1612            Toolchain::Python,
1613            "def test_a():\n    pass\n"
1614        ));
1615        assert!(!defines_tests(
1616            Toolchain::Python,
1617            "import pytest\n@pytest.fixture\ndef x(): ...\n"
1618        ));
1619        assert!(defines_tests(
1620            Toolchain::JavaScript,
1621            "it('greets', () => {})"
1622        ));
1623        assert!(!defines_tests(
1624            Toolchain::JavaScript,
1625            "export const split = (s) => s.split(',')"
1626        ));
1627        assert!(defines_tests(
1628            Toolchain::Go,
1629            "package g\nfunc testLower(t *testing.T) {}\n"
1630        ));
1631        assert!(!defines_tests(
1632            Toolchain::Go,
1633            "package g\nfunc helper() {}\n"
1634        ));
1635    }
1636
1637    #[test]
1638    fn the_retry_line_and_reason_read_as_the_contract_says() {
1639        let f = not_collected(
1640            "tests/unit/greet_name.rs",
1641            DecidedBy::Static,
1642            rust_rule(&pkg()),
1643            "this file is neither",
1644            rust_fixes("", "tests/unit/greet_name.rs"),
1645        );
1646        assert_eq!(
1647            reason(&f),
1648            "tests not collected: `tests/unit/greet_name.rs` — Cargo builds `tests/*.rs` and `tests/<dir>/main.rs` (and the modules they declare); this file is neither"
1649        );
1650        assert_eq!(
1651            retry_line(&f),
1652            "`tests/unit/greet_name.rs` never ran: Cargo builds `tests/*.rs` and `tests/<dir>/main.rs`. Move the tests to `tests/greet_name.rs`, or add `tests/unit/main.rs` with `mod greet_name;`."
1653        );
1654    }
1655
1656    #[test]
1657    fn pytest_rules() {
1658        let r = PytestRule::default();
1659        assert_eq!(
1660            r.breaks("tests/check_greet.py").as_deref(),
1661            Some("this file matches neither")
1662        );
1663        assert_eq!(r.breaks("tests/test_greet.py"), None);
1664        assert_eq!(r.breaks("greet_test.py"), None);
1665        assert!(
1666            r.breaks("build/test_x.py")
1667                .is_some_and(|w| w.contains("norecursedirs"))
1668        );
1669        let r = PytestRule {
1670            testpaths: vec!["tests".to_owned()],
1671            ..PytestRule::default()
1672        };
1673        assert_eq!(
1674            r.breaks("src/test_x.py").as_deref(),
1675            Some("this file is outside testpaths")
1676        );
1677        assert_eq!(
1678            r.rule().text,
1679            "pytest collects `test_*.py` and `*_test.py` (python_files) under `tests` (testpaths)"
1680        );
1681        assert!(python_id_matches(
1682            "tests/test_x.py",
1683            "tests/test_x.py::test_a"
1684        ));
1685        assert!(python_id_matches("tests/test_x.py", "tests.test_x::test_a"));
1686        assert!(python_id_matches(
1687            "tests/test_x.py",
1688            "tests.test_x.TestC::test_a"
1689        ));
1690        assert!(!python_id_matches(
1691            "tests/test_x.py",
1692            "tests.test_xy::test_a"
1693        ));
1694    }
1695
1696    #[test]
1697    fn js_defaults_and_globs() {
1698        let v = JsRule {
1699            runner: JsRunner::Vitest,
1700            include: JsRunner::Vitest.defaults(),
1701            exclude: Vec::new(),
1702            source: "vitest (default)".to_owned(),
1703        };
1704        assert_eq!(v.breaks("src/greet.test.ts"), None);
1705        assert_eq!(v.breaks("src/greet.spec.mjs"), None);
1706        assert_eq!(
1707            v.breaks("src/greet.tests.ts").as_deref(),
1708            Some("this file does not match")
1709        );
1710        assert!(v.breaks("node_modules/x/a.test.ts").is_some());
1711        let j = JsRule {
1712            runner: JsRunner::Jest,
1713            include: JsRunner::Jest.defaults(),
1714            exclude: Vec::new(),
1715            source: "jest (default)".to_owned(),
1716        };
1717        assert_eq!(j.breaks("src/__tests__/greet.js"), None);
1718        assert_eq!(j.breaks("greet.test.tsx"), None);
1719        assert!(j.breaks("src/greet.check.js").is_some());
1720        assert!(glob_match("src/**/*.test.ts", "src/a/b/c.test.ts"));
1721        assert!(glob_match("src/**/*.test.ts", "src/c.test.ts"));
1722        assert!(!glob_match("src/*.test.ts", "src/a/c.test.ts"));
1723    }
1724
1725    #[test]
1726    fn go_rules() {
1727        let none = BTreeSet::new();
1728        assert!(
1729            go_breaks(
1730                "pkg/greet/greet_test.go",
1731                "package greet\nfunc TestGreet(t *testing.T) {}\n",
1732                &none
1733            )
1734            .is_none()
1735        );
1736        assert!(
1737            go_breaks(
1738                "pkg/testdata/x_test.go",
1739                "func TestX(t *testing.T) {}",
1740                &none
1741            )
1742            .is_some_and(|(w, _)| w.contains("testdata"))
1743        );
1744        let tagged = "//go:build integration\n\npackage g\nfunc TestX(t *testing.T) {}\n";
1745        assert!(
1746            go_breaks("g/x_test.go", tagged, &none).is_some_and(|(w, _)| w.contains("integration"))
1747        );
1748        assert!(go_breaks("g/x_test.go", tagged, &set(&["integration"])).is_none());
1749        assert!(
1750            go_breaks(
1751                "g/x_test.go",
1752                "package g\nfunc testLower(t *testing.T) {}\n",
1753                &none
1754            )
1755            .is_some_and(|(w, _)| w.contains("testLower"))
1756        );
1757        assert_eq!(
1758            go_declared_tests(
1759                "func TestA(t *T) {}\nfunc Testb(t *T) {}\nfunc Example() {}\nfunc Test(t *T) {}\n"
1760            ),
1761            ["TestA", "Example", "Test"]
1762        );
1763        assert!(go_build_satisfied("linux || darwin", &none));
1764        assert!(!go_build_satisfied("!unix", &none));
1765        assert!(go_build_satisfied(
1766            "integration && !windows",
1767            &set(&["integration"])
1768        ));
1769        assert!(go_id_matches(
1770            "ex.com/m/g",
1771            &["TestA".to_owned()],
1772            "ex.com/m/g::TestA"
1773        ));
1774        assert!(go_id_matches(
1775            "ex.com/m/g",
1776            &["TestA".to_owned()],
1777            "TestA/sub"
1778        ));
1779        assert!(!go_id_matches(
1780            "ex.com/m/g",
1781            &["TestA".to_owned()],
1782            "ex.com/m/h::TestA"
1783        ));
1784    }
1785
1786    proptest! {
1787        // Every file declared by construction from a crate root is reachable.
1788        #[test]
1789        fn declared_modules_are_reachable(names in proptest::collection::vec("[a-z]{1,6}", 1..6)) {
1790            let mut files: Vec<(String, String)> = Vec::new();
1791            let mut root = String::new();
1792            let mut dir = "tests/suite".to_owned();
1793            let mut prev: Option<String> = None;
1794            let mut uniq = BTreeSet::new();
1795            for n in names.iter().filter(|n| uniq.insert((*n).clone())) {
1796                let decl = format!("mod {n};\n");
1797                match &prev {
1798                    None => root.push_str(&decl),
1799                    Some(p) => {
1800                        if let Some(f) = files.iter_mut().find(|(f, _)| f == p) {
1801                            f.1.push_str(&decl);
1802                        }
1803                    }
1804                }
1805                let path = format!("{dir}/{n}.rs");
1806                files.push((path.clone(), "#[test] fn t() {}\n".to_owned()));
1807                dir = format!("{dir}/{n}");
1808                prev = Some(path);
1809            }
1810            files.push(("tests/suite/main.rs".to_owned(), root));
1811            let refs: Vec<(&str, &str)> = files.iter().map(|(a, b)| (a.as_str(), b.as_str())).collect();
1812            let r = reach(&refs);
1813            for (f, _) in &files {
1814                prop_assert!(r.contains_key(f), "{f} unreachable");
1815            }
1816        }
1817    }
1818}