use crate::refactor::textdiff;
#[derive(Debug, Clone, PartialEq)]
pub struct Discarded {
pub content: String,
pub reverted: usize,
pub kept: usize,
}
impl Discarded {
pub fn is_noop(&self) -> bool {
self.reverted == 0
}
}
pub fn discard_render(head: &str, rendered: &str, worktree: &str) -> Result<Discarded, String> {
if head == rendered {
return Ok(Discarded {
content: worktree.to_string(),
reverted: 0,
kept: 0,
});
}
if worktree == rendered {
return Ok(Discarded {
content: head.to_string(),
reverted: 1,
kept: 0,
});
}
merge(head, rendered, worktree)
}
fn merge(head: &str, rendered: &str, worktree: &str) -> Result<Discarded, String> {
let base = lines(rendered);
let ours = lines(head);
let theirs = lines(worktree);
let head_changes = changes(&base, &ours)?;
let work_changes = changes(&base, &theirs)?;
let mut content = String::new();
let mut reverted = 0;
let mut kept = 0;
let mut at = 0usize; let mut h = 0usize; let mut w = 0usize;
while at < base.len() || h < head_changes.len() || w < work_changes.len() {
let next_head = head_changes.get(h).map(|c| c.base_start);
let next_work = work_changes.get(w).map(|c| c.base_start);
let next = match (next_head, next_work) {
(Some(a), Some(b)) => a.min(b),
(Some(a), None) => a,
(None, Some(b)) => b,
(None, None) => base.len(),
};
while at < next && at < base.len() {
content.push_str(base[at]);
at += 1;
}
if h >= head_changes.len() && w >= work_changes.len() {
while at < base.len() {
content.push_str(base[at]);
at += 1;
}
break;
}
let seed = match (head_changes.get(h), work_changes.get(w)) {
(Some(a), Some(b)) if b.base_start < a.base_start => (b.base_start, b.base_end),
(Some(a), _) => (a.base_start, a.base_end),
(None, Some(b)) => (b.base_start, b.base_end),
(None, None) => break,
};
let (lo, mut hi) = seed;
let mut touched_head = false;
let mut touched_work = false;
let overlaps = |c: &Change, lo: usize, hi: usize| {
c.base_start < hi || (hi == lo && c.base_start == lo)
};
loop {
let mut grew = false;
while let Some(change) = head_changes.get(h).copied() {
if !overlaps(&change, lo, hi) {
break;
}
if change.base_end > hi {
hi = change.base_end;
grew = true;
}
touched_head = true;
h += 1;
}
while let Some(change) = work_changes.get(w).copied() {
if !overlaps(&change, lo, hi) {
break;
}
if change.base_end > hi {
hi = change.base_end;
grew = true;
}
touched_work = true;
w += 1;
}
if !grew {
break;
}
}
let head_text = slice_for(&ours, &head_changes, lo, hi);
let work_text = slice_for(&theirs, &work_changes, lo, hi);
match (touched_head, touched_work) {
(true, false) => {
content.push_str(&head_text);
reverted += 1;
}
(false, true) => content.push_str(&work_text),
(true, true) => {
if head_text == work_text {
content.push_str(&head_text);
} else {
content.push_str(&work_text);
kept += 1;
}
}
(false, false) => unreachable!("a region exists because a side changed it"),
}
at = hi;
}
Ok(Discarded {
content,
reverted,
kept,
})
}
#[derive(Debug, Clone, Copy)]
struct Change {
base_start: usize,
base_end: usize,
side_start: usize,
side_end: usize,
}
impl Change {
fn length_difference(&self) -> isize {
(self.side_end as isize - self.side_start as isize)
- (self.base_end as isize - self.base_start as isize)
}
}
fn changes(base: &[&str], side: &[&str]) -> Result<Vec<Change>, String> {
let script = textdiff::script(base, side)?;
let mut out = Vec::new();
let (mut b, mut s) = (0usize, 0usize);
let mut index = 0;
while index < script.len() {
if script[index] == textdiff::Edit::Keep {
b += 1;
s += 1;
index += 1;
continue;
}
let base_start = b;
let side_start = s;
while index < script.len() && script[index] != textdiff::Edit::Keep {
match &script[index] {
textdiff::Edit::Remove => b += 1,
textdiff::Edit::Insert(_) => s += 1,
textdiff::Edit::Keep => unreachable!("the loop guards on Keep"),
}
index += 1;
}
out.push(Change {
base_start,
base_end: b,
side_start,
side_end: s,
});
}
Ok(out)
}
fn slice_for(side: &[&str], changes: &[Change], lo: usize, hi: usize) -> String {
let start = map_start(changes, lo);
let end = map_end(changes, hi);
if start >= end {
return String::new();
}
side[start.min(side.len())..end.min(side.len())].concat()
}
fn map_start(changes: &[Change], base_position: usize) -> usize {
let mut shift: isize = 0;
for change in changes {
if change.base_end <= base_position && change.base_start < base_position {
shift += change.length_difference();
} else if change.base_start < base_position && change.base_end > base_position {
return change.side_start;
}
}
(base_position as isize + shift).max(0) as usize
}
fn map_end(changes: &[Change], base_position: usize) -> usize {
let mut shift: isize = 0;
for change in changes {
if change.base_end <= base_position {
shift += change.length_difference();
} else if change.base_start < base_position {
return change.side_end;
}
}
(base_position as isize + shift).max(0) as usize
}
fn lines(text: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut rest = text;
while !rest.is_empty() {
match rest.find('\n') {
Some(at) => {
out.push(&rest[..=at]);
rest = &rest[at + 1..];
}
None => {
out.push(rest);
rest = "";
}
}
}
out
}
#[cfg(test)]
#[path = "discard_tests.rs"]
mod discard_tests;
use crate::prelude::*;
#[derive(Debug, Clone, PartialEq)]
pub enum Outcome {
Reverted,
Partial { kept: usize },
Untouched,
Removed,
Unresolvable { reason: String },
}
#[derive(Debug, Clone)]
pub struct FileOutcome {
pub path: PathBuf,
pub outcome: Outcome,
}
#[derive(Debug, Clone, Default)]
pub struct Report {
pub files: Vec<FileOutcome>,
}
impl Report {
pub fn changed(&self) -> usize {
self.files
.iter()
.filter(|f| {
matches!(
f.outcome,
Outcome::Reverted | Outcome::Partial { .. } | Outcome::Removed
)
})
.count()
}
pub fn partial(&self) -> impl Iterator<Item = &FileOutcome> {
self.files
.iter()
.filter(|f| matches!(f.outcome, Outcome::Partial { .. }))
}
}
pub fn run(project_root: &Path, dry_run: bool) -> Result<Report> {
run_with_cache(project_root, dry_run, &crate::state::RunCache::new())
}
pub fn run_with_cache(
project_root: &Path,
dry_run: bool,
cache: &Arc<crate::state::RunCache>,
) -> Result<Report> {
let outputs = planned_outputs_with_cache(project_root, cache)?;
let mut report = Report::default();
for (path, rendered) in outputs {
let worktree = match std::fs::read_to_string(&path) {
Ok(text) => Some(text),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => {
report.files.push(FileOutcome {
path,
outcome: Outcome::Unresolvable {
reason: error.to_string(),
},
});
continue;
}
};
let Some(worktree) = worktree else {
report.files.push(FileOutcome {
path,
outcome: Outcome::Untouched,
});
continue;
};
let head = head_text(&path);
match head {
Some(head) => {
let merged = discard_render(&head, &rendered, &worktree)
.map_err(|error| anyhow::anyhow!("{}: {error}", path.display()))?;
if merged.content == worktree {
report.files.push(FileOutcome {
path,
outcome: Outcome::Untouched,
});
continue;
}
if !dry_run {
std::fs::write(&path, &merged.content).with_context(|| {
format!("cannot write {} back", path.display())
})?;
let own_lock = path.file_name().is_some_and(|name| name != "Cargo.toml");
let committed_lock = (merged.content == head && own_lock)
.then(|| crate::state::wstemplate::lockfile_followed_by(&path))
.flatten()
.and_then(|lock| head_text(&lock).map(|text| (lock, text)));
match committed_lock {
Some((lock, text)) => std::fs::write(&lock, text)
.with_context(|| format!("cannot put {} back", lock.display()))?,
None => {
crate::state::wstemplate::follow_into_lockfile(&path, &merged.content)?;
}
}
}
report.files.push(FileOutcome {
path,
outcome: if merged.kept > 0 {
Outcome::Partial { kept: merged.kept }
} else {
Outcome::Reverted
},
});
}
None => {
if worktree == rendered {
if !dry_run {
std::fs::remove_file(&path).with_context(|| {
format!("cannot remove {}", path.display())
})?;
}
report.files.push(FileOutcome {
path,
outcome: Outcome::Removed,
});
} else {
report.files.push(FileOutcome {
path,
outcome: Outcome::Unresolvable {
reason: "created by the update and edited since, with no committed \
version to return the update's part to"
.to_string(),
},
});
}
}
}
}
Ok(report)
}
pub fn planned_outputs_with_cache(
project_root: &Path,
cache: &Arc<crate::state::RunCache>,
) -> Result<Vec<(PathBuf, String)>> {
let config = crate::state::StateConfig::load(project_root)?;
let workspace_state = crate::workspace_state::WorkspaceState::load(project_root)?;
let mut version_info =
crate::state::state_common::calculate_workspace_version_cached(project_root, cache)?;
version_info.full_version = workspace_state.format_version(&version_info, None);
let mut outputs: Vec<(PathBuf, String)> = Vec::new();
if !config.version_file.is_empty() {
let path = if Path::new(&config.version_file).is_absolute() {
PathBuf::from(&config.version_file)
} else {
project_root.join(&config.version_file)
};
outputs.push((path, format!("{}\n", version_info.full_version)));
}
let template_manager = crate::state::TemplateManager::new(&workspace_state)?;
outputs.extend(
template_manager
.produce_all_templates(&version_info, workspace_state.project_name.as_deref()),
);
let entry = workspace_state.effective_wstemplate_entry();
let engine = crate::state::wstemplate::WstemplateEngine::with_cache(
version_info.clone(),
workspace_state.project_name.clone(),
entry.alias,
project_root.to_path_buf(),
entry.root,
Arc::clone(cache),
);
for produced in engine.produce_relevant()? {
outputs.push((produced.output_path, produced.content));
}
Ok(outputs)
}
pub fn rendered_as_written(
workspace_root: &Path,
cache: &Arc<crate::state::RunCache>,
) -> Result<std::collections::HashSet<PathBuf>> {
let published = planned_fleet_outputs(workspace_root, cache)?;
let local = match super::stamp_config::StampConfig::load(workspace_root)?
.and_then(|config| config.local)
{
Some(local) => planned_fleet_outputs(
workspace_root,
&crate::state::RunCache::with_variable(&local.variable, "1"),
)?,
None => Vec::new(),
};
let mut settled = std::collections::HashSet::new();
for (path, rendered) in published.into_iter().chain(local) {
if settled.contains(&path) {
continue;
}
if std::fs::read_to_string(&path).is_ok_and(|worktree| worktree == rendered) {
settled.insert(path);
}
}
Ok(settled)
}
fn planned_fleet_outputs(
workspace_root: &Path,
cache: &Arc<crate::state::RunCache>,
) -> Result<Vec<(PathBuf, String)>> {
use crate::fleet::git::tree::SubmoduleTree;
use std::collections::HashMap;
let tree = SubmoduleTree::discover_allow_empty(workspace_root).map_err(|e| {
anyhow::anyhow!(
"discovering submodules under {}: {e}",
workspace_root.display()
)
})?;
let mut repositories: Vec<PathBuf> = tree
.bottom_up()
.iter()
.map(|node| node.abs_path.clone())
.collect();
repositories.push(workspace_root.to_path_buf());
let boundaries: Vec<PathBuf> = tree
.top_down()
.iter()
.map(|node| node.abs_path.clone())
.collect();
let mut landing: HashMap<PathBuf, String> = HashMap::new();
for repository in &repositories {
if !owns_templates(repository, &boundaries, cache)? {
continue;
}
for (path, rendered) in planned_outputs_with_cache(repository, cache)? {
landing.entry(path).or_insert(rendered);
}
}
Ok(landing.into_iter().collect())
}
pub fn owns_templates(
repository: &Path,
boundaries: &[PathBuf],
cache: &crate::state::RunCache,
) -> Result<bool> {
let nested: Vec<&PathBuf> = boundaries
.iter()
.filter(|boundary| boundary.as_path() != repository && boundary.starts_with(repository))
.collect();
let templates = cache.templates_under(repository, || {
crate::search_backend::discover_files_with_suffix(repository, ".wstemplate")
})?;
Ok(templates
.iter()
.any(|template| !nested.iter().any(|inner| template.starts_with(inner))))
}
pub fn head_text(path: &Path) -> Option<String> {
let directory = path.parent()?;
let repo = crate::git_backend::repo_root(directory).ok()?;
let relative = path.strip_prefix(&repo).ok()?;
let output = crate::git_backend::git_command()
.arg("-C")
.arg(&repo)
.arg("show")
.arg(format!("HEAD:{}", relative.display()))
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).into_owned())
}