1use std::collections::HashSet;
2use std::fs;
3use std::path::PathBuf;
4
5use orgize::export::{Container, Event, from_fn, from_fn_with_ctx};
6use orgize::{Org, ParseConfig};
7
8use crate::OrgModeError;
9use crate::org_mode::capture::ParsedTimestamp;
10use crate::org_mode::{ClearField, OrgMode, UpdateEntry, UpdateResult};
11
12#[derive(Debug, Default, Clone, PartialEq, Eq)]
13pub(crate) struct PlanningValues {
14 pub scheduled: Option<String>,
15 pub deadline: Option<String>,
16 pub closed: Option<String>,
17}
18
19pub(crate) fn render_planning_line(values: &PlanningValues) -> Option<String> {
20 let mut parts = Vec::new();
21 if let Some(s) = &values.scheduled {
22 parts.push(format!("SCHEDULED: {s}"));
23 }
24 if let Some(d) = &values.deadline {
25 parts.push(format!("DEADLINE: {d}"));
26 }
27 if let Some(c) = &values.closed {
28 parts.push(format!("CLOSED: {c}"));
29 }
30 (!parts.is_empty()).then(|| parts.join(" "))
31}
32
33#[derive(Debug)]
34pub(crate) struct ResolvedUpdate {
35 pub scheduled: Option<ParsedTimestamp>,
36 pub deadline: Option<ParsedTimestamp>,
37 pub closed: Option<ParsedTimestamp>,
38}
39
40#[derive(Debug)]
41struct TargetHeadline {
42 line_idx: usize,
43 level: usize,
44 title: String,
45 keyword: Option<String>,
46 priority: Option<String>,
47 tags: Vec<String>,
48 ambiguity: Option<String>,
49 planning_first_line: usize,
50 planning_line_count: usize,
51 planning_values: PlanningValues,
52 property_drawer_first_line: usize,
53 property_drawer_line_count: usize,
54 existing_properties: Vec<(String, String)>,
55 body_first_line: usize,
56 body_last_line: usize,
57}
58
59fn line_index_at(content: &str, byte_offset: usize) -> usize {
60 content[..byte_offset]
61 .bytes()
62 .filter(|b| *b == b'\n')
63 .count()
64}
65
66fn extract_planning(h: &orgize::ast::Headline, content: &str) -> (usize, usize, PlanningValues) {
67 match h.planning() {
68 None => (
69 line_index_at(content, h.start().into()) + 1,
70 0,
71 PlanningValues::default(),
72 ),
73 Some(p) => {
74 let p_start: usize = p.start().into();
75 let p_end: usize = p.end().into();
76 let first_line = line_index_at(content, p_start);
77 let ast_line_count = content[p_start..p_end]
79 .bytes()
80 .filter(|&b| b == b'\n')
81 .count()
82 .max(1);
83 let mut values = PlanningValues {
84 scheduled: p.scheduled().map(|ts| ts.raw()),
85 deadline: p.deadline().map(|ts| ts.raw()),
86 closed: p.closed().map(|ts| ts.raw()),
87 };
88 let all_lines: Vec<&str> = content.lines().collect();
92 let mut extra = 0;
93 let mut next = first_line + ast_line_count;
94 while let Some(line) = all_lines.get(next) {
95 let t = line.trim_start();
96 let keyword = if t.starts_with("SCHEDULED:") {
97 Some("SCHEDULED")
98 } else if t.starts_with("DEADLINE:") {
99 Some("DEADLINE")
100 } else if t.starts_with("CLOSED:") {
101 Some("CLOSED")
102 } else {
103 None
104 };
105 if let Some(kw) = keyword {
106 let raw = t[kw.len()..].trim_start_matches(':').trim().to_string();
108 match kw {
109 "SCHEDULED" if values.scheduled.is_none() => {
110 values.scheduled = Some(raw);
111 }
112 "DEADLINE" if values.deadline.is_none() => {
113 values.deadline = Some(raw);
114 }
115 "CLOSED" if values.closed.is_none() => {
116 values.closed = Some(raw);
117 }
118 _ => {}
119 }
120 extra += 1;
121 next += 1;
122 } else {
123 break;
124 }
125 }
126 (first_line, ast_line_count + extra, values)
127 }
128 }
129}
130
131fn extract_property_drawer(
132 h: &orgize::ast::Headline,
133 content: &str,
134 after_line: usize,
135) -> (usize, usize, Vec<(String, String)>) {
136 use orgize::rowan::ast::AstNode;
137
138 let drawer = match h.properties() {
139 Some(d) => d,
140 None => return (after_line, 0, vec![]),
141 };
142
143 let range = drawer.syntax().text_range();
144 let start_offset = usize::from(range.start());
145 let end_offset = usize::from(range.end());
146
147 let start_line = content[..start_offset]
148 .chars()
149 .filter(|&c| c == '\n')
150 .count();
151 let drawer_text = &content[start_offset..end_offset];
152 let newlines = drawer_text.chars().filter(|&c| c == '\n').count();
153 let drawer_line_count = if drawer_text.ends_with('\n') {
154 newlines
155 } else {
156 newlines + 1
157 };
158
159 let properties = drawer
160 .iter()
161 .map(|(k, v)| (k.to_string(), v.to_string()))
162 .collect();
163
164 (start_line, drawer_line_count, properties)
165}
166
167fn find_body_last_line(content: &str, body_first_line: usize) -> usize {
168 let all_lines: Vec<&str> = content.lines().collect();
169 all_lines[body_first_line..]
170 .iter()
171 .position(|line| line.starts_with('*'))
172 .map(|pos| body_first_line + pos)
173 .unwrap_or(all_lines.len())
174}
175
176impl OrgMode {
177 pub(crate) fn validate_update(
178 &self,
179 entry: &UpdateEntry,
180 ) -> Result<ResolvedUpdate, OrgModeError> {
181 if entry.file.is_some() != entry.heading_path.is_some() {
183 return Err(OrgModeError::InvalidUpdate(
184 "file and heading_path must be given together".to_string(),
185 ));
186 }
187 if entry.id.is_none() && entry.file.is_none() {
188 return Err(OrgModeError::InvalidUpdate(
189 "target required: pass id or file + heading_path".to_string(),
190 ));
191 }
192 if let Some(ref id) = entry.id
193 && id.trim().is_empty()
194 {
195 return Err(OrgModeError::InvalidUpdate(
196 "id must not be empty".to_string(),
197 ));
198 }
199 if let Some(ref path) = entry.heading_path {
200 for segment in path.split('/') {
201 if segment.trim().is_empty() {
202 return Err(OrgModeError::InvalidHeadingPath(format!(
203 "heading_path contains an empty or whitespace-only segment: '{path}'"
204 )));
205 }
206 }
207 }
208
209 if entry.todo_state.is_none()
211 && entry.priority.is_none()
212 && entry.tags.is_none()
213 && entry.scheduled.is_none()
214 && entry.deadline.is_none()
215 && entry.closed.is_none()
216 && entry.title.is_none()
217 && entry.body.is_none()
218 && entry.properties.is_none()
219 && entry.remove_properties.is_none()
220 && entry.clear.is_empty()
221 {
222 return Err(OrgModeError::InvalidUpdate("nothing to update".to_string()));
223 }
224
225 let mut seen: HashSet<ClearField> = HashSet::new();
227 for field in &entry.clear {
228 if !seen.insert(*field) {
229 return Err(OrgModeError::InvalidUpdate(format!(
230 "duplicate clear entry '{field}'"
231 )));
232 }
233 let conflict = match field {
234 ClearField::TodoState => entry.todo_state.is_some(),
235 ClearField::Priority => entry.priority.is_some(),
236 ClearField::Tags => entry.tags.is_some(),
237 ClearField::Scheduled => entry.scheduled.is_some(),
238 ClearField::Deadline => entry.deadline.is_some(),
239 ClearField::Closed => entry.closed.is_some(),
240 ClearField::Body => entry.body.is_some(),
241 };
242 if conflict {
243 return Err(OrgModeError::InvalidUpdate(format!(
244 "field '{field}' is both set and cleared"
245 )));
246 }
247 }
248
249 if let Some(ref kw) = entry.todo_state {
250 let valid_keywords: Vec<&str> = self
251 .config
252 .org_todo_keywords
253 .iter()
254 .filter(|k| k.as_str() != "|")
255 .map(|k| k.as_str())
256 .collect();
257 if !valid_keywords.contains(&kw.as_str()) {
258 return Err(OrgModeError::InvalidTodoKeyword(kw.clone()));
259 }
260 }
261
262 if let Some(ref p) = entry.priority
263 && !matches!(p.as_str(), "A" | "B" | "C")
264 {
265 return Err(OrgModeError::InvalidPriority(p.clone()));
266 }
267
268 if let Some(ref tags) = entry.tags {
269 for tag in tags {
270 if !super::capture::is_valid_tag(tag) {
271 return Err(OrgModeError::InvalidTag(tag.clone()));
272 }
273 }
274 }
275
276 let scheduled = entry
277 .scheduled
278 .as_deref()
279 .map(|v| Self::parse_iso_timestamp("scheduled", v))
280 .transpose()?;
281 let deadline = entry
282 .deadline
283 .as_deref()
284 .map(|v| Self::parse_iso_timestamp("deadline", v))
285 .transpose()?;
286 let closed = entry
287 .closed
288 .as_deref()
289 .map(|v| Self::parse_iso_timestamp("closed", v))
290 .transpose()?;
291
292 if let Some(ref t) = entry.title {
293 let trimmed = t.trim();
294 if trimmed.is_empty() || t.contains('\n') || t.contains('\r') {
295 return Err(OrgModeError::InvalidTitle(t.clone()));
296 }
297 }
298
299 if let Some(ref props) = entry.properties {
300 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
301 for p in props {
302 if !super::capture::is_valid_property_key(&p.key) {
303 return Err(OrgModeError::InvalidPropertyKey(p.key.clone()));
304 }
305 if p.value.contains('\n') || p.value.contains('\r') {
306 return Err(OrgModeError::InvalidPropertyValue {
307 key: p.key.clone(),
308 reason: "value must not contain newline or carriage return".to_string(),
309 });
310 }
311 if !seen.insert(p.key.to_uppercase()) {
312 return Err(OrgModeError::DuplicatePropertyKey(p.key.clone()));
313 }
314 }
315 }
316
317 if let (Some(props), Some(removes)) = (&entry.properties, &entry.remove_properties) {
319 let remove_upper: std::collections::HashSet<String> =
320 removes.iter().map(|k| k.to_uppercase()).collect();
321 for p in props {
322 if remove_upper.contains(&p.key.to_uppercase()) {
323 return Err(OrgModeError::InvalidUpdate(format!(
324 "property key '{}' appears in both properties and remove_properties",
325 p.key
326 )));
327 }
328 }
329 }
330
331 if let Some(ref f) = entry.file {
332 Self::validate_relative_file_path(f)?;
333 }
334
335 Ok(ResolvedUpdate {
336 scheduled,
337 deadline,
338 closed,
339 })
340 }
341
342 pub fn update_todo(&self, entry: UpdateEntry) -> Result<UpdateResult, OrgModeError> {
343 let resolved = self.validate_update(&entry)?;
344 let (file_rel, full_path) = self.resolve_target_file(&entry)?;
345
346 let lock_path = Self::lock_path_for(&full_path)?;
347 let lock_file = Self::acquire_capture_lock(&lock_path)?;
348
349 let result = self.apply_update(&file_rel, &full_path, &entry, &resolved);
350
351 #[cfg(unix)]
353 let _ = fs::remove_file(&lock_path);
354 drop(lock_file);
355 #[cfg(not(unix))]
356 let _ = fs::remove_file(&lock_path);
357
358 result
359 }
360
361 fn resolve_target_file(&self, entry: &UpdateEntry) -> Result<(String, PathBuf), OrgModeError> {
362 if let Some(ref id) = entry.id {
363 let mut matches: Vec<String> = Vec::new();
364 for path in self.list_files(None, None)? {
365 let content = self.read_file(&path)?;
366 if Self::file_contains_id(&content, id) {
367 matches.push(path);
368 }
369 }
370 return match matches.len() {
371 0 => Err(OrgModeError::HeadingNotFound(id.clone())),
372 1 => {
373 let file_rel = matches.pop().unwrap();
374 let full_path = self.prepare_target_path(&file_rel)?;
375 Ok((file_rel, full_path))
376 }
377 _ => Err(OrgModeError::AmbiguousTarget(id.clone())),
378 };
379 }
380
381 let file_rel = entry.file.clone().unwrap();
382 let full_path = PathBuf::from(&self.config.org_directory).join(&file_rel);
383 if !full_path.is_file() {
384 return Err(OrgModeError::HeadingNotFound(format!(
385 "{file_rel} (file does not exist)"
386 )));
387 }
388 let full_path = self.prepare_target_path(&file_rel)?;
389 Ok((file_rel, full_path))
390 }
391
392 fn file_contains_id(content: &str, id: &str) -> bool {
393 let mut found = false;
394 let mut handler = from_fn_with_ctx(|event, ctx| {
395 if let Event::Enter(Container::Headline(ref h)) = event
396 && h.properties()
397 .and_then(|props| {
398 props
399 .to_hash_map()
400 .into_iter()
401 .find(|(k, v)| k.to_lowercase() == "id" && v == id)
402 })
403 .is_some()
404 {
405 found = true;
406 ctx.stop();
407 }
408 });
409 Org::parse(content).traverse(&mut handler);
410 found
411 }
412
413 fn apply_update(
414 &self,
415 file_rel: &str,
416 full_path: &PathBuf,
417 entry: &UpdateEntry,
418 resolved: &ResolvedUpdate,
419 ) -> Result<UpdateResult, OrgModeError> {
420 let content = fs::read_to_string(full_path).map_err(OrgModeError::IoError)?;
421
422 let parse_config = ParseConfig {
423 todo_keywords: (
424 self.config.unfinished_keywords(),
425 self.config.finished_keywords(),
426 ),
427 ..Default::default()
428 };
429 let org = parse_config.parse(&content);
430
431 let target = match self.locate_headline(&org, &content, entry)? {
432 Some(t) if t.ambiguity.is_none() => t,
433 Some(t) => return Err(OrgModeError::AmbiguousTarget(t.ambiguity.unwrap())),
434 None => {
435 let shown = entry
436 .id
437 .clone()
438 .unwrap_or_else(|| entry.heading_path.clone().unwrap_or_default());
439 return Err(OrgModeError::HeadingNotFound(shown));
440 }
441 };
442
443 let mut lines: Vec<String> = content.lines().map(String::from).collect();
444 let cleared = |f: ClearField| entry.clear.contains(&f);
446
447 let new_keyword = if cleared(ClearField::TodoState) {
448 None
449 } else {
450 entry.todo_state.clone().or(target.keyword.clone())
451 };
452 let new_priority = if cleared(ClearField::Priority) {
453 None
454 } else {
455 entry.priority.clone().or(target.priority.clone())
456 };
457 let new_tags = if cleared(ClearField::Tags) {
458 Vec::new()
459 } else {
460 entry.tags.clone().unwrap_or_else(|| target.tags.clone())
461 };
462 let new_scheduled = if cleared(ClearField::Scheduled) {
463 None
464 } else {
465 resolved
466 .scheduled
467 .as_ref()
468 .map(|ts| Self::format_org_timestamp(ts, true))
469 .or(target.planning_values.scheduled.clone())
470 };
471 let new_deadline = if cleared(ClearField::Deadline) {
472 None
473 } else {
474 resolved
475 .deadline
476 .as_ref()
477 .map(|ts| Self::format_org_timestamp(ts, true))
478 .or(target.planning_values.deadline.clone())
479 };
480
481 let is_done = new_keyword
482 .as_ref()
483 .map(|k| self.config.finished_keywords().contains(k))
484 .unwrap_or(false);
485 let new_closed = if cleared(ClearField::Closed) {
486 None
487 } else if let Some(ts) = &resolved.closed {
488 Some(Self::format_org_timestamp(ts, false))
489 } else if is_done {
490 match target.planning_values.closed.clone() {
491 Some(existing) => Some(existing),
492 None if self.config.org_auto_closed_timestamp => {
493 let now = chrono::Local::now();
494 Some(Self::format_org_timestamp(
495 &ParsedTimestamp {
496 date: now.date_naive(),
497 time: Some(now.time()),
498 repeater: None,
499 warning: None,
500 },
501 false,
502 ))
503 }
504 None => None,
505 }
506 } else if self.config.org_auto_closed_timestamp {
507 None
509 } else {
510 target.planning_values.closed.clone()
511 };
512
513 let new_title = entry.title.as_deref().unwrap_or(&target.title);
514 let new_headline = Self::format_heading(
515 target.level,
516 new_keyword.as_deref(),
517 new_priority.as_deref(),
518 new_title,
519 Some(&new_tags),
520 );
521 let new_planning = render_planning_line(&PlanningValues {
522 scheduled: new_scheduled.clone(),
523 deadline: new_deadline.clone(),
524 closed: new_closed.clone(),
525 });
526
527 lines[target.line_idx] = new_headline.clone();
529 let replacement: Vec<String> = new_planning.into_iter().collect();
530 let replacement_len = replacement.len();
531 lines.splice(
532 target.planning_first_line..target.planning_first_line + target.planning_line_count,
533 replacement,
534 );
535
536 let newline = if content.contains("\r\n") {
537 "\r\n"
538 } else {
539 "\n"
540 };
541
542 let plan_delta: isize = replacement_len as isize - target.planning_line_count as isize;
543
544 let drawer_first = (target.property_drawer_first_line as isize + plan_delta) as usize;
546 let drawer_count = target.property_drawer_line_count;
547 let need_drawer_update = entry.properties.is_some() || entry.remove_properties.is_some();
548 let drawer_delta: isize;
549 let mut changes = Vec::new();
550
551 if need_drawer_update {
552 let mut props: Vec<(String, String)> = target.existing_properties.clone();
553
554 if let Some(ref new_props) = entry.properties {
555 for pair in new_props {
556 let key_upper = pair.key.to_uppercase();
557 if let Some(existing) = props
558 .iter_mut()
559 .find(|(k, _)| k.to_uppercase() == key_upper)
560 {
561 existing.1 = pair.value.clone();
562 } else {
563 props.push((pair.key.clone(), pair.value.clone()));
564 }
565 }
566 }
567
568 if let Some(ref removes) = entry.remove_properties {
569 for key in removes {
570 let key_upper = key.to_uppercase();
571 props.retain(|(k, _)| k.to_uppercase() != key_upper);
572 }
573 }
574
575 let new_drawer_lines: Vec<String> = if props.is_empty() {
576 vec![]
577 } else {
578 let mut drawer = vec![":PROPERTIES:".to_string()];
579 for (k, v) in &props {
580 drawer.push(format!(":{k}: {v}"));
581 }
582 drawer.push(":END:".to_string());
583 drawer
584 };
585
586 drawer_delta = new_drawer_lines.len() as isize - drawer_count as isize;
587
588 let old_map: std::collections::HashMap<String, String> = target
589 .existing_properties
590 .iter()
591 .map(|(k, v)| (k.to_uppercase(), v.clone()))
592 .collect();
593
594 if let Some(ref new_props) = entry.properties {
595 for pair in new_props {
596 let old_val = old_map.get(&pair.key.to_uppercase()).map(|s| s.as_str());
597 Self::push_change(
598 &mut changes,
599 &format!("property:{}", pair.key),
600 old_val,
601 Some(&pair.value),
602 );
603 }
604 }
605 if let Some(ref removes) = entry.remove_properties {
606 for key in removes {
607 if let Some(old_val) = old_map.get(&key.to_uppercase()) {
608 Self::push_change(
609 &mut changes,
610 &format!("property:{key}"),
611 Some(old_val.as_str()),
612 None,
613 );
614 }
615 }
616 }
617
618 lines.splice(drawer_first..drawer_first + drawer_count, new_drawer_lines);
619 } else {
620 drawer_delta = 0;
621 }
622
623 let body_first = (target.body_first_line as isize + plan_delta + drawer_delta) as usize;
625 let body_last = (target.body_last_line as isize + plan_delta + drawer_delta) as usize;
626
627 let new_body: Option<Vec<String>> = if entry.clear.contains(&ClearField::Body) {
628 Some(vec![])
629 } else if let Some(ref b) = entry.body {
630 if b.is_empty() {
631 Some(vec![])
632 } else {
633 Some(b.lines().map(String::from).collect())
634 }
635 } else {
636 None
637 };
638
639 let body_diff: Option<(String, String)> = if let Some(new_body_lines) = new_body {
640 let old_body_text = lines[body_first..body_last].join(newline);
641 let new_body_text = new_body_lines.join(newline);
642 lines.splice(body_first..body_last, new_body_lines);
643 Some((old_body_text, new_body_text))
644 } else {
645 None
646 };
647
648 let mut out = lines.join(newline);
649 if content.ends_with('\n') {
650 out.push_str(newline);
651 }
652 Self::atomic_write(full_path, out.as_bytes())?;
653
654 Self::push_change(
655 &mut changes,
656 "todo_state",
657 target.keyword.as_deref(),
658 new_keyword.as_deref(),
659 );
660 Self::push_change(
661 &mut changes,
662 "priority",
663 target.priority.as_deref(),
664 new_priority.as_deref(),
665 );
666 let old_tags = target.tags.join(":");
667 let new_tags_joined = new_tags.join(":");
668 Self::push_change(
669 &mut changes,
670 "tags",
671 (!old_tags.is_empty()).then_some(old_tags.as_str()),
672 (!new_tags_joined.is_empty()).then_some(new_tags_joined.as_str()),
673 );
674 Self::push_change(
675 &mut changes,
676 "scheduled",
677 target.planning_values.scheduled.as_deref(),
678 new_scheduled.as_deref(),
679 );
680 Self::push_change(
681 &mut changes,
682 "deadline",
683 target.planning_values.deadline.as_deref(),
684 new_deadline.as_deref(),
685 );
686 Self::push_change(
687 &mut changes,
688 "closed",
689 target.planning_values.closed.as_deref(),
690 new_closed.as_deref(),
691 );
692 Self::push_change(
693 &mut changes,
694 "title",
695 Some(target.title.as_str()),
696 Some(new_title),
697 );
698 if let Some((ref old_body, ref new_body_text)) = body_diff {
699 Self::push_change(
700 &mut changes,
701 "body",
702 if old_body.is_empty() {
703 None
704 } else {
705 Some(old_body.as_str())
706 },
707 if new_body_text.is_empty() {
708 None
709 } else {
710 Some(new_body_text.as_str())
711 },
712 );
713 }
714
715 Ok(UpdateResult {
716 file_path: file_rel.to_string(),
717 heading_line: new_headline,
718 changes,
719 })
720 }
721
722 fn push_change(changes: &mut Vec<String>, name: &str, old: Option<&str>, new: Option<&str>) {
723 if old != new {
724 changes.push(format!(
725 "{name}: {} -> {}",
726 old.unwrap_or("<none>"),
727 new.unwrap_or("<none>")
728 ));
729 }
730 }
731
732 fn locate_headline(
733 &self,
734 org: &Org,
735 content: &str,
736 entry: &UpdateEntry,
737 ) -> Result<Option<TargetHeadline>, OrgModeError> {
738 let mut matches: Vec<TargetHeadline> = Vec::new();
739
740 if let Some(ref id) = entry.id {
741 let mut handler = from_fn_with_ctx(|event, ctx| {
742 if let Event::Enter(Container::Headline(ref h)) = event {
743 let has_id = h
744 .properties()
745 .and_then(|props| {
746 props
747 .to_hash_map()
748 .into_iter()
749 .find(|(k, v)| k.to_lowercase() == "id" && v == id)
750 })
751 .is_some();
752 if has_id {
753 let (planning_first_line, planning_line_count, planning_values) =
754 extract_planning(h, content);
755 let after_planning = planning_first_line + planning_line_count;
756 let (
757 property_drawer_first_line,
758 property_drawer_line_count,
759 existing_properties,
760 ) = extract_property_drawer(h, content, after_planning);
761 let body_first_line =
762 property_drawer_first_line + property_drawer_line_count;
763 let body_last_line = find_body_last_line(content, body_first_line);
764 matches.push(TargetHeadline {
765 line_idx: line_index_at(content, h.start().into()),
766 level: h.level(),
767 title: h.title_raw().trim_end().to_string(),
768 keyword: h.todo_keyword().map(|t| t.to_string()),
769 priority: h.priority().map(|p| p.to_string()),
770 tags: h.tags().map(|s| s.to_string()).collect(),
771 ambiguity: None,
772 planning_first_line,
773 planning_line_count,
774 planning_values,
775 property_drawer_first_line,
776 property_drawer_line_count,
777 existing_properties,
778 body_first_line,
779 body_last_line,
780 });
781 if matches.len() == 2 {
783 ctx.stop();
784 }
785 }
786 }
787 });
788 org.traverse(&mut handler);
789 } else {
790 let path = entry.heading_path.clone().unwrap_or_default();
791 let parts: Vec<&str> = path.split('/').collect();
792 let mut stack: Vec<(usize, String)> = Vec::new();
793 let mut handler = from_fn(|event| {
794 if let Event::Enter(Container::Headline(ref h)) = event {
795 let level = h.level();
796 while stack.last().map(|(l, _)| *l >= level).unwrap_or(false) {
797 stack.pop();
798 }
799 stack.push((level, h.title_raw().trim_end().to_string()));
803 if stack.len() == parts.len()
804 && stack
805 .iter()
806 .map(|(_, t)| t.as_str())
807 .eq(parts.iter().copied())
808 {
809 let (planning_first_line, planning_line_count, planning_values) =
810 extract_planning(h, content);
811 let after_planning = planning_first_line + planning_line_count;
812 let (
813 property_drawer_first_line,
814 property_drawer_line_count,
815 existing_properties,
816 ) = extract_property_drawer(h, content, after_planning);
817 let body_first_line =
818 property_drawer_first_line + property_drawer_line_count;
819 let body_last_line = find_body_last_line(content, body_first_line);
820 matches.push(TargetHeadline {
821 line_idx: line_index_at(content, h.start().into()),
822 level,
823 title: h.title_raw().trim_end().to_string(),
824 keyword: h.todo_keyword().map(|t| t.to_string()),
825 priority: h.priority().map(|p| p.to_string()),
826 tags: h.tags().map(|s| s.to_string()).collect(),
827 ambiguity: None,
828 planning_first_line,
829 planning_line_count,
830 planning_values,
831 property_drawer_first_line,
832 property_drawer_line_count,
833 existing_properties,
834 body_first_line,
835 body_last_line,
836 });
837 }
838 }
839 });
840 org.traverse(&mut handler);
841 }
842
843 match matches.len() {
844 0 => Ok(None),
845 1 => Ok(matches.pop().map(Some).unwrap()),
846 _ => {
847 let shown = entry
848 .id
849 .clone()
850 .unwrap_or_else(|| entry.heading_path.clone().unwrap_or_default());
851 Ok(Some(TargetHeadline {
852 line_idx: 0,
853 level: 0,
854 title: String::new(),
855 keyword: None,
856 priority: None,
857 tags: vec![],
858 ambiguity: Some(shown),
859 planning_first_line: 0,
860 planning_line_count: 0,
861 planning_values: PlanningValues::default(),
862 property_drawer_first_line: 0,
863 property_drawer_line_count: 0,
864 existing_properties: vec![],
865 body_first_line: 0,
866 body_last_line: 0,
867 }))
868 }
869 }
870 }
871}
872
873#[cfg(test)]
874mod tests {
875 use super::*;
876 use crate::config::OrgConfig;
877
878 fn make_org_mode(temp_dir: &tempfile::TempDir) -> OrgMode {
879 OrgMode::new(OrgConfig {
880 org_directory: temp_dir.path().to_str().unwrap().to_string(),
881 ..OrgConfig::default()
882 })
883 .unwrap()
884 }
885
886 fn entry_by_path() -> UpdateEntry {
887 UpdateEntry {
888 id: None,
889 file: Some("notes.org".to_string()),
890 heading_path: Some("Daily Tasks/Buy groceries".to_string()),
891 todo_state: Some("DONE".to_string()),
892 priority: None,
893 tags: None,
894 scheduled: None,
895 deadline: None,
896 closed: None,
897 clear: vec![],
898 title: None,
899 body: None,
900 properties: None,
901 remove_properties: None,
902 }
903 }
904
905 #[test]
906 fn test_validate_rejects_missing_target() {
907 let temp_dir = tempfile::tempdir().unwrap();
908 let org_mode = make_org_mode(&temp_dir);
909 let mut e = entry_by_path();
910 e.file = None;
911 e.heading_path = None;
912 let err = org_mode.validate_update(&e).unwrap_err();
913 assert!(matches!(err, OrgModeError::InvalidUpdate(_)));
914 }
915
916 #[test]
917 fn test_validate_rejects_file_without_heading_path() {
918 let temp_dir = tempfile::tempdir().unwrap();
919 let org_mode = make_org_mode(&temp_dir);
920 let mut e = entry_by_path();
921 e.heading_path = None;
922 let err = org_mode.validate_update(&e).unwrap_err();
923 assert!(matches!(err, OrgModeError::InvalidUpdate(_)));
924 }
925
926 #[test]
927 fn test_validate_rejects_noop() {
928 let temp_dir = tempfile::tempdir().unwrap();
929 let org_mode = make_org_mode(&temp_dir);
930 let mut e = entry_by_path();
931 e.todo_state = None;
932 let err = org_mode.validate_update(&e).unwrap_err();
933 match err {
934 OrgModeError::InvalidUpdate(msg) => assert_eq!(msg, "nothing to update"),
935 other => panic!("expected InvalidUpdate, got {other:?}"),
936 }
937 }
938
939 #[test]
940 fn test_validate_rejects_set_and_clear_same_field() {
941 let temp_dir = tempfile::tempdir().unwrap();
942 let org_mode = make_org_mode(&temp_dir);
943 let mut e = entry_by_path();
944 e.todo_state = None;
945 e.scheduled = Some("2026-05-15".to_string());
946 e.clear = vec![ClearField::Scheduled];
947 let err = org_mode.validate_update(&e).unwrap_err();
948 match err {
949 OrgModeError::InvalidUpdate(msg) => assert!(msg.contains("scheduled")),
950 other => panic!("expected InvalidUpdate, got {other:?}"),
951 }
952 }
953
954 #[test]
955 fn test_validate_rejects_invalid_keyword_priority_tag() {
956 let temp_dir = tempfile::tempdir().unwrap();
957 let org_mode = make_org_mode(&temp_dir);
958
959 let mut e = entry_by_path();
960 e.todo_state = Some("MAYBE".to_string());
961 assert!(matches!(
962 org_mode.validate_update(&e).unwrap_err(),
963 OrgModeError::InvalidTodoKeyword(_)
964 ));
965
966 let mut e = entry_by_path();
967 e.todo_state = None;
968 e.priority = Some("Z".to_string());
969 assert!(matches!(
970 org_mode.validate_update(&e).unwrap_err(),
971 OrgModeError::InvalidPriority(_)
972 ));
973
974 let mut e = entry_by_path();
975 e.todo_state = None;
976 e.tags = Some(vec!["bad tag".to_string()]);
977 assert!(matches!(
978 org_mode.validate_update(&e).unwrap_err(),
979 OrgModeError::InvalidTag(_)
980 ));
981 }
982
983 #[test]
984 fn test_validate_rejects_invalid_timestamp_and_traversal() {
985 let temp_dir = tempfile::tempdir().unwrap();
986 let org_mode = make_org_mode(&temp_dir);
987
988 let mut e = entry_by_path();
989 e.todo_state = None;
990 e.scheduled = Some("tomorrow".to_string());
991 assert!(matches!(
992 org_mode.validate_update(&e).unwrap_err(),
993 OrgModeError::InvalidTimestamp { .. }
994 ));
995
996 let mut e = entry_by_path();
997 e.file = Some("../escape.org".to_string());
998 assert!(matches!(
999 org_mode.validate_update(&e).unwrap_err(),
1000 OrgModeError::InvalidDirectory(_)
1001 ));
1002 }
1003
1004 #[test]
1005 fn test_validate_rejects_empty_heading_path_segment() {
1006 let temp_dir = tempfile::tempdir().unwrap();
1007 let org_mode = make_org_mode(&temp_dir);
1008 let mut e = entry_by_path();
1009 e.heading_path = Some("Daily Tasks//Buy groceries".to_string());
1010 assert!(matches!(
1011 org_mode.validate_update(&e).unwrap_err(),
1012 OrgModeError::InvalidHeadingPath(_)
1013 ));
1014 }
1015
1016 #[test]
1017 fn test_validate_accepts_clear_only_update() {
1018 let temp_dir = tempfile::tempdir().unwrap();
1019 let org_mode = make_org_mode(&temp_dir);
1020 let mut e = entry_by_path();
1021 e.todo_state = None;
1022 e.clear = vec![ClearField::Priority];
1023 assert!(org_mode.validate_update(&e).is_ok());
1024 }
1025
1026 #[test]
1027 fn test_render_planning_line_order_and_none() {
1028 let values = PlanningValues {
1029 scheduled: Some("<2026-05-15 Fri>".to_string()),
1030 deadline: None,
1031 closed: Some("[2026-05-16 Sat]".to_string()),
1032 };
1033 assert_eq!(
1034 render_planning_line(&values).as_deref(),
1035 Some("SCHEDULED: <2026-05-15 Fri> CLOSED: [2026-05-16 Sat]")
1036 );
1037 assert_eq!(render_planning_line(&PlanningValues::default()), None);
1038 }
1039
1040 use crate::org_mode::UpdateResult;
1041 use std::fs;
1042
1043 const FIXTURE: &str = "\
1044* Daily Tasks
1045:PROPERTIES:
1046:ID: daily-tasks-123
1047:END:
1048** TODO Buy groceries
1049:PROPERTIES:
1050:ID: task-groceries-456
1051:END:
1052** DONE Read book
1053CLOSED: [2026-05-01 Fri 09:00]
1054:PROPERTIES:
1055:ID: task-book-789
1056:END:
1057* Projects
1058** Work
1059*** TODO Refactor API
1060SCHEDULED: <2026-05-15 Fri ++1w>
1061Body line must survive.
1062";
1063
1064 fn setup_fixture(temp_dir: &tempfile::TempDir) {
1065 fs::write(temp_dir.path().join("notes.org"), FIXTURE).unwrap();
1066 }
1067
1068 fn update_by_id(id: &str) -> UpdateEntry {
1069 UpdateEntry {
1070 id: Some(id.to_string()),
1071 file: None,
1072 heading_path: None,
1073 todo_state: None,
1074 priority: None,
1075 tags: None,
1076 scheduled: None,
1077 deadline: None,
1078 closed: None,
1079 clear: vec![],
1080 title: None,
1081 body: None,
1082 properties: None,
1083 remove_properties: None,
1084 }
1085 }
1086
1087 #[test]
1088 fn test_update_state_to_done_stamps_closed_by_id() {
1089 let temp_dir = tempfile::tempdir().unwrap();
1090 setup_fixture(&temp_dir);
1091 let org_mode = make_org_mode(&temp_dir);
1092
1093 let mut e = update_by_id("task-groceries-456");
1094 e.todo_state = Some("DONE".to_string());
1095 let result: UpdateResult = org_mode.update_todo(e).unwrap();
1096
1097 assert_eq!(result.file_path, "notes.org");
1098 assert_eq!(result.heading_line, "** DONE Buy groceries");
1099 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
1100 let content = fs::read_to_string(temp_dir.path().join("notes.org")).unwrap();
1101 assert!(content.contains("** DONE Buy groceries"));
1102 assert!(
1103 content.contains(&format!("CLOSED: [{today}")),
1104 "expected auto CLOSED stamp with today's date:\n{content}"
1105 );
1106 assert!(content.contains(":ID: task-groceries-456"));
1108 }
1109
1110 #[test]
1111 fn test_update_done_to_todo_removes_closed_by_path() {
1112 let temp_dir = tempfile::tempdir().unwrap();
1113 setup_fixture(&temp_dir);
1114 let org_mode = make_org_mode(&temp_dir);
1115
1116 let mut e = UpdateEntry {
1117 id: None,
1118 file: Some("notes.org".to_string()),
1119 heading_path: Some("Daily Tasks/Read book".to_string()),
1120 todo_state: Some("TODO".to_string()),
1121 priority: None,
1122 tags: None,
1123 scheduled: None,
1124 deadline: None,
1125 closed: None,
1126 clear: vec![],
1127 title: None,
1128 body: None,
1129 properties: None,
1130 remove_properties: None,
1131 };
1132 let result = org_mode.update_todo(e.clone()).unwrap();
1133 assert_eq!(result.heading_line, "** TODO Read book");
1134 let content = fs::read_to_string(temp_dir.path().join("notes.org")).unwrap();
1135 assert!(
1136 !content.contains("CLOSED:"),
1137 "CLOSED must be removed:\n{content}"
1138 );
1139 assert!(content.contains(":ID: task-book-789"));
1140
1141 e.id = Some("task-groceries-456".to_string());
1143 e.todo_state = Some("DONE".to_string());
1144 let result = org_mode.update_todo(e).unwrap();
1145 assert_eq!(result.heading_line, "** DONE Buy groceries");
1146 }
1147
1148 #[test]
1149 fn test_update_explicit_closed_wins_over_auto_stamp() {
1150 let temp_dir = tempfile::tempdir().unwrap();
1151 setup_fixture(&temp_dir);
1152 let org_mode = make_org_mode(&temp_dir);
1153
1154 let mut e = update_by_id("task-groceries-456");
1155 e.todo_state = Some("DONE".to_string());
1156 e.closed = Some("2026-05-17 12:00".to_string());
1157 org_mode.update_todo(e).unwrap();
1158
1159 let content = fs::read_to_string(temp_dir.path().join("notes.org")).unwrap();
1160 assert!(content.contains("CLOSED: [2026-05-17 Sun 12:00]"));
1161 }
1162
1163 #[test]
1164 fn test_update_metadata_preserves_body_and_repeater() {
1165 let temp_dir = tempfile::tempdir().unwrap();
1166 setup_fixture(&temp_dir);
1167 let org_mode = make_org_mode(&temp_dir);
1168
1169 let mut e = UpdateEntry {
1170 id: None,
1171 file: Some("notes.org".to_string()),
1172 heading_path: Some("Projects/Work/Refactor API".to_string()),
1173 todo_state: None,
1174 priority: Some("A".to_string()),
1175 tags: Some(vec!["backend".to_string()]),
1176 scheduled: None,
1177 deadline: Some("2026-05-20 17:00".to_string()),
1178 closed: None,
1179 clear: vec![],
1180 title: None,
1181 body: None,
1182 properties: None,
1183 remove_properties: None,
1184 };
1185 let result = org_mode.update_todo(e.clone()).unwrap();
1186 assert_eq!(result.heading_line, "*** TODO [#A] Refactor API :backend:");
1187 let content = fs::read_to_string(temp_dir.path().join("notes.org")).unwrap();
1188 assert!(content.contains("SCHEDULED: <2026-05-15 Fri ++1w>"));
1190 assert!(content.contains("DEADLINE: <2026-05-20 Wed 17:00>"));
1191 assert!(content.contains("Body line must survive."));
1192
1193 e.priority = Some("B".to_string());
1195 e.tags = None;
1196 e.deadline = None;
1197 org_mode.update_todo(e).unwrap();
1198 let after = fs::read_to_string(temp_dir.path().join("notes.org")).unwrap();
1199 assert!(after.contains("*** TODO [#B] Refactor API :backend:"));
1200 assert!(after.ends_with("Body line must survive.\n"));
1201 assert!(after.starts_with("* Daily Tasks\n:PROPERTIES:\n:ID: daily-tasks-123"));
1202 }
1203
1204 #[test]
1205 fn test_update_target_errors() {
1206 let temp_dir = tempfile::tempdir().unwrap();
1207 setup_fixture(&temp_dir);
1208 let org_mode = make_org_mode(&temp_dir);
1209
1210 let mut e = update_by_id("no-such-id");
1211 e.todo_state = Some("DONE".to_string());
1212 assert!(matches!(
1213 org_mode.update_todo(e.clone()).unwrap_err(),
1214 OrgModeError::HeadingNotFound(_)
1215 ));
1216
1217 e.id = None;
1218 e.file = Some("notes.org".to_string());
1219 e.heading_path = Some("No/Such/Path".to_string());
1220 assert!(matches!(
1221 org_mode.update_todo(e.clone()).unwrap_err(),
1222 OrgModeError::HeadingNotFound(_)
1223 ));
1224
1225 e.heading_path = Some("Daily Tasks".to_string());
1226 let result = org_mode.update_todo(e).unwrap();
1229 assert_eq!(result.heading_line, "* DONE Daily Tasks");
1230 let content = fs::read_to_string(temp_dir.path().join("notes.org")).unwrap();
1231 assert!(content.contains("* DONE Daily Tasks"));
1232 }
1233
1234 #[test]
1235 fn test_update_preserves_crlf_line_endings() {
1236 let temp_dir = tempfile::tempdir().unwrap();
1237 fs::write(
1238 temp_dir.path().join("crlf.org"),
1239 "* TODO Task\r\nSCHEDULED: <2026-05-15 Fri>\r\nbody\r\n",
1240 )
1241 .unwrap();
1242 let org_mode = make_org_mode(&temp_dir);
1243
1244 let mut e = update_by_id("irrelevant");
1245 e.id = None;
1246 e.file = Some("crlf.org".to_string());
1247 e.heading_path = Some("Task".to_string());
1248 e.todo_state = Some("DONE".to_string());
1249 org_mode.update_todo(e).unwrap();
1250
1251 let content = fs::read_to_string(temp_dir.path().join("crlf.org")).unwrap();
1252 assert!(content.contains("* DONE Task\r\n"));
1253 assert!(content.contains("\r\nbody\r\n"));
1254 assert!(content.contains("CLOSED: ["));
1255 assert!(
1256 !content.replace("\r\n", "").contains('\n'),
1257 "found bare LF after CRLF update:\n{content}"
1258 );
1259 }
1260
1261 #[test]
1262 fn test_update_ambiguous_path_rejected() {
1263 let temp_dir = tempfile::tempdir().unwrap();
1264 fs::write(
1265 temp_dir.path().join("dup.org"),
1266 "* A\n** TODO Dup\n* B\n** TODO Dup\n",
1267 )
1268 .unwrap();
1269 let org_mode = make_org_mode(&temp_dir);
1270
1271 let mut e = update_by_id("irrelevant");
1272 e.id = None;
1273 e.file = Some("dup.org".to_string());
1274 e.heading_path = Some("B/Dup".to_string());
1275 e.todo_state = Some("DONE".to_string());
1276 org_mode.update_todo(e.clone()).unwrap();
1278 fs::write(
1280 temp_dir.path().join("dup2.org"),
1281 "* X\n** TODO Same\n* X\n** TODO Same\n",
1282 )
1283 .unwrap();
1284 e.file = Some("dup2.org".to_string());
1285 e.heading_path = Some("X/Same".to_string());
1286 assert!(matches!(
1287 org_mode.update_todo(e).unwrap_err(),
1288 OrgModeError::AmbiguousTarget(_)
1289 ));
1290 }
1291
1292 #[test]
1293 fn test_update_by_id_preserves_single_spacing_with_tags() {
1294 let temp_dir = tempfile::tempdir().unwrap();
1295 fs::write(
1296 temp_dir.path().join("ids.org"),
1297 "* TODO Tagged task :a:b:\n:PROPERTIES:\n:ID: tagged-1\n:END:\nbody\n",
1298 )
1299 .unwrap();
1300 let org_mode = make_org_mode(&temp_dir);
1301
1302 let mut e = update_by_id("tagged-1");
1303 e.priority = Some("A".to_string());
1304 let result = org_mode.update_todo(e).unwrap();
1305 assert_eq!(result.heading_line, "* TODO [#A] Tagged task :a:b:");
1306 let content = fs::read_to_string(temp_dir.path().join("ids.org")).unwrap();
1307 assert!(content.contains("* TODO [#A] Tagged task :a:b:\n"));
1308 assert!(content.contains("\nbody\n"));
1309 }
1310
1311 #[test]
1312 fn test_clear_scheduled_and_priority() {
1313 let temp_dir = tempfile::tempdir().unwrap();
1314 setup_fixture(&temp_dir);
1315 let org_mode = make_org_mode(&temp_dir);
1316
1317 let e = UpdateEntry {
1318 id: None,
1319 file: Some("notes.org".to_string()),
1320 heading_path: Some("Projects/Work/Refactor API".to_string()),
1321 todo_state: None,
1322 priority: None,
1323 tags: None,
1324 scheduled: None,
1325 deadline: None,
1326 closed: None,
1327 clear: vec![ClearField::Scheduled, ClearField::Priority],
1328 title: None,
1329 body: None,
1330 properties: None,
1331 remove_properties: None,
1332 };
1333 let result = org_mode.update_todo(e).unwrap();
1334 assert_eq!(result.heading_line, "*** TODO Refactor API");
1335 let content = fs::read_to_string(temp_dir.path().join("notes.org")).unwrap();
1336 assert!(
1337 !content.contains("SCHEDULED:"),
1338 "planning line removed:\n{content}"
1339 );
1340 assert!(content.contains("Body line must survive."));
1341 assert!(
1342 result
1343 .changes
1344 .iter()
1345 .any(|c| c.starts_with("scheduled: ") && c.ends_with("-> <none>")),
1346 "expected a scheduled removal entry, got {:?}",
1347 result.changes
1348 );
1349 }
1350
1351 #[test]
1352 fn test_clear_todo_state_strips_keyword_and_closed() {
1353 let temp_dir = tempfile::tempdir().unwrap();
1354 setup_fixture(&temp_dir);
1355 let org_mode = make_org_mode(&temp_dir);
1356
1357 let mut e = update_by_id("task-book-789");
1358 e.clear = vec![ClearField::TodoState];
1359 let result = org_mode.update_todo(e).unwrap();
1360 assert_eq!(result.heading_line, "** Read book");
1361 let content = fs::read_to_string(temp_dir.path().join("notes.org")).unwrap();
1362 assert!(
1363 !content.contains("CLOSED:"),
1364 "CLOSED removed with keyword:\n{content}"
1365 );
1366 }
1367
1368 #[test]
1369 fn test_clear_tags_only() {
1370 let temp_dir = tempfile::tempdir().unwrap();
1371 fs::write(
1372 temp_dir.path().join("tags.org"),
1373 "* TODO Tagged task :a:b:\nbody\n",
1374 )
1375 .unwrap();
1376 let org_mode = make_org_mode(&temp_dir);
1377
1378 let e = UpdateEntry {
1379 id: None,
1380 file: Some("tags.org".to_string()),
1381 heading_path: Some("Tagged task".to_string()),
1382 todo_state: None,
1383 priority: None,
1384 tags: None,
1385 scheduled: None,
1386 deadline: None,
1387 closed: None,
1388 clear: vec![ClearField::Tags],
1389 title: None,
1390 body: None,
1391 properties: None,
1392 remove_properties: None,
1393 };
1394 let result = org_mode.update_todo(e).unwrap();
1395 assert_eq!(result.heading_line, "* TODO Tagged task");
1396 let content = fs::read_to_string(temp_dir.path().join("tags.org")).unwrap();
1397 assert_eq!(content, "* TODO Tagged task\nbody\n");
1398 }
1399
1400 #[test]
1401 fn test_planning_lands_before_property_drawer() {
1402 let temp_dir = tempfile::tempdir().unwrap();
1403 setup_fixture(&temp_dir);
1404 let org_mode = make_org_mode(&temp_dir);
1405
1406 let mut e = update_by_id("task-groceries-456");
1407 e.scheduled = Some("2026-05-18".to_string());
1408 org_mode.update_todo(e).unwrap();
1409
1410 let content = fs::read_to_string(temp_dir.path().join("notes.org")).unwrap();
1411 let block_start = content.find("** TODO Buy groceries").unwrap();
1412 let rest = &content[block_start..];
1413 let planning_pos = rest.find("SCHEDULED: <2026-05-18 Mon>").unwrap();
1414 let drawer_pos = rest.find(":PROPERTIES:").unwrap();
1415 assert!(
1416 planning_pos < drawer_pos,
1417 "planning line must come before the property drawer:\n{rest}"
1418 );
1419 }
1420
1421 #[test]
1422 fn test_auto_closed_disabled_keeps_stale_closed() {
1423 let temp_dir = tempfile::tempdir().unwrap();
1424 setup_fixture(&temp_dir);
1425 let org_mode = OrgMode::new(OrgConfig {
1426 org_directory: temp_dir.path().to_str().unwrap().to_string(),
1427 org_auto_closed_timestamp: false,
1428 ..OrgConfig::default()
1429 })
1430 .unwrap();
1431
1432 let mut e = update_by_id("task-book-789");
1433 e.todo_state = Some("TODO".to_string());
1434 org_mode.update_todo(e).unwrap();
1435 let content = fs::read_to_string(temp_dir.path().join("notes.org")).unwrap();
1436 assert!(content.contains("** TODO Read book"));
1437 assert!(
1438 content.contains("CLOSED: [2026-05-01 Fri 09:00]"),
1439 "config off keeps existing CLOSED:\n{content}"
1440 );
1441
1442 let mut e2 = update_by_id("task-groceries-456");
1444 e2.todo_state = Some("DONE".to_string());
1445 org_mode.update_todo(e2).unwrap();
1446 let content = fs::read_to_string(temp_dir.path().join("notes.org")).unwrap();
1447 let groceries = &content[content.find("** DONE Buy groceries").unwrap()..];
1448 assert!(
1449 !groceries.lines().nth(1).unwrap_or("").contains("CLOSED:"),
1450 "config off must not stamp CLOSED:\n{groceries}"
1451 );
1452 }
1453
1454 #[test]
1455 fn test_lock_file_cleaned_up() {
1456 let temp_dir = tempfile::tempdir().unwrap();
1457 setup_fixture(&temp_dir);
1458 let org_mode = make_org_mode(&temp_dir);
1459
1460 let mut e = update_by_id("task-groceries-456");
1461 e.priority = Some("B".to_string());
1462 org_mode.update_todo(e).unwrap();
1463
1464 assert!(!temp_dir.path().join(".notes.org.lock").exists());
1465 }
1466
1467 #[test]
1468 fn test_update_planning_at_eof_no_trailing_newline() {
1469 let temp_dir = tempfile::tempdir().unwrap();
1470 fs::write(
1471 temp_dir.path().join("eof.org"),
1472 "* TODO Task\nSCHEDULED: <2026-05-15 Fri>", )
1474 .unwrap();
1475 let org_mode = make_org_mode(&temp_dir);
1476 let e = UpdateEntry {
1477 id: None,
1478 file: Some("eof.org".to_string()),
1479 heading_path: Some("Task".to_string()),
1480 todo_state: Some("DONE".to_string()),
1481 priority: None,
1482 tags: None,
1483 scheduled: None,
1484 deadline: None,
1485 closed: None,
1486 clear: vec![],
1487 title: None,
1488 body: None,
1489 properties: None,
1490 remove_properties: None,
1491 };
1492 org_mode.update_todo(e).unwrap();
1493 let content = fs::read_to_string(temp_dir.path().join("eof.org")).unwrap();
1494 assert_eq!(
1496 content.matches("SCHEDULED:").count(),
1497 1,
1498 "duplicate planning line:\n{content}"
1499 );
1500 assert!(content.contains("* DONE Task"));
1501 }
1502
1503 #[test]
1504 fn test_locate_headline_captures_drawer_and_body_spans() {
1505 let temp_dir = tempfile::tempdir().unwrap();
1506 fs::write(
1507 temp_dir.path().join("spans.org"),
1508 "* TODO Task\nSCHEDULED: <2026-05-15 Fri>\n\
1509 :PROPERTIES:\n:ID: span-1\n:EFFORT: 2h\n:END:\nfirst body\nsecond body\n\
1510 * Other\n",
1511 )
1512 .unwrap();
1513 let org_mode = make_org_mode(&temp_dir);
1514 let mut e = update_by_id("span-1");
1515 e.todo_state = Some("DONE".to_string());
1516 let result = org_mode.update_todo(e).unwrap();
1517 let content = fs::read_to_string(temp_dir.path().join("spans.org")).unwrap();
1518 assert!(
1519 content.contains("first body"),
1520 "body must survive:\n{content}"
1521 );
1522 assert!(
1523 content.contains("second body"),
1524 "body must survive:\n{content}"
1525 );
1526 assert!(
1527 content.contains(":EFFORT: 2h"),
1528 "drawer must survive:\n{content}"
1529 );
1530 assert_eq!(result.heading_line, "* DONE Task");
1531 }
1532
1533 #[test]
1534 fn test_update_multi_line_planning_no_orphan() {
1535 let temp_dir = tempfile::tempdir().unwrap();
1536 fs::write(
1537 temp_dir.path().join("multi.org"),
1538 "* TODO Task\nSCHEDULED: <2026-05-15 Fri>\nDEADLINE: <2026-05-20 Wed>\nbody\n",
1539 )
1540 .unwrap();
1541 let org_mode = make_org_mode(&temp_dir);
1542 let e = UpdateEntry {
1543 id: None,
1544 file: Some("multi.org".to_string()),
1545 heading_path: Some("Task".to_string()),
1546 todo_state: Some("DONE".to_string()),
1547 priority: None,
1548 tags: None,
1549 scheduled: None,
1550 deadline: None,
1551 closed: None,
1552 clear: vec![],
1553 title: None,
1554 body: None,
1555 properties: None,
1556 remove_properties: None,
1557 };
1558 org_mode.update_todo(e).unwrap();
1559 let content = fs::read_to_string(temp_dir.path().join("multi.org")).unwrap();
1560 assert_eq!(
1561 content.matches("SCHEDULED:").count(),
1562 1,
1563 "duplicate SCHEDULED:\n{content}"
1564 );
1565 assert_eq!(
1566 content.matches("DEADLINE:").count(),
1567 1,
1568 "duplicate DEADLINE:\n{content}"
1569 );
1570 assert!(
1571 content.contains("body"),
1572 "body line must survive:\n{content}"
1573 );
1574 }
1575
1576 #[test]
1577 fn test_validate_rejects_empty_title() {
1578 let temp_dir = tempfile::tempdir().unwrap();
1579 let org_mode = make_org_mode(&temp_dir);
1580 let mut e = entry_by_path();
1581 e.todo_state = None;
1582 e.title = Some(" ".to_string());
1583 assert!(matches!(
1584 org_mode.validate_update(&e).unwrap_err(),
1585 OrgModeError::InvalidTitle(_)
1586 ));
1587 }
1588
1589 #[test]
1590 fn test_validate_rejects_title_with_newline() {
1591 let temp_dir = tempfile::tempdir().unwrap();
1592 let org_mode = make_org_mode(&temp_dir);
1593 let mut e = entry_by_path();
1594 e.todo_state = None;
1595 e.title = Some("bad\ntitle".to_string());
1596 assert!(matches!(
1597 org_mode.validate_update(&e).unwrap_err(),
1598 OrgModeError::InvalidTitle(_)
1599 ));
1600 }
1601
1602 #[test]
1603 fn test_validate_rejects_body_and_clear_body_together() {
1604 let temp_dir = tempfile::tempdir().unwrap();
1605 let org_mode = make_org_mode(&temp_dir);
1606 let mut e = entry_by_path();
1607 e.todo_state = None;
1608 e.body = Some("some text".to_string());
1609 e.clear = vec![ClearField::Body];
1610 assert!(matches!(
1611 org_mode.validate_update(&e).unwrap_err(),
1612 OrgModeError::InvalidUpdate(_)
1613 ));
1614 }
1615
1616 #[test]
1617 fn test_validate_rejects_property_key_conflict() {
1618 let temp_dir = tempfile::tempdir().unwrap();
1619 let org_mode = make_org_mode(&temp_dir);
1620 let mut e = entry_by_path();
1621 e.todo_state = None;
1622 e.properties = Some(vec![crate::PropertyPair {
1623 key: "EFFORT".to_string(),
1624 value: "1h".to_string(),
1625 }]);
1626 e.remove_properties = Some(vec!["EFFORT".to_string()]);
1627 assert!(matches!(
1628 org_mode.validate_update(&e).unwrap_err(),
1629 OrgModeError::InvalidUpdate(_)
1630 ));
1631 }
1632
1633 #[test]
1634 fn test_validate_rejects_invalid_property_key() {
1635 let temp_dir = tempfile::tempdir().unwrap();
1636 let org_mode = make_org_mode(&temp_dir);
1637 let mut e = entry_by_path();
1638 e.todo_state = None;
1639 e.properties = Some(vec![crate::PropertyPair {
1640 key: "bad key".to_string(),
1641 value: "v".to_string(),
1642 }]);
1643 assert!(matches!(
1644 org_mode.validate_update(&e).unwrap_err(),
1645 OrgModeError::InvalidPropertyKey(_)
1646 ));
1647 }
1648
1649 #[test]
1650 fn test_validate_rejects_property_value_with_newline() {
1651 let temp_dir = tempfile::tempdir().unwrap();
1652 let org_mode = make_org_mode(&temp_dir);
1653 let mut e = entry_by_path();
1654 e.todo_state = None;
1655 e.properties = Some(vec![crate::PropertyPair {
1656 key: "NOTE".to_string(),
1657 value: "line1\nline2".to_string(),
1658 }]);
1659 assert!(matches!(
1660 org_mode.validate_update(&e).unwrap_err(),
1661 OrgModeError::InvalidPropertyValue { .. }
1662 ));
1663 }
1664
1665 #[test]
1666 fn test_update_title_rename() {
1667 let temp_dir = tempfile::tempdir().unwrap();
1668 setup_fixture(&temp_dir);
1669 let org_mode = make_org_mode(&temp_dir);
1670
1671 let mut e = update_by_id("task-groceries-456");
1672 e.title = Some("Buy organic groceries".to_string());
1673 let result = org_mode.update_todo(e).unwrap();
1674
1675 assert_eq!(result.heading_line, "** TODO Buy organic groceries");
1676 assert!(
1677 result.changes.iter().any(|c| c.starts_with("title:")),
1678 "expected title change entry, got {:?}",
1679 result.changes
1680 );
1681 let content = fs::read_to_string(temp_dir.path().join("notes.org")).unwrap();
1682 assert!(content.contains("** TODO Buy organic groceries"));
1683 assert!(!content.contains("** TODO Buy groceries\n"));
1684 }
1685
1686 #[test]
1687 fn test_update_body_replace() {
1688 let temp_dir = tempfile::tempdir().unwrap();
1689 setup_fixture(&temp_dir);
1690 let org_mode = make_org_mode(&temp_dir);
1691
1692 let mut e = UpdateEntry {
1693 id: None,
1694 file: Some("notes.org".to_string()),
1695 heading_path: Some("Projects/Work/Refactor API".to_string()),
1696 todo_state: None,
1697 priority: None,
1698 tags: None,
1699 scheduled: None,
1700 deadline: None,
1701 closed: None,
1702 clear: vec![],
1703 title: None,
1704 body: Some("New body content.\nSecond line.".to_string()),
1705 properties: None,
1706 remove_properties: None,
1707 };
1708 org_mode.update_todo(e.clone()).unwrap();
1709 let content = fs::read_to_string(temp_dir.path().join("notes.org")).unwrap();
1710 assert!(content.contains("New body content.\nSecond line."));
1711 assert!(!content.contains("Body line must survive."));
1712
1713 e.body = None;
1714 e.clear = vec![ClearField::Body];
1715 org_mode.update_todo(e).unwrap();
1716 let content = fs::read_to_string(temp_dir.path().join("notes.org")).unwrap();
1717 assert!(!content.contains("New body content."));
1718 }
1719
1720 #[test]
1721 fn test_update_body_heading_without_body() {
1722 let temp_dir = tempfile::tempdir().unwrap();
1723 setup_fixture(&temp_dir);
1724 let org_mode = make_org_mode(&temp_dir);
1725
1726 let mut e = update_by_id("task-groceries-456");
1727 e.body = Some("Remember the milk.".to_string());
1728 org_mode.update_todo(e).unwrap();
1729 let content = fs::read_to_string(temp_dir.path().join("notes.org")).unwrap();
1730 assert!(content.contains("Remember the milk."));
1731 }
1732
1733 #[test]
1734 fn test_update_property_upsert_existing_key() {
1735 let temp_dir = tempfile::tempdir().unwrap();
1736 setup_fixture(&temp_dir);
1737 let org_mode = make_org_mode(&temp_dir);
1738
1739 let mut e = update_by_id("task-groceries-456");
1740 e.properties = Some(vec![crate::PropertyPair {
1741 key: "EFFORT".to_string(),
1742 value: "30m".to_string(),
1743 }]);
1744 org_mode.update_todo(e).unwrap();
1745 let content = fs::read_to_string(temp_dir.path().join("notes.org")).unwrap();
1746 assert!(content.contains(":EFFORT: 30m"), "upserted:\n{content}");
1747 assert!(
1748 content.contains(":ID: task-groceries-456"),
1749 "ID preserved:\n{content}"
1750 );
1751 }
1752
1753 #[test]
1754 fn test_update_property_upsert_creates_drawer_when_absent() {
1755 let temp_dir = tempfile::tempdir().unwrap();
1756 fs::write(
1757 temp_dir.path().join("nodrawer.org"),
1758 "* TODO No drawer task\nbody text\n",
1759 )
1760 .unwrap();
1761 let org_mode = make_org_mode(&temp_dir);
1762 let e = UpdateEntry {
1763 id: None,
1764 file: Some("nodrawer.org".to_string()),
1765 heading_path: Some("No drawer task".to_string()),
1766 todo_state: None,
1767 priority: None,
1768 tags: None,
1769 scheduled: None,
1770 deadline: None,
1771 closed: None,
1772 clear: vec![],
1773 title: None,
1774 body: None,
1775 properties: Some(vec![crate::PropertyPair {
1776 key: "CATEGORY".to_string(),
1777 value: "work".to_string(),
1778 }]),
1779 remove_properties: None,
1780 };
1781 org_mode.update_todo(e).unwrap();
1782 let content = fs::read_to_string(temp_dir.path().join("nodrawer.org")).unwrap();
1783 assert!(
1784 content.contains(":PROPERTIES:"),
1785 "drawer created:\n{content}"
1786 );
1787 assert!(
1788 content.contains(":CATEGORY: work"),
1789 "property set:\n{content}"
1790 );
1791 assert!(content.contains(":END:"), "drawer ended:\n{content}");
1792 assert!(content.contains("body text"), "body preserved:\n{content}");
1793 }
1794
1795 #[test]
1796 fn test_update_property_remove_existing_key() {
1797 let temp_dir = tempfile::tempdir().unwrap();
1798 fs::write(
1799 temp_dir.path().join("props.org"),
1800 "* TODO Task\n:PROPERTIES:\n:ID: p-1\n:EFFORT: 1h\n:END:\n",
1801 )
1802 .unwrap();
1803 let org_mode = make_org_mode(&temp_dir);
1804 let e = UpdateEntry {
1805 id: None,
1806 file: Some("props.org".to_string()),
1807 heading_path: Some("Task".to_string()),
1808 todo_state: None,
1809 priority: None,
1810 tags: None,
1811 scheduled: None,
1812 deadline: None,
1813 closed: None,
1814 clear: vec![],
1815 title: None,
1816 body: None,
1817 properties: None,
1818 remove_properties: Some(vec!["EFFORT".to_string()]),
1819 };
1820 org_mode.update_todo(e).unwrap();
1821 let content = fs::read_to_string(temp_dir.path().join("props.org")).unwrap();
1822 assert!(!content.contains(":EFFORT:"), "removed:\n{content}");
1823 assert!(content.contains(":ID: p-1"), "ID preserved:\n{content}");
1824 }
1825
1826 #[test]
1827 fn test_update_property_remove_nonexistent_is_noop() {
1828 let temp_dir = tempfile::tempdir().unwrap();
1829 fs::write(
1830 temp_dir.path().join("noop.org"),
1831 "* TODO Task\n:PROPERTIES:\n:ID: noop-1\n:END:\n",
1832 )
1833 .unwrap();
1834 let org_mode = make_org_mode(&temp_dir);
1835 let e = UpdateEntry {
1836 id: None,
1837 file: Some("noop.org".to_string()),
1838 heading_path: Some("Task".to_string()),
1839 todo_state: None,
1840 priority: None,
1841 tags: None,
1842 scheduled: None,
1843 deadline: None,
1844 closed: None,
1845 clear: vec![],
1846 title: None,
1847 body: None,
1848 properties: None,
1849 remove_properties: Some(vec!["NONEXISTENT".to_string()]),
1850 };
1851 let before = fs::read_to_string(temp_dir.path().join("noop.org")).unwrap();
1852 org_mode.update_todo(e).unwrap();
1853 let after = fs::read_to_string(temp_dir.path().join("noop.org")).unwrap();
1854 assert_eq!(before, after, "noop removal must not change file");
1855 }
1856
1857 #[test]
1858 fn test_update_property_remove_all_keys_removes_drawer() {
1859 let temp_dir = tempfile::tempdir().unwrap();
1860 fs::write(
1861 temp_dir.path().join("drain.org"),
1862 "* TODO Task\n:PROPERTIES:\n:EFFORT: 1h\n:END:\nbody\n",
1863 )
1864 .unwrap();
1865 let org_mode = make_org_mode(&temp_dir);
1866 let e = UpdateEntry {
1867 id: None,
1868 file: Some("drain.org".to_string()),
1869 heading_path: Some("Task".to_string()),
1870 todo_state: None,
1871 priority: None,
1872 tags: None,
1873 scheduled: None,
1874 deadline: None,
1875 closed: None,
1876 clear: vec![],
1877 title: None,
1878 body: None,
1879 properties: None,
1880 remove_properties: Some(vec!["EFFORT".to_string()]),
1881 };
1882 org_mode.update_todo(e).unwrap();
1883 let content = fs::read_to_string(temp_dir.path().join("drain.org")).unwrap();
1884 assert!(
1885 !content.contains(":PROPERTIES:"),
1886 "drawer removed:\n{content}"
1887 );
1888 assert!(content.contains("body"), "body preserved:\n{content}");
1889 }
1890}