use anyhow::{Context, Result, bail};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use super::{
AdoptCandidate, AdoptDiff, AdoptOptions, AdoptReport, AdoptionState, ManagedOutput, apply, classify,
classify_binary, render_diff,
};
pub(crate) struct TargetMatcher {
literal: String,
pattern: Option<glob::Pattern>,
}
impl TargetMatcher {
pub(crate) fn new(target: &str) -> Self {
let literal = target.trim_start_matches("./").to_owned();
let pattern = glob::Pattern::new(&literal).ok();
Self { literal, pattern }
}
pub(crate) fn matches(&self, relative: &Path) -> bool {
let spelled = relative.to_string_lossy().replace('\\', "/");
if spelled == self.literal {
return true;
}
self.pattern.as_ref().is_some_and(|pattern| pattern.matches(&spelled))
}
}
#[derive(Clone)]
enum Classification {
Absent,
Unreadable,
Ready(Rc<AdoptCandidate>),
}
struct SelectedCandidates {
candidates: Vec<Rc<AdoptCandidate>>,
unreadable: Vec<PathBuf>,
}
pub struct AdoptBatchOptions {
pub base_dir: PathBuf,
pub write: bool,
pub converged_only: bool,
pub clobber_create_once_seeds: bool,
}
impl AdoptBatchOptions {
fn for_target(&self, target: &str) -> AdoptOptions {
AdoptOptions {
target: target.to_owned(),
base_dir: self.base_dir.clone(),
write: self.write,
converged_only: self.converged_only,
clobber_create_once_seeds: self.clobber_create_once_seeds,
}
}
}
pub struct AdoptBatchOutcome {
pub results: Vec<(String, Result<AdoptReport>)>,
pub classification_passes: usize,
}
impl AdoptBatchOutcome {
pub fn failures(&self) -> impl Iterator<Item = (&str, &anyhow::Error)> {
self.results.iter().filter_map(|(target, result)| match result {
Ok(_) => None,
Err(error) => Some((target.as_str(), error)),
})
}
pub fn reports(&self) -> impl Iterator<Item = &AdoptReport> {
self.results.iter().filter_map(|(_, result)| result.as_ref().ok())
}
}
pub(crate) struct AdoptSession<'m> {
managed: &'m [ManagedOutput],
base_dir: PathBuf,
cache: HashMap<PathBuf, Classification>,
diff_bodies: HashMap<PathBuf, String>,
to_record: Vec<PathBuf>,
classification_passes: usize,
}
impl<'m> AdoptSession<'m> {
pub(crate) fn new(base_dir: &Path, managed: &'m [ManagedOutput]) -> Self {
Self {
managed,
base_dir: base_dir.to_path_buf(),
cache: HashMap::new(),
diff_bodies: HashMap::new(),
to_record: Vec::new(),
classification_passes: 0,
}
}
pub(crate) fn classification_passes(&self) -> usize {
self.classification_passes
}
fn classify_output(&mut self, output: &ManagedOutput) -> Result<Classification> {
if let Some(cached) = self.cache.get(&output.relative) {
return Ok(cached.clone());
}
let full_path = self.base_dir.join(&output.relative);
let classification = if full_path.exists() {
let bytes = std::fs::read(&full_path)
.with_context(|| format!("failed to read existing {}", full_path.display()))?;
self.classification_passes += 1;
self.classify_bytes(&full_path, output, bytes)
} else {
Classification::Absent
};
self.cache.insert(output.relative.clone(), classification.clone());
Ok(classification)
}
fn classify_bytes(&self, full_path: &Path, output: &ManagedOutput, bytes: Vec<u8>) -> Classification {
if crate::cli::pipeline::is_base64_binary_output(&output.relative) {
return match classify_binary(&self.base_dir, full_path, output, &bytes) {
Some(candidate) => Classification::Ready(Rc::new(candidate)),
None => Classification::Unreadable,
};
}
match String::from_utf8(bytes) {
Ok(existing) => Classification::Ready(Rc::new(classify(
full_path,
&output.relative,
&output.content,
&existing,
output.create_once,
))),
Err(_) => Classification::Unreadable,
}
}
fn select(&mut self, options: &AdoptOptions, matcher: &TargetMatcher) -> Result<SelectedCandidates> {
let managed: &'m [ManagedOutput] = self.managed;
let mut matched: Vec<&'m ManagedOutput> = managed
.iter()
.filter(|output| matcher.matches(&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 {
match self.classify_output(output)? {
Classification::Ready(candidate) => candidates.push(candidate),
Classification::Unreadable => unreadable.push(output.relative.clone()),
Classification::Absent => {}
}
}
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(SelectedCandidates { candidates, unreadable })
}
fn diff_body(&mut self, candidate: &AdoptCandidate) -> String {
if let Some(body) = self.diff_bodies.get(&candidate.relative) {
return body.clone();
}
let body = render_diff(candidate);
self.diff_bodies.insert(candidate.relative.clone(), body.clone());
body
}
fn refresh_after_apply(&mut self, candidate: &AdoptCandidate) {
let refreshed = match (&candidate.stamped, &candidate.binary) {
(Some(stamped), _) => classify(
&candidate.full_path,
&candidate.relative,
&candidate.generated,
stamped,
candidate.create_once,
),
(None, Some(facts)) => AdoptCandidate {
relative: candidate.relative.clone(),
full_path: candidate.full_path.clone(),
existing: String::new(),
generated: String::new(),
state: AdoptionState::AlreadyOwned,
stamped: None,
create_once: candidate.create_once,
binary: Some(facts.clone()),
},
(None, None) => return,
};
self.cache
.insert(candidate.relative.clone(), Classification::Ready(Rc::new(refreshed)));
}
pub(crate) fn adopt_target(&mut self, options: &AdoptOptions, matcher: &TargetMatcher) -> Result<AdoptReport> {
let SelectedCandidates { candidates, unreadable } = self.select(options, matcher)?;
let mut report = AdoptReport {
preview: !options.write,
unreadable,
..AdoptReport::default()
};
let (adoptable, blocked): (Vec<Rc<AdoptCandidate>>, Vec<Rc<AdoptCandidate>>) =
candidates.into_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 overwriting regen (an \
`alef version` sync, `alef all --clobber-create-once-seeds`) -- a plain `alef generate` \
skips it"
);
}
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 => {
let body = self.diff_body(candidate);
report.diffs.push(AdoptDiff {
relative: candidate.relative.clone(),
state: candidate.state,
body,
});
}
}
}
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 overwriting regen (an `alef version` sync, `alef all --clobber-create-once-seeds`), \
so nothing was written. Pass --clobber-create-once-seeds to adopt them anyway.",
options.target
);
}
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 self.to_record)?;
self.refresh_after_apply(candidate);
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");
}
}
Ok(report)
}
pub(crate) fn finish(self) -> Result<()> {
let record_refs: Vec<&Path> = self.to_record.iter().map(PathBuf::as_path).collect();
crate::cli::cache::record_scaffold_owned_paths(&self.base_dir, &record_refs)
}
}
pub(crate) fn run_single(options: &AdoptOptions, managed: &[ManagedOutput]) -> Result<AdoptReport> {
let mut session = AdoptSession::new(&options.base_dir, managed);
let matcher = TargetMatcher::new(&options.target);
let report = session.adopt_target(options, &matcher)?;
session.finish()?;
Ok(report)
}
pub fn run_batch(
targets: &[String],
options: &AdoptBatchOptions,
managed: &[ManagedOutput],
) -> Result<AdoptBatchOutcome> {
let mut session = AdoptSession::new(&options.base_dir, managed);
let mut results = Vec::with_capacity(targets.len());
for target in targets {
let per_target = options.for_target(target);
let matcher = TargetMatcher::new(target);
results.push((target.clone(), session.adopt_target(&per_target, &matcher)));
}
let classification_passes = session.classification_passes();
tracing::debug!(
targets = targets.len(),
classifications = classification_passes,
"adopt resolved every target against one managed surface"
);
session.finish()?;
Ok(AdoptBatchOutcome {
results,
classification_passes,
})
}
#[cfg(test)]
mod tests;