use super::normalization::{format_rust_content, normalize_content, normalize_whitespace};
use crate::cli::pipeline::format::poly_format_strict;
use crate::core::backend::GeneratedFile;
use std::path::{Path, PathBuf};
use std::process::Command;
pub fn find_create_once_template_drift(scaffold_files: &[GeneratedFile], base_dir: &Path) -> Vec<PathBuf> {
scaffold_files
.iter()
.filter(|file| !file.generated_header)
.filter(|file| !super::binary::is_base64_binary_output(&file.path))
.filter(|file| differs_from_template(file, base_dir))
.filter(|file| untouched_since_scaffold(base_dir, &file.path) == Some(true))
.map(|file| file.path.clone())
.collect()
}
fn differs_from_template(file: &GeneratedFile, base_dir: &Path) -> bool {
let full_path = base_dir.join(&file.path);
let Ok(existing) = std::fs::read_to_string(&full_path) else {
return false;
};
let is_rust = file.path.extension().is_some_and(|ext| ext == "rs");
let generated = normalize_content(&file.path, &file.content);
let on_disk = if is_rust {
format_rust_content(&full_path, &existing)
} else {
existing
};
if normalize_whitespace(&on_disk) == normalize_whitespace(&generated) {
return false;
}
if is_rust {
return true;
}
non_rust_content_still_differs_after_formatting(&file.path, base_dir, &generated, &on_disk)
}
fn non_rust_content_still_differs_after_formatting(
relative_path: &Path,
base_dir: &Path,
generated: &str,
on_disk: &str,
) -> bool {
let (Some(formatted_generated), Some(formatted_on_disk)) = (
normalize_with_poly(relative_path, base_dir, generated),
normalize_with_poly(relative_path, base_dir, on_disk),
) else {
return false;
};
normalize_whitespace(&formatted_on_disk) != normalize_whitespace(&formatted_generated)
}
fn normalize_with_poly(relative_path: &Path, base_dir: &Path, content: &str) -> Option<String> {
let file_name = relative_path.file_name()?;
let temp_dir = tempfile::Builder::new()
.prefix("alef-drift-check-")
.tempdir_in(base_dir)
.ok()?;
let temp_path = temp_dir.path().join(file_name);
std::fs::write(&temp_path, content).ok()?;
poly_format_strict(&[temp_dir.path().to_path_buf()], base_dir).ok()?;
std::fs::read_to_string(&temp_path).ok()
}
fn untouched_since_scaffold(base_dir: &Path, relative: &Path) -> Option<bool> {
let output = Command::new("git")
.arg("-C")
.arg(base_dir)
.args(["log", "--follow", "--format=%H", "--"])
.arg(relative)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let commit_count = String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|line| !line.trim().is_empty())
.count();
Some(commit_count == 1)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{git_commit_all, git_init as init_git_repo};
fn seed_file(base_dir: &Path, relative: &str, content: &str) -> PathBuf {
let full = base_dir.join(relative);
if let Some(parent) = full.parent() {
std::fs::create_dir_all(parent).expect("mkdir");
}
std::fs::write(&full, content).expect("write seed");
PathBuf::from(relative)
}
fn create_once_file(path: PathBuf, content: &str) -> GeneratedFile {
GeneratedFile {
path,
content: content.to_string(),
generated_header: false,
}
}
#[test]
fn a_create_once_file_matching_the_current_template_is_silent() {
let dir = tempfile::tempdir().expect("tempdir");
let relative = seed_file(dir.path(), "build.zig", "const std = @import(\"std\");\n");
init_git_repo(dir.path());
git_commit_all(dir.path(), "scaffold");
let files = vec![create_once_file(relative, "const std = @import(\"std\");\n")];
let drift = find_create_once_template_drift(&files, dir.path());
assert_eq!(
drift,
Vec::<PathBuf>::new(),
"unchanged template must never be reported"
);
}
#[test]
fn a_create_once_file_untouched_since_scaffold_and_differing_from_template_is_reported() {
let dir = tempfile::tempdir().expect("tempdir");
let relative = seed_file(dir.path(), "build.zig", "const std = @import(\"std\");\n");
init_git_repo(dir.path());
git_commit_all(dir.path(), "scaffold");
let files = vec![create_once_file(
relative.clone(),
"const std = @import(\"std\");\nif (!exists) return;\n",
)];
let drift = find_create_once_template_drift(&files, dir.path());
if crate::cli::pipeline::format::is_tool_available("poly") {
assert_eq!(
drift,
vec![relative],
"an untouched file that predates a template fix must be reported"
);
} else {
assert_eq!(
drift,
Vec::<PathBuf>::new(),
"without poly on PATH the check cannot confirm the difference isn't formatting-only, so \
it must stay silent (false negative preferred over false positive)"
);
}
}
#[test]
fn a_create_once_file_edited_after_scaffolding_is_not_reported_as_template_drift() {
let dir = tempfile::tempdir().expect("tempdir");
let relative = seed_file(dir.path(), "build.zig", "const std = @import(\"std\");\n");
init_git_repo(dir.path());
git_commit_all(dir.path(), "scaffold");
std::fs::write(
dir.path().join("build.zig"),
"const std = @import(\"std\");\n// hand edit\n",
)
.expect("hand edit");
git_commit_all(dir.path(), "consumer edit");
let files = vec![create_once_file(
relative,
"const std = @import(\"std\");\nif (!exists) return;\n",
)];
let drift = find_create_once_template_drift(&files, dir.path());
assert_eq!(
drift,
Vec::<PathBuf>::new(),
"a file with more than one commit must not be reported -- the second commit might be a \
legitimate consumer edit, and this detector favors silence over a guess"
);
}
#[test]
fn an_uncommitted_create_once_file_is_not_reported() {
let dir = tempfile::tempdir().expect("tempdir");
let relative = seed_file(dir.path(), "build.zig", "const std = @import(\"std\");\n");
init_git_repo(dir.path());
let files = vec![create_once_file(
relative,
"const std = @import(\"std\");\nif (!exists) return;\n",
)];
let drift = find_create_once_template_drift(&files, dir.path());
assert_eq!(
drift,
Vec::<PathBuf>::new(),
"a file with zero commits has no history to prove drift from, so it must stay silent"
);
}
#[test]
fn a_marker_rail_file_is_never_considered_for_create_once_drift() {
let dir = tempfile::tempdir().expect("tempdir");
let relative = seed_file(dir.path(), "src/lib.rs", "// stale\n");
init_git_repo(dir.path());
git_commit_all(dir.path(), "scaffold");
let files = vec![GeneratedFile {
path: relative,
content: "// fresh\n".to_string(),
generated_header: true,
}];
let drift = find_create_once_template_drift(&files, dir.path());
assert_eq!(
drift,
Vec::<PathBuf>::new(),
"marker-rail files are never in scope for this check"
);
}
#[test]
fn a_non_rust_create_once_file_reformatted_only_by_poly_is_not_reported_as_drift() {
let dir = tempfile::tempdir().expect("tempdir");
let relative = seed_file(dir.path(), "packages/python/scaffolded.py", "x = 1\n");
init_git_repo(dir.path());
git_commit_all(dir.path(), "scaffold");
let files = vec![create_once_file(relative, "x=1")];
let drift = find_create_once_template_drift(&files, dir.path());
assert_eq!(
drift,
Vec::<PathBuf>::new(),
"a difference poly's own formatter accounts for must never be reported as template drift"
);
}
#[test]
fn a_non_rust_create_once_file_with_a_genuine_content_change_is_still_reported() {
let dir = tempfile::tempdir().expect("tempdir");
let relative = seed_file(dir.path(), "packages/python/scaffolded.py", "x = 1\n");
init_git_repo(dir.path());
git_commit_all(dir.path(), "scaffold");
let files = vec![create_once_file(relative.clone(), "x=2")];
let drift = find_create_once_template_drift(&files, dir.path());
if crate::cli::pipeline::format::is_tool_available("poly") {
assert_eq!(
drift,
vec![relative],
"a genuine content change must still be reported once poly confirms it isn't just formatting"
);
} else {
assert_eq!(
drift,
Vec::<PathBuf>::new(),
"without poly on PATH the check cannot confirm the difference isn't formatting-only, so \
it must stay silent -- a false negative is accepted in exchange for never reporting a \
formatter's own output as drift"
);
}
}
}