Skip to main content

speccoder_runtime/
layout.rs

1//! The project's test layout, detected by code: the runners of
2//! the checkout, each runner's collection rule as configured, where tests
3//! live today, the naming habit, and where speccoder puts a story's
4//! acceptance file. A fact handed to the implementer and every test author,
5//! never a judge input; undeterminable parts are listed as unknown, never
6//! guessed. Same checkout, same bytes.
7
8use std::collections::BTreeMap;
9use std::path::Path;
10
11use serde::Serialize;
12use speccoder_core::collection::{
13    CollectionRule, JsRunner, Toolchain, defines_tests, go_rule, rust_reachable, rust_rule,
14    rust_targets, strip_comments, toolchain_of,
15};
16
17use crate::collection::static_cfg;
18
19/// Where tests live today.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
21pub struct Location {
22    /// `inline_modules`, `tests_dir`, `beside_sources`, `package`.
23    pub kind: String,
24    /// The directory (or `*` for inline modules across the sources).
25    pub path: String,
26    /// How many test files.
27    pub files: usize,
28}
29
30/// One runner's layout.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
32pub struct RunnerLayout {
33    /// The toolchain.
34    pub toolchain: Toolchain,
35    /// The member or package root (`""` at the checkout's root).
36    pub root: String,
37    /// Its collection rule as configured.
38    pub rule: CollectionRule,
39    /// Where tests live today.
40    pub locations: Vec<Location>,
41    /// Test files present that nothing runs.
42    pub not_run: Vec<String>,
43    /// The project's naming habit.
44    pub naming: Option<String>,
45    /// Where speccoder puts story files; `None`: no place the runner collects.
46    pub story_dir: Option<String>,
47    /// The file extension story files take (JS/TS: the project's majority).
48    pub extension: String,
49    /// What could not be determined.
50    pub unknown: Vec<String>,
51}
52
53/// The checkout's test layout.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
55pub struct TestLayout {
56    /// Every runner found, in toolchain order.
57    pub runners: Vec<RunnerLayout>,
58    /// SHA-256 of the runners' canonical JSON.
59    pub digest: String,
60}
61
62fn parent(p: &str) -> &str {
63    p.rsplit_once('/').map_or("", |(d, _)| d)
64}
65
66fn at(root: &str, rel: &str) -> String {
67    if root.is_empty() {
68        rel.to_owned()
69    } else {
70        format!("{root}/{rel}")
71    }
72}
73
74/// Detects the layout of the checkout at `root`; `check_argvs` are the
75/// checks' commands (they name the runners).
76#[must_use]
77pub fn detect(root: &Path, check_argvs: &[Vec<String>]) -> TestLayout {
78    let files = static_cfg::files(root);
79    let read = |p: &str| std::fs::read_to_string(root.join(p)).ok();
80    let tests_in = |pred: &dyn Fn(&str) -> bool| -> Vec<&String> {
81        files
82            .iter()
83            .filter(|f| pred(f))
84            .filter(|f| {
85                toolchain_of(f).is_some_and(|t| read(f).is_some_and(|s| defines_tests(t, &s)))
86            })
87            .collect()
88    };
89    let mut runners = Vec::new();
90
91    for pkg in static_cfg::rust_packages(root, &files) {
92        let targets = rust_targets(&pkg, &files);
93        let reach = rust_reachable(&targets, &|p| read(p));
94        let inside = |f: &str| {
95            f.ends_with(".rs")
96                && static_cfg::owner(std::slice::from_ref(&pkg), f).is_some()
97                && !f.starts_with(&at(&pkg.root, "target/"))
98        };
99        let tests_dir = at(&pkg.root, "tests");
100        let inline = tests_in(&|f| {
101            inside(f)
102                && !f.starts_with(&format!("{tests_dir}/"))
103                && read(f).is_some_and(|s| strip_comments(&s).contains("#[cfg(test)]"))
104        });
105        let top = tests_in(&|f| {
106            inside(f)
107                && f.strip_prefix(&format!("{tests_dir}/"))
108                    .is_some_and(|r| !r.contains('/'))
109        });
110        let not_run: Vec<String> =
111            tests_in(&|f| inside(f) && f.starts_with(&format!("{tests_dir}/")))
112                .into_iter()
113                .filter(|f| !reach.contains_key(*f))
114                .cloned()
115                .collect();
116        let mut locations = vec![
117            Location {
118                kind: "inline_modules".to_owned(),
119                path: at(&pkg.root, "src"),
120                files: inline.len(),
121            },
122            Location {
123                kind: "tests_dir".to_owned(),
124                path: format!("{tests_dir}/*.rs"),
125                files: top.len(),
126            },
127        ];
128        let mains = files
129            .iter()
130            .filter(|f| {
131                f.strip_prefix(&format!("{tests_dir}/"))
132                    .is_some_and(|r| r.matches('/').count() == 1 && r.ends_with("/main.rs"))
133            })
134            .count();
135        if mains > 0 {
136            locations.push(Location {
137                kind: "tests_dir".to_owned(),
138                path: format!("{tests_dir}/<dir>/main.rs"),
139                files: mains,
140            });
141        }
142        let mut unknown = Vec::new();
143        let story_dir = if pkg.autotests {
144            Some(tests_dir.clone())
145        } else {
146            unknown.push(format!(
147                "{}: `autotests = false` — a new integration test needs a `[[test]]` entry, which speccoder does not add",
148                at(&pkg.root, "Cargo.toml")
149            ));
150            None
151        };
152        runners.push(RunnerLayout {
153            toolchain: Toolchain::Rust,
154            root: pkg.root.clone(),
155            rule: rust_rule(&pkg),
156            locations,
157            not_run,
158            naming: Some(format!("{tests_dir}/<area>.rs")),
159            story_dir,
160            extension: "rs".to_owned(),
161            unknown,
162        });
163    }
164
165    if static_cfg::uses_pytest(root, &files, check_argvs) {
166        let rule = static_cfg::pytest_rule(root);
167        let py = tests_in(&|f| f.ends_with(".py"));
168        let mut by_dir: BTreeMap<String, usize> = BTreeMap::new();
169        for f in &py {
170            *by_dir.entry(parent(f).to_owned()).or_default() += 1;
171        }
172        let exists = |d: &str| d.is_empty() || root.join(d).is_dir();
173        let mut unknown = Vec::new();
174        let story_dir = match rule.testpaths.first() {
175            Some(t) if exists(t.trim_end_matches('/')) => Some(t.trim_end_matches('/').to_owned()),
176            Some(t) => {
177                unknown.push(format!(
178                    "testpaths names `{t}`, which does not exist: no place pytest collects"
179                ));
180                None
181            }
182            None if exists("tests") => Some("tests".to_owned()),
183            None => Some(String::new()),
184        };
185        let prefix = py
186            .iter()
187            .filter(|f| f.rsplit('/').next().is_some_and(|n| n.starts_with("test_")))
188            .count();
189        runners.push(RunnerLayout {
190            toolchain: Toolchain::Python,
191            root: String::new(),
192            rule: rule.rule(),
193            locations: by_dir
194                .into_iter()
195                .map(|(path, files)| Location {
196                    kind: "tests_dir".to_owned(),
197                    path,
198                    files,
199                })
200                .collect(),
201            not_run: py
202                .iter()
203                .filter(|f| rule.breaks(f).is_some())
204                .map(|f| (*f).clone())
205                .collect(),
206            naming: Some(if prefix * 2 >= py.len() {
207                "test_<name>.py".to_owned()
208            } else {
209                "<name>_test.py".to_owned()
210            }),
211            story_dir,
212            extension: "py".to_owned(),
213            unknown,
214        });
215    }
216
217    for pkg in static_cfg::js_packages(root, &files, check_argvs) {
218        let inside = |f: &str| pkg.root.is_empty() || f.starts_with(&format!("{}/", pkg.root));
219        let js = tests_in(&|f| inside(f) && toolchain_of(f) == Some(Toolchain::JavaScript));
220        let rel = |f: &str| {
221            if pkg.root.is_empty() {
222                f.to_owned()
223            } else {
224                f.trim_start_matches(&format!("{}/", pkg.root)).to_owned()
225            }
226        };
227        let collected: Vec<&String> = js
228            .iter()
229            .copied()
230            .filter(|f| {
231                pkg.rule
232                    .as_ref()
233                    .map_or(true, |r| r.breaks(&rel(f)).is_none())
234            })
235            .collect();
236        let mut by_dir: BTreeMap<String, usize> = BTreeMap::new();
237        for f in &collected {
238            *by_dir.entry(parent(f).to_owned()).or_default() += 1;
239        }
240        let ts = collected.iter().filter(|f| f.contains(".ts")).count();
241        let extension = if collected.is_empty() {
242            if files.iter().any(|f| inside(f) && f.ends_with(".ts")) {
243                "ts"
244            } else {
245                "js"
246            }
247        } else if ts * 2 >= collected.len() {
248            "ts"
249        } else {
250            "js"
251        }
252        .to_owned();
253        let habit = by_dir
254            .iter()
255            .max_by_key(|(_, n)| **n)
256            .map(|(d, _)| d.clone());
257        let src = at(&pkg.root, "src");
258        let story_dir = habit.clone().or_else(|| {
259            Some(if root.join(&src).is_dir() {
260                src.clone()
261            } else {
262                pkg.root.clone()
263            })
264        });
265        let mut unknown = Vec::new();
266        let rule = match &pkg.rule {
267            Ok(r) => r.rule(),
268            Err(why) => {
269                unknown.push(why.clone());
270                CollectionRule {
271                    toolchain: Toolchain::JavaScript,
272                    text: format!(
273                        "{} collects the files its configuration includes (not read: {why})",
274                        pkg.runner.name()
275                    ),
276                    source: at(&pkg.root, "package.json"),
277                }
278            }
279        };
280        runners.push(RunnerLayout {
281            toolchain: Toolchain::JavaScript,
282            root: pkg.root.clone(),
283            rule,
284            locations: by_dir
285                .into_iter()
286                .map(|(path, files)| Location {
287                    kind: if path.contains("__tests__") {
288                        "tests_dir"
289                    } else {
290                        "beside_sources"
291                    }
292                    .to_owned(),
293                    path,
294                    files,
295                })
296                .collect(),
297            not_run: js
298                .iter()
299                .filter(|f| !collected.contains(f))
300                .map(|f| (*f).clone())
301                .collect(),
302            naming: Some(match pkg.runner {
303                JsRunner::Vitest | JsRunner::Jest => format!("<name>.test.{extension}"),
304            }),
305            story_dir,
306            extension,
307            unknown,
308        });
309    }
310
311    for m in static_cfg::go_modules(root, &files) {
312        let inside = |f: &str| m.root.is_empty() || f.starts_with(&format!("{}/", m.root));
313        let go = tests_in(&|f| inside(f) && f.ends_with("_test.go"));
314        let mut by_dir: BTreeMap<String, usize> = BTreeMap::new();
315        for f in &go {
316            *by_dir.entry(parent(f).to_owned()).or_default() += 1;
317        }
318        let tags = static_cfg::go_tags(check_argvs);
319        runners.push(RunnerLayout {
320            toolchain: Toolchain::Go,
321            root: m.root.clone(),
322            rule: go_rule(),
323            locations: by_dir
324                .into_iter()
325                .map(|(path, files)| Location {
326                    kind: "package".to_owned(),
327                    path,
328                    files,
329                })
330                .collect(),
331            not_run: go
332                .iter()
333                .filter(|f| {
334                    speccoder_core::collection::go_breaks(f, &read(f).unwrap_or_default(), &tags)
335                        .is_some()
336                })
337                .map(|f| (*f).clone())
338                .collect(),
339            naming: Some("<name>_test.go beside the package's sources".to_owned()),
340            story_dir: Some(m.root.clone()),
341            extension: "go".to_owned(),
342            unknown: Vec::new(),
343        });
344    }
345
346    let canonical = serde_json::to_string(&runners).unwrap_or_default();
347    TestLayout {
348        digest: crate::integrity::sha256_hex(canonical.as_bytes()),
349        runners,
350    }
351}
352
353/// The layout's text for the project at `root`, its checks read from its
354/// `speccoder.toml`.
355#[must_use]
356pub fn text_at(root: &Path) -> String {
357    detect(root, &crate::verify::check_argvs(root)).text()
358}
359
360impl TestLayout {
361    /// The runner speccoder's acceptance tests go to: the first with a
362    /// collected place, else the first at all.
363    #[must_use]
364    pub fn primary(&self) -> Option<&RunnerLayout> {
365        self.runners
366            .iter()
367            .find(|r| r.story_dir.is_some())
368            .or_else(|| self.runners.first())
369    }
370
371    /// The story file for `story` (`US1`) — for Go, in `package` (a
372    /// directory). `None`: no place the runner collects.
373    #[must_use]
374    pub fn story_file(&self, story: &str, package: Option<&str>) -> Option<String> {
375        let r = self.primary()?;
376        let dir = r.story_dir.as_ref()?;
377        let s = story.to_ascii_lowercase();
378        Some(match r.toolchain {
379            Toolchain::Rust => at(dir, &format!("acceptance_{s}.rs")),
380            Toolchain::Python => at(dir, &format!("test_acceptance_{s}.py")),
381            Toolchain::JavaScript => at(dir, &format!("acceptance-{s}.test.{}", r.extension)),
382            Toolchain::Go => at(package.unwrap_or(dir), &format!("acceptance_{s}_test.go")),
383        })
384    }
385
386    /// The command that runs only `file`, for the author's prompt.
387    #[must_use]
388    pub fn command_for(&self, file: &str) -> Option<String> {
389        let r = self.primary()?;
390        let stem = file.rsplit('/').next()?.split('.').next()?.to_owned();
391        Some(match r.toolchain {
392            Toolchain::Rust => format!("cargo test --test {stem}"),
393            Toolchain::Python => format!("python3 -m pytest {file}"),
394            Toolchain::JavaScript => format!("npx vitest run {file}"),
395            Toolchain::Go => format!("go test ./{}", parent(file)),
396        })
397    }
398
399    /// Whether `argv` is the command that runs only one of `files` — the
400    /// one the author's prompt names, which an exact `[commands] allow`
401    /// (`cargo test`) would otherwise refuse.
402    #[must_use]
403    pub fn runs_only(&self, argv: &[String], files: &[&str]) -> bool {
404        files.iter().any(|f| {
405            self.command_for(f)
406                .is_some_and(|c| c.split_whitespace().eq(argv.iter().map(String::as_str)))
407        })
408    }
409
410    /// The part a model reads (contracts/prompts.md).
411    #[must_use]
412    pub fn text(&self) -> String {
413        let mut out = String::from("# Test layout (detected by speccoder from this checkout)\n");
414        if self.runners.is_empty() {
415            out.push_str("\nNo test runner speccoder knows (Cargo, pytest, vitest/jest, go test) was found.\n");
416        }
417        for r in &self.runners {
418            let name = match r.toolchain {
419                Toolchain::Rust => "cargo (Rust)",
420                Toolchain::Python => "pytest (Python)",
421                Toolchain::JavaScript => "vitest/jest (JavaScript/TypeScript)",
422                Toolchain::Go => "go test (Go)",
423            };
424            out.push_str(&format!(
425                "\nRunner: {name}{}\nRule: {} — from {}\n",
426                if r.root.is_empty() {
427                    String::new()
428                } else {
429                    format!(", in `{}`", r.root)
430                },
431                r.rule.text,
432                r.rule.source
433            ));
434            let today: Vec<String> = r
435                .locations
436                .iter()
437                .map(|l| match l.kind.as_str() {
438                    "inline_modules" => format!(
439                        "inline `#[cfg(test)]` modules in {} file(s) under {}",
440                        l.files, l.path
441                    ),
442                    _ => format!("{}: {} file(s)", l.path, l.files),
443                })
444                .collect();
445            if !today.is_empty() {
446                out.push_str(&format!("Tests today: {}\n", today.join("; ")));
447            }
448            if !r.not_run.is_empty() {
449                out.push_str(&format!("Not run by anything: {}\n", r.not_run.join(", ")));
450            }
451            if let Some(n) = &r.naming {
452                out.push_str(&format!("Naming: {n}\n"));
453            }
454            for u in &r.unknown {
455                out.push_str(&format!("Not determined: {u}\n"));
456            }
457        }
458        out.push_str(
459            "\nPut new tests where the runner collects them. A path the task names that the runner would not\ncollect is a mistake in the plan — use the nearest collected place and say so in your report.\nA test file that never runs fails the verdict.\n",
460        );
461        out
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    fn write(root: &Path, files: &[(&str, &str)]) {
470        for (p, t) in files {
471            let f = root.join(p);
472            std::fs::create_dir_all(f.parent().unwrap_or(root)).ok();
473            std::fs::write(f, t).ok();
474        }
475    }
476
477    #[test]
478    fn a_rust_crate_s_layout_names_its_rule_its_tests_and_its_dead_files() {
479        let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
480        write(
481            d.path(),
482            &[
483                (
484                    "Cargo.toml",
485                    "[package]\nname = \"greet\"\nversion = \"0.1.0\"\n",
486                ),
487                (
488                    "src/lib.rs",
489                    "pub fn greet() {}\n#[cfg(test)]\nmod tests { #[test] fn t() {} }\n",
490                ),
491                ("tests/unit/greet_name.rs", "#[test]\nfn a() {}\n"),
492            ],
493        );
494        let l = detect(d.path(), &[]);
495        assert_eq!(
496            l.story_file("US1", None).as_deref(),
497            Some("tests/acceptance_us1.rs")
498        );
499        assert_eq!(
500            l.command_for("tests/acceptance_us1.rs").as_deref(),
501            Some("cargo test --test acceptance_us1")
502        );
503        let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
504        let file = ["tests/acceptance_us1.rs"];
505        assert!(l.runs_only(&argv("cargo test --test acceptance_us1"), &file));
506        assert!(!l.runs_only(&argv("cargo test --test acceptance_us2"), &file));
507        assert!(!l.runs_only(&argv("cargo test"), &file));
508        let text = l.text();
509        assert!(text.contains("Rule: Cargo builds `tests/*.rs`"), "{text}");
510        assert!(
511            text.contains("inline `#[cfg(test)]` modules in 1 file(s)"),
512            "{text}"
513        );
514        assert!(
515            text.contains("Not run by anything: tests/unit/greet_name.rs"),
516            "{text}"
517        );
518        // Same checkout, same bytes.
519        assert_eq!(detect(d.path(), &[]), l);
520    }
521
522    #[test]
523    fn no_collected_place_is_said_never_guessed() {
524        let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
525        write(
526            d.path(),
527            &[
528                (
529                    "Cargo.toml",
530                    "[package]\nname = \"g\"\nversion = \"0.1.0\"\nautotests = false\n",
531                ),
532                ("src/lib.rs", ""),
533            ],
534        );
535        let l = detect(d.path(), &[]);
536        assert_eq!(l.story_file("US1", None), None);
537        assert!(l.runners[0].unknown[0].contains("autotests = false"));
538        let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
539        write(d.path(), &[("pytest.ini", "[pytest]\ntestpaths = spec\n")]);
540        assert_eq!(detect(d.path(), &[]).story_file("US1", None), None);
541    }
542
543    #[test]
544    fn python_js_and_go_story_files_follow_the_layout() {
545        let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
546        write(
547            d.path(),
548            &[
549                ("pytest.ini", "[pytest]\n"),
550                ("tests/test_a.py", "def test_a():\n    pass\n"),
551            ],
552        );
553        assert_eq!(
554            detect(d.path(), &[]).story_file("US2", None).as_deref(),
555            Some("tests/test_acceptance_us2.py")
556        );
557        let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
558        write(
559            d.path(),
560            &[
561                (
562                    "package.json",
563                    "{ \"devDependencies\": { \"vitest\": \"3\" } }",
564                ),
565                ("src/a.test.ts", "it('a', () => {})\n"),
566            ],
567        );
568        assert_eq!(
569            detect(d.path(), &[]).story_file("US1", None).as_deref(),
570            Some("src/acceptance-us1.test.ts")
571        );
572        let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
573        write(
574            d.path(),
575            &[("go.mod", "module ex.com/m\n"), ("g/g.go", "package g\n")],
576        );
577        assert_eq!(
578            detect(d.path(), &[])
579                .story_file("US1", Some("g"))
580                .as_deref(),
581            Some("g/acceptance_us1_test.go")
582        );
583    }
584}