Skip to main content

speccoder_runtime/bug/
named.rs

1//! `--test PATH::NAME`: the person's own reproduction. It must exist, be
2//! collected and fail on the untouched code by an assertion, twice — checked
3//! before anything is written. Naming it is the confirmation.
4
5use std::path::Path;
6
7use speccoder_core::bug::bug_class;
8use speccoder_core::verify::{RedClass, classify_red};
9
10use crate::format::BugRefusal;
11
12/// A named test.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Named {
15    /// Project-relative file.
16    pub file: String,
17    /// The test's name.
18    pub name: String,
19    /// The command that runs only it.
20    pub argv: Vec<String>,
21    /// The file's content.
22    pub content: String,
23}
24
25/// `PATH::NAME` split at its last `::`.
26#[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/// The command that runs only `name` in `file`: `template` (`[bug] test`,
35/// `{file}` and `{name}` filled) or the toolchain's own runner.
36#[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
92/// Resolves `spec` in the project at `root` (the file and the test name
93/// must be there) without running it.
94///
95/// # Errors
96///
97/// `BUG-TEST-NOT-FOUND`.
98pub 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/// Its class on the untouched code: run twice, red only by assertion.
124#[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/// The refusal for a named test that is not red.
144#[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}