speccoder_runtime/collection/
listing.rs1use std::path::Path;
6use std::time::Duration;
7
8use speccoder_core::collection::ListingRun;
9use speccoder_core::verify::CheckStatus;
10
11use crate::verify::process::{CommandRunner, Invocation, OUTPUT_CAP, scrubbed_env};
12
13pub struct Listed {
15 pub stdout: String,
17 pub run: ListingRun,
19}
20
21pub fn run(
23 runner: &dyn CommandRunner,
24 cwd: &Path,
25 argv: &[String],
26 timeout: Duration,
27) -> (Result<Listed, String>, ListingRun) {
28 let out = runner.run(&Invocation {
29 argv: argv.to_vec(),
30 cwd: cwd.to_path_buf(),
31 env: scrubbed_env(std::env::vars()),
32 timeout,
33 output_cap: OUTPUT_CAP,
34 });
35 let status = if out.spawn_error.is_some() {
36 CheckStatus::Error
37 } else if out.timed_out {
38 CheckStatus::TimedOut
39 } else if out.exit_code == Some(0) {
40 CheckStatus::Passed
41 } else {
42 CheckStatus::Failed
43 };
44 let run = ListingRun {
45 argv: argv.to_vec(),
46 duration_ms: u64::try_from(out.duration.as_millis()).unwrap_or(u64::MAX),
47 status,
48 };
49 let shown = argv.iter().take(3).cloned().collect::<Vec<_>>().join(" ");
50 let result = if let Some(e) = &out.spawn_error {
51 Err(format!("`{shown}` could not run: {e}"))
52 } else if out.timed_out {
53 Err(format!("`{shown}` timed out"))
54 } else {
55 let pytest_empty = out.exit_code == Some(5) && argv.iter().any(|a| a.contains("pytest"));
57 if out.exit_code == Some(0) || pytest_empty {
58 Ok(Listed {
59 stdout: String::from_utf8_lossy(&out.stdout.kept).into_owned(),
60 run: run.clone(),
61 })
62 } else {
63 Err(format!(
64 "`{shown}` failed: exit {}",
65 out.exit_code
66 .map_or_else(|| "signal".to_owned(), |c| c.to_string())
67 ))
68 }
69 };
70 (result, run)
71}
72
73#[must_use]
76pub fn nextest_ids(stdout: &str) -> Option<Vec<String>> {
77 let start = stdout.find('{')?;
78 let v: serde_json::Value = serde_json::from_str(stdout[start..].trim()).ok()?;
79 let mut out = Vec::new();
80 for (binary, suite) in v["rust-suites"].as_object()? {
81 for (name, case) in suite["testcases"].as_object().into_iter().flatten() {
82 if case["ignored"] != true && case["filter-match"]["status"] != "mismatch" {
83 out.push(format!("{binary}::{name}"));
84 }
85 }
86 }
87 Some(out)
88}
89
90#[must_use]
92pub fn libtest_names(stdout: &str) -> Vec<String> {
93 stdout
94 .lines()
95 .filter_map(|l| l.strip_suffix(": test"))
96 .map(str::to_owned)
97 .collect()
98}
99
100#[must_use]
102pub fn pytest_ids(stdout: &str) -> Vec<String> {
103 stdout
104 .lines()
105 .map(str::trim)
106 .filter(|l| l.contains("::"))
107 .map(str::to_owned)
108 .collect()
109}
110
111#[must_use]
113pub fn vitest_files(stdout: &str, root: &Path) -> Option<Vec<String>> {
114 let start = stdout.find('[')?;
115 let v: serde_json::Value = serde_json::from_str(stdout[start..].trim()).ok()?;
116 Some(
117 v.as_array()?
118 .iter()
119 .filter_map(|t| t["file"].as_str())
120 .map(|f| relative(f, root))
121 .collect(),
122 )
123}
124
125#[must_use]
127pub fn jest_files(stdout: &str, root: &Path) -> Vec<String> {
128 stdout
129 .lines()
130 .map(str::trim)
131 .filter(|l| !l.is_empty())
132 .map(|f| relative(f, root))
133 .collect()
134}
135
136#[must_use]
138pub fn go_names(stdout: &str) -> Vec<String> {
139 stdout
140 .lines()
141 .map(str::trim)
142 .filter(|l| {
143 !l.is_empty() && !l.starts_with("ok ") && !l.starts_with("?") && !l.contains('\t')
144 })
145 .map(str::to_owned)
146 .collect()
147}
148
149#[must_use]
152pub fn go_ids(stdout: &str) -> Vec<String> {
153 let mut out = Vec::new();
154 let mut names: Vec<&str> = Vec::new();
155 for l in stdout.lines().map(str::trim) {
156 if let Some(rest) = l.strip_prefix("ok") {
157 let pkg = rest.split_whitespace().next().unwrap_or_default();
158 out.extend(names.drain(..).map(|n| format!("{pkg}::{n}")));
159 } else if !l.is_empty() && !l.starts_with('?') && !l.contains('\t') && !l.contains(' ') {
160 names.push(l);
161 }
162 }
163 out
164}
165
166#[must_use]
168pub fn lines(stdout: &str, root: &Path) -> Vec<String> {
169 stdout
170 .lines()
171 .map(str::trim)
172 .filter(|l| !l.is_empty())
173 .map(|l| relative(l, root))
174 .collect()
175}
176
177fn relative(p: &str, root: &Path) -> String {
178 let root = root.to_string_lossy();
179 let canon = std::fs::canonicalize(root.as_ref())
180 .map(|c| c.to_string_lossy().into_owned())
181 .unwrap_or_default();
182 for r in [root.as_ref(), canon.as_str()] {
183 if !r.is_empty()
184 && let Some(rest) = p.strip_prefix(r)
185 {
186 return rest.trim_start_matches('/').to_owned();
187 }
188 }
189 p.trim_start_matches("./").to_owned()
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn listings_parse() {
198 let nx = r#"{"rust-build-meta":{},"rust-suites":{"greet::greet_name":{"testcases":{"a":{"ignored":false,"filter-match":{"status":"matches"}},"b":{"ignored":true,"filter-match":{"status":"matches"}}}}}}"#;
199 assert_eq!(nextest_ids(nx).unwrap(), ["greet::greet_name::a"]);
200 assert_eq!(
201 libtest_names("tests::a: test\nbench: benchmark\n\n1 test, 0 benchmarks\n"),
202 ["tests::a"]
203 );
204 assert_eq!(
205 pytest_ids("tests/test_x.py::test_a\n\n1 test collected in 0.01s\n"),
206 ["tests/test_x.py::test_a"]
207 );
208 let root = Path::new("/w");
209 assert_eq!(
210 vitest_files(r#"[{"name":"g","file":"/w/src/a.test.ts"}]"#, root).unwrap(),
211 ["src/a.test.ts"]
212 );
213 assert_eq!(jest_files("/w/src/a.test.js\n", root), ["src/a.test.js"]);
214 assert_eq!(
215 go_names("TestA\nExampleB\nok \tex.com/m/g\t0.01s\n"),
216 ["TestA", "ExampleB"]
217 );
218 assert_eq!(
219 go_ids("TestA\nok \tex.com/m/g\t0.01s\nTestB\nok \tex.com/m/h\t0.01s\n"),
220 ["ex.com/m/g::TestA", "ex.com/m/h::TestB"]
221 );
222 }
223}