Skip to main content

agd_core/write/
edit.rs

1//! Edits: `WriteEngine::edit` for one item and `WriteEngine::edit_batch` for
2//! several as one journal transaction, with the request types (`EditIntent`,
3//! `Change`, `ItemEdit`) and the batch result (`BatchMutation`).
4//!
5//! A batch resolves and computes every edit first; if any is refused, nothing
6//! is written and the error names that target. Each edit is computed again
7//! under the write lock, and a target that moved to another file meanwhile is
8//! refused (`ref_conflict`). The Org text an edit writes is `org_edit`'s.
9
10use std::collections::{BTreeMap, BTreeSet};
11use std::path::PathBuf;
12
13use chrono::NaiveDateTime;
14
15use crate::{
16    AgdError, ErrorCode, HeadlineSpan, IndexedDocument, Item, Mutation, PlanningTimestamp, Repeat,
17    WorkspaceIndex,
18};
19
20use super::engine::{
21    DRY_RUN_JOURNAL_ID, JournalImage, PreparedFile, WriteContext, WriteEngine, mutation,
22    parse_text, parse_updated_item,
23};
24use super::file::refuse_emacs_lock;
25use super::journal::{JournalOperation, JournalRecord};
26use super::org_edit::build_edit_patches;
27use super::patch::{Patch, apply_patches};
28use super::validation::{validate_id_change, validate_intent, validate_intent_for_file};
29
30#[derive(Clone, Debug, Default, Eq, PartialEq)]
31pub enum Change<T> {
32    #[default]
33    Keep,
34    Set(T),
35    Clear,
36}
37
38#[derive(Clone, Debug, Default, Eq, PartialEq)]
39pub struct EditIntent {
40    pub state: Change<String>,
41    pub title: Change<String>,
42    pub priority: Change<String>,
43    pub tags_add: BTreeSet<String>,
44    pub tags_remove: BTreeSet<String>,
45    pub scheduled: Change<PlanningTimestamp>,
46    pub deadline: Change<PlanningTimestamp>,
47    pub properties: BTreeMap<String, Option<String>>,
48    pub note: Option<String>,
49    /// Checklist boxes to set, by their place in the entry's checklist
50    /// (0 first): true checks, false unchecks.
51    pub checklist: BTreeMap<usize, bool>,
52}
53
54impl EditIntent {
55    /// What the journal calls this edit: the one kind of change it makes,
56    /// with or without a note, else `Edit`.
57    pub fn operation(&self) -> JournalOperation {
58        let tags = !self.tags_add.is_empty() || !self.tags_remove.is_empty();
59        let changes = [
60            (!matches!(self.state, Change::Keep), JournalOperation::State),
61            (
62                !matches!(self.scheduled, Change::Keep),
63                JournalOperation::Schedule,
64            ),
65            (
66                !matches!(self.deadline, Change::Keep),
67                JournalOperation::Deadline,
68            ),
69            (
70                !matches!(self.priority, Change::Keep),
71                JournalOperation::Priority,
72            ),
73            (tags, JournalOperation::Tags),
74            (!matches!(self.title, Change::Keep), JournalOperation::Edit),
75            (!self.properties.is_empty(), JournalOperation::Edit),
76            (!self.checklist.is_empty(), JournalOperation::Checklist),
77        ];
78        let mut made = changes.iter().filter(|(changed, _)| *changed);
79        match (made.next(), made.next()) {
80            (Some((_, operation)), None) => *operation,
81            _ => JournalOperation::Edit,
82        }
83    }
84}
85
86/// A batch's operation: the one its edits share, else `Edit`.
87fn batch_operation(edits: &[ItemEdit]) -> JournalOperation {
88    let first = edits
89        .first()
90        .map_or(JournalOperation::Edit, |edit| edit.intent.operation());
91    if edits.iter().all(|edit| edit.intent.operation() == first) {
92        first
93    } else {
94        JournalOperation::Edit
95    }
96}
97
98/// One item and the edit to apply to it, for [`WriteEngine::edit_batch`].
99#[derive(Clone, Debug, Eq, PartialEq)]
100pub struct ItemEdit {
101    pub item_ref: String,
102    pub intent: EditIntent,
103}
104
105/// What [`WriteEngine::edit_batch`] wrote: one mutation per edit, in the
106/// order given, all under one journal transaction; undoing any of its
107/// journal ids, or the transaction id, reverts them all. A dry run's
108/// transaction and journal ids are `dry-run`.
109#[derive(Clone, Debug)]
110pub struct BatchMutation {
111    pub transaction: String,
112    pub mutations: Vec<Mutation>,
113}
114
115/// An edited item as it reads after the edit, and whether marking it done
116/// repeated it.
117struct EditedItem {
118    item: Item,
119    repeated: Option<Repeat>,
120}
121
122impl EditedItem {
123    /// A done state requested for an item that is still not done after
124    /// the edit means a repeater moved its dates on.
125    fn new(item: Item, intent: &EditIntent, document: &IndexedDocument) -> Self {
126        let repeated = match &intent.state {
127            Change::Set(state) if document.todo_done().contains(state) && !item.done => {
128                let repeating = |timestamp: &Option<PlanningTimestamp>| {
129                    timestamp
130                        .as_ref()
131                        .filter(|timestamp| timestamp.repeater.is_some())
132                        .map(|timestamp| timestamp.date.clone())
133                };
134                repeating(&item.planning.scheduled)
135                    .or_else(|| repeating(&item.planning.deadline))
136                    .map(|next_date| Repeat {
137                        done_state: state.clone(),
138                        next_date,
139                    })
140            }
141            _ => None,
142        };
143        Self { item, repeated }
144    }
145
146    fn into_mutation(self, diff: String, journal_id: &str) -> Mutation {
147        Mutation {
148            repeated: self.repeated,
149            ..mutation(self.item, diff, journal_id)
150        }
151    }
152}
153
154/// A batch's file images, sorted by path, and each edit's updated item and
155/// file, in the order the edits were given.
156struct PreparedBatch {
157    files: Vec<PreparedFile>,
158    /// The first edited item's ref in each file, for its journal record.
159    file_refs: Vec<String>,
160    items: Vec<Option<EditedItem>>,
161    file_of: Vec<usize>,
162}
163
164impl PreparedBatch {
165    /// The batch's result; `records` are the committed files' records, in
166    /// file order, or empty for a dry run.
167    fn result(self, transaction: String, records: &[JournalRecord]) -> BatchMutation {
168        let mutations = self
169            .items
170            .into_iter()
171            .zip(self.file_of)
172            .map(|(item, file)| {
173                let journal_id = records
174                    .get(file)
175                    .map_or(transaction.as_str(), |record| record.id.as_str());
176                item.expect("every edit has an item")
177                    .into_mutation(self.files[file].diff.clone(), journal_id)
178            })
179            .collect();
180        BatchMutation {
181            transaction,
182            mutations,
183        }
184    }
185}
186
187impl WriteEngine {
188    /// Applies one edit. A dry run computes the same diff from the current
189    /// bytes and touches nothing: no lock, journal entry or file.
190    pub fn edit(
191        &self,
192        item_ref: &str,
193        intent: &EditIntent,
194        dry_run: bool,
195        context: &WriteContext,
196    ) -> Result<Mutation, AgdError> {
197        validate_intent(intent)?;
198        let now = context.org_now();
199        let initial = WorkspaceIndex::load(self.settings.clone())?;
200        let (preview, item) = self.prepare_edit(&initial, item_ref, intent, now)?;
201        let path = preview.path.clone();
202        self.roots.resolve_write(&path, false)?;
203        refuse_emacs_lock(&path)?;
204        if dry_run {
205            return Ok(item.into_mutation(preview.diff, DRY_RUN_JOURNAL_ID));
206        }
207
208        let _locks = self.lock_paths(std::slice::from_ref(&path))?;
209        let fresh = WorkspaceIndex::load(self.settings.clone())?;
210        let (prepared, item) = self.prepare_edit(&fresh, item_ref, intent, now)?;
211        if prepared.path != path {
212            return Err(AgdError::new(
213                ErrorCode::RefConflict,
214                "item resolved to a different file while acquiring the write lock",
215            ));
216        }
217        let record = self.commit_image(
218            prepared.journal_image(Some(item_ref)),
219            intent.operation(),
220            context,
221        )?;
222        Ok(item.into_mutation(prepared.diff, &record.id))
223    }
224
225    /// The edit's patches against the item's file as `index` read it.
226    fn prepare_edit(
227        &self,
228        index: &WorkspaceIndex,
229        item_ref: &str,
230        intent: &EditIntent,
231        now: NaiveDateTime,
232    ) -> Result<(PreparedFile, EditedItem), AgdError> {
233        let (document, headline) = index.resolve(item_ref)?;
234        let path = document.path().to_path_buf();
235        validate_intent_for_file(intent, document)?;
236        validate_id_change(intent, headline, index)?;
237        let patches = build_edit_patches(document, headline, intent, &self.settings, now)?;
238        let (post_image, stored) = apply_patches(document.source(), patches)?;
239        if post_image == document.source() {
240            return Err(AgdError::new(
241                ErrorCode::InvalidEdit,
242                "edit does not change the item",
243            ));
244        }
245        let item = parse_updated_item(&path, &post_image, headline.item.line, &self.settings)?;
246        let prepared = PreparedFile::new(path, document.source().to_string(), post_image, stored);
247        Ok((prepared, EditedItem::new(item, intent, document)))
248    }
249
250    /// Applies several edits as one transaction: every target is resolved
251    /// and its edit computed first, and if any is refused nothing is
252    /// written and the error names that target. Otherwise each file is
253    /// written once and journaled under one transaction, so one undo
254    /// reverts them all. A dry run touches nothing.
255    pub fn edit_batch(
256        &self,
257        edits: &[ItemEdit],
258        dry_run: bool,
259        context: &WriteContext,
260    ) -> Result<BatchMutation, AgdError> {
261        if edits.is_empty() {
262            return Err(AgdError::new(ErrorCode::InvalidEdit, "nothing to edit"));
263        }
264        for edit in edits {
265            validate_intent(&edit.intent).map_err(|error| error.with_context(&edit.item_ref))?;
266        }
267        let now = context.org_now();
268        let initial = WorkspaceIndex::load(self.settings.clone())?;
269        let preview = self.prepare_batch(&initial, edits, now)?;
270        let paths: Vec<PathBuf> = preview.files.iter().map(|file| file.path.clone()).collect();
271        for path in &paths {
272            self.roots.resolve_write(path, false)?;
273            refuse_emacs_lock(path)?;
274        }
275        if dry_run {
276            return Ok(preview.result(DRY_RUN_JOURNAL_ID.to_string(), &[]));
277        }
278
279        let _locks = self.lock_paths(&paths)?;
280        let fresh = WorkspaceIndex::load(self.settings.clone())?;
281        let prepared = self.prepare_batch(&fresh, edits, now)?;
282        let fresh_paths: Vec<PathBuf> = prepared
283            .files
284            .iter()
285            .map(|file| file.path.clone())
286            .collect();
287        if fresh_paths != paths {
288            return Err(AgdError::new(
289                ErrorCode::RefConflict,
290                "targets resolved to different files while acquiring the write locks",
291            ));
292        }
293        let images: Vec<JournalImage<'_>> = prepared
294            .files
295            .iter()
296            .zip(&prepared.file_refs)
297            .map(|(file, item_ref)| file.journal_image(Some(item_ref)))
298            .collect();
299        let records = self.commit_transaction(&images, batch_operation(edits), context)?;
300        let transaction = records
301            .first()
302            .and_then(|record| record.transaction.clone())
303            .unwrap_or_default();
304        Ok(prepared.result(transaction, &records))
305    }
306
307    /// Every edit's patches, grouped per file, against `index`.
308    fn prepare_batch(
309        &self,
310        index: &WorkspaceIndex,
311        edits: &[ItemEdit],
312        now: NaiveDateTime,
313    ) -> Result<PreparedBatch, AgdError> {
314        let mut batch = PreparedBatch {
315            files: Vec::new(),
316            file_refs: Vec::new(),
317            items: (0..edits.len()).map(|_| None).collect(),
318            file_of: vec![0; edits.len()],
319        };
320        for (path, targets) in resolve_batch_targets(index, edits)? {
321            let document = index.document(&path).expect("a resolved item's document");
322            let (file, items) = self.prepare_batch_file(document, &targets, edits, now)?;
323            for (position, item) in items {
324                batch.items[position] = Some(item);
325                batch.file_of[position] = batch.files.len();
326            }
327            batch.file_refs.push(edits[targets[0].0].item_ref.clone());
328            batch.files.push(file);
329        }
330        Ok(batch)
331    }
332
333    /// One file's edits applied together: each target's own patches, all
334    /// applied at once, and each edited item found again in the result.
335    fn prepare_batch_file(
336        &self,
337        document: &IndexedDocument,
338        targets: &[(usize, &HeadlineSpan)],
339        edits: &[ItemEdit],
340        now: NaiveDateTime,
341    ) -> Result<(PreparedFile, Vec<(usize, EditedItem)>), AgdError> {
342        let mut patches = Vec::new();
343        for (position, headline) in targets {
344            let label = |error: AgdError| error.with_context(&headline.item.title);
345            let intent = &edits[*position].intent;
346            let own = build_edit_patches(document, headline, intent, &self.settings, now)
347                .map_err(label)?;
348            let (alone, _) = apply_patches(document.source(), own.clone()).map_err(label)?;
349            if alone == document.source() {
350                return Err(label(AgdError::new(
351                    ErrorCode::InvalidEdit,
352                    "edit does not change the item",
353                )));
354            }
355            patches.extend(own);
356        }
357        let starts: Vec<(usize, usize)> = targets
358            .iter()
359            .map(|(position, headline)| {
360                (
361                    *position,
362                    post_image_offset(&patches, headline.line_span.start),
363                )
364            })
365            .collect();
366        let (post_image, stored) = apply_patches(document.source(), patches)?;
367        let updated = parse_text(document.path(), &post_image, &self.settings)?;
368        let mut items = Vec::with_capacity(starts.len());
369        for (position, start) in starts {
370            let line = post_image[..start]
371                .bytes()
372                .filter(|byte| *byte == b'\n')
373                .count()
374                + 1;
375            let item = updated
376                .headline_at_line(line)
377                .map(|headline| headline.item.clone())
378                .ok_or_else(|| {
379                    AgdError::new(ErrorCode::ParseFailed, "edit produced no target headline")
380                })?;
381            items.push((
382                position,
383                EditedItem::new(item, &edits[position].intent, document),
384            ));
385        }
386        let file = PreparedFile::new(
387            document.path().to_path_buf(),
388            document.source().to_string(),
389            post_image,
390            stored,
391        );
392        Ok((file, items))
393    }
394}
395
396/// A batch's targets by file: (position of the edit, its heading).
397type BatchTargets<'a> = BTreeMap<PathBuf, Vec<(usize, &'a HeadlineSpan)>>;
398
399/// Resolves every edit's target and checks them against each other and
400/// their files, grouped by file: (edit position, heading) pairs. A refusal
401/// names the target.
402fn resolve_batch_targets<'a>(
403    index: &'a WorkspaceIndex,
404    edits: &[ItemEdit],
405) -> Result<BatchTargets<'a>, AgdError> {
406    let mut by_file = BatchTargets::new();
407    let mut new_ids: BTreeSet<&str> = BTreeSet::new();
408    for (position, edit) in edits.iter().enumerate() {
409        let (document, headline) = index
410            .resolve(&edit.item_ref)
411            .map_err(|error| error.with_context(&edit.item_ref))?;
412        let label = |error: AgdError| error.with_context(&headline.item.title);
413        validate_intent_for_file(&edit.intent, document).map_err(label)?;
414        validate_id_change(&edit.intent, headline, index).map_err(label)?;
415        let new_id = edit
416            .intent
417            .properties
418            .iter()
419            .find(|(key, _)| key.eq_ignore_ascii_case("ID"))
420            .and_then(|(_, value)| value.as_deref());
421        if let Some(id) = new_id
422            && headline.item.id.is_none()
423            && !new_ids.insert(id)
424        {
425            return Err(label(AgdError::new(
426                ErrorCode::IdConflict,
427                format!("ID '{id}' is given to two of the targets"),
428            )));
429        }
430        let targets = by_file.entry(document.path().to_path_buf()).or_default();
431        if targets
432            .iter()
433            .any(|(_, other)| other.line_span.start == headline.line_span.start)
434        {
435            return Err(label(AgdError::new(
436                ErrorCode::InvalidBatch,
437                "the same item is listed twice",
438            )));
439        }
440        targets.push((position, headline));
441    }
442    Ok(by_file)
443}
444
445/// Where `offset` in the pre-image lands after `patches`: shifted by every
446/// patch that ends at or before it.
447fn post_image_offset(patches: &[Patch], offset: usize) -> usize {
448    patches
449        .iter()
450        .filter(|patch| patch.span.end <= offset)
451        .fold(offset, |shifted, patch| {
452            shifted + patch.replacement.len() - (patch.span.end - patch.span.start)
453        })
454}
455
456#[cfg(test)]
457mod tests {
458    use std::fs;
459
460    use super::*;
461    use crate::write::test_support::{context, refs_by_title, scheduled, settings, state_change};
462
463    #[test]
464    fn an_edit_is_journaled_as_the_one_kind_of_change_it_makes() {
465        let state = EditIntent {
466            state: Change::Set("DONE".to_string()),
467            note: Some("shipped".to_string()),
468            ..EditIntent::default()
469        };
470        assert_eq!(state.operation(), JournalOperation::State);
471        assert_eq!(
472            scheduled("2026-10-19").operation(),
473            JournalOperation::Schedule
474        );
475        let cleared = EditIntent {
476            deadline: Change::Clear,
477            ..EditIntent::default()
478        };
479        assert_eq!(cleared.operation(), JournalOperation::Deadline);
480        let tags = EditIntent {
481            tags_add: BTreeSet::from(["work".to_string()]),
482            ..EditIntent::default()
483        };
484        assert_eq!(tags.operation(), JournalOperation::Tags);
485        let both = EditIntent {
486            deadline: Change::Clear,
487            ..scheduled("2026-10-19")
488        };
489        assert_eq!(both.operation(), JournalOperation::Edit);
490        assert_eq!(JournalOperation::State.label(), "state change");
491    }
492
493    #[test]
494    fn a_batch_writes_every_file_or_none_and_one_undo_reverts_it() {
495        let temp = tempfile::tempdir().unwrap();
496        let work = temp.path().join("work.org");
497        let home = temp.path().join("home.org");
498        let work_bytes = "* TODO Pay rent\n* TODO Call bank\nbody\n* TODO Untouched\n";
499        let home_bytes = "#+TODO: OPEN | CLOSED\n* OPEN Water plants\n";
500        fs::write(&work, work_bytes).unwrap();
501        fs::write(&home, home_bytes).unwrap();
502        let settings = settings(temp.path());
503        let refs = refs_by_title(&settings);
504        let engine = WriteEngine::new(settings, temp.path().join("journal.jsonl")).unwrap();
505        let edit = |title: &str, intent: EditIntent| ItemEdit {
506            item_ref: refs[title].clone(),
507            intent,
508        };
509
510        // DONE is not a keyword of home.org: nothing is written anywhere.
511        let done = state_change("DONE");
512        let error = engine
513            .edit_batch(
514                &[edit("Pay rent", done.clone()), edit("Water plants", done)],
515                false,
516                &context(),
517            )
518            .unwrap_err();
519        assert_eq!(error.code().as_str(), "invalid_todo");
520        assert!(error.to_string().starts_with("Water plants: "), "{error}");
521        assert_eq!(fs::read_to_string(&work).unwrap(), work_bytes);
522        assert_eq!(fs::read_to_string(&home).unwrap(), home_bytes);
523        assert!(engine.journal().unwrap().is_empty());
524
525        // An Emacs lock on one file refuses the whole batch too.
526        fs::write(temp.path().join(".#home.org"), "user@host.1").unwrap();
527        let batch = [
528            edit("Pay rent", scheduled("2026-10-19")),
529            edit("Call bank", scheduled("2026-10-20")),
530            edit("Water plants", scheduled("2026-10-21")),
531        ];
532        assert_eq!(
533            engine
534                .edit_batch(&batch, false, &context())
535                .unwrap_err()
536                .code()
537                .as_str(),
538            "editor_conflict"
539        );
540        assert_eq!(fs::read_to_string(&work).unwrap(), work_bytes);
541        fs::remove_file(temp.path().join(".#home.org")).unwrap();
542
543        let preview = engine.edit_batch(&batch, true, &context()).unwrap();
544        assert_eq!(preview.transaction, "dry-run");
545        assert_eq!(fs::read_to_string(&work).unwrap(), work_bytes);
546        let written = engine.edit_batch(&batch, false, &context()).unwrap();
547        assert_eq!(
548            fs::read_to_string(&work).unwrap(),
549            "* TODO Pay rent\nSCHEDULED: <2026-10-19 Mon>\n* TODO Call bank\nSCHEDULED: <2026-10-20 Tue>\nbody\n* TODO Untouched\n"
550        );
551        let lines: Vec<(String, usize)> = written
552            .mutations
553            .iter()
554            .map(|mutation| (mutation.item.title.clone(), mutation.item.line))
555            .collect();
556        assert_eq!(
557            lines,
558            [
559                ("Pay rent".to_string(), 1),
560                ("Call bank".to_string(), 3),
561                ("Water plants".to_string(), 2)
562            ]
563        );
564        assert_eq!(engine.journal().unwrap().len(), 2);
565        assert!(
566            engine
567                .journal()
568                .unwrap()
569                .iter()
570                .all(|record| record.operation == Some(JournalOperation::Schedule))
571        );
572
573        let undone = engine
574            .undo(&written.mutations[0].journal_id, &context())
575            .unwrap();
576        assert_eq!(undone.journal_id, written.transaction);
577        assert_eq!(fs::read_to_string(&work).unwrap(), work_bytes);
578        assert_eq!(fs::read_to_string(&home).unwrap(), home_bytes);
579    }
580}