use alloc::collections::BTreeMap;
use std::collections::HashSet;
use camino::Utf8PathBuf;
use derive_more::Display;
use serde::{Deserialize, Serialize};
use crate::commit::{lock, prune, remove_if_present, to_toml, write};
use crate::digest::Digest;
use crate::errors::{MergeError, Result};
use crate::path::RelPath;
use crate::target::{
ANSWERS, CONFIG, CONFLICTS, DIR, LOCK, STATE, State, Target, UNDO, UNDO_BLOBS, V1,
read_optional, read_toml,
};
const RECORDS: [&str; 4] = [CONFIG, ANSWERS, LOCK, STATE];
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Display)]
pub(crate) enum Location {
File(RelPath),
#[display("{DIR}/{CONFLICTS}/{_0}")]
Sidecar(RelPath),
#[display("{DIR}/{_0}")]
Record(&'static str),
}
impl Location {
fn under(&self, target: &Target) -> Utf8PathBuf {
match self {
Self::File(path) => path.under(target.root()),
Self::Sidecar(path) => target.sidecar(path),
Self::Record(name) => target.dir().join(name),
}
}
fn parse(key: &str) -> Option<Self> {
let Some(inner) = key.strip_prefix(".devset/") else {
return RelPath::new(key).ok().map(Self::File);
};
if let Some(path) = inner.strip_prefix("conflicts/") {
return RelPath::new(path).ok().map(Self::Sidecar);
}
RECORDS.into_iter().find(|&name| name == inner).map(Self::Record)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Change {
#[serde(default, skip_serializing_if = "Option::is_none")]
before: Option<Digest>,
#[serde(default, skip_serializing_if = "Option::is_none")]
after: Option<Digest>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Undo {
version: V1,
#[serde(default)]
files: BTreeMap<String, Change>,
}
impl Undo {
pub(crate) fn load(target: &Target) -> Result<Self> {
Ok(read_toml(&target.dir(), &format!("{CONFLICTS}/{DIR}/{UNDO}"))?.unwrap_or_default())
}
pub(crate) fn record(
&mut self, target: &Target, location: &Location, bytes: Option<&[u8]>,
) -> Result<()> {
let after = bytes.map(Digest::of);
let key = location.to_string();
if let Some(change) = self.files.get_mut(&key) {
change.after = after;
if change.before == after {
self.files.remove(&key);
}
return Ok(());
}
let current = read_optional(&location.under(target))?;
let before = current.as_deref().map(Digest::of);
if before == after {
return Ok(());
}
if let (Some(current), Some(digest)) = (¤t, before) {
let blob = target.unfinished().join(UNDO_BLOBS).join(digest.to_string());
if !blob.is_file() {
write(&blob, current)?;
}
}
self.files.insert(key, Change { before, after });
Ok(())
}
pub(crate) fn save(&self, target: &Target) -> Result<()> {
write(&target.unfinished().join(UNDO), to_toml(self)?.as_bytes())
}
fn changes(&self) -> impl Iterator<Item = (Location, Change)> + '_ {
self.files.iter().filter_map(|(key, &change)| Some((Location::parse(key)?, change)))
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Revert {
Restore(RelPath),
Remove(RelPath),
}
#[derive(Debug)]
pub struct Rollback {
undo: Undo,
reverts: Vec<Revert>,
changed: Vec<String>,
}
impl Rollback {
pub fn of(target: &Target) -> Result<Self> {
if !target.dir().join(CONFLICTS).is_dir() {
return Err(MergeError::NothingToAbort.into());
}
let undo = Undo::load(target)?;
let (mut reverts, mut changed) = (Vec::new(), Vec::new());
for (location, change) in undo.changes() {
let now = read_optional(&location.under(target))?.as_deref().map(Digest::of);
if now == change.before {
continue;
}
if now != change.after {
changed.push(location.to_string());
}
if let Location::File(path) = location {
reverts.push(match change.before {
Some(_) => Revert::Restore(path),
None => Revert::Remove(path),
});
}
}
Ok(Self { undo, reverts, changed })
}
#[must_use]
pub fn reverts(&self) -> &[Revert] {
&self.reverts
}
#[must_use]
pub fn changed(&self) -> &[String] {
&self.changed
}
pub fn apply(self, target: &Target, force: bool) -> Result<Vec<Revert>> {
if !force && !self.changed.is_empty() {
return Err(MergeError::ChangedSince { paths: self.changed }.into());
}
let _guard = lock(target)?;
let mut changes: Vec<(Location, Change)> = self.undo.changes().collect();
changes.sort_by_key(|(location, _)| match location {
Location::Record(name) => RECORDS.iter().position(|record| record == name),
Location::File(_) | Location::Sidecar(_) => None,
});
let blobs = target.unfinished().join(UNDO_BLOBS);
for (location, change) in &changes {
let file = location.under(target);
match change.before {
None => remove_if_present(&file)?,
Some(digest) => {
let saved = read_optional(&blobs.join(digest.to_string()))?
.filter(|bytes| Digest::of(bytes) == digest)
.ok_or_else(|| MergeError::CorruptUndo { path: location.to_string() })?;
write(&file, &saved)?;
},
}
}
let state: State = read_toml(&target.dir(), STATE)?.unwrap_or_default();
let live: HashSet<Digest> = state.bases().collect();
prune(target, &live)?;
remove_if_present(&target.dir().join(CONFLICTS))?;
Ok(self.reverts)
}
}
#[cfg(test)]
mod tests {
use super::Location;
use crate::path::RelPath;
#[test]
fn locations_round_trip_through_their_keys() {
let path = || RelPath::new(".github/ci.yml").expect("a valid path");
for location in
[Location::File(path()), Location::Sidecar(path()), Location::Record("lock.toml")]
{
assert_eq!(Location::parse(&location.to_string()), Some(location), "as written");
}
for key in [".devset/base/x", ".devset/conflicts/.devset/undo.toml", "../escape"] {
assert_eq!(Location::parse(key), None, "{key} names no location");
}
}
}