Skip to main content

org_core/org_mode/
capture.rs

1use std::collections::HashSet;
2use std::ffi::OsString;
3use std::fs::OpenOptions;
4use std::io::Write;
5use std::path::Path;
6use std::{fs, io, path::PathBuf};
7
8use chrono::NaiveDate;
9use orgize::export::{Container, Event, from_fn_with_ctx};
10use orgize::{Org, ParseConfig, TextRange, TextSize};
11#[cfg(unix)]
12use std::os::unix::fs::MetadataExt;
13
14use crate::OrgModeError;
15use crate::org_mode::{CaptureEntry, CaptureResult, OrgMode, PropertyPair};
16
17const MAX_HEADING_LEVEL: usize = 19;
18
19struct HeadingSearchResult {
20    insert_pos: TextSize,
21    matched_depth: usize,
22    last_matched_level: usize,
23    remaining_parts: Vec<String>,
24}
25
26struct ResolvedCapture {
27    file_rel: String,
28    scheduled: Option<ParsedTimestamp>,
29    deadline: Option<ParsedTimestamp>,
30    closed: Option<ParsedTimestamp>,
31    datetree_date: Option<NaiveDate>,
32    properties: Vec<PropertyPair>,
33}
34
35pub(crate) fn is_valid_tag(tag: &str) -> bool {
36    !tag.is_empty()
37        && tag
38            .chars()
39            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '@')
40}
41
42pub(crate) fn is_valid_property_key(key: &str) -> bool {
43    !key.is_empty()
44        && key
45            .chars()
46            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
47}
48
49#[derive(Debug, Clone)]
50pub(crate) struct ParsedTimestamp {
51    pub(crate) date: NaiveDate,
52    pub(crate) time: Option<chrono::NaiveTime>,
53    pub(crate) repeater: Option<String>,
54    pub(crate) warning: Option<String>,
55}
56
57impl OrgMode {
58    pub fn capture_append(&self, entry: CaptureEntry) -> Result<CaptureResult, OrgModeError> {
59        let resolved = self.validate_and_resolve(&entry)?;
60        let full_path = self.prepare_target_path(&resolved.file_rel)?;
61        let file_rel = resolved.file_rel.as_str();
62
63        let lock_path = Self::lock_path_for(&full_path)?;
64        let lock_file = Self::acquire_capture_lock(&lock_path)?;
65
66        let result = self.write_entry(&full_path, file_rel, &entry, &resolved);
67
68        // On Unix, unlink while holding the lock so a racing locker that opens the same
69        // path gets a different inode and retries (stat-after-lock invariant).
70        #[cfg(unix)]
71        let _ = fs::remove_file(&lock_path);
72        drop(lock_file);
73        // On Windows, attempt to delete after close. The lock file is opened without
74        // FILE_SHARE_DELETE, so remove_file only succeeds when no other thread has it
75        // open (i.e. no waiters). When it fails we ignore the error — the file persists
76        // but serialisation is maintained because all waiters open the same inode.
77        #[cfg(not(unix))]
78        let _ = fs::remove_file(&lock_path);
79
80        result
81    }
82
83    fn write_entry(
84        &self,
85        full_path: &Path,
86        file_rel: &str,
87        entry: &CaptureEntry,
88        resolved: &ResolvedCapture,
89    ) -> Result<CaptureResult, OrgModeError> {
90        let content = if full_path.exists() {
91            fs::read_to_string(full_path).map_err(OrgModeError::IoError)?
92        } else {
93            String::new()
94        };
95
96        let parse_config = ParseConfig {
97            todo_keywords: (
98                self.config.unfinished_keywords(),
99                self.config.finished_keywords(),
100            ),
101            ..Default::default()
102        };
103        let mut org = parse_config.parse(&content);
104
105        let (insert_pos, prefix_text, parent_level, under_target) =
106            self.build_target_context(&org, entry, resolved.datetree_date, content.len())?;
107
108        let level = match entry.level {
109            Some(l) if under_target.is_some() => {
110                let computed = l.max(parent_level + 1);
111                if computed > MAX_HEADING_LEVEL {
112                    return Err(OrgModeError::InvalidLevel(computed));
113                }
114                computed
115            }
116            Some(l) => l,
117            None if under_target.is_some() => {
118                let computed = parent_level + 1;
119                if computed > MAX_HEADING_LEVEL {
120                    return Err(OrgModeError::InvalidLevel(computed));
121                }
122                computed
123            }
124            None => 1,
125        };
126
127        let heading_line = Self::format_heading(
128            level,
129            entry.todo_state.as_deref(),
130            entry.priority.as_deref(),
131            &entry.title,
132            entry.tags.as_deref(),
133        );
134
135        let insert_text = self.build_insert_text(
136            &prefix_text,
137            &heading_line,
138            content.is_empty(),
139            resolved,
140            entry.body.as_deref(),
141        );
142
143        org.replace_range(TextRange::empty(insert_pos), &insert_text);
144        let new_content = org.to_org();
145        Self::atomic_write(full_path, new_content.as_bytes())?;
146
147        Ok(CaptureResult {
148            file_path: file_rel.to_string(),
149            level,
150            heading_line,
151            under_target,
152        })
153    }
154
155    fn build_target_context(
156        &self,
157        org: &Org,
158        entry: &CaptureEntry,
159        datetree_date: Option<NaiveDate>,
160        content_len: usize,
161    ) -> Result<(TextSize, String, usize, Option<String>), OrgModeError> {
162        let mut effective_target_parts: Vec<String> = Vec::new();
163        if let Some(ref target) = entry.target_heading {
164            effective_target_parts.extend(target.split('/').map(|s| s.trim().to_string()));
165        }
166        if let Some(d) = datetree_date {
167            effective_target_parts.extend(Self::datetree_segments(d));
168        }
169        let effective_target = if effective_target_parts.is_empty() {
170            None
171        } else {
172            Some(effective_target_parts.join("/"))
173        };
174
175        if let Some(ref target) = effective_target {
176            let search = self.find_heading_path(org, target, content_len as u32);
177
178            if search.remaining_parts.is_empty() {
179                return Ok((
180                    search.insert_pos,
181                    String::new(),
182                    search.last_matched_level,
183                    Some(target.clone()),
184                ));
185            }
186
187            let base_level = if let Some(explicit_level) = entry.level {
188                let from_explicit = explicit_level.saturating_sub(search.remaining_parts.len());
189                if search.matched_depth > 0 {
190                    from_explicit.max(search.last_matched_level + 1)
191                } else {
192                    from_explicit.max(1)
193                }
194            } else if search.matched_depth > 0 {
195                search.last_matched_level + 1
196            } else {
197                1
198            };
199
200            let mut prefix = String::new();
201            let mut last_level = search.last_matched_level;
202            for (i, part) in search.remaining_parts.iter().enumerate() {
203                let hlevel = base_level + i;
204                if hlevel > MAX_HEADING_LEVEL {
205                    return Err(OrgModeError::InvalidLevel(hlevel));
206                }
207                prefix.push_str(&"*".repeat(hlevel));
208                prefix.push(' ');
209                prefix.push_str(part);
210                prefix.push('\n');
211                last_level = hlevel;
212            }
213
214            Ok((search.insert_pos, prefix, last_level, Some(target.clone())))
215        } else {
216            Ok((
217                TextSize::from(content_len as u32),
218                String::new(),
219                0usize,
220                None,
221            ))
222        }
223    }
224
225    fn build_insert_text(
226        &self,
227        prefix_text: &str,
228        heading_line: &str,
229        content_is_empty: bool,
230        resolved: &ResolvedCapture,
231        body: Option<&str>,
232    ) -> String {
233        let mut insert_text = String::new();
234        if !content_is_empty {
235            insert_text.push('\n');
236        }
237        insert_text.push_str(prefix_text);
238        insert_text.push_str(heading_line);
239        insert_text.push('\n');
240
241        let mut planning_parts: Vec<String> = Vec::new();
242        if let Some(ref ts) = resolved.scheduled {
243            planning_parts.push(format!(
244                "SCHEDULED: {}",
245                Self::format_org_timestamp(ts, true)
246            ));
247        }
248        if let Some(ref ts) = resolved.deadline {
249            planning_parts.push(format!(
250                "DEADLINE: {}",
251                Self::format_org_timestamp(ts, true)
252            ));
253        }
254        if let Some(ref ts) = resolved.closed {
255            planning_parts.push(format!("CLOSED: {}", Self::format_org_timestamp(ts, false)));
256        }
257        if !planning_parts.is_empty() {
258            insert_text.push_str(&planning_parts.join(" "));
259            insert_text.push('\n');
260        }
261
262        let user_has_created = resolved
263            .properties
264            .iter()
265            .any(|p| p.key.eq_ignore_ascii_case("CREATED"));
266        let mut effective: Vec<PropertyPair> = Vec::new();
267        if self.config.org_auto_created_property && !user_has_created {
268            let now = chrono::Local::now();
269            let dow = now.format("%a");
270            effective.push(PropertyPair {
271                key: "CREATED".to_string(),
272                value: format!("[{} {dow} {}]", now.format("%Y-%m-%d"), now.format("%H:%M")),
273            });
274        }
275        effective.extend(resolved.properties.iter().cloned());
276
277        if !effective.is_empty() {
278            insert_text.push_str(":PROPERTIES:\n");
279            for pp in &effective {
280                insert_text.push_str(&format!(":{}: {}\n", pp.key, pp.value));
281            }
282            insert_text.push_str(":END:\n");
283        }
284
285        if let Some(body) = body {
286            insert_text.push_str(body);
287            if !body.ends_with('\n') {
288                insert_text.push('\n');
289            }
290        }
291
292        insert_text
293    }
294
295    pub(crate) fn prepare_target_path(&self, file_rel: &str) -> Result<PathBuf, OrgModeError> {
296        let org_dir = PathBuf::from(&self.config.org_directory);
297        let full_path = org_dir.join(file_rel);
298
299        let canonical_org_dir = org_dir.canonicalize().map_err(|e| {
300            OrgModeError::InvalidDirectory(format!(
301                "Cannot canonicalize org directory '{}': {e}",
302                self.config.org_directory
303            ))
304        })?;
305
306        if full_path.exists() {
307            if full_path.is_dir() {
308                return Err(OrgModeError::InvalidDirectory(format!(
309                    "path resolves to a directory, not a file: {file_rel}"
310                )));
311            }
312            // Canonicalize the parent directory instead of the full path.
313            // canonicalize(full_path) opens a handle to the target file, which
314            // races with a concurrent atomic rename on Windows: MoveFileExW with
315            // MOVEFILE_REPLACE_EXISTING fails with ERROR_ACCESS_DENIED when any
316            // handle to the target is open.  The parent is never atomically
317            // replaced, so canonicalize(parent) is race-free.  Appending the
318            // bare filename is safe: rename() replaces a symlink as a directory
319            // entry rather than following it, so a file-level symlink cannot
320            // redirect writes outside the org directory.
321            let canonical_file = match full_path.parent() {
322                Some(parent) => parent
323                    .canonicalize()
324                    .map(|p| p.join(full_path.file_name().unwrap_or_default()))
325                    .map_err(OrgModeError::IoError)?,
326                None => full_path.canonicalize().map_err(OrgModeError::IoError)?,
327            };
328            if !canonical_file.starts_with(&canonical_org_dir) {
329                return Err(OrgModeError::InvalidDirectory(format!(
330                    "Path is outside org directory: {file_rel}"
331                )));
332            }
333        } else if let Some(parent) = full_path.parent() {
334            if parent.exists() {
335                let canonical_parent = parent.canonicalize().map_err(OrgModeError::IoError)?;
336                if !canonical_parent.starts_with(&canonical_org_dir) {
337                    return Err(OrgModeError::InvalidDirectory(format!(
338                        "Path is outside org directory: {file_rel}"
339                    )));
340                }
341            } else {
342                let mut ancestor = parent;
343                // Validate the nearest existing ancestor before creating any
344                // directories to prevent symlinks inside org_dir from escaping
345                // the root before the post-creation canonicalization check runs.
346                loop {
347                    match ancestor.parent() {
348                        Some(p) if p.exists() => {
349                            let canonical_ancestor =
350                                p.canonicalize().map_err(OrgModeError::IoError)?;
351                            if !canonical_ancestor.starts_with(&canonical_org_dir) {
352                                return Err(OrgModeError::InvalidDirectory(format!(
353                                    "Path is outside org directory: {file_rel}"
354                                )));
355                            }
356                            break;
357                        }
358                        Some(p) => ancestor = p,
359                        None => break,
360                    }
361                }
362                fs::create_dir_all(parent).map_err(OrgModeError::IoError)?;
363                let canonical_parent = parent.canonicalize().map_err(OrgModeError::IoError)?;
364                if !canonical_parent.starts_with(&canonical_org_dir) {
365                    return Err(OrgModeError::InvalidDirectory(format!(
366                        "Path is outside org directory: {file_rel}"
367                    )));
368                }
369            }
370        }
371
372        Ok(full_path)
373    }
374
375    fn validate_and_resolve(&self, entry: &CaptureEntry) -> Result<ResolvedCapture, OrgModeError> {
376        let file_rel = entry
377            .file
378            .as_deref()
379            .unwrap_or(&self.config.org_default_notes_file)
380            .to_string();
381
382        if entry.title.trim().is_empty() {
383            return Err(OrgModeError::InvalidTitle(
384                "title must not be empty or whitespace-only".to_string(),
385            ));
386        }
387        if entry.title.contains('\n') || entry.title.contains('\r') {
388            return Err(OrgModeError::InvalidTitle(
389                "title must not contain newline or carriage return characters".to_string(),
390            ));
391        }
392
393        if let Some(level) = entry.level
394            && !(1..=MAX_HEADING_LEVEL).contains(&level)
395        {
396            return Err(OrgModeError::InvalidLevel(level));
397        }
398
399        Self::validate_relative_file_path(&file_rel)?;
400
401        if let Some(ref target) = entry.target_heading {
402            for segment in target.split('/') {
403                if segment.trim().is_empty() {
404                    return Err(OrgModeError::InvalidHeadingPath(format!(
405                        "target_heading contains an empty or whitespace-only segment: '{target}'"
406                    )));
407                }
408            }
409        }
410
411        if let Some(ref kw) = entry.todo_state {
412            let valid_keywords: Vec<&str> = self
413                .config
414                .org_todo_keywords
415                .iter()
416                .filter(|k| k.as_str() != "|")
417                .map(|k| k.as_str())
418                .collect();
419            if !valid_keywords.contains(&kw.as_str()) {
420                return Err(OrgModeError::InvalidTodoKeyword(kw.clone()));
421            }
422        }
423
424        if let Some(ref p) = entry.priority
425            && !matches!(p.as_str(), "A" | "B" | "C")
426        {
427            return Err(OrgModeError::InvalidPriority(p.clone()));
428        }
429
430        if let Some(ref tags) = entry.tags {
431            for tag in tags {
432                if !is_valid_tag(tag) {
433                    return Err(OrgModeError::InvalidTag(tag.clone()));
434                }
435            }
436        }
437
438        let scheduled = entry
439            .scheduled
440            .as_deref()
441            .map(|v| Self::parse_iso_timestamp("scheduled", v))
442            .transpose()?;
443        let deadline = entry
444            .deadline
445            .as_deref()
446            .map(|v| Self::parse_iso_timestamp("deadline", v))
447            .transpose()?;
448        let closed = entry
449            .closed
450            .as_deref()
451            .map(|v| Self::parse_iso_timestamp("closed", v))
452            .transpose()?;
453
454        if entry.datetree_date.is_some() && !entry.datetree {
455            return Err(OrgModeError::DatetreeDateWithoutFlag);
456        }
457        let datetree_date: Option<NaiveDate> = if entry.datetree {
458            match entry.datetree_date.as_deref() {
459                Some(s) => {
460                    if s.contains(char::is_whitespace) {
461                        return Err(OrgModeError::InvalidDatetreeDate(s.to_string()));
462                    }
463                    Some(
464                        NaiveDate::parse_from_str(s, "%Y-%m-%d")
465                            .map_err(|_| OrgModeError::InvalidDatetreeDate(s.to_string()))?,
466                    )
467                }
468                None => Some(chrono::Local::now().date_naive()),
469            }
470        } else {
471            None
472        };
473
474        let properties: Vec<PropertyPair> = match entry.properties {
475            Some(ref ps) => {
476                let mut seen: HashSet<String> = HashSet::new();
477                for p in ps {
478                    if !is_valid_property_key(&p.key) {
479                        return Err(OrgModeError::InvalidPropertyKey(p.key.clone()));
480                    }
481                    if p.value.contains('\n') || p.value.contains('\r') {
482                        return Err(OrgModeError::InvalidPropertyValue {
483                            key: p.key.clone(),
484                            reason: "value must not contain newline or carriage return".to_string(),
485                        });
486                    }
487                    if !seen.insert(p.key.to_uppercase()) {
488                        return Err(OrgModeError::DuplicatePropertyKey(p.key.clone()));
489                    }
490                }
491                ps.clone()
492            }
493            None => Vec::new(),
494        };
495
496        Ok(ResolvedCapture {
497            file_rel,
498            scheduled,
499            deadline,
500            closed,
501            datetree_date,
502            properties,
503        })
504    }
505
506    pub(crate) fn lock_path_for(target: &Path) -> Result<PathBuf, OrgModeError> {
507        let parent = target.parent().ok_or_else(|| {
508            OrgModeError::IoError(io::Error::new(
509                io::ErrorKind::InvalidInput,
510                "target path has no parent directory",
511            ))
512        })?;
513        let file_name = target.file_name().ok_or_else(|| {
514            OrgModeError::IoError(io::Error::new(
515                io::ErrorKind::InvalidInput,
516                "target path has no file name",
517            ))
518        })?;
519        let mut name = OsString::from(".");
520        name.push(file_name);
521        name.push(".lock");
522        Ok(parent.join(name))
523    }
524
525    pub(crate) fn validate_relative_file_path(file_rel: &str) -> Result<(), OrgModeError> {
526        use std::path::Component;
527        let p = Path::new(file_rel);
528        if p.is_absolute() {
529            return Err(OrgModeError::InvalidDirectory(format!(
530                "absolute path not allowed: {file_rel}"
531            )));
532        }
533        if file_rel.trim_end_matches('/').ends_with("/.") {
534            return Err(OrgModeError::InvalidDirectory(format!(
535                "file path must refer to a file, not a directory: {file_rel}"
536            )));
537        }
538        let mut has_normal = false;
539        for comp in p.components() {
540            match comp {
541                Component::Normal(_) => has_normal = true,
542                Component::CurDir => {}
543                Component::ParentDir => {
544                    return Err(OrgModeError::InvalidDirectory(format!(
545                        "path traversal segment '..' not allowed: {file_rel}"
546                    )));
547                }
548                Component::RootDir | Component::Prefix(_) => {
549                    return Err(OrgModeError::InvalidDirectory(format!(
550                        "absolute or drive-prefix path not allowed: {file_rel}"
551                    )));
552                }
553            }
554        }
555        if !has_normal {
556            return Err(OrgModeError::InvalidDirectory(
557                "file path must refer to a file, not empty or '.'".to_string(),
558            ));
559        }
560        Ok(())
561    }
562
563    #[cfg(unix)]
564    pub(crate) fn acquire_capture_lock(lock_path: &Path) -> Result<std::fs::File, OrgModeError> {
565        loop {
566            let fd = OpenOptions::new()
567                .read(true)
568                .write(true)
569                .create(true)
570                .truncate(false)
571                .open(lock_path)
572                .map_err(OrgModeError::IoError)?;
573            fd.lock().map_err(OrgModeError::IoError)?;
574
575            let our_ino = fd.metadata().map_err(OrgModeError::IoError)?.ino();
576            match fs::metadata(lock_path) {
577                Ok(m) if m.ino() == our_ino => return Ok(fd),
578                _ => {
579                    drop(fd);
580                    continue;
581                }
582            }
583        }
584    }
585
586    #[cfg(not(unix))]
587    pub(crate) fn acquire_capture_lock(lock_path: &Path) -> Result<std::fs::File, OrgModeError> {
588        use std::os::windows::fs::OpenOptionsExt;
589        // Open without FILE_SHARE_DELETE (READ=0x1 | WRITE=0x2, no DELETE=0x4).
590        // This makes remove_file fail with a sharing violation while any other thread
591        // has the file open, which prevents the race where a new thread creates a fresh
592        // inode and acquires an uncontested lock while the previous waiter still holds
593        // what it believes to be the same exclusive lock.
594        let fd = OpenOptions::new()
595            .read(true)
596            .write(true)
597            .create(true)
598            .truncate(false)
599            .share_mode(0x00000001 | 0x00000002)
600            .open(lock_path)
601            .map_err(OrgModeError::IoError)?;
602        fd.lock().map_err(OrgModeError::IoError)?;
603        Ok(fd)
604    }
605
606    pub(crate) fn atomic_write(target: &Path, bytes: &[u8]) -> Result<(), OrgModeError> {
607        let parent = target.parent().ok_or_else(|| {
608            OrgModeError::IoError(io::Error::new(
609                io::ErrorKind::InvalidInput,
610                "target path has no parent directory",
611            ))
612        })?;
613
614        let mut tmp = tempfile::Builder::new()
615            .prefix(".")
616            .tempfile_in(parent)
617            .map_err(OrgModeError::IoError)?;
618
619        tmp.write_all(bytes).map_err(OrgModeError::IoError)?;
620        tmp.as_file().sync_all().map_err(OrgModeError::IoError)?;
621
622        #[cfg(unix)]
623        if let Ok(meta) = fs::metadata(target) {
624            let _ = tmp.as_file().set_permissions(meta.permissions());
625        }
626
627        tmp.persist(target)
628            .map_err(|e| OrgModeError::IoError(e.error))?;
629        Ok(())
630    }
631
632    pub(crate) fn format_heading(
633        level: usize,
634        todo_state: Option<&str>,
635        priority: Option<&str>,
636        title: &str,
637        tags: Option<&[String]>,
638    ) -> String {
639        let stars = "*".repeat(level);
640        let mut parts = vec![stars];
641
642        if let Some(kw) = todo_state {
643            parts.push(kw.to_string());
644        }
645        if let Some(p) = priority {
646            parts.push(format!("[#{p}]"));
647        }
648        parts.push(title.to_string());
649
650        let mut line = parts.join(" ");
651
652        if let Some(tags) = tags
653            && !tags.is_empty()
654        {
655            let tag_str = format!(" :{}:", tags.join(":"));
656            line.push_str(&tag_str);
657        }
658
659        line
660    }
661
662    pub(crate) fn parse_iso_timestamp(
663        field: &'static str,
664        value: &str,
665    ) -> Result<ParsedTimestamp, OrgModeError> {
666        let invalid = || OrgModeError::InvalidTimestamp {
667            field,
668            value: value.to_string(),
669        };
670
671        let mut tokens = value.split_whitespace();
672        let date_tok = tokens.next().ok_or_else(invalid)?;
673        let date = NaiveDate::parse_from_str(date_tok, "%Y-%m-%d").map_err(|_| invalid())?;
674
675        let mut time: Option<chrono::NaiveTime> = None;
676        let mut repeater: Option<String> = None;
677        let mut warning: Option<String> = None;
678
679        for tok in tokens {
680            if time.is_none()
681                && repeater.is_none()
682                && warning.is_none()
683                && let Ok(t) = chrono::NaiveTime::parse_from_str(tok, "%H:%M")
684            {
685                time = Some(t);
686                continue;
687            }
688
689            if Self::is_repeater_suffix(tok) {
690                if repeater.is_some() {
691                    return Err(invalid());
692                }
693                repeater = Some(tok.to_string());
694                continue;
695            }
696            if Self::is_warning_suffix(tok) {
697                if warning.is_some() {
698                    return Err(invalid());
699                }
700                warning = Some(tok.to_string());
701                continue;
702            }
703            return Err(invalid());
704        }
705
706        Ok(ParsedTimestamp {
707            date,
708            time,
709            repeater,
710            warning,
711        })
712    }
713
714    pub(crate) fn datetree_segments(date: NaiveDate) -> Vec<String> {
715        vec![
716            date.format("%Y").to_string(),
717            date.format("%Y-%m %B").to_string(),
718            date.format("%Y-%m-%d %A").to_string(),
719        ]
720    }
721
722    pub(crate) fn format_org_timestamp(ts: &ParsedTimestamp, active: bool) -> String {
723        let (open, close) = if active { ('<', '>') } else { ('[', ']') };
724        let dow = ts.date.format("%a");
725        let mut s = format!("{open}{} {dow}", ts.date.format("%Y-%m-%d"));
726        if let Some(t) = ts.time {
727            s.push_str(&format!(" {}", t.format("%H:%M")));
728        }
729        if let Some(r) = &ts.repeater {
730            s.push(' ');
731            s.push_str(r);
732        }
733        if let Some(w) = &ts.warning {
734            s.push(' ');
735            s.push_str(w);
736        }
737        s.push(close);
738        s
739    }
740
741    fn is_repeater_suffix(tok: &str) -> bool {
742        let body = if let Some(rest) = tok.strip_prefix("++") {
743            rest
744        } else if let Some(rest) = tok.strip_prefix(".+") {
745            rest
746        } else if let Some(rest) = tok.strip_prefix('+') {
747            rest
748        } else {
749            return false;
750        };
751        Self::is_count_unit(body)
752    }
753
754    fn is_warning_suffix(tok: &str) -> bool {
755        let body = match tok.strip_prefix('-') {
756            Some(rest) => rest,
757            None => return false,
758        };
759        Self::is_count_unit(body)
760    }
761
762    fn is_count_unit(s: &str) -> bool {
763        if s.len() < 2 {
764            return false;
765        }
766        let mut chars = s.chars();
767        let unit = chars.next_back().unwrap();
768        if !matches!(unit, 'h' | 'd' | 'w' | 'm' | 'y') {
769            return false;
770        }
771        let num: String = chars.collect();
772        if num.is_empty() {
773            return false;
774        }
775        matches!(
776            num.parse::<u32>(),
777            Ok(n) if n > 0 && num.chars().all(|c| c.is_ascii_digit())
778        )
779    }
780
781    fn find_heading_path(
782        &self,
783        org: &Org,
784        heading_path: &str,
785        content_len: u32,
786    ) -> HeadingSearchResult {
787        let path_parts: Vec<&str> = heading_path.split('/').collect();
788        let total = path_parts.len();
789
790        let mut open_stack: Vec<(usize, Option<usize>)> = Vec::new();
791        let mut matched = 0usize;
792        let mut insert_pos = TextSize::from(content_len);
793        let mut last_level = 0usize;
794
795        let mut handler = from_fn_with_ctx(|event, ctx| match event {
796            Event::Enter(Container::Headline(h)) => {
797                let level = h.level();
798
799                while let Some(&(top_level, _)) = open_stack.last() {
800                    if top_level >= level {
801                        open_stack.pop();
802                    } else {
803                        break;
804                    }
805                }
806
807                let mut step_matched_depth: Option<usize> = None;
808                if matched < total {
809                    let part = path_parts[matched];
810                    let parent_ok = if matched == 0 {
811                        true
812                    } else {
813                        open_stack
814                            .last()
815                            .map(|&(_, d)| d == Some(matched - 1))
816                            .unwrap_or(false)
817                    };
818                    if parent_ok && h.title_raw() == part {
819                        insert_pos = h.end();
820                        last_level = level;
821                        step_matched_depth = Some(matched);
822                        matched += 1;
823                        if matched == total {
824                            ctx.stop();
825                        }
826                    }
827                }
828
829                open_stack.push((level, step_matched_depth));
830            }
831            Event::Leave(Container::Headline(h)) => {
832                let level = h.level();
833                while let Some(&(top_level, _)) = open_stack.last() {
834                    if top_level >= level {
835                        open_stack.pop();
836                        if top_level == level {
837                            break;
838                        }
839                    } else {
840                        break;
841                    }
842                }
843            }
844            _ => {}
845        });
846
847        org.traverse(&mut handler);
848
849        HeadingSearchResult {
850            insert_pos,
851            matched_depth: matched,
852            last_matched_level: last_level,
853            remaining_parts: path_parts[matched..]
854                .iter()
855                .map(|s| s.to_string())
856                .collect(),
857        }
858    }
859}
860
861#[cfg(test)]
862mod tests {
863    use super::*;
864    use chrono::NaiveDate;
865
866    #[test]
867    fn test_format_heading_simple() {
868        let result = OrgMode::format_heading(1, None, None, "My Title", None);
869        assert_eq!(result, "* My Title");
870    }
871
872    #[test]
873    fn test_format_heading_with_todo() {
874        let result = OrgMode::format_heading(2, Some("TODO"), None, "Task", None);
875        assert_eq!(result, "** TODO Task");
876    }
877
878    #[test]
879    fn test_format_heading_with_priority() {
880        let result = OrgMode::format_heading(1, None, Some("A"), "Important", None);
881        assert_eq!(result, "* [#A] Important");
882    }
883
884    #[test]
885    fn test_format_heading_with_tags() {
886        let tags = vec!["work".to_string(), "urgent".to_string()];
887        let result = OrgMode::format_heading(1, None, None, "Title", Some(&tags));
888        assert_eq!(result, "* Title :work:urgent:");
889    }
890
891    #[test]
892    fn test_format_heading_full() {
893        let tags = vec!["proj".to_string()];
894        let result = OrgMode::format_heading(2, Some("TODO"), Some("B"), "My Task", Some(&tags));
895        assert_eq!(result, "** TODO [#B] My Task :proj:");
896    }
897
898    #[test]
899    fn test_parse_iso_timestamp_date_only() {
900        let ts = OrgMode::parse_iso_timestamp("scheduled", "2026-05-15").unwrap();
901        assert_eq!(ts.date, NaiveDate::from_ymd_opt(2026, 5, 15).unwrap());
902        assert!(ts.time.is_none());
903        assert!(ts.repeater.is_none());
904        assert!(ts.warning.is_none());
905    }
906
907    #[test]
908    fn test_parse_iso_timestamp_with_time() {
909        let ts = OrgMode::parse_iso_timestamp("deadline", "2026-05-15 14:30").unwrap();
910        assert_eq!(ts.date, NaiveDate::from_ymd_opt(2026, 5, 15).unwrap());
911        assert_eq!(
912            ts.time,
913            Some(chrono::NaiveTime::from_hms_opt(14, 30, 0).unwrap())
914        );
915    }
916
917    #[test]
918    fn test_parse_iso_timestamp_rejects_garbage() {
919        for bad in ["2026/05/15", "tomorrow", "<2026-05-15 Fri>", ""] {
920            let err = OrgMode::parse_iso_timestamp("scheduled", bad).unwrap_err();
921            assert!(matches!(err, OrgModeError::InvalidTimestamp { .. }));
922        }
923    }
924
925    #[test]
926    fn test_parse_iso_timestamp_with_repeater() {
927        let ts = OrgMode::parse_iso_timestamp("scheduled", "2026-05-15 ++1w").unwrap();
928        assert_eq!(ts.repeater.as_deref(), Some("++1w"));
929        assert!(ts.warning.is_none());
930    }
931
932    #[test]
933    fn test_parse_iso_timestamp_with_warning() {
934        let ts = OrgMode::parse_iso_timestamp("deadline", "2026-05-15 -3d").unwrap();
935        assert!(ts.repeater.is_none());
936        assert_eq!(ts.warning.as_deref(), Some("-3d"));
937    }
938
939    #[test]
940    fn test_parse_iso_timestamp_with_time_repeater_warning() {
941        let ts = OrgMode::parse_iso_timestamp("scheduled", "2026-05-15 14:30 ++1w -3d").unwrap();
942        assert_eq!(
943            ts.time,
944            Some(chrono::NaiveTime::from_hms_opt(14, 30, 0).unwrap())
945        );
946        assert_eq!(ts.repeater.as_deref(), Some("++1w"));
947        assert_eq!(ts.warning.as_deref(), Some("-3d"));
948    }
949
950    #[test]
951    fn test_parse_iso_timestamp_repeater_other_forms() {
952        for r in ["+1d", "++2w", ".+3m"] {
953            let raw = format!("2026-05-15 {r}");
954            let ts = OrgMode::parse_iso_timestamp("scheduled", &raw).unwrap();
955            assert_eq!(ts.repeater.as_deref(), Some(r));
956        }
957    }
958
959    #[test]
960    fn test_parse_iso_timestamp_rejects_two_repeaters() {
961        let err = OrgMode::parse_iso_timestamp("scheduled", "2026-05-15 +1d ++1w").unwrap_err();
962        assert!(matches!(err, OrgModeError::InvalidTimestamp { .. }));
963    }
964
965    #[test]
966    fn test_parse_iso_timestamp_rejects_two_warnings() {
967        let err = OrgMode::parse_iso_timestamp("scheduled", "2026-05-15 -1d -3d").unwrap_err();
968        assert!(matches!(err, OrgModeError::InvalidTimestamp { .. }));
969    }
970
971    #[test]
972    fn test_parse_iso_timestamp_rejects_unknown_suffix() {
973        let err = OrgMode::parse_iso_timestamp("scheduled", "2026-05-15 garbage").unwrap_err();
974        assert!(matches!(err, OrgModeError::InvalidTimestamp { .. }));
975    }
976
977    #[test]
978    fn test_parse_iso_timestamp_rejects_zero_count() {
979        for bad in ["2026-05-15 +0d", "2026-05-15 -0w", "2026-05-15 ++0m"] {
980            let err = OrgMode::parse_iso_timestamp("scheduled", bad).unwrap_err();
981            assert!(matches!(err, OrgModeError::InvalidTimestamp { .. }));
982        }
983    }
984
985    #[test]
986    fn test_parse_iso_timestamp_rejects_leading_zero_count() {
987        for bad in ["2026-05-15 +00d", "2026-05-15 -000w", "2026-05-15 ++0m"] {
988            let err = OrgMode::parse_iso_timestamp("scheduled", bad).unwrap_err();
989            assert!(matches!(err, OrgModeError::InvalidTimestamp { .. }));
990        }
991    }
992
993    #[test]
994    fn test_parse_iso_timestamp_rejects_multibyte_utf8() {
995        let err = OrgMode::parse_iso_timestamp("scheduled", "2026-05-15 +1\u{00F6}").unwrap_err();
996        assert!(matches!(err, OrgModeError::InvalidTimestamp { .. }));
997    }
998
999    fn ts(
1000        date: (i32, u32, u32),
1001        time: Option<(u32, u32)>,
1002        rep: Option<&str>,
1003        warn: Option<&str>,
1004    ) -> ParsedTimestamp {
1005        ParsedTimestamp {
1006            date: NaiveDate::from_ymd_opt(date.0, date.1, date.2).unwrap(),
1007            time: time.map(|(h, m)| chrono::NaiveTime::from_hms_opt(h, m, 0).unwrap()),
1008            repeater: rep.map(String::from),
1009            warning: warn.map(String::from),
1010        }
1011    }
1012
1013    #[test]
1014    fn test_format_timestamp_active_date_only() {
1015        let s = OrgMode::format_org_timestamp(&ts((2026, 5, 15), None, None, None), true);
1016        assert_eq!(s, "<2026-05-15 Fri>");
1017    }
1018
1019    #[test]
1020    fn test_format_timestamp_inactive_date_only() {
1021        let s = OrgMode::format_org_timestamp(&ts((2026, 5, 10), None, None, None), false);
1022        assert_eq!(s, "[2026-05-10 Sun]");
1023    }
1024
1025    #[test]
1026    fn test_format_timestamp_active_with_time() {
1027        let s = OrgMode::format_org_timestamp(&ts((2026, 5, 15), Some((14, 30)), None, None), true);
1028        assert_eq!(s, "<2026-05-15 Fri 14:30>");
1029    }
1030
1031    #[test]
1032    fn test_format_timestamp_inactive_with_time() {
1033        let s =
1034            OrgMode::format_org_timestamp(&ts((2026, 5, 15), Some((14, 30)), None, None), false);
1035        assert_eq!(s, "[2026-05-15 Fri 14:30]");
1036    }
1037
1038    #[test]
1039    fn test_format_timestamp_with_repeater_and_warning() {
1040        let s = OrgMode::format_org_timestamp(
1041            &ts((2026, 5, 15), Some((14, 30)), Some("++1w"), Some("-3d")),
1042            true,
1043        );
1044        assert_eq!(s, "<2026-05-15 Fri 14:30 ++1w -3d>");
1045    }
1046
1047    #[test]
1048    fn test_datetree_segments_format() {
1049        let date = NaiveDate::from_ymd_opt(2026, 5, 10).unwrap();
1050        let segs = OrgMode::datetree_segments(date);
1051        assert_eq!(segs, vec!["2026", "2026-05 May", "2026-05-10 Sunday"]);
1052    }
1053
1054    #[test]
1055    fn test_datetree_segments_january() {
1056        let date = NaiveDate::from_ymd_opt(2027, 1, 1).unwrap();
1057        let segs = OrgMode::datetree_segments(date);
1058        assert_eq!(segs, vec!["2027", "2027-01 January", "2027-01-01 Friday"]);
1059    }
1060
1061    #[test]
1062    fn test_is_count_unit_rejects_short_input() {
1063        assert!(!OrgMode::is_count_unit(""));
1064        assert!(!OrgMode::is_count_unit("d"));
1065    }
1066}