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 #[cfg(unix)]
71 let _ = fs::remove_file(&lock_path);
72 drop(lock_file);
73 #[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 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 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 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}