1use std::collections::{BTreeMap, BTreeSet};
2use std::fs::{self, File, OpenOptions};
3use std::io::Write;
4use std::path::{Path, PathBuf};
5
6use chrono::{Duration, Local, Months, NaiveDate, NaiveDateTime};
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use similar::TextDiff;
10
11use crate::{
12 AgdError, ByteSpan, CaptureDraft, CaptureTemplate, HeadlineSpan, IdPolicy, IndexedDocument,
13 Item, LogKind, MUTATION_SCHEMA, Mutation, PlanningTimestamp, RootSet, Settings, WorkspaceIndex,
14 format_active_timestamp, render_capture_template,
15};
16
17#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct Patch {
19 pub span: ByteSpan,
20 pub replacement: String,
21}
22
23#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
28pub struct StoredPatch {
29 pub start: usize,
30 pub end: usize,
31 pub before: String,
32 pub after: String,
33 #[serde(default)]
34 pub context_before: Option<String>,
35 #[serde(default)]
36 pub context_after: Option<String>,
37}
38
39#[derive(Clone, Debug, Default, Eq, PartialEq)]
40pub enum Change<T> {
41 #[default]
42 Keep,
43 Set(T),
44 Clear,
45}
46
47#[derive(Clone, Debug, Default, Eq, PartialEq)]
48pub struct EditIntent {
49 pub state: Change<String>,
50 pub title: Change<String>,
51 pub priority: Change<String>,
52 pub tags_add: BTreeSet<String>,
53 pub tags_remove: BTreeSet<String>,
54 pub scheduled: Change<PlanningTimestamp>,
55 pub deadline: Change<PlanningTimestamp>,
56 pub properties: BTreeMap<String, Option<String>>,
57 pub note: Option<String>,
58}
59
60#[derive(Clone, Debug)]
61pub struct WriteContext {
62 pub actor: String,
63 pub project: String,
64 pub now: Option<NaiveDateTime>,
67}
68
69impl WriteContext {
70 pub fn org_now(&self) -> NaiveDateTime {
71 self.now.unwrap_or_else(|| Local::now().naive_local())
72 }
73
74 pub fn is_agent(&self) -> bool {
77 self.actor.starts_with("mcp:")
78 }
79}
80
81#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
82#[serde(rename_all = "snake_case")]
83pub enum JournalStatus {
84 Pending,
85 Committed,
86 Unapplied,
87 Conflict,
88 Reviewed,
89 Undone,
90}
91
92#[derive(Clone, Debug, Deserialize, Serialize)]
93pub struct JournalRecord {
94 pub schema: String,
95 pub id: String,
96 pub status: JournalStatus,
97 pub timestamp: String,
98 pub actor: String,
99 pub project: String,
100 pub file: String,
101 pub item_ref: Option<String>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub transaction: Option<String>,
104 pub pre_hash: String,
105 pub post_hash: String,
106 pub patches: Vec<StoredPatch>,
107 pub diff: String,
108}
109
110#[derive(Clone, Debug, Deserialize, Serialize)]
111pub struct UndoResult {
112 pub schema: String,
113 pub ok: bool,
114 pub journal_id: String,
115 pub file: String,
116 pub diff: String,
117}
118
119#[derive(Clone, Debug, Deserialize, Serialize)]
120pub struct RefileResult {
121 pub schema: String,
122 pub ok: bool,
123 pub item: Item,
124 pub diffs: Vec<String>,
125 pub journal_ids: Vec<String>,
126}
127
128#[derive(Debug)]
129pub struct WriteEngine {
130 settings: Settings,
131 roots: RootSet,
132 journal_path: PathBuf,
133}
134
135struct JournalImage<'a> {
136 path: &'a Path,
137 item_ref: Option<&'a str>,
138 pre_image: &'a str,
139 post_image: &'a str,
140 patches: &'a [StoredPatch],
141 diff: &'a str,
142}
143
144impl WriteEngine {
145 pub fn new(settings: Settings, journal_path: PathBuf) -> Result<Self, AgdError> {
146 let roots = RootSet::new(&settings.roots, &settings.agenda_files)?;
147 let engine = Self {
148 settings,
149 roots,
150 journal_path,
151 };
152 engine.recover_pending()?;
153 Ok(engine)
154 }
155
156 pub fn with_default_journal(settings: Settings) -> Result<Self, AgdError> {
157 Self::new(settings, crate::paths::data_dir()?.join("journal.jsonl"))
158 }
159
160 pub fn settings(&self) -> &Settings {
161 &self.settings
162 }
163
164 pub fn edit(
165 &self,
166 item_ref: &str,
167 intent: &EditIntent,
168 dry_run: bool,
169 context: &WriteContext,
170 ) -> Result<Mutation, AgdError> {
171 validate_intent(intent)?;
172 let initial = WorkspaceIndex::load(self.settings.clone())?;
173 let (initial_document, _) = initial.resolve(item_ref)?;
174 let path = initial_document.path().to_path_buf();
175 self.roots.resolve_write(&path, false)?;
176 let _lock = self.lock_target(&path)?;
177 refuse_emacs_lock(&path)?;
178
179 let fresh = WorkspaceIndex::load(self.settings.clone())?;
180 let (document, headline) = fresh.resolve(item_ref)?;
181 if document.path() != path {
182 return Err(AgdError::new(
183 "stale_ref",
184 "item resolved to a different file while acquiring the write lock",
185 ));
186 }
187 let patches = build_edit_patches(
188 document,
189 headline,
190 intent,
191 &self.settings,
192 context.org_now(),
193 )?;
194 let (post_image, stored) = apply_patches(document.source(), patches)?;
195 if post_image == document.source() {
196 return Err(AgdError::new("no_change", "edit does not change the item"));
197 }
198 let diff = unified_diff(&path, document.source(), &post_image);
199 let updated = parse_updated_item(&path, &post_image, headline.item.line, &self.settings)?;
200 if dry_run {
201 return Ok(Mutation {
202 schema: MUTATION_SCHEMA.to_string(),
203 ok: true,
204 item: updated,
205 diff,
206 journal_id: "dry-run".to_string(),
207 });
208 }
209
210 let record = self.commit_image(
211 JournalImage {
212 path: &path,
213 item_ref: Some(item_ref),
214 pre_image: document.source(),
215 post_image: &post_image,
216 patches: &stored,
217 diff: &diff,
218 },
219 context,
220 )?;
221 Ok(Mutation {
222 schema: MUTATION_SCHEMA.to_string(),
223 ok: true,
224 item: updated,
225 diff,
226 journal_id: record.id,
227 })
228 }
229
230 pub fn capture(
231 &self,
232 template_key: &str,
233 draft: &CaptureDraft,
234 body: &str,
235 prompts: &BTreeMap<String, String>,
236 dry_run: bool,
237 context: &WriteContext,
238 ) -> Result<Mutation, AgdError> {
239 let template = self
240 .settings
241 .capture_template(template_key)
242 .ok_or_else(|| {
243 AgdError::new(
244 "unknown_template",
245 format!("capture template '{template_key}' is not configured"),
246 )
247 })?;
248 let (template, draft) = apply_agent_capture_gate(template, draft, &self.settings, context);
249 let draft = &draft;
250 let file = template.file.as_deref().ok_or_else(|| {
251 AgdError::new(
252 "unsupported_target",
253 "capture by ID requires an existing target and is not valid for a new file",
254 )
255 })?;
256 let path = self.roots.resolve_write(Path::new(file), true)?;
257 if let Some(parent) = path.parent() {
258 fs::create_dir_all(parent).map_err(AgdError::from)?;
259 }
260 let path = match (path.parent().map(fs::canonicalize), path.file_name()) {
263 (Some(Ok(parent)), Some(name)) => parent.join(name),
264 _ => path,
265 };
266 self.roots.resolve_write(&path, true)?;
267 let _lock = self.lock_target(&path)?;
268 refuse_emacs_lock(&path)?;
269 let pre_image = match fs::read(&path) {
270 Ok(bytes) => String::from_utf8(bytes).map_err(|error| {
271 AgdError::with_source(
272 "non_utf8",
273 format!("org file is not UTF-8: {}", path.display()),
274 error,
275 )
276 })?,
277 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
278 Err(error) => return Err(AgdError::from(error)),
279 };
280 let now = context.org_now();
281 let rendered = render_capture_template(&template, draft, body, prompts, now)?;
282 let (offset, level) = capture_target(&path, &pre_image, &template, &self.settings)?;
283 let rendered = set_capture_level(&rendered, level);
284 let newline = line_ending(&pre_image);
285 let mut insertion = String::new();
286 if offset > 0 && !pre_image[..offset].ends_with('\n') {
287 insertion.push_str(newline);
288 }
289 insertion.push_str(&rendered);
290 if !insertion.ends_with('\n') {
291 insertion.push_str(newline);
292 }
293 let patches = vec![Patch {
294 span: ByteSpan {
295 start: offset,
296 end: offset,
297 },
298 replacement: insertion,
299 }];
300 let (post_image, stored) = apply_patches(&pre_image, patches)?;
301 let diff = unified_diff(&path, &pre_image, &post_image);
302 let line = pre_image[..offset]
303 .bytes()
304 .filter(|byte| *byte == b'\n')
305 .count()
306 + 1;
307 let updated = parse_updated_item(&path, &post_image, line, &self.settings)?;
308 if dry_run {
309 return Ok(Mutation {
310 schema: MUTATION_SCHEMA.to_string(),
311 ok: true,
312 item: updated,
313 diff,
314 journal_id: "dry-run".to_string(),
315 });
316 }
317 let record = self.commit_image(
318 JournalImage {
319 path: &path,
320 item_ref: Some(&updated.item_ref),
321 pre_image: &pre_image,
322 post_image: &post_image,
323 patches: &stored,
324 diff: &diff,
325 },
326 context,
327 )?;
328 Ok(Mutation {
329 schema: MUTATION_SCHEMA.to_string(),
330 ok: true,
331 item: updated,
332 diff,
333 journal_id: record.id,
334 })
335 }
336
337 pub fn refile(
338 &self,
339 item_ref: &str,
340 target_ref: &str,
341 dry_run: bool,
342 context: &WriteContext,
343 ) -> Result<RefileResult, AgdError> {
344 let initial = WorkspaceIndex::load(self.settings.clone())?;
345 let (source_document, _) = initial.resolve(item_ref)?;
346 let (target_document, _) = initial.resolve(target_ref)?;
347 let source_path = source_document.path().to_path_buf();
348 let target_path = target_document.path().to_path_buf();
349 self.roots.resolve_write(&source_path, false)?;
350 self.roots.resolve_write(&target_path, false)?;
351
352 let mut paths = vec![source_path.clone(), target_path.clone()];
353 paths.sort();
354 paths.dedup();
355 let mut locks = Vec::with_capacity(paths.len());
356 for path in &paths {
357 locks.push(self.lock_target(path)?);
358 refuse_emacs_lock(path)?;
359 }
360
361 let fresh = WorkspaceIndex::load(self.settings.clone())?;
362 let (source_document, source_headline) = fresh.resolve(item_ref)?;
363 let (target_document, target_headline) = fresh.resolve(target_ref)?;
364 if source_document.path() == target_document.path()
365 && target_headline.line_span.start >= source_headline.subtree_span.start
366 && target_headline.line_span.start < source_headline.subtree_span.end
367 {
368 return Err(AgdError::new(
369 "invalid_refile",
370 "an item cannot be refiled into itself or one of its descendants",
371 ));
372 }
373
374 let moved = &source_document.source()
375 [source_headline.subtree_span.start..source_headline.subtree_span.end];
376 let moved = relevel_subtree(moved, target_headline.level + 1, source_headline.level)?;
377 let moved = normalize_insertion(
378 target_document.source(),
379 target_headline.subtree_span.end,
380 moved,
381 );
382
383 if source_document.path() == target_document.path() {
384 let path = source_document.path();
385 let (post_image, stored) = apply_patches(
386 source_document.source(),
387 vec![
388 Patch {
389 span: source_headline.subtree_span,
390 replacement: String::new(),
391 },
392 Patch {
393 span: ByteSpan {
394 start: target_headline.subtree_span.end,
395 end: target_headline.subtree_span.end,
396 },
397 replacement: moved,
398 },
399 ],
400 )?;
401 let diff = unified_diff(path, source_document.source(), &post_image);
402 let item = find_refiled_item(
403 path,
404 &post_image,
405 &source_headline.item,
406 &target_headline.item,
407 &self.settings,
408 )?;
409 if dry_run {
410 return Ok(RefileResult {
411 schema: "agd.refile/v1".to_string(),
412 ok: true,
413 item,
414 diffs: vec![diff],
415 journal_ids: vec!["dry-run".to_string()],
416 });
417 }
418 let record = self.commit_image(
419 JournalImage {
420 path,
421 item_ref: Some(item_ref),
422 pre_image: source_document.source(),
423 post_image: &post_image,
424 patches: &stored,
425 diff: &diff,
426 },
427 context,
428 )?;
429 return Ok(RefileResult {
430 schema: "agd.refile/v1".to_string(),
431 ok: true,
432 item,
433 diffs: vec![diff],
434 journal_ids: vec![record.id],
435 });
436 }
437
438 let (source_post, source_patches) = apply_patches(
439 source_document.source(),
440 vec![Patch {
441 span: source_headline.subtree_span,
442 replacement: String::new(),
443 }],
444 )?;
445 let (target_post, target_patches) = apply_patches(
446 target_document.source(),
447 vec![Patch {
448 span: ByteSpan {
449 start: target_headline.subtree_span.end,
450 end: target_headline.subtree_span.end,
451 },
452 replacement: moved,
453 }],
454 )?;
455 let source_diff = unified_diff(
456 source_document.path(),
457 source_document.source(),
458 &source_post,
459 );
460 let target_diff = unified_diff(
461 target_document.path(),
462 target_document.source(),
463 &target_post,
464 );
465 let item = find_refiled_item(
466 target_document.path(),
467 &target_post,
468 &source_headline.item,
469 &target_headline.item,
470 &self.settings,
471 )?;
472 if dry_run {
473 return Ok(RefileResult {
474 schema: "agd.refile/v1".to_string(),
475 ok: true,
476 item,
477 diffs: vec![source_diff, target_diff],
478 journal_ids: vec!["dry-run".to_string()],
479 });
480 }
481
482 let transaction = journal_id();
483 let mut source_record = pending_record(
484 format!("{transaction}:source"),
485 Some(transaction.clone()),
486 &JournalImage {
487 path: source_document.path(),
488 item_ref: Some(item_ref),
489 pre_image: source_document.source(),
490 post_image: &source_post,
491 patches: &source_patches,
492 diff: &source_diff,
493 },
494 context,
495 );
496 let mut target_record = pending_record(
497 format!("{transaction}:target"),
498 Some(transaction),
499 &JournalImage {
500 path: target_document.path(),
501 item_ref: Some(target_ref),
502 pre_image: target_document.source(),
503 post_image: &target_post,
504 patches: &target_patches,
505 diff: &target_diff,
506 },
507 context,
508 );
509 self.append_record(&source_record)?;
510 self.append_record(&target_record)?;
511 atomic_replace(target_document.path(), target_post.as_bytes())?;
512 if let Err(error) = atomic_replace(source_document.path(), source_post.as_bytes()) {
513 let _ = atomic_replace(target_document.path(), target_document.source().as_bytes());
514 return Err(error);
515 }
516 source_record.status = JournalStatus::Committed;
517 source_record.timestamp = Local::now().to_rfc3339();
518 target_record.status = JournalStatus::Committed;
519 target_record.timestamp = source_record.timestamp.clone();
520 self.append_record(&source_record)?;
521 self.append_record(&target_record)?;
522 Ok(RefileResult {
523 schema: "agd.refile/v1".to_string(),
524 ok: true,
525 item,
526 diffs: vec![source_diff, target_diff],
527 journal_ids: vec![source_record.id, target_record.id],
528 })
529 }
530 pub fn undo(
531 &self,
532 journal_id: Option<&str>,
533 context: &WriteContext,
534 ) -> Result<UndoResult, AgdError> {
535 self.recover_pending()?;
536 let records = self.latest_records()?;
537 let selected = match journal_id {
538 Some(id) => records.get(id),
539 None => records
540 .values()
541 .filter(|record| {
542 matches!(
543 record.status,
544 JournalStatus::Committed | JournalStatus::Reviewed
545 )
546 })
547 .max_by_key(|record| record.timestamp.as_str()),
548 }
549 .ok_or_else(|| AgdError::new("nothing_to_undo", "no committed journal entry found"))?;
550 let mut group: Vec<JournalRecord> = if let Some(transaction) = &selected.transaction {
551 records
552 .values()
553 .filter(|record| {
554 record.transaction.as_ref() == Some(transaction)
555 && matches!(
556 record.status,
557 JournalStatus::Committed | JournalStatus::Reviewed
558 )
559 })
560 .cloned()
561 .collect()
562 } else {
563 vec![selected.clone()]
564 };
565 group.sort_by(|left, right| left.file.cmp(&right.file));
566
567 let mut locks = Vec::with_capacity(group.len());
568 for record in &group {
569 let path = PathBuf::from(&record.file);
570 self.roots.resolve_write(&path, false)?;
571 locks.push(self.lock_target(&path)?);
572 refuse_emacs_lock(&path)?;
573 }
574
575 let mut prepared: Vec<(PathBuf, String, String, String)> = Vec::with_capacity(group.len());
576 for record in &group {
577 let path = PathBuf::from(&record.file);
578 let current = fs::read_to_string(&path).map_err(|error| {
579 AgdError::with_source(
580 "io",
581 format!("cannot read {}: {error}", path.display()),
582 error,
583 )
584 })?;
585 let unchanged = content_hash(¤t) == record.post_hash;
586 let (pre_image, _) = apply_patches(¤t, relocate_inverse(¤t, record)?)?;
587 if unchanged && content_hash(&pre_image) != record.pre_hash {
588 return Err(AgdError::new(
589 "journal_corrupt",
590 format!(
591 "inverse patch for {} does not reproduce its pre-image",
592 record.id
593 ),
594 ));
595 }
596 let diff = unified_diff(&path, ¤t, &pre_image);
597 prepared.push((path, current, pre_image, diff));
598 }
599
600 let mut written: Vec<usize> = Vec::new();
601 for (index, (path, _, pre_image, _)) in prepared.iter().enumerate() {
602 if let Err(error) = atomic_replace(path, pre_image.as_bytes()) {
603 for index in written.into_iter().rev() {
604 let (written_path, original, _, _) = &prepared[index];
605 let _ = atomic_replace(written_path, original.as_bytes());
606 }
607 return Err(error);
608 }
609 written.push(index);
610 }
611 for mut record in group.clone() {
612 record.status = JournalStatus::Undone;
613 record.timestamp = Local::now().to_rfc3339();
614 record.actor = context.actor.clone();
615 record.project = context.project.clone();
616 self.append_record(&record)?;
617 }
618 Ok(UndoResult {
619 schema: "agd.undo/v1".to_string(),
620 ok: true,
621 journal_id: selected
622 .transaction
623 .clone()
624 .unwrap_or_else(|| selected.id.clone()),
625 file: group
626 .iter()
627 .map(|record| record.file.as_str())
628 .collect::<Vec<_>>()
629 .join(","),
630 diff: prepared
631 .into_iter()
632 .map(|(_, _, _, diff)| diff)
633 .collect::<Vec<_>>()
634 .join("\n"),
635 })
636 }
637
638 pub fn review(&self, journal_id: &str) -> Result<(), AgdError> {
639 let records = self.latest_records()?;
640 let mut record = records.get(journal_id).cloned().ok_or_else(|| {
641 AgdError::new(
642 "journal_not_found",
643 format!("journal entry not found: {journal_id}"),
644 )
645 })?;
646 if record.status != JournalStatus::Committed {
647 return Err(AgdError::new(
648 "invalid_journal_state",
649 format!("journal entry {journal_id} is not awaiting review"),
650 ));
651 }
652 record.status = JournalStatus::Reviewed;
653 record.timestamp = Local::now().to_rfc3339();
654 self.append_record(&record)
655 }
656
657 pub fn journal(&self) -> Result<Vec<JournalRecord>, AgdError> {
658 let mut records: Vec<JournalRecord> = self.latest_records()?.into_values().collect();
659 records.sort_by(|left, right| right.timestamp.cmp(&left.timestamp));
660 Ok(records)
661 }
662
663 fn commit_image(
664 &self,
665 image: JournalImage<'_>,
666 context: &WriteContext,
667 ) -> Result<JournalRecord, AgdError> {
668 let mut record = pending_record(journal_id(), None, &image, context);
669 self.append_record(&record)?;
670 atomic_replace(image.path, image.post_image.as_bytes())?;
671 record.status = JournalStatus::Committed;
672 record.timestamp = Local::now().to_rfc3339();
673 self.append_record(&record)?;
674 Ok(record)
675 }
676
677 fn lock_target(&self, path: &Path) -> Result<File, AgdError> {
678 let lock_path = sidecar_lock_path(path)?;
679 let lock = OpenOptions::new()
680 .create(true)
681 .read(true)
682 .write(true)
683 .truncate(false)
684 .open(&lock_path)
685 .map_err(|error| {
686 AgdError::with_source(
687 "lock_failed",
688 format!("cannot open write lock {}: {error}", lock_path.display()),
689 error,
690 )
691 })?;
692 lock.lock().map_err(|error| {
693 AgdError::with_source(
694 "lock_failed",
695 format!("cannot lock {}: {error}", lock_path.display()),
696 error,
697 )
698 })?;
699 Ok(lock)
700 }
701
702 fn append_record(&self, record: &JournalRecord) -> Result<(), AgdError> {
703 if let Some(parent) = self.journal_path.parent() {
704 fs::create_dir_all(parent).map_err(AgdError::from)?;
705 #[cfg(unix)]
706 {
707 use std::os::unix::fs::PermissionsExt;
708 fs::set_permissions(parent, fs::Permissions::from_mode(0o700))
709 .map_err(AgdError::from)?;
710 }
711 }
712 let serialized = serde_json::to_vec(record).map_err(|error| {
713 AgdError::new(
714 "journal_error",
715 format!("cannot serialize journal record: {error}"),
716 )
717 })?;
718 let mut options = OpenOptions::new();
719 options.create(true).append(true);
720 #[cfg(unix)]
721 {
722 use std::os::unix::fs::OpenOptionsExt;
723 options.mode(0o600);
724 }
725 let mut file = options.open(&self.journal_path).map_err(AgdError::from)?;
726 file.write_all(&serialized).map_err(AgdError::from)?;
727 file.write_all(b"\n").map_err(AgdError::from)?;
728 file.sync_all().map_err(AgdError::from)
729 }
730
731 fn latest_records(&self) -> Result<BTreeMap<String, JournalRecord>, AgdError> {
732 let source = match fs::read_to_string(&self.journal_path) {
733 Ok(source) => source,
734 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
735 return Ok(BTreeMap::new());
736 }
737 Err(error) => return Err(AgdError::from(error)),
738 };
739 let mut records = BTreeMap::new();
740 for (line, raw) in source.lines().enumerate() {
741 let record: JournalRecord = serde_json::from_str(raw).map_err(|error| {
742 AgdError::new(
743 "journal_corrupt",
744 format!("invalid journal JSON at line {}: {error}", line + 1),
745 )
746 })?;
747 records.insert(record.id.clone(), record);
748 }
749 Ok(records)
750 }
751
752 fn recover_pending(&self) -> Result<(), AgdError> {
753 let mut conflicts = Vec::new();
754 for mut record in self.latest_records()?.into_values().filter(|record| {
755 matches!(
756 record.status,
757 JournalStatus::Pending | JournalStatus::Conflict
758 )
759 }) {
760 let path = PathBuf::from(&record.file);
761 let current = match fs::read_to_string(&path) {
762 Ok(source) => content_hash(&source),
763 Err(error) if error.kind() == std::io::ErrorKind::NotFound => content_hash(""),
764 Err(error) => return Err(AgdError::from(error)),
765 };
766 let previous = record.status;
767 record.status = if current == record.post_hash {
768 JournalStatus::Committed
769 } else if current == record.pre_hash {
770 JournalStatus::Unapplied
771 } else {
772 JournalStatus::Conflict
773 };
774 if record.status != previous {
775 record.timestamp = Local::now().to_rfc3339();
776 self.append_record(&record)?;
777 }
778 if record.status == JournalStatus::Conflict {
779 conflicts.push(record.id);
780 }
781 }
782 if conflicts.is_empty() {
783 Ok(())
784 } else {
785 Err(AgdError::new(
786 "journal_recovery_conflict",
787 format!(
788 "journal entries require explicit file resolution: {}",
789 conflicts.join(", ")
790 ),
791 ))
792 }
793 }
794}
795
796fn pending_record(
797 id: String,
798 transaction: Option<String>,
799 image: &JournalImage<'_>,
800 context: &WriteContext,
801) -> JournalRecord {
802 JournalRecord {
803 schema: "agd.journal/v1".to_string(),
804 id,
805 status: JournalStatus::Pending,
806 timestamp: Local::now().to_rfc3339(),
807 actor: context.actor.clone(),
808 project: context.project.clone(),
809 file: image.path.to_string_lossy().into_owned(),
810 item_ref: image.item_ref.map(str::to_string),
811 transaction,
812 pre_hash: content_hash(image.pre_image),
813 post_hash: content_hash(image.post_image),
814 patches: image.patches.to_vec(),
815 diff: image.diff.to_string(),
816 }
817}
818
819fn relevel_subtree(
820 source: &str,
821 target_level: usize,
822 source_level: usize,
823) -> Result<String, AgdError> {
824 let delta = target_level as isize - source_level as isize;
825 let mut output = String::with_capacity(source.len());
826 for line in source.split_inclusive('\n') {
827 let stars = line.bytes().take_while(|byte| *byte == b'*').count();
828 if stars > 0
829 && line
830 .as_bytes()
831 .get(stars)
832 .is_some_and(u8::is_ascii_whitespace)
833 {
834 let level = stars as isize + delta;
835 if level < 1 {
836 return Err(AgdError::new(
837 "invalid_refile",
838 "refile would produce an invalid headline level",
839 ));
840 }
841 output.push_str(&"*".repeat(level as usize));
842 output.push_str(&line[stars..]);
843 } else {
844 output.push_str(line);
845 }
846 }
847 Ok(output)
848}
849
850fn normalize_insertion(target: &str, offset: usize, moved: String) -> String {
851 let newline = line_ending(target);
852 let mut insertion = String::new();
853 if offset > 0 && !target[..offset].ends_with('\n') {
854 insertion.push_str(newline);
855 }
856 insertion.push_str(&moved);
857 if !insertion.ends_with('\n') {
858 insertion.push_str(newline);
859 }
860 insertion
861}
862
863fn find_refiled_item(
864 path: &Path,
865 source: &str,
866 moved: &Item,
867 target: &Item,
868 settings: &Settings,
869) -> Result<Item, AgdError> {
870 let document =
871 IndexedDocument::parse(path.to_path_buf(), source.as_bytes().to_vec(), settings)?;
872 if let Some(id) = &moved.id
873 && let Some(headline) = document
874 .headlines()
875 .iter()
876 .find(|headline| headline.item.id.as_ref() == Some(id))
877 {
878 return Ok(headline.item.clone());
879 }
880 let mut expected = target.olpath.clone();
881 expected.push(moved.title.clone());
882 document
883 .headlines()
884 .iter()
885 .find(|headline| headline.item.olpath == expected)
886 .map(|headline| headline.item.clone())
887 .ok_or_else(|| AgdError::new("parse_error", "refiled item was not found at its target"))
888}
889
890pub fn apply_patches(
891 source: &str,
892 mut patches: Vec<Patch>,
893) -> Result<(String, Vec<StoredPatch>), AgdError> {
894 patches.sort_by_key(|patch| (patch.span.start, patch.span.end));
895 let mut cursor = 0;
896 let mut post_cursor = 0;
897 let mut output = String::with_capacity(source.len());
898 let mut stored = Vec::with_capacity(patches.len());
899 for patch in patches {
900 if patch.span.start < cursor
901 || patch.span.end < patch.span.start
902 || patch.span.end > source.len()
903 {
904 return Err(AgdError::new(
905 "invalid_patch",
906 format!(
907 "patch {}..{} overlaps or exceeds the source",
908 patch.span.start, patch.span.end
909 ),
910 ));
911 }
912 if !source.is_char_boundary(patch.span.start) || !source.is_char_boundary(patch.span.end) {
913 return Err(AgdError::new(
914 "invalid_patch",
915 "patch is not on UTF-8 boundaries",
916 ));
917 }
918 output.push_str(&source[cursor..patch.span.start]);
919 post_cursor += patch.span.start - cursor;
920 let post_start = post_cursor;
921 let before = source[patch.span.start..patch.span.end].to_string();
922 output.push_str(&patch.replacement);
923 post_cursor += patch.replacement.len();
924 stored.push(StoredPatch {
925 start: post_start,
926 end: post_cursor,
927 before,
928 after: patch.replacement,
929 context_before: None,
930 context_after: None,
931 });
932 cursor = patch.span.end;
933 }
934 output.push_str(&source[cursor..]);
935 for patch in &mut stored {
936 let context_start = line_start(&output, line_start(&output, patch.start).saturating_sub(1));
937 let context_end = line_end(&output, line_end(&output, patch.end) + 1);
938 patch.context_before = Some(output[context_start..patch.start].to_string());
939 patch.context_after = Some(output[patch.end..context_end].to_string());
940 }
941 Ok((output, stored))
942}
943
944fn line_start(text: &str, offset: usize) -> usize {
945 text[..offset.min(text.len())]
946 .rfind('\n')
947 .map_or(0, |index| index + 1)
948}
949
950fn line_end(text: &str, offset: usize) -> usize {
952 if offset >= text.len() {
953 return text.len();
954 }
955 text[offset..]
956 .find('\n')
957 .map_or(text.len(), |index| offset + index + 1)
958}
959
960fn relocate_inverse(current: &str, record: &JournalRecord) -> Result<Vec<Patch>, AgdError> {
964 if content_hash(current) == record.post_hash {
965 return Ok(inverse_patches(&record.patches));
966 }
967 let conflict = |detail: String| {
968 AgdError::new(
969 "undo_conflict",
970 format!(
971 "{} changed after journal entry {}: {detail}",
972 record.file, record.id
973 ),
974 )
975 };
976 let mut patches = Vec::with_capacity(record.patches.len());
977 for patch in &record.patches {
978 let (Some(before), Some(after)) = (&patch.context_before, &patch.context_after) else {
979 return Err(conflict("entry predates context-aware undo".to_string()));
980 };
981 let needle = format!("{before}{}{after}", patch.after);
982 let mut found = current.match_indices(&needle);
983 let (Some((position, _)), None) = (found.next(), found.next()) else {
984 return Err(conflict(format!(
985 "the edited text near byte {} changed",
986 patch.start
987 )));
988 };
989 let start = position + before.len();
990 patches.push(Patch {
991 span: ByteSpan {
992 start,
993 end: start + patch.after.len(),
994 },
995 replacement: patch.before.clone(),
996 });
997 }
998 Ok(patches)
999}
1000
1001fn inverse_patches(patches: &[StoredPatch]) -> Vec<Patch> {
1002 patches
1003 .iter()
1004 .map(|patch| Patch {
1005 span: ByteSpan {
1006 start: patch.start,
1007 end: patch.end,
1008 },
1009 replacement: patch.before.clone(),
1010 })
1011 .collect()
1012}
1013
1014fn build_edit_patches(
1015 document: &IndexedDocument,
1016 headline: &HeadlineSpan,
1017 intent: &EditIntent,
1018 settings: &Settings,
1019 now: NaiveDateTime,
1020) -> Result<Vec<Patch>, AgdError> {
1021 let source = document.source();
1022 let logging = document.logging();
1023 let stamp = format!("[{}]", now.format("%Y-%m-%d %a %H:%M"));
1024 let mut patches = Vec::new();
1025 let previous_state = headline.item.todo.clone();
1026 let mut state = change_value(&headline.item.todo, &intent.state);
1027 if let Some(requested) = &state
1028 && !document.todo_active().contains(requested)
1029 && !document.todo_done().contains(requested)
1030 {
1031 return Err(AgdError::new(
1032 "invalid_todo",
1033 format!("TODO state '{requested}' is not enabled for this file"),
1034 ));
1035 }
1036 let mut scheduled = planning_change(&headline.item.planning.scheduled, &intent.scheduled);
1037 let mut deadline = planning_change(&headline.item.planning.deadline, &intent.deadline);
1038 let mut closed = headline.item.planning.closed.clone();
1039 let mut properties = intent.properties.clone();
1040 if settings.ids == IdPolicy::AddOnWrite
1041 && headline.item.id.is_none()
1042 && !properties.contains_key("ID")
1043 {
1044 properties.insert("ID".to_string(), Some(uuid::Uuid::new_v4().to_string()));
1046 }
1047 let mut log: Vec<LogEntry> = Vec::new();
1049 let mut note = intent.note.clone();
1050 for (old, new, logged, moved, removed) in [
1051 (
1052 &headline.item.planning.scheduled,
1053 &scheduled,
1054 logging.reschedule,
1055 "Rescheduled from",
1056 "Not scheduled, was",
1057 ),
1058 (
1059 &headline.item.planning.deadline,
1060 &deadline,
1061 logging.redeadline,
1062 "New deadline from",
1063 "Removed deadline, was",
1064 ),
1065 ] {
1066 let (Some(kind), Some(old)) = (logged, old) else {
1067 continue;
1068 };
1069 let heading = match new {
1070 Some(new) if format_active_timestamp(new)? == format_active_timestamp(old)? => {
1071 continue;
1072 }
1073 Some(_) => format!("{moved} \"{}\" on {stamp}", inactive_timestamp(old)?),
1074 None => format!("{removed} \"{}\" on {stamp}", inactive_timestamp(old)?),
1075 };
1076 log.push(LogEntry::new(heading, kind, &mut note));
1077 }
1078
1079 let state_changed = !matches!(intent.state, Change::Keep) && state != previous_state;
1080 let requested_done = state_changed
1081 && state
1082 .as_ref()
1083 .is_some_and(|value| done_keywords(document).contains(value));
1084 let repeating = scheduled
1085 .as_ref()
1086 .is_some_and(|timestamp| timestamp.repeater.is_some())
1087 || deadline
1088 .as_ref()
1089 .is_some_and(|timestamp| timestamp.repeater.is_some());
1090 let state_heading = |to: &str| {
1091 let from = previous_state
1092 .as_deref()
1093 .map(|value| format!("\"{value}\""))
1094 .unwrap_or_default();
1095 format!("State {:<12} from {from:<12} {stamp}", format!("\"{to}\""))
1096 };
1097 let keyword_log = state.as_deref().and_then(|value| {
1100 document
1101 .todo_keyword(value)
1102 .and_then(|keyword| keyword.log_enter)
1103 .or_else(|| {
1104 previous_state
1105 .as_deref()
1106 .and_then(|value| document.todo_keyword(value))
1107 .and_then(|keyword| keyword.log_leave)
1108 })
1109 });
1110 if requested_done && repeating {
1111 if let Some(timestamp) = scheduled.as_mut()
1114 && timestamp.repeater.is_some()
1115 {
1116 advance_repeater(timestamp, now.date())?;
1117 }
1118 if let Some(timestamp) = deadline.as_mut()
1119 && timestamp.repeater.is_some()
1120 {
1121 advance_repeater(timestamp, now.date())?;
1122 }
1123 let done_word = state.clone().unwrap_or_default();
1124 state = sequence_head(document, previous_state.as_deref());
1125 closed = None;
1126 if let Some(kind) = logging.repeat {
1127 properties.insert("LAST_REPEAT".to_string(), Some(stamp.clone()));
1128 log.insert(0, LogEntry::new(state_heading(&done_word), kind, &mut note));
1129 }
1130 } else if requested_done {
1131 if logging.done.is_some() {
1132 closed = Some(PlanningTimestamp {
1133 date: now.format("%Y-%m-%d").to_string(),
1134 time: Some(now.format("%H:%M").to_string()),
1135 end_time: None,
1136 repeater: None,
1137 warning: None,
1138 raw: None,
1139 });
1140 }
1141 let done_word = state.clone().unwrap_or_default();
1142 if let Some(kind) = keyword_log {
1143 log.insert(0, LogEntry::new(state_heading(&done_word), kind, &mut note));
1144 } else if logging.done == Some(LogKind::Note) {
1145 log.insert(
1146 0,
1147 LogEntry::new(format!("CLOSING NOTE {stamp}"), LogKind::Note, &mut note),
1148 );
1149 }
1150 } else if state_changed {
1151 closed = None;
1152 if let Some(kind) = keyword_log {
1153 let to = state.clone().unwrap_or_default();
1154 log.insert(0, LogEntry::new(state_heading(&to), kind, &mut note));
1155 }
1156 }
1157 if let Some(text) = note.take() {
1158 log.insert(
1159 0,
1160 LogEntry {
1161 heading: format!("Note taken on {stamp}"),
1162 note: Some(text),
1163 },
1164 );
1165 }
1166
1167 if !matches!(intent.state, Change::Keep)
1168 || !matches!(intent.title, Change::Keep)
1169 || !matches!(intent.priority, Change::Keep)
1170 || !intent.tags_add.is_empty()
1171 || !intent.tags_remove.is_empty()
1172 {
1173 let title = change_value(&Some(headline.item.title.clone()), &intent.title)
1174 .ok_or_else(|| AgdError::new("invalid_edit", "title cannot be cleared"))?;
1175 let priority = change_value(&headline.item.priority, &intent.priority);
1176 let mut tags: Vec<String> = headline
1177 .item
1178 .tags
1179 .iter()
1180 .filter(|tag| !intent.tags_remove.contains(*tag))
1181 .cloned()
1182 .collect();
1183 for tag in &intent.tags_add {
1184 if !tags.contains(tag) {
1185 tags.push(tag.clone());
1186 }
1187 }
1188 let line = align_tags(
1189 render_headline(
1190 headline.level,
1191 state.as_deref(),
1192 priority.as_deref(),
1193 &title,
1194 &tags,
1195 ),
1196 !tags.is_empty(),
1197 settings.tags_column,
1198 );
1199 patches.push(Patch {
1200 span: headline.line_span,
1201 replacement: line,
1202 });
1203 }
1204
1205 if !matches!(intent.scheduled, Change::Keep)
1206 || !matches!(intent.deadline, Change::Keep)
1207 || closed != headline.item.planning.closed
1208 || repeating
1209 {
1210 let replacement =
1211 render_planning_line(scheduled.as_ref(), deadline.as_ref(), closed.as_ref())?;
1212 let indent = headline
1213 .planning_span
1214 .map_or("", |span| leading_indent(source, span.start));
1215 patches.push(Patch {
1216 span: headline.planning_span.unwrap_or(ByteSpan {
1217 start: after_headline_line(source, headline.line_span.end),
1218 end: after_headline_line(source, headline.line_span.end),
1219 }),
1220 replacement: replacement
1221 .map(|line| format!("{indent}{line}{}", line_ending(source)))
1222 .unwrap_or_default(),
1223 });
1224 }
1225
1226 if !properties.is_empty() {
1227 let replacement = render_properties(source, headline, &properties, line_ending(source));
1228 let insert = headline.drawer_span.unwrap_or(ByteSpan {
1229 start: headline.planning_span.map_or(
1230 after_headline_line(source, headline.line_span.end),
1231 |span| span.end,
1232 ),
1233 end: headline.planning_span.map_or(
1234 after_headline_line(source, headline.line_span.end),
1235 |span| span.end,
1236 ),
1237 });
1238 patches.push(Patch {
1239 span: insert,
1240 replacement,
1241 });
1242 }
1243
1244 if !log.is_empty() {
1245 patches.push(log_patch(source, headline, &log, logging.into_drawer)?);
1246 }
1247 Ok(patches)
1248}
1249
1250struct LogEntry {
1252 heading: String,
1253 note: Option<String>,
1254}
1255
1256impl LogEntry {
1257 fn new(heading: String, kind: LogKind, note: &mut Option<String>) -> Self {
1260 let note = if kind == LogKind::Note {
1261 note.take()
1262 } else {
1263 None
1264 };
1265 Self { heading, note }
1266 }
1267}
1268
1269fn log_patch(
1273 source: &str,
1274 headline: &HeadlineSpan,
1275 entries: &[LogEntry],
1276 into_drawer: bool,
1277) -> Result<Patch, AgdError> {
1278 let newline = line_ending(source);
1279 let start = headline.drawer_span.or(headline.planning_span).map_or_else(
1280 || after_headline_line(source, headline.line_span.end),
1281 |span| span.end,
1282 );
1283 let indent = headline
1284 .drawer_span
1285 .or(headline.planning_span)
1286 .map_or("", |span| leading_indent(source, span.start));
1287 let mut lines = String::new();
1288 for entry in entries {
1289 let Some(note) = &entry.note else {
1290 lines.push_str(&format!("{indent}- {}{newline}", entry.heading));
1291 continue;
1292 };
1293 if note.contains(['\n', '\r']) {
1294 return Err(AgdError::new("invalid_note", "log notes must be one line"));
1295 }
1296 lines.push_str(&format!(
1297 "{indent}- {} \\\\{newline}{indent} {note}{newline}",
1298 entry.heading
1299 ));
1300 }
1301 if into_drawer {
1302 let section = &source[start..headline.section_span.end];
1303 if let Some(offset) = logbook_line_end(section) {
1304 let at = start + offset;
1305 return Ok(Patch {
1306 span: ByteSpan { start: at, end: at },
1307 replacement: lines,
1308 });
1309 }
1310 lines = format!("{indent}:LOGBOOK:{newline}{lines}{indent}:END:{newline}");
1311 }
1312 Ok(Patch {
1313 span: ByteSpan { start, end: start },
1314 replacement: lines,
1315 })
1316}
1317
1318fn logbook_line_end(section: &str) -> Option<usize> {
1320 let mut offset = 0;
1321 for line in section.split_inclusive('\n') {
1322 offset += line.len();
1323 if line.trim().eq_ignore_ascii_case(":LOGBOOK:") {
1324 return Some(offset);
1325 }
1326 }
1327 None
1328}
1329
1330fn inactive_timestamp(timestamp: &PlanningTimestamp) -> Result<String, AgdError> {
1332 let active = format_active_timestamp(timestamp)?;
1333 Ok(format!("[{}]", &active[1..active.len() - 1]))
1334}
1335
1336fn planning_change(
1339 current: &Option<PlanningTimestamp>,
1340 change: &Change<PlanningTimestamp>,
1341) -> Option<PlanningTimestamp> {
1342 match (change, current) {
1343 (Change::Set(new), Some(old)) => {
1344 let mut new = new.clone();
1345 new.repeater = new.repeater.or_else(|| old.repeater.clone());
1346 new.warning = new.warning.or_else(|| old.warning.clone());
1347 Some(new)
1348 }
1349 _ => change_value(current, change),
1350 }
1351}
1352
1353fn sequence_head(document: &IndexedDocument, state: Option<&str>) -> Option<String> {
1356 let sequence = state
1357 .and_then(|value| document.todo_keyword(value))
1358 .map_or(0, |keyword| keyword.sequence);
1359 document
1360 .todo_keywords()
1361 .iter()
1362 .find(|keyword| keyword.sequence == sequence)
1363 .or_else(|| document.todo_keywords().first())
1364 .map(|keyword| keyword.name.clone())
1365}
1366
1367fn validate_intent(intent: &EditIntent) -> Result<(), AgdError> {
1368 if intent == &EditIntent::default() {
1369 return Err(AgdError::new("invalid_edit", "nothing to edit"));
1370 }
1371 if let Change::Set(title) = &intent.title
1372 && (title.trim().is_empty() || title.contains(['\n', '\r']))
1373 {
1374 return Err(AgdError::new(
1375 "invalid_title",
1376 "title must be one non-empty line",
1377 ));
1378 }
1379 if let Change::Set(priority) = &intent.priority
1380 && (priority.len() != 1 || !priority.bytes().all(|byte| byte.is_ascii_alphanumeric()))
1381 {
1382 return Err(AgdError::new(
1383 "invalid_priority",
1384 "priority must be one alphanumeric character",
1385 ));
1386 }
1387 for tag in intent.tags_add.iter().chain(&intent.tags_remove) {
1388 if tag.is_empty()
1389 || !tag
1390 .chars()
1391 .all(|c| c.is_alphanumeric() || matches!(c, '_' | '@' | '#' | '%'))
1392 {
1393 return Err(AgdError::new("invalid_tag", format!("invalid tag: {tag}")));
1394 }
1395 }
1396 for (key, value) in &intent.properties {
1397 if key.is_empty()
1398 || !key
1399 .bytes()
1400 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
1401 {
1402 return Err(AgdError::new(
1403 "invalid_property",
1404 format!("invalid property key: {key}"),
1405 ));
1406 }
1407 if value
1408 .as_ref()
1409 .is_some_and(|value| value.contains(['\n', '\r']))
1410 {
1411 return Err(AgdError::new(
1412 "invalid_property",
1413 format!("property {key} must be one line"),
1414 ));
1415 }
1416 }
1417 Ok(())
1418}
1419
1420fn change_value<T: Clone>(current: &Option<T>, change: &Change<T>) -> Option<T> {
1421 match change {
1422 Change::Keep => current.clone(),
1423 Change::Set(value) => Some(value.clone()),
1424 Change::Clear => None,
1425 }
1426}
1427
1428fn done_keywords(document: &IndexedDocument) -> BTreeSet<String> {
1429 document.todo_done().iter().cloned().collect()
1430}
1431
1432fn render_headline(
1433 level: usize,
1434 state: Option<&str>,
1435 priority: Option<&str>,
1436 title: &str,
1437 tags: &[String],
1438) -> String {
1439 let mut fields = vec!["*".repeat(level)];
1440 if let Some(state) = state {
1441 fields.push(state.to_string());
1442 }
1443 if let Some(priority) = priority {
1444 fields.push(format!("[#{priority}]"));
1445 }
1446 fields.push(title.to_string());
1447 let mut line = fields.join(" ");
1448 if !tags.is_empty() {
1449 line.push_str(&format!(" :{}:", tags.join(":")));
1450 }
1451 line
1452}
1453
1454fn align_tags(rendered: String, has_tags: bool, column: i64) -> String {
1458 use unicode_width::UnicodeWidthStr;
1459
1460 let Some(tags_start) = rendered.rfind(" :").filter(|_| has_tags) else {
1461 return rendered;
1462 };
1463 let (head, tags) = rendered.split_at(tags_start);
1464 let tags = tags.trim_start();
1465 let start = if column < 0 {
1466 column.unsigned_abs() as usize - tags.width().min(column.unsigned_abs() as usize)
1467 } else {
1468 column as usize
1469 };
1470 let gap = start.saturating_sub(head.width()).max(1);
1471 format!("{head}{}{tags}", " ".repeat(gap))
1472}
1473
1474fn render_planning_line(
1475 scheduled: Option<&PlanningTimestamp>,
1476 deadline: Option<&PlanningTimestamp>,
1477 closed: Option<&PlanningTimestamp>,
1478) -> Result<Option<String>, AgdError> {
1479 let mut fields = Vec::new();
1480 if let Some(timestamp) = scheduled {
1481 fields.push(format!("SCHEDULED: {}", render_timestamp(timestamp, true)?));
1482 }
1483 if let Some(timestamp) = deadline {
1484 fields.push(format!("DEADLINE: {}", render_timestamp(timestamp, true)?));
1485 }
1486 if let Some(timestamp) = closed {
1487 fields.push(format!("CLOSED: {}", render_timestamp(timestamp, false)?));
1488 }
1489 Ok((!fields.is_empty()).then(|| fields.join(" ")))
1490}
1491
1492fn render_timestamp(timestamp: &PlanningTimestamp, active: bool) -> Result<String, AgdError> {
1493 if active {
1494 return format_active_timestamp(timestamp);
1495 }
1496 let active = format_active_timestamp(timestamp)?;
1497 Ok(format!("[{}]", &active[1..active.len() - 1]))
1498}
1499
1500fn render_properties(
1501 source: &str,
1502 headline: &HeadlineSpan,
1503 changes: &BTreeMap<String, Option<String>>,
1504 newline: &str,
1505) -> String {
1506 let mut properties: Vec<(String, String)> = headline
1507 .properties
1508 .iter()
1509 .map(|property| (property.key.clone(), property.value.clone()))
1510 .collect();
1511 for (key, value) in changes {
1512 let position = properties
1513 .iter()
1514 .position(|(existing, _)| existing.eq_ignore_ascii_case(key));
1515 match (position, value) {
1516 (Some(index), Some(value)) => properties[index].1 = value.clone(),
1517 (Some(index), None) => {
1518 properties.remove(index);
1519 }
1520 (None, Some(value)) => properties.push((key.clone(), value.clone())),
1521 (None, None) => {}
1522 }
1523 }
1524 if properties.is_empty() {
1525 return String::new();
1526 }
1527 let indent = headline
1528 .drawer_span
1529 .or(headline.planning_span)
1530 .map_or("", |span| leading_indent(source, span.start));
1531 let mut output = format!("{indent}:PROPERTIES:{newline}");
1532 for (key, value) in properties {
1533 output.push_str(&format!("{indent}:{key}: {value}{newline}"));
1534 }
1535 output.push_str(&format!("{indent}:END:{newline}"));
1536 output
1537}
1538
1539fn advance_repeater(timestamp: &mut PlanningTimestamp, today: NaiveDate) -> Result<(), AgdError> {
1540 let repeater = timestamp
1541 .repeater
1542 .as_deref()
1543 .ok_or_else(|| AgdError::new("invalid_repeater", "timestamp has no repeater"))?;
1544 let (kind, amount_and_unit) = if let Some(value) = repeater.strip_prefix("++") {
1545 ("++", value)
1546 } else if let Some(value) = repeater.strip_prefix(".+") {
1547 (".+", value)
1548 } else if let Some(value) = repeater.strip_prefix('+') {
1549 ("+", value)
1550 } else {
1551 return Err(AgdError::new(
1552 "invalid_repeater",
1553 format!("invalid repeater: {repeater}"),
1554 ));
1555 };
1556 let split = amount_and_unit.len().saturating_sub(1);
1557 let amount = amount_and_unit[..split]
1558 .parse::<u64>()
1559 .map_err(|_| AgdError::new("invalid_repeater", format!("invalid repeater: {repeater}")))?;
1560 let unit = &amount_and_unit[split..];
1561 let original = NaiveDate::parse_from_str(×tamp.date, "%Y-%m-%d")
1562 .map_err(|_| AgdError::new("invalid_date", format!("invalid date: {}", timestamp.date)))?;
1563 let mut next = if kind == ".+" {
1564 add_period(today, amount, unit)?
1565 } else {
1566 add_period(original, amount, unit)?
1567 };
1568 if kind == "++" {
1569 while next <= today {
1570 next = add_period(next, amount, unit)?;
1571 }
1572 }
1573 timestamp.date = next.format("%Y-%m-%d").to_string();
1574 timestamp.raw = None;
1575 Ok(())
1576}
1577
1578fn add_period(date: NaiveDate, amount: u64, unit: &str) -> Result<NaiveDate, AgdError> {
1579 let result = match unit {
1580 "d" => date.checked_add_signed(Duration::days(amount as i64)),
1581 "w" => date.checked_add_signed(Duration::weeks(amount as i64)),
1582 "m" => date.checked_add_months(Months::new(amount as u32)),
1583 "y" => date.checked_add_months(Months::new((amount * 12) as u32)),
1584 _ => None,
1585 };
1586 result.ok_or_else(|| AgdError::new("invalid_repeater", "repeater date is out of range"))
1587}
1588
1589fn capture_target(
1590 path: &Path,
1591 source: &str,
1592 template: &CaptureTemplate,
1593 settings: &Settings,
1594) -> Result<(usize, usize), AgdError> {
1595 if source.is_empty() {
1596 return Ok((0, 1));
1597 }
1598 let document =
1599 IndexedDocument::parse(path.to_path_buf(), source.as_bytes().to_vec(), settings)?;
1600 let target = if let Some(outline) = &template.outline_path {
1601 document
1602 .headlines()
1603 .iter()
1604 .find(|headline| &headline.item.olpath == outline)
1605 } else if let Some(heading) = &template.heading {
1606 document
1607 .headlines()
1608 .iter()
1609 .find(|headline| headline.item.title == *heading)
1610 } else if let Some(id) = &template.id {
1611 document
1612 .headlines()
1613 .iter()
1614 .find(|headline| headline.item.id.as_deref() == Some(id))
1615 } else {
1616 None
1617 };
1618 match target {
1619 Some(headline) => Ok((headline.subtree_span.end, headline.level + 1)),
1620 None if template.heading.is_none()
1621 && template.outline_path.is_none()
1622 && template.id.is_none() =>
1623 {
1624 Ok((source.len(), 1))
1625 }
1626 None => Err(AgdError::new(
1627 "capture_target_not_found",
1628 "configured capture target heading was not found",
1629 )),
1630 }
1631}
1632
1633fn set_capture_level(rendered: &str, level: usize) -> String {
1634 let stars = rendered.bytes().take_while(|byte| *byte == b'*').count();
1635 if stars == 0 {
1636 return rendered.to_string();
1637 }
1638 format!("{}{}", "*".repeat(level), &rendered[stars..])
1639}
1640
1641fn apply_agent_capture_gate(
1644 template: &CaptureTemplate,
1645 draft: &CaptureDraft,
1646 settings: &Settings,
1647 context: &WriteContext,
1648) -> (CaptureTemplate, CaptureDraft) {
1649 if !context.is_agent() || settings.agent_capture_to != "inbox" {
1650 return (template.clone(), draft.clone());
1651 }
1652 let inbox = settings
1653 .capture_template(&settings.agent_inbox_template)
1654 .cloned()
1655 .unwrap_or_else(|| template.clone());
1656 let mut draft = draft.clone();
1657 draft.scheduled = None;
1658 draft.deadline = None;
1659 (inbox, draft)
1660}
1661
1662fn parse_updated_item(
1663 path: &Path,
1664 source: &str,
1665 line_hint: usize,
1666 settings: &Settings,
1667) -> Result<Item, AgdError> {
1668 let document =
1669 IndexedDocument::parse(path.to_path_buf(), source.as_bytes().to_vec(), settings)?;
1670 document
1671 .headline_at_line(line_hint)
1672 .or_else(|| {
1673 document
1674 .headlines()
1675 .iter()
1676 .min_by_key(|headline| headline.item.line.abs_diff(line_hint))
1677 })
1678 .map(|headline| headline.item.clone())
1679 .ok_or_else(|| AgdError::new("parse_error", "write produced no target headline"))
1680}
1681
1682fn sidecar_lock_path(path: &Path) -> Result<PathBuf, AgdError> {
1683 let parent = path
1684 .parent()
1685 .ok_or_else(|| AgdError::new("invalid_path", "target has no parent"))?;
1686 let name = path
1687 .file_name()
1688 .ok_or_else(|| AgdError::new("invalid_path", "target has no file name"))?;
1689 Ok(parent.join(format!(".{}.agd.lock", name.to_string_lossy())))
1690}
1691
1692fn refuse_emacs_lock(path: &Path) -> Result<(), AgdError> {
1693 let parent = path
1694 .parent()
1695 .ok_or_else(|| AgdError::new("invalid_path", "target has no parent"))?;
1696 let name = path
1697 .file_name()
1698 .ok_or_else(|| AgdError::new("invalid_path", "target has no file name"))?;
1699 let lock = parent.join(format!(".#{}", name.to_string_lossy()));
1700 if fs::symlink_metadata(&lock).is_ok() {
1701 let owner = fs::read_link(&lock)
1702 .map(|target| target.to_string_lossy().into_owned())
1703 .or_else(|_| fs::read_to_string(&lock))
1704 .unwrap_or_else(|_| "ownership unavailable".to_string());
1705 return Err(AgdError::new(
1706 "editor_dirty",
1707 format!(
1708 "Emacs lock {} exists ({owner}); save or close the buffer",
1709 lock.display()
1710 ),
1711 ));
1712 }
1713 Ok(())
1714}
1715
1716fn atomic_replace(path: &Path, bytes: &[u8]) -> Result<(), AgdError> {
1717 let parent = path
1718 .parent()
1719 .ok_or_else(|| AgdError::new("invalid_path", "target has no parent"))?;
1720 let permissions = fs::metadata(path)
1721 .ok()
1722 .map(|metadata| metadata.permissions());
1723 let mut temporary = tempfile::Builder::new()
1724 .prefix(".agd-")
1725 .tempfile_in(parent)
1726 .map_err(AgdError::from)?;
1727 temporary.write_all(bytes).map_err(AgdError::from)?;
1728 if let Some(permissions) = permissions {
1729 temporary
1730 .as_file()
1731 .set_permissions(permissions)
1732 .map_err(AgdError::from)?;
1733 }
1734 temporary.as_file().sync_all().map_err(AgdError::from)?;
1735 temporary
1736 .persist(path)
1737 .map_err(|error| AgdError::from(error.error))?;
1738 sync_parent_directory(parent)
1739}
1740
1741#[cfg(not(target_os = "windows"))]
1742fn sync_parent_directory(parent: &Path) -> Result<(), AgdError> {
1743 File::open(parent)
1744 .and_then(|directory| directory.sync_all())
1745 .map_err(AgdError::from)
1746}
1747
1748#[cfg(target_os = "windows")]
1749fn sync_parent_directory(_: &Path) -> Result<(), AgdError> {
1750 Ok(())
1752}
1753
1754fn after_headline_line(source: &str, content_end: usize) -> usize {
1755 if source.as_bytes().get(content_end) == Some(&b'\r')
1756 && source.as_bytes().get(content_end + 1) == Some(&b'\n')
1757 {
1758 content_end + 2
1759 } else if source.as_bytes().get(content_end) == Some(&b'\n') {
1760 content_end + 1
1761 } else {
1762 content_end
1763 }
1764}
1765
1766fn line_ending(source: &str) -> &'static str {
1767 if source.contains("\r\n") {
1768 "\r\n"
1769 } else {
1770 "\n"
1771 }
1772}
1773
1774fn leading_indent(source: &str, offset: usize) -> &str {
1775 let end = source[offset..]
1776 .find(['\r', '\n'])
1777 .map_or(source.len(), |relative| offset + relative);
1778 let line = &source[offset..end];
1779 let bytes = line
1780 .bytes()
1781 .take_while(|byte| matches!(byte, b' ' | b'\t'))
1782 .count();
1783 &line[..bytes]
1784}
1785
1786fn content_hash(source: &str) -> String {
1787 Sha256::digest(source.as_bytes())
1788 .iter()
1789 .map(|byte| format!("{byte:02x}"))
1790 .collect()
1791}
1792
1793fn unified_diff(path: &Path, before: &str, after: &str) -> String {
1794 TextDiff::from_lines(before, after)
1795 .unified_diff()
1796 .header(&path.display().to_string(), &path.display().to_string())
1797 .to_string()
1798}
1799
1800fn journal_id() -> String {
1801 format!(
1802 "{}-{}",
1803 Local::now().format("%Y%m%dT%H%M%S%.9f"),
1804 std::process::id()
1805 )
1806}
1807
1808#[cfg(test)]
1809mod tests {
1810 use super::*;
1811
1812 fn settings(root: &Path) -> Settings {
1813 Settings {
1814 roots: vec![root.to_string_lossy().into_owned()],
1815 agenda_files: vec!["**/*.org".to_string()],
1816 ..Settings::default()
1817 }
1818 }
1819
1820 fn context() -> WriteContext {
1821 WriteContext {
1822 actor: "test".to_string(),
1823 project: "fixture".to_string(),
1824 now: None,
1825 }
1826 }
1827
1828 #[test]
1829 fn capture_journal_records_the_new_item_ref() {
1830 let temp = tempfile::tempdir().unwrap();
1831 let mut settings = settings(temp.path());
1832 settings.captures[0].file =
1833 Some(temp.path().join("inbox.org").to_string_lossy().into_owned());
1834 let engine = WriteEngine::new(settings, temp.path().join("journal.jsonl")).unwrap();
1835 let draft = CaptureDraft {
1836 title: "New".to_string(),
1837 ..CaptureDraft::default()
1838 };
1839 let mutation = engine
1840 .capture("t", &draft, "", &BTreeMap::new(), false, &context())
1841 .unwrap();
1842 let record = engine
1843 .journal()
1844 .unwrap()
1845 .into_iter()
1846 .find(|record| record.id == mutation.journal_id)
1847 .unwrap();
1848 assert_eq!(
1849 record.item_ref.as_deref(),
1850 Some(mutation.item.item_ref.as_str())
1851 );
1852 assert_eq!(
1853 Path::new(&record.file),
1854 fs::canonicalize(temp.path().join("inbox.org")).unwrap()
1855 );
1856 }
1857
1858 #[test]
1859 fn inbox_gate_redirects_only_agent_captures_and_drops_their_planning() {
1860 let temp = tempfile::tempdir().unwrap();
1861 let mut settings = settings(temp.path());
1862 settings.agent_capture_to = "inbox".to_string();
1863 settings.captures.push(CaptureTemplate {
1864 key: "w".to_string(),
1865 file: Some("work.org".to_string()),
1866 heading: None,
1867 template: "* TODO %?".to_string(),
1868 ..CaptureTemplate::default()
1869 });
1870 let engine = WriteEngine::new(settings, temp.path().join("journal.jsonl")).unwrap();
1871 let draft = CaptureDraft {
1872 title: "Planned".to_string(),
1873 scheduled: Some(PlanningTimestamp {
1874 date: "2026-10-15".to_string(),
1875 time: None,
1876 end_time: None,
1877 repeater: None,
1878 warning: None,
1879 raw: None,
1880 }),
1881 deadline: None,
1882 tags: Vec::new(),
1883 priority: None,
1884 properties: BTreeMap::new(),
1885 };
1886 let agent = WriteContext {
1887 actor: "mcp:claude-code".to_string(),
1888 project: "fixture".to_string(),
1889 now: None,
1890 };
1891 let agent_capture = engine
1892 .capture("w", &draft, "", &BTreeMap::new(), true, &agent)
1893 .unwrap();
1894 assert!(agent_capture.item.file.ends_with("inbox.org"));
1895 assert!(!agent_capture.diff.contains("SCHEDULED"));
1896
1897 let human_capture = engine
1898 .capture("w", &draft, "", &BTreeMap::new(), true, &context())
1899 .unwrap();
1900 assert!(human_capture.item.file.ends_with("work.org"));
1901 assert!(human_capture.diff.contains("SCHEDULED: <2026-10-15 Thu>"));
1902 }
1903
1904 #[test]
1905 fn add_on_write_inserts_an_id_only_when_missing() {
1906 let temp = tempfile::tempdir().unwrap();
1907 let path = temp.path().join("tasks.org");
1908 fs::write(
1909 &path,
1910 "* TODO New\n* TODO Known\n:PROPERTIES:\n:ID: kept\n:END:\n",
1911 )
1912 .unwrap();
1913 let mut settings = settings(temp.path());
1914 settings.ids = IdPolicy::AddOnWrite;
1915 let refs: Vec<String> = WorkspaceIndex::load(settings.clone())
1916 .unwrap()
1917 .items()
1918 .map(|item| item.item_ref.clone())
1919 .collect();
1920 let engine = WriteEngine::new(settings, temp.path().join("journal.jsonl")).unwrap();
1921 let done = EditIntent {
1922 state: Change::Set("DONE".to_string()),
1923 ..EditIntent::default()
1924 };
1925 let added = engine.edit(&refs[0], &done, false, &context()).unwrap();
1926 let id = added.item.id.clone().unwrap();
1927 assert_eq!(id.len(), 36);
1928 assert_eq!(added.item.item_ref, id);
1929 engine.edit("kept", &done, false, &context()).unwrap();
1930 let written = fs::read_to_string(&path).unwrap();
1931 assert_eq!(written.matches(":ID:").count(), 2);
1932 assert!(
1933 written.contains(&format!(":ID: {id}"))
1934 || written.contains(&format!(":ID: {id}"))
1935 );
1936 }
1937
1938 #[test]
1939 fn undo_survives_unrelated_edits_and_refuses_overlapping_ones() {
1940 let temp = tempfile::tempdir().unwrap();
1941 let path = temp.path().join("tasks.org");
1942 fs::write(&path, "* TODO First\n* TODO Second\nbody\n").unwrap();
1943 let settings = settings(temp.path());
1944 let refs: Vec<String> = WorkspaceIndex::load(settings.clone())
1945 .unwrap()
1946 .items()
1947 .map(|item| item.item_ref.clone())
1948 .collect();
1949 let engine = WriteEngine::new(settings, temp.path().join("journal.jsonl")).unwrap();
1950 let done = EditIntent {
1951 state: Change::Set("DONE".to_string()),
1952 ..EditIntent::default()
1953 };
1954
1955 let second = engine.edit(&refs[1], &done, false, &context()).unwrap();
1956 let edited = fs::read_to_string(&path).unwrap();
1957 fs::write(
1958 &path,
1959 format!("#+TITLE: Added by an editor\n{edited}* TODO Third\n"),
1960 )
1961 .unwrap();
1962 engine.undo(Some(&second.journal_id), &context()).unwrap();
1963 assert_eq!(
1964 fs::read_to_string(&path).unwrap(),
1965 "#+TITLE: Added by an editor\n* TODO First\n* TODO Second\nbody\n* TODO Third\n"
1966 );
1967
1968 let first = engine.edit(&refs[0], &done, false, &context()).unwrap();
1969 let edited = fs::read_to_string(&path).unwrap();
1970 fs::write(
1971 &path,
1972 edited.replace("* DONE First", "* DONE First, renamed"),
1973 )
1974 .unwrap();
1975 let error = engine
1976 .undo(Some(&first.journal_id), &context())
1977 .unwrap_err();
1978 assert_eq!(error.code(), "undo_conflict");
1979 assert!(
1980 fs::read_to_string(&path)
1981 .unwrap()
1982 .contains("First, renamed")
1983 );
1984 }
1985
1986 #[test]
1987 fn patch_and_inverse_preserve_every_untouched_byte() {
1988 let source = "α before\r\n* TODO Keep spacing :x:\r\nbody\r\n";
1989 let start = source.find("TODO").unwrap();
1990 let (after, stored) = apply_patches(
1991 source,
1992 vec![Patch {
1993 span: ByteSpan {
1994 start,
1995 end: start + 4,
1996 },
1997 replacement: "DONE".to_string(),
1998 }],
1999 )
2000 .unwrap();
2001 assert_eq!(after, "α before\r\n* DONE Keep spacing :x:\r\nbody\r\n");
2002 assert_eq!(
2003 apply_patches(&after, inverse_patches(&stored)).unwrap().0,
2004 source
2005 );
2006 }
2007
2008 #[test]
2009 fn edit_revalidates_writes_journal_and_undo_keeps_later_unrelated_text() {
2010 let temp = tempfile::tempdir().unwrap();
2011 let path = temp.path().join("tasks.org");
2012 let original = "#+TODO: TODO | DONE\r\n* TODO Exact spacing :work:\r\nbody\r\n";
2013 fs::write(&path, original).unwrap();
2014 let settings = settings(temp.path());
2015 let item_ref = WorkspaceIndex::load(settings.clone())
2016 .unwrap()
2017 .items()
2018 .next()
2019 .unwrap()
2020 .item_ref
2021 .clone();
2022 let engine = WriteEngine::new(settings, temp.path().join("journal.jsonl")).unwrap();
2023 let mutation = engine
2024 .edit(
2025 &item_ref,
2026 &EditIntent {
2027 scheduled: Change::Set(PlanningTimestamp {
2028 date: "2026-10-15".to_string(),
2029 time: None,
2030 end_time: None,
2031 repeater: None,
2032 warning: None,
2033 raw: None,
2034 }),
2035 ..EditIntent::default()
2036 },
2037 false,
2038 &context(),
2039 )
2040 .unwrap();
2041 let written = fs::read_to_string(&path).unwrap();
2042 assert!(written.starts_with("#+TODO: TODO | DONE\r\n* TODO Exact spacing :work:\r\n"));
2043 assert!(written.contains("SCHEDULED: <2026-10-15 Thu>\r\nbody\r\n"));
2044 engine.undo(Some(&mutation.journal_id), &context()).unwrap();
2045 assert_eq!(fs::read_to_string(&path).unwrap(), original);
2046
2047 let mutation = engine
2048 .edit(
2049 &item_ref,
2050 &EditIntent {
2051 scheduled: Change::Set(PlanningTimestamp {
2052 date: "2026-10-16".to_string(),
2053 time: None,
2054 end_time: None,
2055 repeater: None,
2056 warning: None,
2057 raw: None,
2058 }),
2059 ..EditIntent::default()
2060 },
2061 false,
2062 &context(),
2063 )
2064 .unwrap();
2065 fs::write(
2066 &path,
2067 format!("{}external\r\n", fs::read_to_string(&path).unwrap()),
2068 )
2069 .unwrap();
2070 engine.undo(Some(&mutation.journal_id), &context()).unwrap();
2071 assert_eq!(
2072 fs::read_to_string(&path).unwrap(),
2073 format!("{original}external\r\n")
2074 );
2075 }
2076
2077 #[test]
2078 fn refuses_editor_locks() {
2079 let temp = tempfile::tempdir().unwrap();
2080 let path = temp.path().join("tasks.org");
2081 fs::write(&path, "* TODO Task\n").unwrap();
2082 let settings = settings(temp.path());
2083 let item_ref = WorkspaceIndex::load(settings.clone())
2084 .unwrap()
2085 .items()
2086 .next()
2087 .unwrap()
2088 .item_ref
2089 .clone();
2090 let engine = WriteEngine::new(settings.clone(), temp.path().join("journal.jsonl")).unwrap();
2091 fs::write(temp.path().join(".#tasks.org"), "user@host.123").unwrap();
2092 let error = engine
2093 .edit(
2094 &item_ref,
2095 &EditIntent {
2096 state: Change::Set("DONE".to_string()),
2097 ..EditIntent::default()
2098 },
2099 false,
2100 &context(),
2101 )
2102 .unwrap_err();
2103 assert_eq!(error.code(), "editor_dirty");
2104 }
2105
2106 #[test]
2107 fn capture_preview_matches_committed_bytes() {
2108 let temp = tempfile::tempdir().unwrap();
2109 let settings = settings(temp.path());
2110 let engine = WriteEngine::new(settings, temp.path().join("journal.jsonl")).unwrap();
2111 let draft = CaptureDraft {
2112 title: "Captured".to_string(),
2113 scheduled: None,
2114 deadline: None,
2115 tags: Vec::new(),
2116 priority: None,
2117 properties: BTreeMap::new(),
2118 };
2119 let preview = engine
2120 .capture("t", &draft, "", &BTreeMap::new(), true, &context())
2121 .unwrap();
2122 let committed = engine
2123 .capture("t", &draft, "", &BTreeMap::new(), false, &context())
2124 .unwrap();
2125 assert_eq!(preview.diff, committed.diff);
2126 assert!(
2127 fs::read_to_string(temp.path().join("inbox.org"))
2128 .unwrap()
2129 .contains("* TODO Captured\n")
2130 );
2131 }
2132
2133 #[test]
2134 fn completing_repeater_advances_date_and_records_last_repeat() {
2135 let temp = tempfile::tempdir().unwrap();
2136 let path = temp.path().join("repeat.org");
2137 fs::write(
2138 &path,
2139 "#+TODO: TODO | DONE\n* TODO Water plants\nSCHEDULED: <2020-01-01 Wed .+1d>\n",
2140 )
2141 .unwrap();
2142 let settings = settings(temp.path());
2143 let item_ref = WorkspaceIndex::load(settings.clone())
2144 .unwrap()
2145 .items()
2146 .next()
2147 .unwrap()
2148 .item_ref
2149 .clone();
2150 let engine = WriteEngine::new(settings, temp.path().join("journal.jsonl")).unwrap();
2151 engine
2152 .edit(
2153 &item_ref,
2154 &EditIntent {
2155 state: Change::Set("DONE".to_string()),
2156 ..EditIntent::default()
2157 },
2158 false,
2159 &context(),
2160 )
2161 .unwrap();
2162 let written = fs::read_to_string(path).unwrap();
2163 let tomorrow = (Local::now().date_naive() + Duration::days(1))
2164 .format("%Y-%m-%d")
2165 .to_string();
2166 assert!(written.contains("* TODO Water plants\n"));
2167 assert!(written.contains(&format!("SCHEDULED: <{tomorrow}")));
2168 assert!(written.contains(":LAST_REPEAT: ["));
2169 }
2170
2171 #[test]
2172 fn indented_planning_edit_and_undo_are_byte_exact() {
2173 let temp = tempfile::tempdir().unwrap();
2174 let path = temp.path().join("agenda.org");
2175 let original = concat!(
2176 "* Work Tasks\n",
2177 "** TODO [#A] Complete quarterly report :work:\n",
2178 " SCHEDULED: <2026-10-14 Wed> DEADLINE: <2026-10-19 Mon>\n",
2179 " :PROPERTIES:\n",
2180 " :ID: work-task-001\n",
2181 " :END:\n",
2182 " Need to finish Q3 report for the board meeting.\n\n",
2183 "** TODO [#B] Review pull requests :work:\n",
2184 );
2185 fs::write(&path, original).unwrap();
2186 let settings = settings(temp.path());
2187 let engine = WriteEngine::new(settings, temp.path().join("journal-real.jsonl")).unwrap();
2188 let mutation = engine
2189 .edit(
2190 "work-task-001",
2191 &EditIntent {
2192 scheduled: Change::Set(PlanningTimestamp {
2193 date: "2026-10-30".to_string(),
2194 time: None,
2195 end_time: None,
2196 repeater: None,
2197 warning: None,
2198 raw: None,
2199 }),
2200 ..EditIntent::default()
2201 },
2202 false,
2203 &context(),
2204 )
2205 .unwrap();
2206 let written = fs::read_to_string(&path).unwrap();
2207 assert!(written.contains(" SCHEDULED: <2026-10-30 Fri> DEADLINE:"));
2208 assert!(written.contains(" Need to finish Q3 report for the board meeting."));
2209 engine.undo(Some(&mutation.journal_id), &context()).unwrap();
2210 assert_eq!(fs::read_to_string(path).unwrap(), original);
2211 }
2212
2213 #[test]
2214 fn cross_file_refile_and_transaction_undo_are_exact() {
2215 let temp = tempfile::tempdir().unwrap();
2216 let source_path = temp.path().join("source.org");
2217 let target_path = temp.path().join("target.org");
2218 let source = "* Source\n** TODO Move me\n:PROPERTIES:\n:ID: move-id\n:END:\nbody\n* Keep\n";
2219 let target = "* Destination\n:PROPERTIES:\n:ID: destination-id\n:END:\n";
2220 fs::write(&source_path, source).unwrap();
2221 fs::write(&target_path, target).unwrap();
2222 let settings = settings(temp.path());
2223 let engine = WriteEngine::new(settings, temp.path().join("journal-refile.jsonl")).unwrap();
2224 let result = engine
2225 .refile("move-id", "destination-id", false, &context())
2226 .unwrap();
2227 assert!(
2228 !fs::read_to_string(&source_path)
2229 .unwrap()
2230 .contains("Move me")
2231 );
2232 assert!(
2233 fs::read_to_string(&target_path)
2234 .unwrap()
2235 .contains("** TODO Move me\n")
2236 );
2237 engine
2238 .undo(Some(&result.journal_ids[0]), &context())
2239 .unwrap();
2240 assert_eq!(fs::read_to_string(source_path).unwrap(), source);
2241 assert_eq!(fs::read_to_string(target_path).unwrap(), target);
2242 }
2243}