Skip to main content

org_core/
write.rs

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/// A journaled replacement: `start..end` is the span of `after` in the
24/// post-image; `context_before`/`context_after` are the post-image bytes
25/// around it (the rest of its lines plus one line each way), used to find the
26/// span again if the file changed elsewhere before undo.
27#[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    /// Clock for timestamps written into Org text (CLOSED, LOGBOOK, %U);
65    /// None uses the local time. Tests and oracles pin it.
66    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    // MCP clients are journaled as "mcp:<client name>"; every other actor is a
75    // person using the CLI or TUI.
76    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        // Canonical like every indexed path, so the journal, refs and the
261        // index agree (on macOS /var is /private/var).
262        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(&current) == record.post_hash;
586            let (pre_image, _) = apply_patches(&current, relocate_inverse(&current, 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, &current, &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
950/// Offset just past the newline that ends the line containing `offset`.
951fn 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
960/// Inverse patches against `current`. An unchanged file uses the recorded
961/// spans; otherwise each patch must be found exactly once by its context and
962/// replaced bytes, so undo never touches text someone changed since.
963fn 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        // Same shape as Emacs org-id with the default uuid method.
1045        properties.insert("ID".to_string(), Some(uuid::Uuid::new_v4().to_string()));
1046    }
1047    // LOGBOOK entries in Org's org-log-note-headings format, newest first.
1048    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    // A keyword's own `!`/`@` marker on entering, or the previous one's on
1098    // leaving, decides whether the change is logged (org-todo-log-states).
1099    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        // org-auto-repeat-maybe: shift the dates, return to the head of the
1112        // previous keyword's sequence, drop CLOSED, log only per org-log-repeat.
1113        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
1250/// One `- heading` line in a LOGBOOK, with an optional note below it.
1251struct LogEntry {
1252    heading: String,
1253    note: Option<String>,
1254}
1255
1256impl LogEntry {
1257    /// A `@` (note) entry takes the edit's note text, if any; a `!` (time)
1258    /// entry leaves it for a separate "Note taken" entry.
1259    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
1269/// Inserts log entries where org-log-beginning would: at the top of the
1270/// entry's LOGBOOK drawer (created after planning and properties when
1271/// missing) with org-log-into-drawer, otherwise right after them.
1272fn 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
1318/// End of the `:LOGBOOK:` line within a section, if the entry has one.
1319fn 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
1330/// Org quotes the old timestamp of a reschedule as an inactive one.
1331fn inactive_timestamp(timestamp: &PlanningTimestamp) -> Result<String, AgdError> {
1332    let active = format_active_timestamp(timestamp)?;
1333    Ok(format!("[{}]", &active[1..active.len() - 1]))
1334}
1335
1336/// Setting a new SCHEDULED or DEADLINE date keeps the old repeater and
1337/// warning cookie unless the new value has its own, as org-schedule does.
1338fn 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
1353/// The first keyword of the sequence `state` belongs to; a repeating task
1354/// returns there when marked done (org-todo-kwd-alist head).
1355fn 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
1454/// Aligns tags to org-tags-column the way Emacs does whenever it rewrites a
1455/// headline: a negative column right-aligns them to end there, a positive
1456/// one starts them there, and a long title keeps a single space.
1457fn 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(&timestamp.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
1641// With agents.capture_to = "inbox", an agent capture always lands in the
1642// inbox template without planning, so it reaches the day only through triage.
1643fn 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    // std::fs cannot open directory handles on Windows without platform-specific flags.
1751    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}