1use 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 pub checklist: BTreeMap<usize, bool>,
52}
53
54impl EditIntent {
55 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
86fn 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#[derive(Clone, Debug, Eq, PartialEq)]
100pub struct ItemEdit {
101 pub item_ref: String,
102 pub intent: EditIntent,
103}
104
105#[derive(Clone, Debug)]
110pub struct BatchMutation {
111 pub transaction: String,
112 pub mutations: Vec<Mutation>,
113}
114
115struct EditedItem {
118 item: Item,
119 repeated: Option<Repeat>,
120}
121
122impl EditedItem {
123 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
154struct PreparedBatch {
157 files: Vec<PreparedFile>,
158 file_refs: Vec<String>,
160 items: Vec<Option<EditedItem>>,
161 file_of: Vec<usize>,
162}
163
164impl PreparedBatch {
165 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 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 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 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 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 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
396type BatchTargets<'a> = BTreeMap<PathBuf, Vec<(usize, &'a HeadlineSpan)>>;
398
399fn 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
445fn 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 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 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}