1use std::path::Path;
6
7use speccoder_core::bug::bug_class;
8use speccoder_core::verify::{RedClass, classify_red};
9
10use crate::format::BugRefusal;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Named {
15 pub file: String,
17 pub name: String,
19 pub argv: Vec<String>,
21 pub content: String,
23}
24
25#[must_use]
27pub fn split(spec: &str) -> Option<(String, String)> {
28 let (file, name) = spec.rsplit_once("::")?;
29 let file = file.trim().trim_start_matches("./");
30 let name = name.trim();
31 (!file.is_empty() && !name.is_empty()).then(|| (file.to_owned(), name.to_owned()))
32}
33
34#[must_use]
37pub fn argv(
38 root: &Path,
39 file: &str,
40 name: &str,
41 template: Option<&[String]>,
42) -> Option<Vec<String>> {
43 if let Some(t) = template {
44 return Some(
45 t.iter()
46 .map(|a| a.replace("{file}", file).replace("{name}", name))
47 .collect(),
48 );
49 }
50 let p = Path::new(file);
51 let ext = p.extension().and_then(|e| e.to_str()).unwrap_or_default();
52 let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or_default();
53 let words = |w: &[&str]| w.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>();
54 match ext {
55 "rs" if root.join("Cargo.toml").is_file() => {
56 Some(if file.starts_with("tests/") && !file[6..].contains('/') {
57 words(&["cargo", "test", "--test", stem, "--", "--exact", name])
58 } else {
59 words(&["cargo", "test", "--lib", "--", name])
60 })
61 }
62 "py" => Some(vec![
63 "python".to_owned(),
64 "-m".to_owned(),
65 "pytest".to_owned(),
66 format!("{file}::{name}"),
67 ]),
68 "ts" | "js" | "tsx" | "jsx" | "mjs" | "cjs" if root.join("package.json").is_file() => {
69 Some(words(&["npx", "vitest", "run", file, "-t", name]))
70 }
71 "go" => {
72 let dir = p
73 .parent()
74 .map(|d| d.display().to_string())
75 .unwrap_or_default();
76 Some(vec![
77 "go".to_owned(),
78 "test".to_owned(),
79 if dir.is_empty() {
80 "./".to_owned()
81 } else {
82 format!("./{dir}")
83 },
84 "-run".to_owned(),
85 format!("^{name}$"),
86 ])
87 }
88 _ => None,
89 }
90}
91
92pub fn resolve(root: &Path, spec: &str, template: Option<&[String]>) -> Result<Named, BugRefusal> {
99 let not_found = |why: String| BugRefusal::new("BUG-TEST-NOT-FOUND", why);
100 let (file, name) =
101 split(spec).ok_or_else(|| not_found(format!("`{spec}` is not PATH::NAME")))?;
102 if file.contains("..") || Path::new(&file).is_absolute() {
103 return Err(not_found(format!("`{file}` is not a path in the project")));
104 }
105 let content = std::fs::read_to_string(root.join(&file))
106 .map_err(|_| not_found(format!("there is no test file `{file}`")))?;
107 if !content.contains(name.rsplit("::").next().unwrap_or(&name)) {
108 return Err(not_found(format!("`{file}` has no test `{name}`")));
109 }
110 let argv = argv(root, &file, &name, template).ok_or_else(|| {
111 not_found(format!(
112 "speccoder has no rule to run one test of `{file}`; set `[bug] test` in speccoder.toml"
113 ))
114 })?;
115 Ok(Named {
116 file,
117 name,
118 argv,
119 content,
120 })
121}
122
123#[must_use]
125pub fn baseline(
126 root: &Path,
127 argv: &[String],
128 runner: &crate::sandbox::Runner,
129) -> (RedClass, String) {
130 let (first, summary) = crate::pipeline::acceptance_run(root, argv, runner);
131 let one = classify_red(&first, summary.as_ref());
132 let excerpt = format!("{}\n{}", first.stdout_tail, first.stderr_tail);
133 if one != RedClass::Assertion {
134 return (one, excerpt);
135 }
136 let (second, summary) = crate::pipeline::acceptance_run(root, argv, runner);
137 (
138 bug_class(one, Some(classify_red(&second, summary.as_ref()))),
139 excerpt,
140 )
141}
142
143#[must_use]
145pub fn not_red(spec: &str, class: RedClass) -> BugRefusal {
146 if class == RedClass::Passed {
147 return BugRefusal::new(
148 "BUG-TEST-PASSES",
149 format!(
150 "`{spec}` passes on the untouched code: it cannot show the bug (perhaps it is fixed already)"
151 ),
152 );
153 }
154 if class == RedClass::NoTests {
155 return BugRefusal::new(
156 "BUG-TEST-NOT-FOUND",
157 format!("`{spec}` ran no test: the runner did not collect it"),
158 );
159 }
160 BugRefusal::new(
161 "BUG-TEST-NOT-RED",
162 format!(
163 "`{spec}` does not fail on an assertion ({}): a reproduction must",
164 crate::pipeline::class_word(class)
165 ),
166 )
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[test]
174 fn a_named_test_runs_by_its_toolchain_or_the_configured_template() {
175 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
176 let r = dir.path();
177 std::fs::write(r.join("Cargo.toml"), "[package]\nname = \"t\"\n")
178 .unwrap_or_else(|e| panic!("{e}"));
179 assert_eq!(
180 argv(r, "tests/title.rs", "empty_title_is_refused", None),
181 Some(
182 [
183 "cargo",
184 "test",
185 "--test",
186 "title",
187 "--",
188 "--exact",
189 "empty_title_is_refused"
190 ]
191 .map(String::from)
192 .to_vec()
193 )
194 );
195 assert_eq!(
196 argv(r, "pkg/test_title.py", "test_empty", None).map(|a| a[3].clone()),
197 Some("pkg/test_title.py::test_empty".to_owned())
198 );
199 assert_eq!(
200 argv(r, "title/title_test.go", "TestEmpty", None),
201 Some(
202 ["go", "test", "./title", "-run", "^TestEmpty$"]
203 .map(String::from)
204 .to_vec()
205 )
206 );
207 let t = [
208 "sh".to_owned(),
209 "t.sh".to_owned(),
210 "{file}".to_owned(),
211 "{name}".to_owned(),
212 ];
213 assert_eq!(
214 argv(r, "tests/a.sh", "b", Some(&t)),
215 Some(["sh", "t.sh", "tests/a.sh", "b"].map(String::from).to_vec())
216 );
217 assert_eq!(argv(r, "tests/a.sh", "b", None), None);
218 assert_eq!(
219 split("tests/title.rs::empty"),
220 Some(("tests/title.rs".to_owned(), "empty".to_owned()))
221 );
222 assert_eq!(split("tests/title.rs"), None);
223 assert_eq!(
224 resolve(r, "tests/none.rs::x", None).err().map(|e| e.code),
225 Some("BUG-TEST-NOT-FOUND")
226 );
227 assert_eq!(not_red("x", RedClass::Passed).code, "BUG-TEST-PASSES");
228 assert_eq!(not_red("x", RedClass::BuildError).code, "BUG-TEST-NOT-RED");
229 }
230}