use crate::core::hash;
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>,
pub refused_drifted_paths: std::collections::BTreeSet<std::path::PathBuf>,
pub user_owned_paths: std::collections::BTreeSet<std::path::PathBuf>,
pub refused_create_once_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 refused_drifted_count(&self) -> usize {
self.refused_drifted_paths.len()
}
pub fn user_owned_count(&self) -> usize {
self.user_owned_paths.len()
}
pub fn refused_create_once_count(&self) -> usize {
self.refused_create_once_paths.len()
}
pub fn refuse_drifted(&mut self, path: &Path, create_once: bool) {
self.refused_paths.insert(path.to_path_buf());
self.refused_drifted_paths.insert(path.to_path_buf());
if create_once {
self.refused_create_once_paths.insert(path.to_path_buf());
}
}
pub fn refuse_text(&mut self, path: &Path, existing: Option<&str>, generated: &str, create_once: bool) {
match existing {
Some(existing) if matches_alef_output(path, existing, generated) => {
self.refused_paths.insert(path.to_path_buf());
if create_once {
self.refused_create_once_paths.insert(path.to_path_buf());
}
}
_ => self.refuse_drifted(path, create_once),
}
}
pub fn absorb_unwritten(&mut self, other: &WriteReport) {
self.refused_paths.extend(other.refused_paths.iter().cloned());
self.refused_drifted_paths
.extend(other.refused_drifted_paths.iter().cloned());
self.user_owned_paths.extend(other.user_owned_paths.iter().cloned());
self.refused_create_once_paths
.extend(other.refused_create_once_paths.iter().cloned());
}
}
pub(crate) fn matches_alef_output(path: &Path, existing: &str, generated: &str) -> bool {
let existing_body = hash::strip_hash_line(existing);
let generated_body = hash::strip_hash_line(generated);
existing_body == generated_body
|| hash::strip_hash_line(&super::ensure_generated_header(path, &existing_body)) == generated_body
}
pub fn report_refused_writes(report: &WriteReport) {
if report.refused_paths.is_empty() {
return;
}
let mut adoptable: Vec<&std::path::PathBuf> = report
.refused_paths
.iter()
.filter(|path| !report.refused_create_once_paths.contains(*path))
.collect();
adoptable.sort();
if !adoptable.is_empty() {
let drifted_count = adoptable
.iter()
.filter(|path| report.refused_drifted_paths.contains(**path))
.count();
warn!(
"{} file(s) were NOT written, {} of them holding content that DIFFERS from what alef \
would now generate: 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. The \
differing ones are stale for as long as they stay frozen, and a file whose content is \
derived from the release version is the case that bites, because a stale copy stays \
plausible. 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.",
adoptable.len(),
drifted_count
);
for path in adoptable {
if report.refused_drifted_paths.contains(path) {
warn!(
" not written, content DIFFERS (stale until adopted or deleted): {}",
path.display()
);
} else {
warn!(
" not written, content already matches generated output: {}",
path.display()
);
}
}
}
if !report.refused_create_once_paths.is_empty() {
let mut seeds: Vec<&std::path::PathBuf> = report.refused_create_once_paths.iter().collect();
seeds.sort();
warn!(
"{} file(s) were NOT written because they are create-once seeds: alef emits each of \
these paths only when absent and never rewrites them again, so a plain `alef \
generate` already leaves them untouched on every later run -- this is not drift, and \
nothing here needs fixing. Do NOT run `alef adopt` on these: alef emits a path like \
this only once, so the on-disk copy has almost certainly grown past alef's \
placeholder, and adopting consents to alef replacing its contents wholesale on the \
next overwriting regen (an `alef version` sync, `alef all --clobber-create-once-seeds`). \
`alef adopt --write` already refuses every one of these by design, unless \
`--clobber-create-once-seeds` is passed -- a flag whose own help text calls it \
dangerous.",
seeds.len()
);
for path in seeds {
warn!(
" create-once seed, not rewritten (this is expected): {}",
path.display()
);
}
}
}
pub fn report_user_owned_skips(report: &WriteReport) {
if report.user_owned_paths.is_empty() {
return;
}
tracing::info!(
"{} file(s) were not written because `[workspace.ownership] user_owned` in alef.toml \
declares them owned by this repository rather than by alef. alef does not overwrite \
them, does not stamp them with a provenance marker, and does not verify their \
contents. Remove the matching pattern to hand a path back to alef.",
report.user_owned_paths.len()
);
for path in &report.user_owned_paths {
debug!(" declared user-owned, not written: {}", path.display());
}
}
#[cfg(test)]
mod tests;