use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use crate::cli::commands::adopt::{AdoptBatchOutcome, AdoptReport};
pub(crate) const CONVERGED_ONLY_DIFF_BODY_LIMIT: usize = 20;
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum ReportChunk {
Line(String),
Blank,
Fragment(String),
}
#[derive(Debug, Default)]
pub(crate) struct AdoptSummary {
pub(crate) unreadable: BTreeSet<PathBuf>,
pub(crate) skipped_create_once: BTreeSet<PathBuf>,
pub(crate) converged: BTreeSet<PathBuf>,
pub(crate) already_owned: BTreeSet<PathBuf>,
pub(crate) adopted: BTreeSet<PathBuf>,
pub(crate) recorded_unstampable: BTreeSet<PathBuf>,
pub(crate) skipped_drifted: BTreeSet<PathBuf>,
pub(crate) diffs: BTreeMap<PathBuf, String>,
pub(crate) preview: bool,
}
impl AdoptSummary {
pub(crate) fn merge(outcome: &AdoptBatchOutcome, preview: bool) -> Self {
let mut summary = Self {
preview,
..Self::default()
};
for report in outcome.reports() {
summary.absorb(report);
}
summary
}
fn absorb(&mut self, report: &AdoptReport) {
self.unreadable.extend(report.unreadable.iter().cloned());
self.skipped_create_once
.extend(report.skipped_create_once.iter().cloned());
self.converged.extend(report.converged.iter().cloned());
self.already_owned.extend(report.already_owned.iter().cloned());
self.adopted.extend(report.adopted.iter().cloned());
self.recorded_unstampable
.extend(report.recorded_unstampable.iter().cloned());
self.skipped_drifted.extend(report.skipped_drifted.iter().cloned());
for diff in &report.diffs {
self.diffs.insert(diff.relative.clone(), diff.body.clone());
}
}
pub(crate) fn decided_nothing(&self) -> bool {
self.unreadable.is_empty()
&& self.skipped_create_once.is_empty()
&& self.converged.is_empty()
&& self.already_owned.is_empty()
&& self.adopted.is_empty()
&& self.skipped_drifted.is_empty()
&& self.diffs.is_empty()
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct DiffBudget {
pub(crate) printed: usize,
pub(crate) elided: usize,
}
impl DiffBudget {
pub(crate) fn decide(total: usize, converged_only: bool) -> Self {
if !converged_only {
return Self {
printed: total,
elided: 0,
};
}
let printed = total.min(CONVERGED_ONLY_DIFF_BODY_LIMIT);
Self {
printed,
elided: total - printed,
}
}
}
pub(crate) fn render(summary: &AdoptSummary, converged_only: bool) -> Vec<ReportChunk> {
if summary.decided_nothing() {
return Vec::new();
}
let mut chunks = Vec::new();
render_unreadable(summary, &mut chunks);
render_create_once_seeds(summary, &mut chunks);
render_diffs(summary, converged_only, &mut chunks);
render_tallies(summary, &mut chunks);
chunks
}
fn render_unreadable(summary: &AdoptSummary, chunks: &mut Vec<ReportChunk>) {
if summary.unreadable.is_empty() {
return;
}
chunks.push(ReportChunk::Blank);
chunks.push(ReportChunk::Line(
"NOT ADOPTED -- alef could not read these matches. Their bytes are neither valid \
UTF-8 nor one of alef's own base64-encoded binary outputs, so alef can state \
nothing about them and leaves them alone:"
.to_owned(),
));
for path in &summary.unreadable {
chunks.push(ReportChunk::Line(format!(" {}", path.display())));
}
chunks.push(ReportChunk::Blank);
}
fn render_create_once_seeds(summary: &AdoptSummary, chunks: &mut Vec<ReportChunk>) {
if summary.skipped_create_once.is_empty() {
return;
}
chunks.push(ReportChunk::Blank);
chunks.push(ReportChunk::Line(
"NOT ADOPTED -- create-once seeds. alef emits each of these only when the file is \
absent, so it is a placeholder that the copy on disk has almost certainly grown \
past. Adopting one consents to alef REPLACING its contents with that placeholder \
on the next OVERWRITING regen -- an `alef version` sync or `alef all \
--clobber-create-once-seeds`, not the next plain `alef generate`:"
.to_owned(),
));
for path in &summary.skipped_create_once {
chunks.push(ReportChunk::Line(format!(" {}", path.display())));
}
chunks.push(ReportChunk::Line(
"Read each one and confirm it holds nothing you wrote, then re-run with \
--clobber-create-once-seeds to adopt them anyway."
.to_owned(),
));
chunks.push(ReportChunk::Blank);
}
fn render_diffs(summary: &AdoptSummary, converged_only: bool, chunks: &mut Vec<ReportChunk>) {
let total = summary.diffs.len();
let budget = DiffBudget::decide(total, converged_only);
for (_, body) in summary.diffs.iter().take(budget.printed) {
chunks.push(ReportChunk::Fragment(body.clone()));
chunks.push(ReportChunk::Blank);
}
if budget.elided == 0 {
return;
}
tracing::warn!(
printed = budget.printed,
withheld = budget.elided,
total,
"printed a bounded number of drifted diff bodies; every withheld path is named on stdout"
);
chunks.push(ReportChunk::Line(format!(
"{} of {total} drifted diff(s) are named below WITHOUT their bodies -- only the first {} \
were printed in full above. --converged-only cannot adopt a drifted file, so these diffs \
decide nothing in this run. Run `alef adopt <path>` on one to read its complete diff:",
budget.elided, budget.printed,
)));
for path in summary.diffs.keys().skip(budget.printed) {
chunks.push(ReportChunk::Line(format!(" {}", path.display())));
}
chunks.push(ReportChunk::Blank);
}
fn render_tallies(summary: &AdoptSummary, chunks: &mut Vec<ReportChunk>) {
if !summary.converged.is_empty() {
chunks.push(ReportChunk::Line(format!(
"{} file(s) already match generated output byte-for-byte apart from the marker; \
adopting them changes no content.",
summary.converged.len()
)));
}
if summary.preview {
if !summary.diffs.is_empty() && !summary.converged.is_empty() {
chunks.push(ReportChunk::Line(format!(
"Re-run with --converged-only --write to adopt the {} converged file(s) alone, \
then review the {} drifted diff(s) above before adopting those.",
summary.converged.len(),
summary.diffs.len()
)));
}
chunks.push(ReportChunk::Line(
"Nothing was written. Re-run with --write to stamp these files so alef can regenerate them.".to_owned(),
));
return;
}
if !summary.skipped_drifted.is_empty() {
chunks.push(ReportChunk::Line(format!(
"Left {} drifted file(s) untouched (--converged-only). Their diffs are above; \
adopt them with an explicit target once you have read each one.",
summary.skipped_drifted.len()
)));
}
}
pub(crate) fn emit(chunks: &[ReportChunk]) {
for chunk in chunks {
match chunk {
ReportChunk::Line(text) => crate::bin_cli::output::line(text),
ReportChunk::Blank => crate::bin_cli::output::blank(),
ReportChunk::Fragment(text) => crate::bin_cli::output::fragment(text),
}
}
}
pub(crate) fn log_diagnostics(summary: &AdoptSummary) {
if summary.decided_nothing() {
return;
}
for path in &summary.already_owned {
tracing::debug!(path = %path.display(), "already alef-owned, nothing to adopt");
}
if !summary.already_owned.is_empty() {
tracing::info!(
"{} file(s) already alef-owned, nothing to adopt",
summary.already_owned.len()
);
}
if summary.preview {
return;
}
tracing::info!("Adopted {} file(s)", summary.adopted.len());
if !summary.recorded_unstampable.is_empty() {
tracing::info!(
"{} of these carry no marker syntax; their ownership is recorded in \
.alef-ownership.toml. Commit that file or the adoption applies only to \
this working copy.",
summary.recorded_unstampable.len()
);
}
}