use anyhow::{Context, Result, bail};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdoptionState {
AlreadyOwned,
Converged,
Drifted,
}
pub struct ManagedOutput {
pub relative: PathBuf,
pub content: String,
pub create_once: bool,
}
pub struct AdoptCandidate {
pub relative: PathBuf,
pub full_path: PathBuf,
pub existing: String,
pub generated: String,
pub state: AdoptionState,
pub stamped: Option<String>,
pub create_once: bool,
pub binary: Option<BinaryFacts>,
}
#[derive(Debug, Clone)]
pub struct BinaryFacts {
pub existing_len: usize,
pub existing_digest: String,
pub generated_len: usize,
pub generated_digest: String,
}
impl BinaryFacts {
fn new(existing: &[u8], generated: &[u8]) -> Self {
Self {
existing_len: existing.len(),
existing_digest: crate::core::hash::hash_bytes(existing),
generated_len: generated.len(),
generated_digest: crate::core::hash::hash_bytes(generated),
}
}
}
pub struct AdoptOptions {
pub target: String,
pub base_dir: PathBuf,
pub write: bool,
pub converged_only: bool,
pub clobber_create_once_seeds: bool,
}
#[derive(Debug)]
pub struct AdoptDiff {
pub relative: PathBuf,
pub state: AdoptionState,
pub body: String,
}
#[derive(Debug, Default)]
pub struct AdoptReport {
pub adopted: Vec<PathBuf>,
pub already_owned: Vec<PathBuf>,
pub converged: Vec<PathBuf>,
pub skipped_drifted: Vec<PathBuf>,
pub skipped_create_once: Vec<PathBuf>,
pub recorded_unstampable: Vec<PathBuf>,
pub unreadable: Vec<PathBuf>,
pub diffs: Vec<AdoptDiff>,
pub preview: bool,
}
impl AdoptReport {
pub fn drifted(&self) -> impl Iterator<Item = &AdoptDiff> + '_ {
self.diffs.iter().filter(|diff| diff.state == AdoptionState::Drifted)
}
}
pub(crate) fn is_create_once_seed(file: &crate::core::backend::GeneratedFile) -> bool {
!file.carries_alef_marker() && !crate::cli::cache::is_alef_derived_output(&file.path)
}
pub fn managed_outputs(files: &[crate::core::backend::GeneratedFile], base_dir: &Path) -> Vec<ManagedOutput> {
files
.iter()
.map(|file| {
let full_path = base_dir.join(&file.path);
if crate::cli::pipeline::is_base64_binary_output(&file.path) {
return ManagedOutput {
relative: file.path.clone(),
content: file.content.clone(),
create_once: is_create_once_seed(file),
};
}
let normalized = crate::cli::pipeline::normalize_content(&full_path, &file.content);
let content = if file.generated_header {
crate::cli::pipeline::ensure_generated_header(&full_path, &normalized)
} else {
normalized
};
ManagedOutput {
relative: file.path.clone(),
content,
create_once: is_create_once_seed(file),
}
})
.collect()
}
pub(crate) fn matches_target(target: &str, relative: &Path) -> bool {
let spelled = relative.to_string_lossy().replace('\\', "/");
let target = target.trim_start_matches("./");
if spelled == target {
return true;
}
glob::Pattern::new(target).is_ok_and(|pattern| pattern.matches(&spelled))
}
fn embedded_regenerate_command(generated: &str) -> Option<String> {
let lines: Vec<&str> = generated.lines().collect();
let marker = lines
.iter()
.position(|line| crate::core::hash::content_has_alef_marker(line))?;
let rest = lines
.get(marker + 1)?
.trim()
.strip_prefix("<!--")?
.trim()
.strip_prefix("To regenerate:")?;
Some(rest.trim().trim_end_matches("-->").trim().to_owned())
}
fn stamp_for(full_path: &Path, existing: &str, generated: &str) -> Option<String> {
if !existing.is_empty()
&& crate::core::hash::content_has_alef_marker(generated)
&& let Some(prefix) = generated.strip_suffix(existing)
&& crate::core::hash::is_provenance_only_prefix(prefix)
{
return Some(generated.to_owned());
}
if let Some(stamped) = crate::cli::pipeline::stamp_for_adoption(full_path, existing) {
return Some(stamped);
}
if full_path.extension().and_then(|extension| extension.to_str()) != Some("md") {
return None;
}
if !crate::core::hash::content_has_alef_marker(generated) {
return None;
}
let command = embedded_regenerate_command(generated)?;
Some(crate::docs::with_html_header(existing.to_owned(), &command))
}
pub fn classify(
full_path: &Path,
relative: &Path,
generated: &str,
existing: &str,
create_once: bool,
) -> AdoptCandidate {
let stamped = stamp_for(full_path, existing, generated);
let state = if crate::core::hash::content_has_alef_marker(existing) {
AdoptionState::AlreadyOwned
} else if crate::core::hash::strip_hash_line(stamped.as_deref().unwrap_or(existing))
== crate::core::hash::strip_hash_line(generated)
{
AdoptionState::Converged
} else {
AdoptionState::Drifted
};
AdoptCandidate {
relative: relative.to_path_buf(),
full_path: full_path.to_path_buf(),
existing: existing.to_owned(),
generated: generated.to_owned(),
state,
stamped,
create_once,
binary: None,
}
}
fn classify_binary(
base_dir: &Path,
full_path: &Path,
output: &ManagedOutput,
existing: &[u8],
) -> Option<AdoptCandidate> {
let generated = match crate::cli::pipeline::decode_base64_binary(&output.relative, &output.content) {
Ok(bytes) => bytes,
Err(error) => {
tracing::warn!(
path = %output.relative.display(),
"cannot be adopted: alef's own generated content for this binary path did not decode: {error:#}"
);
return None;
}
};
let state = if crate::cli::cache::is_scaffold_owned_path(base_dir, full_path) {
AdoptionState::AlreadyOwned
} else if existing == generated.as_slice() {
AdoptionState::Converged
} else {
AdoptionState::Drifted
};
Some(AdoptCandidate {
relative: output.relative.clone(),
full_path: full_path.to_path_buf(),
existing: String::new(),
generated: String::new(),
state,
stamped: None,
create_once: output.create_once,
binary: Some(BinaryFacts::new(existing, &generated)),
})
}
pub fn render_diff(candidate: &AdoptCandidate) -> String {
let spelled = candidate.relative.display();
let mut body = format!("--- {spelled} (on disk)\n+++ {spelled} (alef generate output)\n");
if let Some(facts) = &candidate.binary {
body.push_str("Binary output: no line diff exists. The bytes on each side:\n");
body.push_str(&format!(
"-{:>12} bytes blake3:{}\n",
facts.existing_len, facts.existing_digest
));
body.push_str(&format!(
"+{:>12} bytes blake3:{}\n",
facts.generated_len, facts.generated_digest
));
return body;
}
let diff = similar::TextDiff::from_lines(candidate.existing.as_str(), candidate.generated.as_str());
for change in diff.iter_all_changes() {
let prefix = match change.tag() {
similar::ChangeTag::Delete => '-',
similar::ChangeTag::Insert => '+',
similar::ChangeTag::Equal => ' ',
};
body.push(prefix);
body.push_str(change.value());
if !change.value().ends_with('\n') {
body.push('\n');
}
}
body
}
struct CollectedCandidates {
candidates: Vec<AdoptCandidate>,
unreadable: Vec<PathBuf>,
}
fn collect_candidates(options: &AdoptOptions, managed: &[ManagedOutput]) -> Result<CollectedCandidates> {
let mut matched: Vec<&ManagedOutput> = managed
.iter()
.filter(|output| matches_target(&options.target, &output.relative))
.collect();
matched.sort_by(|left, right| left.relative.cmp(&right.relative));
if matched.is_empty() {
bail!(
"no alef-managed output matches `{}` -- adopt only applies to paths alef generates",
options.target
);
}
let mut candidates = Vec::with_capacity(matched.len());
let mut unreadable: Vec<PathBuf> = Vec::new();
for output in matched {
let full_path = options.base_dir.join(&output.relative);
if !full_path.exists() {
continue;
}
let bytes =
std::fs::read(&full_path).with_context(|| format!("failed to read existing {}", full_path.display()))?;
if crate::cli::pipeline::is_base64_binary_output(&output.relative) {
match classify_binary(&options.base_dir, &full_path, output, &bytes) {
Some(candidate) => candidates.push(candidate),
None => unreadable.push(output.relative.clone()),
}
continue;
}
let Ok(existing) = String::from_utf8(bytes) else {
unreadable.push(output.relative.clone());
continue;
};
candidates.push(classify(
&full_path,
&output.relative,
&output.content,
&existing,
output.create_once,
));
}
if candidates.is_empty() && unreadable.is_empty() {
bail!(
"`{}` matches alef-managed output but nothing exists on disk yet -- \
run `alef generate`, there is no ownership conflict to resolve",
options.target
);
}
Ok(CollectedCandidates { candidates, unreadable })
}
fn apply(candidate: &AdoptCandidate, report: &mut AdoptReport, to_record: &mut Vec<PathBuf>) -> Result<()> {
match &candidate.stamped {
Some(stamped) => {
crate::cli::pipeline::atomic_write(&candidate.full_path, stamped.as_bytes())?;
crate::cli::pipeline::apply_shebang_chmod(&candidate.full_path, stamped)?;
report.adopted.push(candidate.relative.clone());
}
None => {
to_record.push(candidate.full_path.clone());
report.recorded_unstampable.push(candidate.relative.clone());
report.adopted.push(candidate.relative.clone());
}
}
Ok(())
}
pub fn run(options: &AdoptOptions, managed: &[ManagedOutput]) -> Result<AdoptReport> {
let CollectedCandidates { candidates, unreadable } = collect_candidates(options, managed)?;
let mut report = AdoptReport {
preview: !options.write,
unreadable,
..AdoptReport::default()
};
let (adoptable, blocked): (Vec<&AdoptCandidate>, Vec<&AdoptCandidate>) = candidates.iter().partition(|candidate| {
options.clobber_create_once_seeds || !candidate.create_once || candidate.state == AdoptionState::AlreadyOwned
});
for candidate in &blocked {
report.skipped_create_once.push(candidate.relative.clone());
tracing::warn!(
path = %candidate.relative.display(),
"create-once seed: alef emits this path only when absent, so adopting it consents to alef \
replacing its contents with a placeholder seed on the next generate"
);
}
for candidate in &adoptable {
match candidate.state {
AdoptionState::AlreadyOwned => report.already_owned.push(candidate.relative.clone()),
AdoptionState::Converged => report.converged.push(candidate.relative.clone()),
AdoptionState::Drifted => report.diffs.push(AdoptDiff {
relative: candidate.relative.clone(),
state: candidate.state,
body: render_diff(candidate),
}),
}
}
for diff in report.drifted() {
tracing::warn!(
path = %diff.relative.display(),
"content differs from generated output: adopting consents to alef replacing it on the next generate"
);
}
if !options.write {
return Ok(report);
}
let has_work = adoptable.iter().any(|c| c.state != AdoptionState::AlreadyOwned);
if !has_work && !report.skipped_create_once.is_empty() {
bail!(
"`{}` matches only create-once seeds, which alef emits solely when absent -- \
adopting one consents to alef replacing its contents with a placeholder seed on the \
next generate, so nothing was written. Pass --clobber-create-once-seeds to adopt them \
anyway.",
options.target
);
}
let mut to_record: Vec<PathBuf> = Vec::new();
for candidate in adoptable.iter().filter(|c| c.state != AdoptionState::AlreadyOwned) {
if options.converged_only && candidate.state == AdoptionState::Drifted {
report.skipped_drifted.push(candidate.relative.clone());
continue;
}
apply(candidate, &mut report, &mut to_record)?;
if candidate.state == AdoptionState::Drifted {
tracing::info!(path = %candidate.relative.display(), "adopted (drifted): marker stamped, content kept");
} else {
tracing::debug!(path = %candidate.relative.display(), "adopted (converged): marker stamped");
}
}
let record_refs: Vec<&Path> = to_record.iter().map(PathBuf::as_path).collect();
crate::cli::cache::record_scaffold_owned_paths(&options.base_dir, &record_refs)?;
Ok(report)
}
#[cfg(test)]
mod tests;