use speccoder_core::bug::{Member, MemberFinal, PassToPass, PassToPassState};
use speccoder_core::verify::{TestOutcome, TestResult};
#[must_use]
pub fn rule(
changed: &str,
test_file: &str,
content: &str,
crate_name: Option<&str>,
) -> Option<String> {
let path = std::path::Path::new(changed);
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default();
if test_file == changed {
return content
.contains("#[cfg(test)]")
.then(|| "same_file".to_owned());
}
match ext {
"rs" => {
let rel = changed.strip_prefix("src/")?;
let module: Vec<&str> = rel
.trim_end_matches(".rs")
.split('/')
.filter(|m| !matches!(*m, "lib" | "main" | "mod"))
.collect();
let name = crate_name.map(|c| c.replace('-', "_"));
let path = module.join("::");
let named = |root: &str| {
if path.is_empty() {
format!("{root}::")
} else {
format!("{root}::{path}")
}
};
let hit = name.as_deref().is_some_and(|n| content.contains(&named(n)))
|| (test_file.starts_with("src/") && content.contains(&named("crate")));
hit.then(|| format!("references:crate::{path}"))
}
"py" => {
let module = changed.trim_end_matches(".py").replace('/', ".");
let module = module.trim_end_matches(".__init__");
let (parent, leaf) = module.rsplit_once('.').unwrap_or(("", module));
let hit = content.lines().any(|l| {
let l = l.trim();
l.starts_with(&format!("import {module}"))
|| l.starts_with(&format!("from {module} import"))
|| (!parent.is_empty()
&& l.starts_with(&format!("from {parent} import"))
&& l.split(['(', ',', ' ']).any(|w| w.trim() == leaf))
});
hit.then(|| format!("imports:{module}"))
}
"ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" => {
let target = changed.rsplit_once('.').map_or(changed, |(a, _)| a);
let base = std::path::Path::new(test_file)
.parent()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let hit = specifiers(content).iter().any(|s| {
s.starts_with('.')
&& normalise(&format!("{base}/{s}"))
.trim_end_matches(".js")
.trim_end_matches(".ts")
== target
});
hit.then(|| format!("imports:{changed}"))
}
"go" => {
let dir = path
.parent()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let tdir = std::path::Path::new(test_file)
.parent()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
(test_file.ends_with("_test.go") && dir == tdir).then(|| format!("package:{dir}"))
}
_ => None,
}
}
fn specifiers(content: &str) -> Vec<String> {
let mut out = Vec::new();
for l in content.lines() {
for q in ['"', '\''] {
if (l.contains("import") || l.contains("require("))
&& let Some(i) = l
.find(" from ")
.map(|i| i + 6)
.or_else(|| l.find("require(").map(|i| i + 8))
{
let rest = l[i..].trim_start();
if let Some(r) = rest.strip_prefix(q)
&& let Some(end) = r.find(q)
{
out.push(r[..end].to_owned());
}
}
}
}
out
}
fn normalise(p: &str) -> String {
let mut parts: Vec<&str> = Vec::new();
for seg in p.split('/') {
match seg {
"" | "." => {}
".." => {
parts.pop();
}
s => parts.push(s),
}
}
parts.join("/")
}
#[must_use]
pub fn belongs(id: &str, file: &str) -> bool {
let stem = std::path::Path::new(file)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default();
if stem.is_empty() {
return false;
}
let dotted = file
.rsplit_once('.')
.map_or(file, |(a, _)| a)
.replace('/', ".");
id.contains(file)
|| id.contains(&dotted)
|| id.split([':', '.', '/', ' ']).any(|seg| seg == stem)
}
#[must_use]
pub fn select(
changed: &[String],
tests: &[(String, String)],
baseline: &[TestResult],
crate_name: Option<&str>,
unchanged: &[(String, String, String)],
) -> PassToPass {
let mut members: Vec<Member> = Vec::new();
for (file, content) in tests {
let Some(why) = changed
.iter()
.find_map(|c| rule(c, file, content, crate_name))
else {
continue;
};
for t in baseline
.iter()
.filter(|t| t.outcome == TestOutcome::Passed && belongs(&t.id, file))
{
if members.iter().any(|m| m.test == t.id) {
continue;
}
members.push(Member {
test: t.id.clone(),
file: file.clone(),
rule: why.clone(),
final_: MemberFinal::NotRun,
});
}
}
for (statement, file, test) in unchanged {
members.push(Member {
test: test.clone(),
file: file.clone(),
rule: format!("unchanged:{statement}"),
final_: MemberFinal::NotRun,
});
}
PassToPass {
state: if members.is_empty() {
PassToPassState::NoneSelected
} else {
PassToPassState::Selected
},
members,
}
}
pub fn settle(
p2p: &mut PassToPass,
finals: &[TestResult],
unchanged_passed: &dyn Fn(&str) -> bool,
) {
for m in &mut p2p.members {
if let Some(statement) = m.rule.strip_prefix("unchanged:") {
m.final_ = if unchanged_passed(statement) {
MemberFinal::Passed
} else {
MemberFinal::Failed
};
continue;
}
m.final_ = match finals.iter().find(|t| t.id == m.test).map(|t| t.outcome) {
Some(TestOutcome::Passed) => MemberFinal::Passed,
Some(TestOutcome::Failed | TestOutcome::Error | TestOutcome::Skipped) => {
MemberFinal::Failed
}
None => MemberFinal::Missing,
};
}
}
#[cfg(test)]
mod tests {
use super::*;
fn passed(id: &str) -> TestResult {
TestResult {
id: id.to_owned(),
outcome: TestOutcome::Passed,
}
}
#[test]
fn rust_tests_naming_the_changed_module_are_selected() {
assert_eq!(
rule(
"src/title.rs",
"tests/save.rs",
"use notes::title::save;",
Some("notes")
),
Some("references:crate::title".to_owned())
);
assert_eq!(
rule(
"src/title.rs",
"tests/other.rs",
"use notes::body;",
Some("notes")
),
None
);
assert_eq!(
rule(
"src/title.rs",
"src/title.rs",
"#[cfg(test)]\nmod tests {}",
Some("notes")
),
Some("same_file".to_owned())
);
assert_eq!(
rule(
"src/a/mod.rs",
"tests/a.rs",
"use my_crate::a::f;",
Some("my-crate")
),
Some("references:crate::a".to_owned())
);
}
#[test]
fn python_js_and_go_rules() {
assert_eq!(
rule(
"app/title.py",
"tests/test_title.py",
"from app.title import save\n",
None
),
Some("imports:app.title".to_owned())
);
assert_eq!(
rule(
"app/title.py",
"tests/test_x.py",
"from app import body, title\n",
None
),
Some("imports:app.title".to_owned())
);
assert_eq!(
rule(
"src/title.ts",
"src/title.test.ts",
"import { save } from './title';\n",
None
),
Some("imports:src/title.ts".to_owned())
);
assert_eq!(
rule(
"src/title.ts",
"test/title.test.ts",
"import { save } from \"../src/title.js\";\n",
None
),
Some("imports:src/title.ts".to_owned())
);
assert_eq!(
rule(
"title/title.go",
"title/title_test.go",
"package title",
None
),
Some("package:title".to_owned())
);
assert_eq!(rule("title/title.go", "other/x_test.go", "", None), None);
}
#[test]
fn only_baseline_green_tests_of_selected_files_and_none_is_recorded_as_none() {
let tests = vec![(
"tests/save.rs".to_owned(),
"use notes::title::save;".to_owned(),
)];
let baseline = vec![
passed("save::saves_a_title"),
TestResult {
id: "save::broken".to_owned(),
outcome: TestOutcome::Failed,
},
passed("other::x"),
];
let mut p = select(
&["src/title.rs".to_owned()],
&tests,
&baseline,
Some("notes"),
&[],
);
assert_eq!(p.state, PassToPassState::Selected);
assert_eq!(p.members.len(), 1);
settle(&mut p, &[], &|_| true);
assert_eq!(
p.members[0].final_,
MemberFinal::Missing,
"a missing test fails"
);
let none = select(
&["src/title.rs".to_owned()],
&[],
&baseline,
Some("notes"),
&[],
);
assert_eq!(none.state, PassToPassState::NoneSelected);
let with_u = select(
&[],
&[],
&[],
None,
&[(
"U1".to_owned(),
"tests/u1.rs".to_owned(),
"keeps".to_owned(),
)],
);
assert_eq!(with_u.members[0].rule, "unchanged:U1");
assert!(belongs("tests.test_title.test_x", "tests/test_title.py"));
assert!(belongs("save::saves", "tests/save.rs"));
}
}