Skip to main content

org_core/org_mode/
update.rs

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            // orgize's planning span ends before the trailing '\n' only at EOF; max(1) guards that.
78            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            // Extend line_count over any additional consecutive planning lines that
89            // orgize does not include in its span (non-standard but written by some tools).
90            // Also extract any timestamps from those extra lines so they survive the splice.
91            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                    // Extract the raw timestamp (everything after "KEYWORD: ").
107                    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        // Targeting: id OR (file + heading_path); id wins when both are given.
182        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        // At least one mutation.
210        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        // No field may be both set and cleared; no duplicate clear entries.
226        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        // Conflict: same key in both properties and remove_properties
318        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        // Same lock-release dance as capture_append.
352        #[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        // Resulting field values: explicit set > clear > existing.
445        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            // Reactivated or keyword-less heading: drop the stale CLOSED.
508            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        // Splice: headline line replaced; planning block (0..n lines) replaced by 0..1 lines.
528        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        // --- Property drawer splice ---
545        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        // --- Body splice (adjust for both plan_delta and drawer_delta) ---
624        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                        // Two matches suffice to report ambiguity; stop early.
782                        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                    // orgize's title_raw() keeps the title/tags separator as a
800                    // trailing space once tags are present; trim so user-supplied
801                    // path segments match the bare title.
802                    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        // property drawer must survive directly under the planning line
1107        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        // id wins when both targeting forms are given
1142        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        // untouched scheduled keeps its raw text including the repeater
1189        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        // everything outside the headline+planning span is byte-identical
1194        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        // Non-leaf paths are valid targets: "Daily Tasks" has children but is a
1227        // unique full-path match. Guards the behavior flipped by PR review.
1228        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        // unique under B: works
1277        org_mode.update_todo(e.clone()).unwrap();
1278        // now make the remaining one ambiguous by checking a duplicated full path
1279        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        // and no auto-stamp on a fresh DONE either
1443        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>", // no trailing newline
1473        )
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        // Must not contain a duplicate SCHEDULED line
1495        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}