1use 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 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 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 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(¤t, record)
136 .map_err(|_| self.undo_conflict_error(record, ¤t))?;
137 let (reverted, stored) = apply_patches(¤t, inverse)?;
138 if content_hash(¤t) == 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 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
177fn 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
231fn 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}