use super::normalization::normalize_content;
use crate::core::backend::GeneratedFile;
use crate::core::config::Language;
use crate::core::hash;
use anyhow::Context as _;
use base64::Engine;
use rayon::prelude::*;
use std::path::Path;
use tracing::{debug, warn};
#[derive(Debug, Default)]
pub struct WriteReport {
pub expected_paths: std::collections::HashSet<std::path::PathBuf>,
pub changed_paths: std::collections::HashSet<std::path::PathBuf>,
pub refused_paths: std::collections::BTreeSet<std::path::PathBuf>,
}
impl WriteReport {
pub fn changed_count(&self) -> usize {
self.changed_paths.len()
}
pub fn expected_count(&self) -> usize {
self.expected_paths.len()
}
pub fn refused_count(&self) -> usize {
self.refused_paths.len()
}
pub fn absorb_refusals(&mut self, other: &WriteReport) {
self.refused_paths.extend(other.refused_paths.iter().cloned());
}
}
pub fn report_refused_writes(report: &WriteReport) {
if report.refused_paths.is_empty() {
return;
}
let mut paths: Vec<&std::path::PathBuf> = report.refused_paths.iter().collect();
paths.sort();
warn!(
"{} file(s) were NOT written: each already exists, carries no alef provenance marker, and \
alef has no durable record of owning it. This will not resolve on its own — the marker can \
only be written by writing the file, which is exactly what the guard declines. Review the \
diff for each and adopt the ones alef should own with `alef adopt <path>`. At migration \
scale, `alef adopt <glob>` previews the whole set and `alef adopt <glob> --converged-only \
--write` clears every file that already matches generated output, leaving the drifted \
ones for you to read one at a time. If these are \
formats that cannot carry a marker (package.json, *.jar) and this is a fresh clone or a CI \
checkout, check whether .alef-ownership.toml was committed — that file is where their \
ownership is recorded. Do NOT hand-add the marker line: a refusal can be protecting a \
deliberate hand-edit, and stamping it blind re-enables exactly the clobbering the guard \
exists to prevent.",
paths.len()
);
for path in paths {
warn!(" not written: {}", path.display());
}
}
pub fn managed_output_paths(files: &[GeneratedFile], base_dir: &Path) -> std::collections::HashSet<std::path::PathBuf> {
files
.iter()
.filter(|file| file.carries_alef_marker())
.map(|file| base_dir.join(&file.path))
.collect()
}
pub fn managed_generated_files(files: &[GeneratedFile]) -> Vec<GeneratedFile> {
files
.iter()
.filter(|file| file.carries_alef_marker())
.cloned()
.collect()
}
pub(crate) fn atomic_write(path: &Path, content: &[u8]) -> anyhow::Result<()> {
let parent = path.parent().context("generated output path has no parent")?;
let mut temporary = tempfile::NamedTempFile::new_in(parent)
.with_context(|| format!("failed to create temporary file in {}", parent.display()))?;
if let Ok(metadata) = std::fs::metadata(path) {
temporary
.as_file()
.set_permissions(metadata.permissions())
.with_context(|| format!("failed to preserve permissions for {}", path.display()))?;
}
std::io::Write::write_all(&mut temporary, content)
.with_context(|| format!("failed to write temporary file for {}", path.display()))?;
temporary
.persist(path)
.map_err(|error| error.error)
.with_context(|| format!("failed to replace {}", path.display()))?;
Ok(())
}
pub(crate) fn marker_comment_style(path: &Path) -> Option<hash::CommentStyle> {
match path.extension().and_then(|extension| extension.to_str()) {
Some("py" | "rb" | "r" | "ex" | "exs" | "toml" | "yaml" | "yml" | "sh") => Some(hash::CommentStyle::Hash),
Some("h" | "hpp") => Some(hash::CommentStyle::Block),
Some(
"c" | "cc" | "cpp" | "cs" | "dart" | "gleam" | "go" | "java" | "js" | "kt" | "kts" | "php" | "rs" | "swift"
| "ts" | "tsx" | "zig",
) => Some(hash::CommentStyle::DoubleSlash),
_ => None,
}
}
pub(crate) fn is_owned_by_ownership_record(base_dir: &Path, path: &Path) -> bool {
crate::cli::cache::is_scaffold_owned_path(base_dir, path)
|| crate::cli::cache::is_alef_derived_output(path)
|| crate::e2e::snippets::is_snippet_coverage_manifest_path(path)
|| crate::e2e::snippets::ownership::is_ledger_owned_snippet_path(base_dir, path)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum MarkerSyntax {
Comment(hash::CommentStyle),
Html,
}
pub(super) fn marker_header_syntax(path: &Path) -> Option<MarkerSyntax> {
if let Some(style) = marker_comment_style(path) {
return Some(MarkerSyntax::Comment(style));
}
match path.file_name().and_then(|name| name.to_str()) {
Some("Makefile" | "GNUmakefile" | "makefile") => return Some(MarkerSyntax::Comment(hash::CommentStyle::Hash)),
Some("go.mod") => return Some(MarkerSyntax::Comment(hash::CommentStyle::DoubleSlash)),
Some("Rakefile" | "Makevars" | "Makevars.in" | "Makevars.win.in") => {
return Some(MarkerSyntax::Comment(hash::CommentStyle::Hash));
}
Some(".clang-format") => return Some(MarkerSyntax::Comment(hash::CommentStyle::Hash)),
_ => {}
}
let extension = path
.extension()
.and_then(|extension| extension.to_str())
.map(str::to_ascii_lowercase);
match extension.as_deref() {
Some("cmake" | "gemspec") => Some(MarkerSyntax::Comment(hash::CommentStyle::Hash)),
Some("zon") => Some(MarkerSyntax::Comment(hash::CommentStyle::DoubleSlash)),
Some("xml" | "csproj") => Some(MarkerSyntax::Html),
Some(other) => marker_comment_style(Path::new("x").with_extension(other).as_path()).map(MarkerSyntax::Comment),
None => None,
}
}
fn html_header() -> String {
hash::header(hash::CommentStyle::DoubleSlash)
.lines()
.map(|line| format!("<!-- {} -->\n", line.strip_prefix("// ").unwrap_or(line)))
.collect()
}
pub(crate) fn provenance_header_for_path(path: &Path) -> Option<String> {
match marker_header_syntax(path)? {
MarkerSyntax::Comment(style) => Some(hash::header(style)),
MarkerSyntax::Html => Some(html_header()),
}
}
fn split_xml_declaration(content: &str) -> Option<(&str, &str)> {
let rest = content.strip_prefix("<?xml")?;
let terminator = rest.find("?>")?;
let split_at = "<?xml".len() + terminator + "?>".len();
let (declaration, body) = content.split_at(split_at);
Some((declaration, body.strip_prefix('\n').unwrap_or(body)))
}
pub(crate) fn ensure_generated_header(path: &Path, content: &str) -> String {
if hash::content_has_alef_marker(content) {
return content.to_owned();
}
let Some(syntax) = marker_header_syntax(path) else {
return content.to_owned();
};
let header = match syntax {
MarkerSyntax::Comment(style) => hash::header(style),
MarkerSyntax::Html => html_header(),
};
if let Some((shebang, body)) = content.split_once('\n').filter(|(line, _)| line.starts_with("#!/")) {
return format!("{shebang}\n{header}\n{body}");
}
if let Some((opening_tag, body)) = content.split_once('\n').filter(|(line, _)| line.trim() == "<?php") {
return format!("{opening_tag}\n{header}\n{body}");
}
if let Some((declaration, body)) = split_xml_declaration(content) {
return format!("{declaration}\n{header}\n{body}");
}
format!("{header}\n{content}")
}
pub(crate) fn stamp_for_adoption(path: &Path, existing: &str) -> Option<String> {
marker_header_syntax(path)?;
Some(ensure_generated_header(path, existing))
}
#[cfg(unix)]
pub(crate) fn apply_shebang_chmod(path: &std::path::Path, content: &str) -> anyhow::Result<()> {
use std::os::unix::fs::PermissionsExt;
if content.starts_with("#!") {
let perms = std::fs::Permissions::from_mode(0o755);
std::fs::set_permissions(path, perms).with_context(|| format!("failed to chmod 755 {}", path.display()))?;
}
Ok(())
}
#[cfg(not(unix))]
pub(crate) fn apply_shebang_chmod(_path: &std::path::Path, _content: &str) -> anyhow::Result<()> {
Ok(())
}
pub fn write_files(files: &[(Language, Vec<GeneratedFile>)], base_dir: &Path) -> anyhow::Result<usize> {
Ok(write_files_report(files, base_dir)?.changed_count())
}
pub fn write_files_report(files: &[(Language, Vec<GeneratedFile>)], base_dir: &Path) -> anyhow::Result<WriteReport> {
let mut prepared = std::collections::BTreeMap::<std::path::PathBuf, (Vec<u8>, bool)>::new();
for file in files.iter().flat_map(|(_, lang_files)| lang_files.iter()) {
let full_path = base_dir.join(&file.path);
let (content, is_text) = if full_path.extension().is_some_and(|extension| extension == "jar") {
(
base64::engine::general_purpose::STANDARD
.decode(&file.content)
.with_context(|| format!("failed to decode base64 for {}", full_path.display()))?,
false,
)
} else {
let normalized = normalize_content(&full_path, &file.content);
let normalized = if file.generated_header {
ensure_generated_header(&full_path, &normalized)
} else {
if hash::content_has_alef_marker(&normalized) {
debug!(
" {}: emitted with generated_header = false but body carries an alef marker",
full_path.display()
);
}
normalized
};
(normalized.into_bytes(), true)
};
if let Some((existing, _)) = prepared.get(&full_path) {
anyhow::ensure!(
existing == &content,
"multiple generators emitted different content for {}",
full_path.display()
);
continue;
}
prepared.insert(full_path, (content, is_text));
}
let dirs: std::collections::BTreeSet<_> = prepared
.keys()
.filter_map(|path| path.parent().map(Path::to_path_buf))
.collect();
for dir in &dirs {
std::fs::create_dir_all(dir).with_context(|| format!("failed to create directory {}", dir.display()))?;
}
let changed_paths = std::sync::Mutex::new(std::collections::HashSet::new());
let refused_paths = std::sync::Mutex::new(std::collections::BTreeSet::new());
let refuse = |path: &Path| {
refused_paths
.lock()
.expect("refused-path mutex poisoned")
.insert(path.to_path_buf());
};
prepared
.par_iter()
.try_for_each(|(full_path, (content, is_text))| -> anyhow::Result<()> {
if *is_text {
let normalized = std::str::from_utf8(content).context("prepared generated text was not UTF-8")?;
let is_markable = marker_comment_style(full_path).is_some();
if full_path.exists() {
let Ok(existing) = std::fs::read_to_string(full_path) else {
warn!(
"refusing to write {}: pre-existing file could not be read as text -- \
leaving it untouched",
full_path.display()
);
refuse(full_path);
return Ok(());
};
let existing_body = crate::core::hash::strip_hash_line(&existing);
let normalized_body = crate::core::hash::strip_hash_line(normalized);
if existing_body == normalized_body {
apply_shebang_chmod(full_path, normalized)?;
debug!(" unchanged: {}", full_path.display());
return Ok(());
}
let has_marker = hash::content_has_alef_marker(&existing);
let owned = has_marker || (!is_markable && is_owned_by_ownership_record(base_dir, full_path));
if !owned {
match hash::near_miss_marker(&existing) {
Some(near_miss) => warn!(
"refusing to write {}: pre-existing file carries no alef marker and \
alef has no durable record of ever owning it -- its leading lines \
contain something close to a marker ({near_miss:?}) that alef does \
not recognize; alef accepts \"generated by alef\" case-insensitively \
-- leaving it untouched",
full_path.display()
),
None => warn!(
"refusing to write {}: pre-existing file carries no alef marker and \
alef has no durable record of ever owning it -- leaving it untouched",
full_path.display()
),
}
refuse(full_path);
return Ok(());
}
}
atomic_write(full_path, content)?;
apply_shebang_chmod(full_path, normalized)?;
if !is_markable {
crate::cli::cache::record_scaffold_owned_path(base_dir, full_path)?;
}
} else {
if full_path.exists() {
let existing_binary = std::fs::read(full_path).ok();
if existing_binary.as_deref() == Some(content.as_slice()) {
debug!(" unchanged: {}", full_path.display());
return Ok(());
}
if existing_binary.is_some() && !crate::cli::cache::is_scaffold_owned_path(base_dir, full_path) {
warn!(
"refusing to write {}: pre-existing file has no durable record of \
alef ownership -- leaving it untouched",
full_path.display()
);
refuse(full_path);
return Ok(());
}
}
atomic_write(full_path, content)?;
crate::cli::cache::record_scaffold_owned_path(base_dir, full_path)?;
}
changed_paths
.lock()
.expect("changed-path mutex poisoned")
.insert(full_path.clone());
debug!(" wrote: {}", full_path.display());
Ok(())
})?;
Ok(WriteReport {
expected_paths: prepared.into_keys().collect(),
changed_paths: changed_paths.into_inner().expect("changed-path mutex poisoned"),
refused_paths: refused_paths.into_inner().expect("refused-path mutex poisoned"),
})
}
pub fn finalize_hashes(
paths: &std::collections::HashSet<std::path::PathBuf>,
sources_hash: &str,
alef_toml_bytes: &[u8],
) -> anyhow::Result<usize> {
let inputs_hash = hash::compute_inputs_hash(sources_hash, alef_toml_bytes);
let updated: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
paths.par_iter().try_for_each(|path| -> anyhow::Result<()> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return Ok(()),
};
if !hash::content_has_alef_marker(&content) {
return Ok(());
}
let stripped = hash::strip_hash_line(&content);
let file_hash = hash::compute_file_hash(&inputs_hash, &stripped);
let final_content = hash::inject_hash_line(&stripped, &file_hash);
if final_content == content {
return Ok(());
}
atomic_write(path, final_content.as_bytes())?;
apply_shebang_chmod(path, &final_content)?;
updated.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(())
})?;
Ok(updated.into_inner())
}
pub fn finalize_hashes_sweeping(
paths: &std::collections::HashSet<std::path::PathBuf>,
roots: &[std::path::PathBuf],
sources_hash: &str,
alef_toml_bytes: &[u8],
) -> anyhow::Result<usize> {
let mut swept = paths.clone();
for root in roots {
swept.extend(super::orphans::collect_alef_headered_paths(root));
}
log_disk_scan_only_restamps(paths, &swept);
finalize_hashes(&swept, sources_hash, alef_toml_bytes)
}
fn log_disk_scan_only_restamps(
paths: &std::collections::HashSet<std::path::PathBuf>,
swept: &std::collections::HashSet<std::path::PathBuf>,
) {
let mut disk_scan_only: Vec<&std::path::PathBuf> = swept.difference(paths).collect();
if disk_scan_only.is_empty() {
return;
}
disk_scan_only.sort();
debug!(
"{} alef-marked file(s) under this sweep's roots were re-stamped from their own on-disk \
content without appearing in this run's explicit generation output -- expected for a \
language skipped by the per-language cache, but also how a file the generator has \
stopped emitting entirely (a dropped type, a manifest whose emit condition changed) gets \
its stale `alef:hash:` line replaced with one that matches, so `alef verify` reports it \
current. If a path below is not owned by a currently cache-skipped language, it is \
orphaned rather than merely unchanged -- cross-check against sweep_manifest_orphans's \
input for this run.",
disk_scan_only.len()
);
for path in disk_scan_only {
debug!(" re-stamped via disk scan only: {}", path.display());
}
}
#[cfg(test)]
mod marker_syntax_tests;