use super::normalization::{format_rust_content, normalize_content, normalize_whitespace};
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
};
normalize_whitespace(&on_disk) != normalize_whitespace(&generated)
}
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::*;
fn init_git_repo(base_dir: &Path) {
let status = Command::new("git")
.arg("-C")
.arg(base_dir)
.args(["init", "--quiet"])
.status()
.expect("git init");
assert!(status.success(), "git init failed");
}
fn git_commit_all(base_dir: &Path, message: &str) {
let status = Command::new("git")
.arg("-C")
.arg(base_dir)
.args(["add", "-A"])
.status()
.expect("git add");
assert!(status.success(), "git add failed");
let status = Command::new("git")
.arg("-C")
.arg(base_dir)
.args([
"-c",
"user.email=test@example.com",
"-c",
"user.name=Test",
"commit",
"--quiet",
"-m",
message,
])
.status()
.expect("git commit");
assert!(status.success(), "git commit failed");
}
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());
assert_eq!(
drift,
vec![relative],
"an untouched file that predates a template fix must be reported"
);
}
#[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"
);
}
}