Skip to main content

agd_core/write/
undo.rs

1//! `WriteEngine::undo`: reverting a journal entry, or every file of its
2//! transaction, by inverting its stored patches.
3//!
4//! A change is reverted only where the file still holds exactly what it wrote,
5//! found again by its surrounding text when the file changed elsewhere;
6//! otherwise nothing is written, and the `undo_conflict` error names the
7//! heading, who changed it, and what to do.
8
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use chrono::Local;
13use serde::{Deserialize, Serialize};
14
15use crate::{AgdError, ByteSpan, ErrorCode, Settings, UNDO_SCHEMA};
16
17use super::engine::{JournalImage, PreparedFile, WriteContext, WriteEngine, parse_text};
18use super::file::replace_all_or_restore;
19use super::journal::{JournalRecord, JournalStatus, content_hash};
20use super::patch::{Patch, StoredPatch, apply_patches};
21
22#[derive(Clone, Debug, Deserialize, Serialize)]
23pub struct UndoResult {
24    pub schema: String,
25    pub ok: bool,
26    pub journal_id: String,
27    pub file: String,
28    pub diff: String,
29}
30
31impl WriteEngine {
32    /// Reverts one journal entry, or every file of its transaction, given
33    /// the entry's id or the transaction id. A change is reverted only where
34    /// the file still holds what it wrote; otherwise nothing is written and
35    /// the error says what changed and what to do.
36    pub fn undo(&self, journal_id: &str, context: &WriteContext) -> Result<UndoResult, AgdError> {
37        let paths: Vec<PathBuf> = self
38            .journal_group(journal_id)?
39            .iter()
40            .map(|record| PathBuf::from(&record.file))
41            .collect();
42        for path in &paths {
43            self.roots.resolve_write(path, false)?;
44        }
45        let _locks = self.lock_paths(&paths)?;
46        let group = self.journal_group(journal_id)?;
47        if let Some(record) = group.iter().find(|record| {
48            !matches!(
49                record.status,
50                JournalStatus::Committed | JournalStatus::Reviewed
51            )
52        }) {
53            return Err(AgdError::new(
54                ErrorCode::InvalidJournalState,
55                format!(
56                    "journal entry {} is {}, not a committed change; nothing to undo",
57                    record.id,
58                    format!("{:?}", record.status).to_lowercase()
59                ),
60            ));
61        }
62        let reverts: Vec<PreparedFile> = group
63            .iter()
64            .map(|record| self.prepare_revert(record))
65            .collect::<Result<_, _>>()?;
66        let images: Vec<JournalImage<'_>> = reverts
67            .iter()
68            .map(|file| file.journal_image(None))
69            .collect();
70        replace_all_or_restore(&images)?;
71        let timestamp = Local::now().to_rfc3339();
72        for mut record in group.clone() {
73            record.status = JournalStatus::Undone;
74            record.timestamp = timestamp.clone();
75            record.actor = context.actor.clone();
76            record.project = context.project.clone();
77            self.append_record(&record)?;
78        }
79        Ok(UndoResult {
80            schema: UNDO_SCHEMA.to_string(),
81            ok: true,
82            journal_id: group[0]
83                .transaction
84                .clone()
85                .unwrap_or_else(|| group[0].id.clone()),
86            file: group
87                .iter()
88                .map(|record| record.file.as_str())
89                .collect::<Vec<_>>()
90                .join(","),
91            diff: reverts
92                .into_iter()
93                .map(|file| file.diff)
94                .collect::<Vec<_>>()
95                .join("\n"),
96        })
97    }
98
99    /// The latest records of one entry, or of every file in its
100    /// transaction, by entry or transaction id, sorted by file.
101    fn journal_group(&self, journal_id: &str) -> Result<Vec<JournalRecord>, AgdError> {
102        let records = self.latest_records()?;
103        let transaction = match records.get(journal_id) {
104            Some(record) => record.transaction.clone(),
105            None => Some(journal_id.to_string()),
106        };
107        let mut group: Vec<JournalRecord> = match &transaction {
108            Some(transaction) => records
109                .values()
110                .filter(|record| record.transaction.as_ref() == Some(transaction))
111                .cloned()
112                .collect(),
113            None => vec![records[journal_id].clone()],
114        };
115        if group.is_empty() {
116            return Err(AgdError::new(
117                ErrorCode::JournalNotFound,
118                format!("journal entry not found: {journal_id}"),
119            ));
120        }
121        group.sort_by(|left, right| left.file.cmp(&right.file));
122        Ok(group)
123    }
124
125    /// The file with the record's change reverted, from its bytes now.
126    fn prepare_revert(&self, record: &JournalRecord) -> Result<PreparedFile, AgdError> {
127        let path = PathBuf::from(&record.file);
128        let current = fs::read_to_string(&path).map_err(|error| {
129            AgdError::with_source(
130                ErrorCode::IoFailed,
131                format!("cannot read {}: {error}", path.display()),
132                error,
133            )
134        })?;
135        let inverse = relocate_inverse(&current, record)
136            .map_err(|_| self.undo_conflict_error(record, &current))?;
137        let (reverted, stored) = apply_patches(&current, inverse)?;
138        if content_hash(&current) == record.post_hash && content_hash(&reverted) != record.pre_hash
139        {
140            return Err(AgdError::new(
141                ErrorCode::InvalidJournal,
142                format!(
143                    "inverse patch for {} does not reproduce its pre-image",
144                    record.id
145                ),
146            ));
147        }
148        Ok(PreparedFile::new(path, current, reverted, stored))
149    }
150
151    /// `can't undo: Rotate certs changed after codex edited it
152    /// (work.org:42); edit it by hand`: the heading the change touched, as
153    /// the file reads now, and who made the change.
154    fn undo_conflict_error(&self, record: &JournalRecord, current: &str) -> AgdError {
155        let path = Path::new(&record.file);
156        let location = self
157            .roots
158            .roots()
159            .iter()
160            .find_map(|root| path.strip_prefix(root).ok())
161            .unwrap_or(path)
162            .display()
163            .to_string();
164        let author = record.actor_name();
165        let message = match touched_heading(path, current, record, &self.settings) {
166            Some((title, line)) => format!(
167                "can't undo: {title} changed after {author} edited it ({location}:{line}); edit it by hand"
168            ),
169            None => {
170                format!("can't undo: {location} changed after {author} edited it; edit it by hand")
171            }
172        };
173        AgdError::new(ErrorCode::UndoConflict, message)
174    }
175}
176
177/// Inverse patches against `current`. An unchanged file uses the recorded
178/// spans; otherwise each patch must be found exactly once by its context and
179/// replaced bytes, so undo never touches text someone changed since.
180fn relocate_inverse(current: &str, record: &JournalRecord) -> Result<Vec<Patch>, AgdError> {
181    if content_hash(current) == record.post_hash {
182        return Ok(inverse_patches(&record.patches));
183    }
184    let conflict = |detail: String| {
185        AgdError::new(
186            ErrorCode::UndoConflict,
187            format!(
188                "{} changed after journal entry {}: {detail}",
189                record.file, record.id
190            ),
191        )
192    };
193    let mut patches = Vec::with_capacity(record.patches.len());
194    for patch in &record.patches {
195        let (Some(before), Some(after)) = (&patch.context_before, &patch.context_after) else {
196            return Err(conflict("entry predates context-aware undo".to_string()));
197        };
198        let needle = format!("{before}{}{after}", patch.after);
199        let mut found = current.match_indices(&needle);
200        let (Some((position, _)), None) = (found.next(), found.next()) else {
201            return Err(conflict(format!(
202                "the edited text near byte {} changed",
203                patch.start
204            )));
205        };
206        let start = position + before.len();
207        patches.push(Patch {
208            span: ByteSpan {
209                start,
210                end: start + patch.after.len(),
211            },
212            replacement: patch.before.clone(),
213        });
214    }
215    Ok(patches)
216}
217
218fn inverse_patches(patches: &[StoredPatch]) -> Vec<Patch> {
219    patches
220        .iter()
221        .map(|patch| Patch {
222            span: ByteSpan {
223                start: patch.start,
224                end: patch.end,
225            },
226            replacement: patch.before.clone(),
227        })
228        .collect()
229}
230
231/// The title and line of the heading a journal entry changed, as `current`
232/// reads now: the heading with its ID, else the one around its first patch.
233fn touched_heading(
234    path: &Path,
235    current: &str,
236    record: &JournalRecord,
237    settings: &Settings,
238) -> Option<(String, usize)> {
239    let document = parse_text(path, current, settings).ok()?;
240    let by_id = record
241        .item_ref
242        .as_deref()
243        .filter(|item_ref| !item_ref.contains("#sha256:"))
244        .and_then(|id| {
245            document
246                .headlines()
247                .iter()
248                .find(|headline| headline.item.id.as_deref() == Some(id))
249        });
250    let at = record
251        .patches
252        .first()
253        .map_or(0, |patch| patch.start)
254        .min(current.len());
255    let headline = by_id.or_else(|| {
256        document
257            .headlines()
258            .iter()
259            .rev()
260            .find(|headline| headline.line_span.start <= at)
261    })?;
262    Some((headline.item.title.clone(), headline.item.line))
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::write::test_support::{
269        context, first_ref, refs_by_title, scheduled, settings, state_change,
270    };
271    use crate::{ItemEdit, WorkspaceIndex};
272
273    #[test]
274    fn undo_takes_an_entry_or_transaction_id_and_refuses_what_is_not_committed() {
275        let temp = tempfile::tempdir().unwrap();
276        fs::write(temp.path().join("a.org"), "* TODO One\n").unwrap();
277        fs::write(temp.path().join("b.org"), "* TODO Two\n").unwrap();
278        let settings = settings(temp.path());
279        let refs = refs_by_title(&settings);
280        let engine = WriteEngine::new(settings, temp.path().join("journal.jsonl")).unwrap();
281        let batch = engine
282            .edit_batch(
283                &[
284                    ItemEdit {
285                        item_ref: refs["One"].clone(),
286                        intent: scheduled("2026-10-19"),
287                    },
288                    ItemEdit {
289                        item_ref: refs["Two"].clone(),
290                        intent: scheduled("2026-10-19"),
291                    },
292                ],
293                false,
294                &context(),
295            )
296            .unwrap();
297        let undone = engine.undo(&batch.transaction, &context()).unwrap();
298        assert_eq!(undone.file.split(',').count(), 2);
299        assert_eq!(
300            fs::read_to_string(temp.path().join("a.org")).unwrap(),
301            "* TODO One\n"
302        );
303        let again = engine.undo(&batch.transaction, &context()).unwrap_err();
304        assert_eq!(again.code().as_str(), "invalid_journal_state");
305        assert_eq!(
306            engine
307                .undo("no-such-entry", &context())
308                .unwrap_err()
309                .code()
310                .as_str(),
311            "journal_not_found"
312        );
313    }
314
315    #[test]
316    fn an_undo_conflict_names_the_heading_its_author_and_what_to_do() {
317        let temp = tempfile::tempdir().unwrap();
318        let path = temp.path().join("work.org");
319        fs::write(
320            &path,
321            "* TODO Rotate staging certs\n:PROPERTIES:\n:ID: certs\n:END:\n",
322        )
323        .unwrap();
324        let engine =
325            WriteEngine::new(settings(temp.path()), temp.path().join("journal.jsonl")).unwrap();
326        let agent = WriteContext {
327            actor: "mcp:codex".to_string(),
328            ..context()
329        };
330        let mutation = engine
331            .edit("certs", &scheduled("2026-10-19"), false, &agent)
332            .unwrap();
333        let edited = fs::read_to_string(&path)
334            .unwrap()
335            .replace("<2026-10-19 Mon>", "<2026-10-22 Thu>");
336        fs::write(&path, &edited).unwrap();
337        let error = engine.undo(&mutation.journal_id, &context()).unwrap_err();
338        assert_eq!(error.code().as_str(), "undo_conflict");
339        assert_eq!(
340            error.to_string(),
341            "can't undo: Rotate staging certs changed after codex edited it (work.org:1); edit it by hand"
342        );
343        assert_eq!(fs::read_to_string(&path).unwrap(), edited);
344    }
345
346    #[test]
347    fn undo_survives_unrelated_edits_and_refuses_overlapping_ones() {
348        let temp = tempfile::tempdir().unwrap();
349        let path = temp.path().join("tasks.org");
350        fs::write(&path, "* TODO First\n* TODO Second\nbody\n").unwrap();
351        let settings = settings(temp.path());
352        let refs: Vec<String> = WorkspaceIndex::load(settings.clone())
353            .unwrap()
354            .items()
355            .map(|item| item.item_ref.clone())
356            .collect();
357        let engine = WriteEngine::new(settings, temp.path().join("journal.jsonl")).unwrap();
358        let done = state_change("DONE");
359
360        let second = engine.edit(&refs[1], &done, false, &context()).unwrap();
361        let edited = fs::read_to_string(&path).unwrap();
362        fs::write(
363            &path,
364            format!("#+TITLE: Added by an editor\n{edited}* TODO Third\n"),
365        )
366        .unwrap();
367        engine.undo(&second.journal_id, &context()).unwrap();
368        assert_eq!(
369            fs::read_to_string(&path).unwrap(),
370            "#+TITLE: Added by an editor\n* TODO First\n* TODO Second\nbody\n* TODO Third\n"
371        );
372
373        let first = engine.edit(&refs[0], &done, false, &context()).unwrap();
374        let edited = fs::read_to_string(&path).unwrap();
375        fs::write(
376            &path,
377            edited.replace("* DONE First", "* DONE First, renamed"),
378        )
379        .unwrap();
380        let error = engine.undo(&first.journal_id, &context()).unwrap_err();
381        assert_eq!(error.code().as_str(), "undo_conflict");
382        assert!(
383            fs::read_to_string(&path)
384                .unwrap()
385                .contains("First, renamed")
386        );
387    }
388
389    #[test]
390    fn patch_and_inverse_preserve_every_untouched_byte() {
391        let source = "α before\r\n* TODO Keep spacing   :x:\r\nbody\r\n";
392        let start = source.find("TODO").unwrap();
393        let (after, stored) = apply_patches(
394            source,
395            vec![Patch {
396                span: ByteSpan {
397                    start,
398                    end: start + 4,
399                },
400                replacement: "DONE".to_string(),
401            }],
402        )
403        .unwrap();
404        assert_eq!(after, "α before\r\n* DONE Keep spacing   :x:\r\nbody\r\n");
405        assert_eq!(
406            apply_patches(&after, inverse_patches(&stored)).unwrap().0,
407            source
408        );
409    }
410
411    #[test]
412    fn edit_revalidates_writes_journal_and_undo_keeps_later_unrelated_text() {
413        let temp = tempfile::tempdir().unwrap();
414        let path = temp.path().join("tasks.org");
415        let original = "#+TODO: TODO | DONE\r\n* TODO Exact  spacing :work:\r\nbody\r\n";
416        fs::write(&path, original).unwrap();
417        let settings = settings(temp.path());
418        let item_ref = first_ref(&settings);
419        let engine = WriteEngine::new(settings, temp.path().join("journal.jsonl")).unwrap();
420        let mutation = engine
421            .edit(&item_ref, &scheduled("2026-10-15"), false, &context())
422            .unwrap();
423        let written = fs::read_to_string(&path).unwrap();
424        assert!(written.starts_with("#+TODO: TODO | DONE\r\n* TODO Exact  spacing :work:\r\n"));
425        assert!(written.contains("SCHEDULED: <2026-10-15 Thu>\r\nbody\r\n"));
426        engine.undo(&mutation.journal_id, &context()).unwrap();
427        assert_eq!(fs::read_to_string(&path).unwrap(), original);
428
429        let mutation = engine
430            .edit(&item_ref, &scheduled("2026-10-16"), false, &context())
431            .unwrap();
432        fs::write(
433            &path,
434            format!("{}external\r\n", fs::read_to_string(&path).unwrap()),
435        )
436        .unwrap();
437        engine.undo(&mutation.journal_id, &context()).unwrap();
438        assert_eq!(
439            fs::read_to_string(&path).unwrap(),
440            format!("{original}external\r\n")
441        );
442    }
443}