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 #[serde(skip)]
63 pub pytest: Vec<String>,
64}
65
66fn parent(p: &str) -> &str {
67 p.rsplit_once('/').map_or("", |(d, _)| d)
68}
69
70fn at(root: &str, rel: &str) -> String {
71 if root.is_empty() {
72 rel.to_owned()
73 } else {
74 format!("{root}/{rel}")
75 }
76}
77
78#[must_use]
81pub fn detect(root: &Path, check_argvs: &[Vec<String>]) -> TestLayout {
82 let files = static_cfg::files(root);
83 let read = |p: &str| std::fs::read_to_string(root.join(p)).ok();
84 let tests_in = |pred: &dyn Fn(&str) -> bool| -> Vec<&String> {
85 files
86 .iter()
87 .filter(|f| pred(f))
88 .filter(|f| {
89 toolchain_of(f).is_some_and(|t| read(f).is_some_and(|s| defines_tests(t, &s)))
90 })
91 .collect()
92 };
93 let mut runners = Vec::new();
94
95 for pkg in static_cfg::rust_packages(root, &files) {
96 let targets = rust_targets(&pkg, &files);
97 let reach = rust_reachable(&targets, &|p| read(p));
98 let inside = |f: &str| {
99 f.ends_with(".rs")
100 && static_cfg::owner(std::slice::from_ref(&pkg), f).is_some()
101 && !f.starts_with(&at(&pkg.root, "target/"))
102 };
103 let tests_dir = at(&pkg.root, "tests");
104 let inline = tests_in(&|f| {
105 inside(f)
106 && !f.starts_with(&format!("{tests_dir}/"))
107 && read(f).is_some_and(|s| strip_comments(&s).contains("#[cfg(test)]"))
108 });
109 let top = tests_in(&|f| {
110 inside(f)
111 && f.strip_prefix(&format!("{tests_dir}/"))
112 .is_some_and(|r| !r.contains('/'))
113 });
114 let not_run: Vec<String> =
115 tests_in(&|f| inside(f) && f.starts_with(&format!("{tests_dir}/")))
116 .into_iter()
117 .filter(|f| !reach.contains_key(*f))
118 .cloned()
119 .collect();
120 let mut locations = vec![
121 Location {
122 kind: "inline_modules".to_owned(),
123 path: at(&pkg.root, "src"),
124 files: inline.len(),
125 },
126 Location {
127 kind: "tests_dir".to_owned(),
128 path: format!("{tests_dir}/*.rs"),
129 files: top.len(),
130 },
131 ];
132 let mains = files
133 .iter()
134 .filter(|f| {
135 f.strip_prefix(&format!("{tests_dir}/"))
136 .is_some_and(|r| r.matches('/').count() == 1 && r.ends_with("/main.rs"))
137 })
138 .count();
139 if mains > 0 {
140 locations.push(Location {
141 kind: "tests_dir".to_owned(),
142 path: format!("{tests_dir}/<dir>/main.rs"),
143 files: mains,
144 });
145 }
146 let mut unknown = Vec::new();
147 let story_dir = if pkg.autotests {
148 Some(tests_dir.clone())
149 } else {
150 unknown.push(format!(
151 "{}: `autotests = false` — a new integration test needs a `[[test]]` entry, which speccoder does not add",
152 at(&pkg.root, "Cargo.toml")
153 ));
154 None
155 };
156 runners.push(RunnerLayout {
157 toolchain: Toolchain::Rust,
158 root: pkg.root.clone(),
159 rule: rust_rule(&pkg),
160 locations,
161 not_run,
162 naming: Some(format!("{tests_dir}/<area>.rs")),
163 story_dir,
164 extension: "rs".to_owned(),
165 unknown,
166 });
167 }
168
169 if static_cfg::uses_pytest(root, &files, check_argvs) {
170 let rule = static_cfg::pytest_rule(root);
171 let py = tests_in(&|f| f.ends_with(".py"));
172 let mut by_dir: BTreeMap<String, usize> = BTreeMap::new();
173 for f in &py {
174 *by_dir.entry(parent(f).to_owned()).or_default() += 1;
175 }
176 let exists = |d: &str| d.is_empty() || root.join(d).is_dir();
177 let mut unknown = Vec::new();
178 let story_dir = match rule.testpaths.first() {
179 Some(t) if exists(t.trim_end_matches('/')) => Some(t.trim_end_matches('/').to_owned()),
180 Some(t) => {
181 unknown.push(format!(
182 "testpaths names `{t}`, which does not exist: no place pytest collects"
183 ));
184 None
185 }
186 None if exists("tests") => Some("tests".to_owned()),
187 None => Some(String::new()),
188 };
189 let prefix = py
190 .iter()
191 .filter(|f| f.rsplit('/').next().is_some_and(|n| n.starts_with("test_")))
192 .count();
193 runners.push(RunnerLayout {
194 toolchain: Toolchain::Python,
195 root: String::new(),
196 rule: rule.rule(),
197 locations: by_dir
198 .into_iter()
199 .map(|(path, files)| Location {
200 kind: "tests_dir".to_owned(),
201 path,
202 files,
203 })
204 .collect(),
205 not_run: py
206 .iter()
207 .filter(|f| rule.breaks(f).is_some())
208 .map(|f| (*f).clone())
209 .collect(),
210 naming: Some(if prefix * 2 >= py.len() {
211 "test_<name>.py".to_owned()
212 } else {
213 "<name>_test.py".to_owned()
214 }),
215 story_dir,
216 extension: "py".to_owned(),
217 unknown,
218 });
219 }
220
221 for pkg in static_cfg::js_packages(root, &files, check_argvs) {
222 let inside = |f: &str| pkg.root.is_empty() || f.starts_with(&format!("{}/", pkg.root));
223 let js = tests_in(&|f| inside(f) && toolchain_of(f) == Some(Toolchain::JavaScript));
224 let rel = |f: &str| {
225 if pkg.root.is_empty() {
226 f.to_owned()
227 } else {
228 f.trim_start_matches(&format!("{}/", pkg.root)).to_owned()
229 }
230 };
231 let collected: Vec<&String> = js
232 .iter()
233 .copied()
234 .filter(|f| {
235 pkg.rule
236 .as_ref()
237 .map_or(true, |r| r.breaks(&rel(f)).is_none())
238 })
239 .collect();
240 let mut by_dir: BTreeMap<String, usize> = BTreeMap::new();
241 for f in &collected {
242 *by_dir.entry(parent(f).to_owned()).or_default() += 1;
243 }
244 let ts = collected.iter().filter(|f| f.contains(".ts")).count();
245 let extension = if collected.is_empty() {
246 if files.iter().any(|f| inside(f) && f.ends_with(".ts")) {
247 "ts"
248 } else {
249 "js"
250 }
251 } else if ts * 2 >= collected.len() {
252 "ts"
253 } else {
254 "js"
255 }
256 .to_owned();
257 let habit = by_dir
258 .iter()
259 .max_by_key(|(_, n)| **n)
260 .map(|(d, _)| d.clone());
261 let src = at(&pkg.root, "src");
262 let story_dir = habit.clone().or_else(|| {
263 Some(if root.join(&src).is_dir() {
264 src.clone()
265 } else {
266 pkg.root.clone()
267 })
268 });
269 let mut unknown = Vec::new();
270 let rule = match &pkg.rule {
271 Ok(r) => r.rule(),
272 Err(why) => {
273 unknown.push(why.clone());
274 CollectionRule {
275 toolchain: Toolchain::JavaScript,
276 text: format!(
277 "{} collects the files its configuration includes (not read: {why})",
278 pkg.runner.name()
279 ),
280 source: at(&pkg.root, "package.json"),
281 }
282 }
283 };
284 runners.push(RunnerLayout {
285 toolchain: Toolchain::JavaScript,
286 root: pkg.root.clone(),
287 rule,
288 locations: by_dir
289 .into_iter()
290 .map(|(path, files)| Location {
291 kind: if path.contains("__tests__") {
292 "tests_dir"
293 } else {
294 "beside_sources"
295 }
296 .to_owned(),
297 path,
298 files,
299 })
300 .collect(),
301 not_run: js
302 .iter()
303 .filter(|f| !collected.contains(f))
304 .map(|f| (*f).clone())
305 .collect(),
306 naming: Some(match pkg.runner {
307 JsRunner::Vitest | JsRunner::Jest => format!("<name>.test.{extension}"),
308 }),
309 story_dir,
310 extension,
311 unknown,
312 });
313 }
314
315 for m in static_cfg::go_modules(root, &files) {
316 let inside = |f: &str| m.root.is_empty() || f.starts_with(&format!("{}/", m.root));
317 let go = tests_in(&|f| inside(f) && f.ends_with("_test.go"));
318 let mut by_dir: BTreeMap<String, usize> = BTreeMap::new();
319 for f in &go {
320 *by_dir.entry(parent(f).to_owned()).or_default() += 1;
321 }
322 let tags = static_cfg::go_tags(check_argvs);
323 runners.push(RunnerLayout {
324 toolchain: Toolchain::Go,
325 root: m.root.clone(),
326 rule: go_rule(),
327 locations: by_dir
328 .into_iter()
329 .map(|(path, files)| Location {
330 kind: "package".to_owned(),
331 path,
332 files,
333 })
334 .collect(),
335 not_run: go
336 .iter()
337 .filter(|f| {
338 speccoder_core::collection::go_breaks(f, &read(f).unwrap_or_default(), &tags)
339 .is_some()
340 })
341 .map(|f| (*f).clone())
342 .collect(),
343 naming: Some("<name>_test.go beside the package's sources".to_owned()),
344 story_dir: Some(m.root.clone()),
345 extension: "go".to_owned(),
346 unknown: Vec::new(),
347 });
348 }
349
350 let canonical = serde_json::to_string(&runners).unwrap_or_default();
351 TestLayout {
352 digest: crate::integrity::sha256_hex(canonical.as_bytes()),
353 runners,
354 pytest: pytest_command(check_argvs),
355 }
356}
357
358fn pytest_command(check_argvs: &[Vec<String>]) -> Vec<String> {
361 check_argvs
362 .iter()
363 .find_map(|a| {
364 let at = a
365 .iter()
366 .position(|w| w == "pytest" || w.ends_with("/pytest"))?;
367 Some(a[..=at].to_vec())
368 })
369 .unwrap_or_else(|| ["python3", "-m", "pytest"].map(str::to_owned).to_vec())
370}
371
372#[must_use]
375pub fn text_at(root: &Path) -> String {
376 detect(root, &crate::verify::check_argvs(root)).text()
377}
378
379impl TestLayout {
380 #[must_use]
383 pub fn primary(&self) -> Option<&RunnerLayout> {
384 self.runners
385 .iter()
386 .find(|r| r.story_dir.is_some())
387 .or_else(|| self.runners.first())
388 }
389
390 #[must_use]
393 pub fn acceptance_file(&self, name: &str, package: Option<&str>) -> Option<String> {
394 let r = self.primary()?;
395 let dir = r.story_dir.as_ref()?;
396 let s = name.to_ascii_lowercase();
397 Some(match r.toolchain {
398 Toolchain::Rust => at(dir, &format!("acceptance_{s}.rs")),
399 Toolchain::Python => at(dir, &format!("test_acceptance_{s}.py")),
400 Toolchain::JavaScript => at(dir, &format!("acceptance-{s}.test.{}", r.extension)),
401 Toolchain::Go => at(package.unwrap_or(dir), &format!("acceptance_{s}_test.go")),
402 })
403 }
404
405 #[must_use]
407 pub fn command_for(&self, file: &str) -> Option<String> {
408 let r = self.primary()?;
409 let stem = file.rsplit('/').next()?.split('.').next()?.to_owned();
410 Some(match r.toolchain {
411 Toolchain::Rust => format!("cargo test --test {stem}"),
412 Toolchain::Python => format!("{} {file}", self.pytest.join(" ")),
413 Toolchain::JavaScript => format!("npx vitest run {file}"),
414 Toolchain::Go => format!("go test ./{}", parent(file)),
415 })
416 }
417
418 #[must_use]
422 pub fn runs_only(&self, argv: &[String], files: &[&str]) -> bool {
423 files.iter().any(|f| {
424 self.command_for(f)
425 .is_some_and(|c| c.split_whitespace().eq(argv.iter().map(String::as_str)))
426 })
427 }
428
429 #[must_use]
431 pub fn text(&self) -> String {
432 let mut out = String::from("# Test layout (detected by speccoder from this checkout)\n");
433 if self.runners.is_empty() {
434 out.push_str("\nNo test runner speccoder knows (Cargo, pytest, vitest/jest, go test) was found.\n");
435 }
436 for r in &self.runners {
437 let name = match r.toolchain {
438 Toolchain::Rust => "cargo (Rust)",
439 Toolchain::Python => "pytest (Python)",
440 Toolchain::JavaScript => "vitest/jest (JavaScript/TypeScript)",
441 Toolchain::Go => "go test (Go)",
442 };
443 out.push_str(&format!(
444 "\nRunner: {name}{}\nRule: {} — from {}\n",
445 if r.root.is_empty() {
446 String::new()
447 } else {
448 format!(", in `{}`", r.root)
449 },
450 r.rule.text,
451 r.rule.source
452 ));
453 let today: Vec<String> = r
454 .locations
455 .iter()
456 .map(|l| match l.kind.as_str() {
457 "inline_modules" => format!(
458 "inline `#[cfg(test)]` modules in {} file(s) under {}",
459 l.files, l.path
460 ),
461 _ => format!("{}: {} file(s)", l.path, l.files),
462 })
463 .collect();
464 if !today.is_empty() {
465 out.push_str(&format!("Tests today: {}\n", today.join("; ")));
466 }
467 if !r.not_run.is_empty() {
468 out.push_str(&format!("Not run by anything: {}\n", r.not_run.join(", ")));
469 }
470 if let Some(n) = &r.naming {
471 out.push_str(&format!("Naming: {n}\n"));
472 }
473 for u in &r.unknown {
474 out.push_str(&format!("Not determined: {u}\n"));
475 }
476 }
477 out.push_str(
478 "\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",
479 );
480 out
481 }
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487
488 fn write(root: &Path, files: &[(&str, &str)]) {
489 for (p, t) in files {
490 let f = root.join(p);
491 std::fs::create_dir_all(f.parent().unwrap_or(root)).ok();
492 std::fs::write(f, t).ok();
493 }
494 }
495
496 #[test]
497 fn a_rust_crate_s_layout_names_its_rule_its_tests_and_its_dead_files() {
498 let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
499 write(
500 d.path(),
501 &[
502 (
503 "Cargo.toml",
504 "[package]\nname = \"greet\"\nversion = \"0.1.0\"\n",
505 ),
506 (
507 "src/lib.rs",
508 "pub fn greet() {}\n#[cfg(test)]\nmod tests { #[test] fn t() {} }\n",
509 ),
510 ("tests/unit/greet_name.rs", "#[test]\nfn a() {}\n"),
511 ],
512 );
513 let l = detect(d.path(), &[]);
514 assert_eq!(
515 l.acceptance_file("US1", None).as_deref(),
516 Some("tests/acceptance_us1.rs")
517 );
518 assert_eq!(
519 l.command_for("tests/acceptance_us1.rs").as_deref(),
520 Some("cargo test --test acceptance_us1")
521 );
522 let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
523 let file = ["tests/acceptance_us1.rs"];
524 assert!(l.runs_only(&argv("cargo test --test acceptance_us1"), &file));
525 assert!(!l.runs_only(&argv("cargo test --test acceptance_us2"), &file));
526 assert!(!l.runs_only(&argv("cargo test"), &file));
527 let text = l.text();
528 assert!(text.contains("Rule: Cargo builds `tests/*.rs`"), "{text}");
529 assert!(
530 text.contains("inline `#[cfg(test)]` modules in 1 file(s)"),
531 "{text}"
532 );
533 assert!(
534 text.contains("Not run by anything: tests/unit/greet_name.rs"),
535 "{text}"
536 );
537 assert_eq!(detect(d.path(), &[]), l);
539 }
540
541 #[test]
542 fn a_python_acceptance_file_runs_with_the_project_s_pytest() {
543 let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
544 write(
545 d.path(),
546 &[
547 ("pytest.ini", "[pytest]\n"),
548 ("tests/test_a.py", "def test_a(): pass\n"),
549 ],
550 );
551 let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
552 let file = "tests/test_acceptance_us1.py";
553 let uv = detect(
554 d.path(),
555 &[argv(
556 "uv run --locked --offline python -m pytest -q --junitxml=.speccoder/junit.xml",
557 )],
558 );
559 assert_eq!(
560 uv.command_for(file).as_deref(),
561 Some("uv run --locked --offline python -m pytest tests/test_acceptance_us1.py")
562 );
563 assert!(uv.runs_only(
564 &argv("uv run --locked --offline python -m pytest tests/test_acceptance_us1.py"),
565 &[file]
566 ));
567 assert!(!uv.runs_only(
568 &argv("python3 -m pytest tests/test_acceptance_us1.py"),
569 &[file]
570 ));
571 assert_eq!(
572 detect(d.path(), &[argv(".venv/bin/pytest -q")])
573 .command_for(file)
574 .as_deref(),
575 Some(".venv/bin/pytest tests/test_acceptance_us1.py")
576 );
577 assert_eq!(
578 detect(d.path(), &[]).command_for(file).as_deref(),
579 Some("python3 -m pytest tests/test_acceptance_us1.py")
580 );
581 }
582
583 #[test]
584 fn no_collected_place_is_said_never_guessed() {
585 let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
586 write(
587 d.path(),
588 &[
589 (
590 "Cargo.toml",
591 "[package]\nname = \"g\"\nversion = \"0.1.0\"\nautotests = false\n",
592 ),
593 ("src/lib.rs", ""),
594 ],
595 );
596 let l = detect(d.path(), &[]);
597 assert_eq!(l.acceptance_file("US1", None), None);
598 assert!(l.runners[0].unknown[0].contains("autotests = false"));
599 let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
600 write(d.path(), &[("pytest.ini", "[pytest]\ntestpaths = spec\n")]);
601 assert_eq!(detect(d.path(), &[]).acceptance_file("US1", None), None);
602 }
603
604 #[test]
605 fn python_js_and_go_acceptance_files_follow_the_layout() {
606 let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
607 write(
608 d.path(),
609 &[
610 ("pytest.ini", "[pytest]\n"),
611 ("tests/test_a.py", "def test_a():\n pass\n"),
612 ],
613 );
614 assert_eq!(
615 detect(d.path(), &[])
616 .acceptance_file("US2", None)
617 .as_deref(),
618 Some("tests/test_acceptance_us2.py")
619 );
620 let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
621 write(
622 d.path(),
623 &[
624 (
625 "package.json",
626 "{ \"devDependencies\": { \"vitest\": \"3\" } }",
627 ),
628 ("src/a.test.ts", "it('a', () => {})\n"),
629 ],
630 );
631 assert_eq!(
632 detect(d.path(), &[])
633 .acceptance_file("US1", None)
634 .as_deref(),
635 Some("src/acceptance-us1.test.ts")
636 );
637 let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
638 write(
639 d.path(),
640 &[("go.mod", "module ex.com/m\n"), ("g/g.go", "package g\n")],
641 );
642 assert_eq!(
643 detect(d.path(), &[])
644 .acceptance_file("US1", Some("g"))
645 .as_deref(),
646 Some("g/acceptance_us1_test.go")
647 );
648 }
649}
650
651#[derive(Debug, Clone, PartialEq, Eq)]
655pub struct Command {
656 pub shown: String,
658 pub runs: Vec<String>,
660}
661
662impl Command {
663 #[must_use]
665 pub fn run_by(&self, test: &str) -> bool {
666 self.runs.iter().any(|w| test.contains(w.as_str()))
667 }
668}
669
670#[must_use]
673pub fn commands(root: &Path) -> Vec<Command> {
674 let read = |p: &str| std::fs::read_to_string(root.join(p)).ok();
675 let quoted = |w: &str| vec![format!("\"{w}\""), format!("'{w}'")];
676 let mut out = Vec::new();
677 if let Some(t) = read("pyproject.toml").and_then(|t| t.parse::<toml::Table>().ok()) {
678 let scripts = t
679 .get("project")
680 .and_then(|p| p.get("scripts"))
681 .and_then(toml::Value::as_table);
682 for (name, target) in scripts.into_iter().flatten() {
683 let module = target
684 .as_str()
685 .and_then(|s| s.split(':').next())
686 .unwrap_or_default();
687 let top = module.split('.').next().unwrap_or_default();
688 let mut runs = quoted(name);
689 if !top.is_empty() {
690 runs.extend([
691 format!("-m {top}"),
692 format!("\"{top}\""),
693 format!("'{top}'"),
694 ]);
695 }
696 out.push(Command {
697 shown: name.clone(),
698 runs,
699 });
700 }
701 }
702 for base in ["src", "."] {
703 let Ok(dirs) = std::fs::read_dir(root.join(base)) else {
704 continue;
705 };
706 for d in dirs.flatten() {
707 let pkg = d.file_name().to_string_lossy().into_owned();
708 if d.path().join("__main__.py").is_file()
709 && !out.iter().any(|c| c.runs.contains(&format!("\"{pkg}\"")))
710 {
711 let mut runs = quoted(&pkg);
712 runs.push(format!("-m {pkg}"));
713 out.push(Command {
714 shown: format!("python -m {pkg}"),
715 runs,
716 });
717 }
718 }
719 }
720 if let Some(t) = read("Cargo.toml").and_then(|t| t.parse::<toml::Table>().ok()) {
721 let mut bins: Vec<String> = t
722 .get("bin")
723 .and_then(toml::Value::as_array)
724 .into_iter()
725 .flatten()
726 .filter_map(|b| {
727 b.get("name")
728 .and_then(toml::Value::as_str)
729 .map(str::to_owned)
730 })
731 .collect();
732 if root.join("src/main.rs").is_file()
733 && let Some(name) = t
734 .get("package")
735 .and_then(|p| p.get("name"))
736 .and_then(toml::Value::as_str)
737 {
738 bins.push(name.to_owned());
739 }
740 for name in bins {
741 let env = format!("CARGO_BIN_EXE_{name}");
742 out.push(Command {
743 shown: name.clone(),
744 runs: vec![
745 env,
746 format!("cargo_bin(\"{name}\")"),
747 "\"cargo\", \"run\"".to_owned(),
748 "cargo run".to_owned(),
749 ],
750 });
751 }
752 }
753 if let Some(v) =
754 read("package.json").and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
755 {
756 let name = v["name"]
757 .as_str()
758 .unwrap_or_default()
759 .rsplit('/')
760 .next()
761 .unwrap_or_default()
762 .to_owned();
763 let bins: Vec<(String, String)> = match &v["bin"] {
764 serde_json::Value::String(path) => vec![(name, path.clone())],
765 serde_json::Value::Object(m) => m
766 .iter()
767 .filter_map(|(k, p)| p.as_str().map(|p| (k.clone(), p.to_owned())))
768 .collect(),
769 _ => Vec::new(),
770 };
771 for (name, path) in bins {
772 let path = path.trim_start_matches("./").to_owned();
773 let mut runs = quoted(&name);
774 runs.push(path.clone());
775 runs.push(path.rsplit('/').next().unwrap_or(&path).to_owned());
776 out.push(Command { shown: name, runs });
777 }
778 }
779 out
780}
781
782pub const STACKS: &[(&str, &str)] = &[
784 ("Cargo.toml", "Rust"),
785 ("pyproject.toml", "Python"),
786 ("package.json", "JavaScript/TypeScript"),
787 ("go.mod", "Go"),
788 ("pom.xml", "Java"),
789];
790
791#[must_use]
795pub fn stack_text(root: &Path) -> Option<String> {
796 let found: Vec<String> = STACKS
797 .iter()
798 .filter(|(m, _)| root.join(m).is_file())
799 .map(|(m, lang)| format!("- {lang}: `{m}`"))
800 .collect();
801 if found.is_empty() {
802 return None;
803 }
804 let checks: Vec<String> = crate::verify::check_argvs(root)
805 .iter()
806 .map(|a| format!("- check: `{}`", a.join(" ")))
807 .collect();
808 Some(format!(
809 "# The project as it is (read by speccoder from this checkout)\n\nPlan in this stack — its language, its manifest, its test runner — never another:\n\n{}\n",
810 [found, checks].concat().join("\n")
811 ))
812}
813
814#[must_use]
819pub fn via_command(story: &str) -> bool {
820 story.lines().any(|l| {
821 if l.contains("Independent Test") {
822 command_words(l)
823 } else {
824 l.split_once("**When**").is_some_and(|(_, when)| {
826 command_words(when.split("**Then**").next().unwrap_or(when))
827 })
828 }
829 })
830}
831
832#[must_use]
835pub fn task_via_command<'a>(title: &str, mut scenarios: impl Iterator<Item = &'a str>) -> bool {
836 command_words(title) || scenarios.any(via_command)
837}
838
839fn command_words(text: &str) -> bool {
840 let line = text.to_ascii_lowercase();
841 let words: Vec<&str> = line.split(|c: char| !c.is_alphanumeric()).collect();
842 let pair = |a: &str, b: &str| words.windows(2).any(|p| p[0] == a && p[1] == b);
843 ["command", "cli", "stdin", "stdout", "stderr", "terminal"]
844 .iter()
845 .any(|w| words.contains(w))
846 || pair("standard", "input")
847 || pair("standard", "output")
848 || pair("exit", "code")
849 || pair("exit", "status")
850}
851
852#[cfg(test)]
853mod command_tests {
854 use super::{commands, stack_text, task_via_command, via_command};
855
856 #[test]
859 fn planning_is_told_the_checkout_s_stack() {
860 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
861 assert_eq!(stack_text(dir.path()), None);
862 std::fs::write(
863 dir.path().join("pyproject.toml"),
864 "[project]\nname = \"m\"\n",
865 )
866 .unwrap_or_else(|e| panic!("{e}"));
867 let t = stack_text(dir.path()).unwrap_or_default();
868 assert!(
869 t.contains("- Python: `pyproject.toml`") && t.contains("never another"),
870 "{t}"
871 );
872 }
873
874 #[test]
877 fn a_project_s_commands_and_what_runs_them() {
878 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
879 let r = dir.path();
880 let write = |p: &str, t: &str| {
881 std::fs::create_dir_all(r.join(p).parent().unwrap_or(r))
882 .unwrap_or_else(|e| panic!("{e}"));
883 std::fs::write(r.join(p), t).unwrap_or_else(|e| panic!("{e}"));
884 };
885 write(
886 "pyproject.toml",
887 "[project]\nname = \"speccoder-poc\"\n\n[project.scripts]\nspeccoder-poc = \"speccoder_poc:main\"\n",
888 );
889 write(
890 "src/speccoder_poc/__main__.py",
891 "from . import main\nmain()\n",
892 );
893 let c = commands(r);
894 assert_eq!(
895 c.iter().map(|c| c.shown.as_str()).collect::<Vec<_>>(),
896 ["speccoder-poc"]
897 );
898 assert!(
899 c[0].run_by("subprocess.run([sys.executable, \"-m\", \"speccoder_poc\"], input=t)")
900 );
901 assert!(c[0].run_by("subprocess.run([\"speccoder-poc\", \"in.md\"])"));
902 assert!(!c[0].run_by("from speccoder_poc import main\nmain([\"in.md\"])"));
904 assert!(!c[0].run_by("from speccoder_poc.cli import CLI\nCLI.run(content=t)"));
905 assert!(!c[0].run_by("from src.services.table_parser import parse_markdown_table"));
906
907 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
908 let r = dir.path();
909 std::fs::create_dir_all(r.join("src")).unwrap_or_else(|e| panic!("{e}"));
910 std::fs::write(r.join("Cargo.toml"), "[package]\nname = \"md2csv\"\n")
911 .unwrap_or_else(|e| panic!("{e}"));
912 std::fs::write(r.join("src/main.rs"), "fn main() {}\n").unwrap_or_else(|e| panic!("{e}"));
913 std::fs::write(
914 r.join("package.json"),
915 "{\"name\": \"@x/tables\", \"bin\": {\"tables\": \"./bin/tables.js\"}}",
916 )
917 .unwrap_or_else(|e| panic!("{e}"));
918 let c = commands(r);
919 assert_eq!(
920 c.iter().map(|c| c.shown.as_str()).collect::<Vec<_>>(),
921 ["md2csv", "tables"]
922 );
923 assert!(c[0].run_by("Command::new(env!(\"CARGO_BIN_EXE_md2csv\"))"));
924 assert!(c[1].run_by("execFileSync('node', ['bin/tables.js'])"));
925 assert!(!c[0].run_by("use md2csv::parse;"));
926
927 assert!(via_command(
928 "**Independent Test**: Can be tested by piping a valid Markdown table into the command"
929 ));
930 assert!(!via_command(
931 "**Independent Test**: Can be tested by calling parse() with a table"
932 ));
933 assert!(!via_command(
936 "**Independent Test**: the client follows the recommended flow"
937 ));
938 assert!(!via_command("1. **Given** the command, **Then** CSV"));
939 assert!(via_command(
942 "**Independent Test**: providing invalid Markdown table input\n1. **Given** malformed input, **When** the user runs the command, **Then** the tool reports it"
943 ));
944 assert!(!via_command(
945 "1. **Given** a table, **When** parse() is called, **Then** the command prints CSV"
946 ));
947 assert!(task_via_command(
949 "Thread the parser through the CLI execution path",
950 std::iter::empty()
951 ));
952 assert!(!task_via_command(
953 "Update Markdown cell splitting so it preserves in-cell whitespace",
954 std::iter::empty()
955 ));
956 assert!(task_via_command(
957 "Escape pipes in cells",
958 ["**Given** `a \\| b`, **When** the user runs the command, **Then** `a | b`"]
959 .into_iter()
960 ));
961 }
962}