1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
21pub struct Location {
22 pub kind: String,
24 pub path: String,
26 pub files: usize,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
32pub struct RunnerLayout {
33 pub toolchain: Toolchain,
35 pub root: String,
37 pub rule: CollectionRule,
39 pub locations: Vec<Location>,
41 pub not_run: Vec<String>,
43 pub naming: Option<String>,
45 pub story_dir: Option<String>,
47 pub extension: String,
49 pub unknown: Vec<String>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
55pub struct TestLayout {
56 pub runners: Vec<RunnerLayout>,
58 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#[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#[must_use]
356pub fn text_at(root: &Path) -> String {
357 detect(root, &crate::verify::check_argvs(root)).text()
358}
359
360impl TestLayout {
361 #[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 #[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 #[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 #[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 #[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 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}