1use speccoder_core::bug::{Member, MemberFinal, PassToPass, PassToPassState};
7use speccoder_core::verify::{TestOutcome, TestResult};
8
9#[must_use]
11pub fn rule(
12 changed: &str,
13 test_file: &str,
14 content: &str,
15 crate_name: Option<&str>,
16) -> Option<String> {
17 let path = std::path::Path::new(changed);
18 let ext = path
19 .extension()
20 .and_then(|e| e.to_str())
21 .unwrap_or_default();
22 if test_file == changed {
23 return content
24 .contains("#[cfg(test)]")
25 .then(|| "same_file".to_owned());
26 }
27 match ext {
28 "rs" => {
29 let rel = changed.strip_prefix("src/")?;
30 let module: Vec<&str> = rel
31 .trim_end_matches(".rs")
32 .split('/')
33 .filter(|m| !matches!(*m, "lib" | "main" | "mod"))
34 .collect();
35 let name = crate_name.map(|c| c.replace('-', "_"));
36 let path = module.join("::");
37 let named = |root: &str| {
38 if path.is_empty() {
39 format!("{root}::")
40 } else {
41 format!("{root}::{path}")
42 }
43 };
44 let hit = name.as_deref().is_some_and(|n| content.contains(&named(n)))
45 || (test_file.starts_with("src/") && content.contains(&named("crate")));
46 hit.then(|| format!("references:crate::{path}"))
47 }
48 "py" => {
49 let module = changed.trim_end_matches(".py").replace('/', ".");
50 let module = module.trim_end_matches(".__init__");
51 let (parent, leaf) = module.rsplit_once('.').unwrap_or(("", module));
52 let hit = content.lines().any(|l| {
53 let l = l.trim();
54 l.starts_with(&format!("import {module}"))
55 || l.starts_with(&format!("from {module} import"))
56 || (!parent.is_empty()
57 && l.starts_with(&format!("from {parent} import"))
58 && l.split(['(', ',', ' ']).any(|w| w.trim() == leaf))
59 });
60 hit.then(|| format!("imports:{module}"))
61 }
62 "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" => {
63 let target = changed.rsplit_once('.').map_or(changed, |(a, _)| a);
64 let base = std::path::Path::new(test_file)
65 .parent()
66 .map(|p| p.to_string_lossy().into_owned())
67 .unwrap_or_default();
68 let hit = specifiers(content).iter().any(|s| {
69 s.starts_with('.')
70 && normalise(&format!("{base}/{s}"))
71 .trim_end_matches(".js")
72 .trim_end_matches(".ts")
73 == target
74 });
75 hit.then(|| format!("imports:{changed}"))
76 }
77 "go" => {
78 let dir = path
79 .parent()
80 .map(|p| p.to_string_lossy().into_owned())
81 .unwrap_or_default();
82 let tdir = std::path::Path::new(test_file)
83 .parent()
84 .map(|p| p.to_string_lossy().into_owned())
85 .unwrap_or_default();
86 (test_file.ends_with("_test.go") && dir == tdir).then(|| format!("package:{dir}"))
87 }
88 _ => None,
89 }
90}
91
92fn specifiers(content: &str) -> Vec<String> {
94 let mut out = Vec::new();
95 for l in content.lines() {
96 for q in ['"', '\''] {
97 if (l.contains("import") || l.contains("require("))
98 && let Some(i) = l
99 .find(" from ")
100 .map(|i| i + 6)
101 .or_else(|| l.find("require(").map(|i| i + 8))
102 {
103 let rest = l[i..].trim_start();
104 if let Some(r) = rest.strip_prefix(q)
105 && let Some(end) = r.find(q)
106 {
107 out.push(r[..end].to_owned());
108 }
109 }
110 }
111 }
112 out
113}
114
115fn normalise(p: &str) -> String {
117 let mut parts: Vec<&str> = Vec::new();
118 for seg in p.split('/') {
119 match seg {
120 "" | "." => {}
121 ".." => {
122 parts.pop();
123 }
124 s => parts.push(s),
125 }
126 }
127 parts.join("/")
128}
129
130#[must_use]
132pub fn belongs(id: &str, file: &str) -> bool {
133 let stem = std::path::Path::new(file)
134 .file_stem()
135 .and_then(|s| s.to_str())
136 .unwrap_or_default();
137 if stem.is_empty() {
138 return false;
139 }
140 let dotted = file
141 .rsplit_once('.')
142 .map_or(file, |(a, _)| a)
143 .replace('/', ".");
144 id.contains(file)
145 || id.contains(&dotted)
146 || id.split([':', '.', '/', ' ']).any(|seg| seg == stem)
147}
148
149#[must_use]
153pub fn select(
154 changed: &[String],
155 tests: &[(String, String)],
156 baseline: &[TestResult],
157 crate_name: Option<&str>,
158 unchanged: &[(String, String, String)],
159) -> PassToPass {
160 let mut members: Vec<Member> = Vec::new();
161 for (file, content) in tests {
162 let Some(why) = changed
163 .iter()
164 .find_map(|c| rule(c, file, content, crate_name))
165 else {
166 continue;
167 };
168 for t in baseline
169 .iter()
170 .filter(|t| t.outcome == TestOutcome::Passed && belongs(&t.id, file))
171 {
172 if members.iter().any(|m| m.test == t.id) {
173 continue;
174 }
175 members.push(Member {
176 test: t.id.clone(),
177 file: file.clone(),
178 rule: why.clone(),
179 final_: MemberFinal::NotRun,
180 });
181 }
182 }
183 for (statement, file, test) in unchanged {
184 members.push(Member {
185 test: test.clone(),
186 file: file.clone(),
187 rule: format!("unchanged:{statement}"),
188 final_: MemberFinal::NotRun,
189 });
190 }
191 PassToPass {
192 state: if members.is_empty() {
193 PassToPassState::NoneSelected
194 } else {
195 PassToPassState::Selected
196 },
197 members,
198 }
199}
200
201pub fn settle(
205 p2p: &mut PassToPass,
206 finals: &[TestResult],
207 unchanged_passed: &dyn Fn(&str) -> bool,
208) {
209 for m in &mut p2p.members {
210 if let Some(statement) = m.rule.strip_prefix("unchanged:") {
211 m.final_ = if unchanged_passed(statement) {
212 MemberFinal::Passed
213 } else {
214 MemberFinal::Failed
215 };
216 continue;
217 }
218 m.final_ = match finals.iter().find(|t| t.id == m.test).map(|t| t.outcome) {
219 Some(TestOutcome::Passed) => MemberFinal::Passed,
220 Some(TestOutcome::Failed | TestOutcome::Error | TestOutcome::Skipped) => {
221 MemberFinal::Failed
222 }
223 None => MemberFinal::Missing,
224 };
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 fn passed(id: &str) -> TestResult {
233 TestResult {
234 id: id.to_owned(),
235 outcome: TestOutcome::Passed,
236 }
237 }
238
239 #[test]
240 fn rust_tests_naming_the_changed_module_are_selected() {
241 assert_eq!(
242 rule(
243 "src/title.rs",
244 "tests/save.rs",
245 "use notes::title::save;",
246 Some("notes")
247 ),
248 Some("references:crate::title".to_owned())
249 );
250 assert_eq!(
251 rule(
252 "src/title.rs",
253 "tests/other.rs",
254 "use notes::body;",
255 Some("notes")
256 ),
257 None
258 );
259 assert_eq!(
260 rule(
261 "src/title.rs",
262 "src/title.rs",
263 "#[cfg(test)]\nmod tests {}",
264 Some("notes")
265 ),
266 Some("same_file".to_owned())
267 );
268 assert_eq!(
269 rule(
270 "src/a/mod.rs",
271 "tests/a.rs",
272 "use my_crate::a::f;",
273 Some("my-crate")
274 ),
275 Some("references:crate::a".to_owned())
276 );
277 }
278
279 #[test]
280 fn python_js_and_go_rules() {
281 assert_eq!(
282 rule(
283 "app/title.py",
284 "tests/test_title.py",
285 "from app.title import save\n",
286 None
287 ),
288 Some("imports:app.title".to_owned())
289 );
290 assert_eq!(
291 rule(
292 "app/title.py",
293 "tests/test_x.py",
294 "from app import body, title\n",
295 None
296 ),
297 Some("imports:app.title".to_owned())
298 );
299 assert_eq!(
300 rule(
301 "src/title.ts",
302 "src/title.test.ts",
303 "import { save } from './title';\n",
304 None
305 ),
306 Some("imports:src/title.ts".to_owned())
307 );
308 assert_eq!(
309 rule(
310 "src/title.ts",
311 "test/title.test.ts",
312 "import { save } from \"../src/title.js\";\n",
313 None
314 ),
315 Some("imports:src/title.ts".to_owned())
316 );
317 assert_eq!(
318 rule(
319 "title/title.go",
320 "title/title_test.go",
321 "package title",
322 None
323 ),
324 Some("package:title".to_owned())
325 );
326 assert_eq!(rule("title/title.go", "other/x_test.go", "", None), None);
327 }
328
329 #[test]
330 fn only_baseline_green_tests_of_selected_files_and_none_is_recorded_as_none() {
331 let tests = vec![(
332 "tests/save.rs".to_owned(),
333 "use notes::title::save;".to_owned(),
334 )];
335 let baseline = vec![
336 passed("save::saves_a_title"),
337 TestResult {
338 id: "save::broken".to_owned(),
339 outcome: TestOutcome::Failed,
340 },
341 passed("other::x"),
342 ];
343 let mut p = select(
344 &["src/title.rs".to_owned()],
345 &tests,
346 &baseline,
347 Some("notes"),
348 &[],
349 );
350 assert_eq!(p.state, PassToPassState::Selected);
351 assert_eq!(p.members.len(), 1);
352 settle(&mut p, &[], &|_| true);
353 assert_eq!(
354 p.members[0].final_,
355 MemberFinal::Missing,
356 "a missing test fails"
357 );
358 let none = select(
359 &["src/title.rs".to_owned()],
360 &[],
361 &baseline,
362 Some("notes"),
363 &[],
364 );
365 assert_eq!(none.state, PassToPassState::NoneSelected);
366 let with_u = select(
367 &[],
368 &[],
369 &[],
370 None,
371 &[(
372 "U1".to_owned(),
373 "tests/u1.rs".to_owned(),
374 "keeps".to_owned(),
375 )],
376 );
377 assert_eq!(with_u.members[0].rule, "unchanged:U1");
378 assert!(belongs("tests.test_title.test_x", "tests/test_title.py"));
379 assert!(belongs("save::saves", "tests/save.rs"));
380 }
381}