Skip to main content

devset_core/
rollback.rs

1//! Taking back an unfinished run, from what [`commit`](crate::commit()) saved beforehand.
2
3use alloc::collections::BTreeMap;
4use std::collections::HashSet;
5
6use camino::Utf8PathBuf;
7use derive_more::Display;
8use serde::{Deserialize, Serialize};
9
10use crate::commit::{lock, prune, remove_if_present, to_toml, write};
11use crate::digest::Digest;
12use crate::errors::{MergeError, Result};
13use crate::path::RelPath;
14use crate::target::{
15    ANSWERS, CONFIG, CONFLICTS, DIR, LOCK, STATE, State, Target, UNDO, UNDO_BLOBS, V1,
16    read_optional, read_toml,
17};
18
19/// devset's own files a run may change, in the order they are restored.
20///
21/// `state.toml` goes last, so an interrupted rollback still describes the run and runs again.
22const RECORDS: [&str; 4] = [CONFIG, ANSWERS, LOCK, STATE];
23
24/// A file an unfinished run changed; displayed as its key in `undo.toml`, from the target root.
25#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Display)]
26pub(crate) enum Location {
27    /// A managed file in the target.
28    File(RelPath),
29    /// A conflicted merge in `.devset/conflicts/`.
30    #[display("{DIR}/{CONFLICTS}/{_0}")]
31    Sidecar(RelPath),
32    /// One of devset's own files in `.devset/`.
33    #[display("{DIR}/{_0}")]
34    Record(&'static str),
35}
36
37impl Location {
38    /// Where it is under `target`.
39    fn under(&self, target: &Target) -> Utf8PathBuf {
40        match self {
41            Self::File(path) => path.under(target.root()),
42            Self::Sidecar(path) => target.sidecar(path),
43            Self::Record(name) => target.dir().join(name),
44        }
45    }
46
47    /// The location a key of `undo.toml` names, if it names one.
48    fn parse(key: &str) -> Option<Self> {
49        let Some(inner) = key.strip_prefix(".devset/") else {
50            return RelPath::new(key).ok().map(Self::File);
51        };
52        if let Some(path) = inner.strip_prefix("conflicts/") {
53            return RelPath::new(path).ok().map(Self::Sidecar);
54        }
55        RECORDS.into_iter().find(|&name| name == inner).map(Self::Record)
56    }
57}
58
59/// One file's change: what it held before the run, and what the run left in it.
60#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(deny_unknown_fields)]
62struct Change {
63    /// Digest of the bytes before; `None` when the file did not exist.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    before: Option<Digest>,
66    /// Digest of the bytes the run wrote; `None` when it removed the file.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    after: Option<Digest>,
69}
70
71/// `undo.toml`: every file an unfinished run changed, the bytes it replaced saved beside it.
72#[derive(Debug, Default, Serialize, Deserialize)]
73#[serde(deny_unknown_fields)]
74pub(crate) struct Undo {
75    /// Format version.
76    version: V1,
77    /// Each change, keyed by the file's path relative to the target root.
78    #[serde(default)]
79    files: BTreeMap<String, Change>,
80}
81
82impl Undo {
83    /// The undo `target` holds, or an empty one.
84    pub(crate) fn load(target: &Target) -> Result<Self> {
85        Ok(read_toml(&target.dir(), &format!("{CONFLICTS}/{DIR}/{UNDO}"))?.unwrap_or_default())
86    }
87
88    /// Notes that `location` is about to hold `bytes`, or be removed.
89    ///
90    /// The first time, saves what it holds now.
91    pub(crate) fn record(
92        &mut self, target: &Target, location: &Location, bytes: Option<&[u8]>,
93    ) -> Result<()> {
94        let after = bytes.map(Digest::of);
95        let key = location.to_string();
96        if let Some(change) = self.files.get_mut(&key) {
97            change.after = after;
98            if change.before == after {
99                self.files.remove(&key);
100            }
101            return Ok(());
102        }
103        let current = read_optional(&location.under(target))?;
104        let before = current.as_deref().map(Digest::of);
105        if before == after {
106            return Ok(());
107        }
108        if let (Some(current), Some(digest)) = (&current, before) {
109            let blob = target.unfinished().join(UNDO_BLOBS).join(digest.to_string());
110            if !blob.is_file() {
111                write(&blob, current)?;
112            }
113        }
114        self.files.insert(key, Change { before, after });
115        Ok(())
116    }
117
118    /// Writes `undo.toml`.
119    pub(crate) fn save(&self, target: &Target) -> Result<()> {
120        write(&target.unfinished().join(UNDO), to_toml(self)?.as_bytes())
121    }
122
123    /// Each change, by where it is.
124    fn changes(&self) -> impl Iterator<Item = (Location, Change)> + '_ {
125        self.files.iter().filter_map(|(key, &change)| Some((Location::parse(key)?, change)))
126    }
127}
128
129/// What taking a target file back does.
130#[derive(Clone, Debug, PartialEq, Eq)]
131pub enum Revert {
132    /// Puts back the bytes it held before the run.
133    Restore(RelPath),
134    /// Removes it: the run created it.
135    Remove(RelPath),
136}
137
138/// An unfinished run, and what taking it back does.
139///
140/// Nothing is written until [`apply`](Self::apply).
141/// A run is unfinished while conflicts wait in `.devset/conflicts/`. Taking it back returns
142/// every file it wrote, and `.devset/`'s records, to what they held before it, and discards its
143/// conflicts, as though it had not run.
144#[derive(Debug)]
145pub struct Rollback {
146    /// What the run changed.
147    undo: Undo,
148    /// What taking it back does to target files, in path order.
149    reverts: Vec<Revert>,
150    /// Files changed since the run, which taking it back would discard.
151    changed: Vec<String>,
152}
153
154impl Rollback {
155    /// The unfinished update in `target`.
156    ///
157    /// # Errors
158    /// - [`MergeError::NothingToAbort`], no run is unfinished.
159    /// - [`Error::Parse`](crate::Error::Parse), what the run saved does not parse.
160    pub fn of(target: &Target) -> Result<Self> {
161        if !target.dir().join(CONFLICTS).is_dir() {
162            return Err(MergeError::NothingToAbort.into());
163        }
164        let undo = Undo::load(target)?;
165        let (mut reverts, mut changed) = (Vec::new(), Vec::new());
166        for (location, change) in undo.changes() {
167            let now = read_optional(&location.under(target))?.as_deref().map(Digest::of);
168            // Already as before: never written, or restored by an interrupted rollback.
169            if now == change.before {
170                continue;
171            }
172            if now != change.after {
173                changed.push(location.to_string());
174            }
175            if let Location::File(path) = location {
176                reverts.push(match change.before {
177                    Some(_) => Revert::Restore(path),
178                    None => Revert::Remove(path),
179                });
180            }
181        }
182        Ok(Self { undo, reverts, changed })
183    }
184
185    /// What taking the run back does to target files, in path order.
186    #[must_use]
187    pub fn reverts(&self) -> &[Revert] {
188        &self.reverts
189    }
190
191    /// Files changed since the run, which taking it back discards, from the target root.
192    #[must_use]
193    pub fn changed(&self) -> &[String] {
194        &self.changed
195    }
196
197    /// Takes the run back in `target`, returning what it did to target files.
198    ///
199    /// Target files and sidecars go first, then `.devset/`'s records with `state.toml` last, then
200    /// `.devset/conflicts/`; an interruption leaves a rollback that runs again.
201    ///
202    /// # Errors
203    /// - [`MergeError::ChangedSince`], files changed since the run and `force` is off; nothing is
204    ///   written.
205    /// - [`TargetError::Busy`](crate::TargetError::Busy) or
206    ///   [`TargetError::Concurrent`](crate::TargetError::Concurrent), another devset is at work.
207    /// - [`MergeError::CorruptUndo`], a saved copy is missing or damaged; what was restored before
208    ///   it stays restored.
209    pub fn apply(self, target: &Target, force: bool) -> Result<Vec<Revert>> {
210        if !force && !self.changed.is_empty() {
211            return Err(MergeError::ChangedSince { paths: self.changed }.into());
212        }
213        let _guard = lock(target)?;
214        let mut changes: Vec<(Location, Change)> = self.undo.changes().collect();
215        // `Location`'s order puts files, then sidecars, then records; records go in `RECORDS`
216        // order.
217        changes.sort_by_key(|(location, _)| match location {
218            Location::Record(name) => RECORDS.iter().position(|record| record == name),
219            Location::File(_) | Location::Sidecar(_) => None,
220        });
221        let blobs = target.unfinished().join(UNDO_BLOBS);
222        for (location, change) in &changes {
223            let file = location.under(target);
224            match change.before {
225                None => remove_if_present(&file)?,
226                Some(digest) => {
227                    let saved = read_optional(&blobs.join(digest.to_string()))?
228                        .filter(|bytes| Digest::of(bytes) == digest)
229                        .ok_or_else(|| MergeError::CorruptUndo { path: location.to_string() })?;
230                    write(&file, &saved)?;
231                },
232            }
233        }
234        let state: State = read_toml(&target.dir(), STATE)?.unwrap_or_default();
235        let live: HashSet<Digest> = state.bases().collect();
236        prune(target, &live)?;
237        remove_if_present(&target.dir().join(CONFLICTS))?;
238        Ok(self.reverts)
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::Location;
245    use crate::path::RelPath;
246
247    #[test]
248    fn locations_round_trip_through_their_keys() {
249        let path = || RelPath::new(".github/ci.yml").expect("a valid path");
250        for location in
251            [Location::File(path()), Location::Sidecar(path()), Location::Record("lock.toml")]
252        {
253            assert_eq!(Location::parse(&location.to_string()), Some(location), "as written");
254        }
255        for key in [".devset/base/x", ".devset/conflicts/.devset/undo.toml", "../escape"] {
256            assert_eq!(Location::parse(key), None, "{key} names no location");
257        }
258    }
259}