use std::collections::BTreeMap;
use std::path::Path;
use crate::compat::added_lines::added_lines_with_offsets;
use crate::compat::patch::PatchedFile;
use crate::model::names::RelativePath;
use crate::model::verdict::Reason;
pub fn analyse_suite_registration(
files: &[PatchedFile],
marker: &str,
read_target: &dyn Fn(&RelativePath) -> Option<String>,
) -> Vec<Reason> {
let makefile_added = collect_makefile_added_lines(files);
let mut reasons = Vec::new();
for file in files {
if file.old_path.is_some() || file.binary {
continue;
}
let Some(new_path) = file.new_path.as_deref() else {
continue;
};
if new_path == Path::new("/dev/null") {
continue;
}
let Some(suite_path) = new_path.to_str().and_then(|s| RelativePath::new(s).ok()) else {
continue;
};
let Some((app_dir, base_name)) = suite_app_and_base(&suite_path) else {
continue;
};
let Some(makefile_path) = makefile_path_for(&app_dir) else {
continue;
};
let target_content = read_target(&makefile_path).unwrap_or_default();
if !makefile_has_marker(&target_content, marker) {
continue;
}
let added_here = makefile_added
.get(&makefile_path)
.map(String::as_str)
.unwrap_or("");
if word_boundary_contains(&target_content, &base_name)
|| word_boundary_contains(added_here, &base_name)
{
continue;
}
reasons.push(Reason::SuiteNotRegisteredForCt {
suite_path,
makefile_path,
});
}
reasons
}
fn collect_makefile_added_lines(files: &[PatchedFile]) -> BTreeMap<RelativePath, String> {
let mut out = BTreeMap::new();
for file in files {
if file.binary {
continue;
}
let Some(new_path) = file.new_path.as_deref() else {
continue;
};
if new_path.file_name().and_then(|n| n.to_str()) != Some("Makefile") {
continue;
}
let Some(path) = new_path.to_str().and_then(|s| RelativePath::new(s).ok()) else {
continue;
};
let (added, _) = added_lines_with_offsets(&file.hunks);
if !added.is_empty() {
out.insert(path, added);
}
}
out
}
fn suite_app_and_base(path: &RelativePath) -> Option<(String, String)> {
let parts: Vec<&str> = path.as_str().split('/').collect();
if parts.len() < 2 {
return None;
}
let file_name = parts[parts.len() - 1];
let parent = parts[parts.len() - 2];
if parent != "test" {
return None;
}
let base_name = file_name.strip_suffix("_SUITE.erl")?;
if base_name.is_empty() {
return None;
}
let app_dir = parts[..parts.len() - 2].join("/");
Some((app_dir, base_name.to_owned()))
}
fn makefile_path_for(app_dir: &str) -> Option<RelativePath> {
if app_dir.is_empty() {
RelativePath::new("Makefile").ok()
} else {
RelativePath::new(format!("{app_dir}/Makefile")).ok()
}
}
fn makefile_has_marker(content: &str, marker: &str) -> bool {
content.lines().any(|line| {
let trimmed = line.trim_start();
!trimmed.starts_with('#') && trimmed.starts_with(marker)
})
}
pub(crate) fn word_boundary_contains(text: &str, word: &str) -> bool {
if word.is_empty() {
return false;
}
let bytes = text.as_bytes();
let mut search_from = 0usize;
while let Some(offset) = text[search_from..].find(word) {
let start = search_from + offset;
let end = start + word.len();
let before_ok = start == 0 || !is_ident_byte(bytes[start - 1]);
let after_ok = end >= bytes.len() || !is_ident_byte(bytes[end]);
if before_ok && after_ok {
return true;
}
search_from = start + 1;
if search_from >= text.len() {
break;
}
}
false
}
fn is_ident_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}