1use std::collections::{BTreeMap, BTreeSet};
2use std::path::{Path, PathBuf};
3use std::sync::LazyLock;
4
5use orgize::ParseConfig;
6use regex::Regex;
7use sha2::{Digest, Sha256};
8
9use crate::{AgdError, ITEM_SCHEMA, Item, Planning, PlanningTimestamp, RootSet, Settings};
10
11static HEADLINE_RE: LazyLock<Regex> =
12 LazyLock::new(|| Regex::new(r"^(\*+)[ \t]+(.*)$").expect("headline regex"));
13static TAGS_RE: LazyLock<Regex> =
14 LazyLock::new(|| Regex::new(r"(?:^|[ \t]+)(:[\w@#%:]+:)[ \t]*$").expect("tags regex"));
15static PRIORITY_RE: LazyLock<Regex> =
16 LazyLock::new(|| Regex::new(r"^\[#([[:alnum:]])\](?:[ \t]+|$)").expect("priority regex"));
17static PROPERTY_RE: LazyLock<Regex> =
18 LazyLock::new(|| Regex::new(r"^:([[:alnum:]_-]+):(?:[ \t]*(.*))?$").expect("property regex"));
19static LINK_TARGET_RE: LazyLock<Regex> =
20 LazyLock::new(|| Regex::new(r"\[\[[^\]\n]*\]").expect("link target regex"));
21static TIMESTAMP_RE: LazyLock<Regex> = LazyLock::new(|| {
22 Regex::new(
23 r"[<\[](\d{4}-\d{2}-\d{2})(?:[ \t]+[^\s\d+\->\]]+)?(?:[ \t]+(\d{1,2}:\d{2})(?:-(\d{1,2}:\d{2}))?)?([^>\]]*)[>\]]",
24 )
25 .expect("timestamp regex")
26});
27
28#[derive(Clone, Copy, Debug, Eq, PartialEq)]
29pub struct ByteSpan {
30 pub start: usize,
31 pub end: usize,
32}
33
34#[derive(Clone, Debug)]
35pub struct SourceLine {
36 pub number: usize,
37 pub full: ByteSpan,
38 pub content: ByteSpan,
39}
40
41#[derive(Clone, Debug)]
42pub struct PropertySpan {
43 pub key: String,
44 pub value: String,
45 pub line: ByteSpan,
46 pub value_span: ByteSpan,
47}
48
49#[derive(Clone, Debug)]
50pub struct TimestampSpan {
51 pub timestamp: PlanningTimestamp,
52 pub span: ByteSpan,
53 pub active: bool,
54}
55
56#[derive(Clone, Debug)]
57pub struct HeadlineSpan {
58 pub item: Item,
59 pub level: usize,
60 pub line_span: ByteSpan,
61 pub subtree_span: ByteSpan,
62 pub section_span: ByteSpan,
63 pub planning_span: Option<ByteSpan>,
64 pub drawer_span: Option<ByteSpan>,
65 pub body_span: ByteSpan,
66 pub properties: Vec<PropertySpan>,
67 pub timestamps: Vec<TimestampSpan>,
68 pub digest: String,
69}
70
71#[derive(Clone, Debug)]
72pub struct OrgDocument {
73 path: PathBuf,
74 source: String,
75 lines: Vec<SourceLine>,
76 headlines: Vec<HeadlineSpan>,
77 todo_active: Vec<String>,
78 todo_done: Vec<String>,
79 todo_keywords: Vec<TodoKeyword>,
80 logging: LogSettings,
81 file_tags: Vec<String>,
82 file_category: String,
83}
84
85#[derive(Clone, Copy, Debug, Eq, PartialEq)]
88pub enum LogKind {
89 Time,
90 Note,
91}
92
93#[derive(Clone, Debug, Eq, PartialEq)]
95pub struct TodoKeyword {
96 pub name: String,
97 pub done: bool,
98 pub sequence: usize,
100 pub log_enter: Option<LogKind>,
101 pub log_leave: Option<LogKind>,
102}
103
104#[derive(Clone, Copy, Debug, Eq, PartialEq)]
106pub struct LogSettings {
107 pub done: Option<LogKind>,
108 pub repeat: Option<LogKind>,
109 pub reschedule: Option<LogKind>,
110 pub redeadline: Option<LogKind>,
111 pub into_drawer: bool,
112}
113
114#[derive(Clone, Debug)]
115pub struct WorkspaceIndex {
116 roots: RootSet,
117 settings: Settings,
118 documents: BTreeMap<PathBuf, OrgDocument>,
119 ids: BTreeMap<String, Vec<(PathBuf, usize)>>,
120 outside_ids: BTreeMap<String, Vec<PathBuf>>,
123 skipped: BTreeMap<PathBuf, String>,
124}
125
126fn load_document(
130 path: &Path,
131 settings: &Settings,
132) -> Result<Result<OrgDocument, AgdError>, AgdError> {
133 let bytes = std::fs::read(path).map_err(|error| {
134 AgdError::with_source(
135 "io",
136 format!("cannot read {}: {error}", path.display()),
137 error,
138 )
139 })?;
140 match OrgDocument::parse(path.to_path_buf(), bytes, settings) {
141 Ok(document) => Ok(Ok(document)),
142 Err(error) if error.code() == "non_utf8" => Ok(Err(error)),
143 Err(error) => Err(error),
144 }
145}
146
147fn scan_property_ids(path: &Path) -> Vec<String> {
148 let Ok(bytes) = std::fs::read(path) else {
149 return Vec::new();
150 };
151 String::from_utf8_lossy(&bytes)
152 .lines()
153 .filter_map(|line| {
154 let line = line.trim();
155 let key = line.get(..4)?;
156 if !key.eq_ignore_ascii_case(":ID:") {
157 return None;
158 }
159 let value = line[4..].trim();
160 (!value.is_empty()).then(|| value.to_string())
161 })
162 .collect()
163}
164
165impl OrgDocument {
166 pub fn parse(path: PathBuf, bytes: Vec<u8>, settings: &Settings) -> Result<Self, AgdError> {
167 let source = String::from_utf8(bytes).map_err(|error| {
168 AgdError::with_source(
169 "non_utf8",
170 format!("org file is not UTF-8: {}", path.display()),
171 error,
172 )
173 })?;
174 let lines = source_lines(&source);
175 let todo_keywords = todo_keywords(&source, settings);
176 let todo_active: Vec<String> = todo_keywords
177 .iter()
178 .filter(|keyword| !keyword.done)
179 .map(|keyword| keyword.name.clone())
180 .collect();
181 let todo_done: Vec<String> = todo_keywords
182 .iter()
183 .filter(|keyword| keyword.done)
184 .map(|keyword| keyword.name.clone())
185 .collect();
186 let logging = log_settings(&source, settings);
187 let file_tags = directive(&source, "FILETAGS")
188 .map(parse_colon_tags)
189 .unwrap_or_default();
190 let file_category = directive(&source, "CATEGORY")
191 .map(str::trim)
192 .filter(|value| !value.is_empty())
193 .map(str::to_string)
194 .unwrap_or_else(|| {
195 path.file_stem()
196 .and_then(|name| name.to_str())
197 .unwrap_or("org")
198 .to_string()
199 });
200
201 let parse_config = ParseConfig {
202 todo_keywords: (todo_active.clone(), todo_done.clone()),
203 ..Default::default()
204 };
205 let parsed = parse_config.parse(&source);
206 if parsed.to_org() != source {
207 return Err(AgdError::new(
208 "parse_drift",
209 format!(
210 "orgize changed bytes during a no-op round trip: {}",
211 path.display()
212 ),
213 ));
214 }
215
216 let raw = scan_headlines(
217 &path,
218 &source,
219 &lines,
220 &todo_active,
221 &todo_done,
222 &file_tags,
223 &file_category,
224 settings,
225 )?;
226 Ok(Self {
227 path,
228 source,
229 lines,
230 headlines: raw,
231 todo_active,
232 todo_done,
233 todo_keywords,
234 logging,
235 file_tags,
236 file_category,
237 })
238 }
239
240 pub fn path(&self) -> &Path {
241 &self.path
242 }
243
244 pub fn source(&self) -> &str {
245 &self.source
246 }
247
248 pub fn lines(&self) -> &[SourceLine] {
249 &self.lines
250 }
251
252 pub fn headlines(&self) -> &[HeadlineSpan] {
253 &self.headlines
254 }
255
256 pub fn headline_at_line(&self, line: usize) -> Option<&HeadlineSpan> {
257 self.headlines
258 .iter()
259 .find(|headline| headline.item.line == line)
260 }
261
262 pub fn todo_active(&self) -> &[String] {
263 &self.todo_active
264 }
265
266 pub fn todo_done(&self) -> &[String] {
267 &self.todo_done
268 }
269
270 pub fn todo_keywords(&self) -> &[TodoKeyword] {
271 &self.todo_keywords
272 }
273
274 pub fn todo_keyword(&self, name: &str) -> Option<&TodoKeyword> {
275 self.todo_keywords
276 .iter()
277 .find(|keyword| keyword.name == name)
278 }
279
280 pub fn logging(&self) -> LogSettings {
281 self.logging
282 }
283
284 pub fn file_tags(&self) -> &[String] {
285 &self.file_tags
286 }
287
288 pub fn file_category(&self) -> &str {
289 &self.file_category
290 }
291}
292
293impl WorkspaceIndex {
294 pub fn load(settings: Settings) -> Result<Self, AgdError> {
295 let roots = RootSet::new(&settings.roots, &settings.agenda_files)?;
296 let mut documents = BTreeMap::new();
297 let mut skipped = BTreeMap::new();
298 for path in roots.agenda_files()? {
299 match load_document(&path, &settings)? {
300 Ok(document) => {
301 documents.insert(path, document);
302 }
303 Err(reason) => {
304 skipped.insert(path, reason.to_string());
305 }
306 }
307 }
308 let mut outside_ids: BTreeMap<String, Vec<PathBuf>> = BTreeMap::new();
309 for path in roots.all_org_files()? {
310 if documents.contains_key(&path) || skipped.contains_key(&path) {
311 continue;
312 }
313 for id in scan_property_ids(&path) {
314 outside_ids.entry(id).or_default().push(path.clone());
315 }
316 }
317 let mut index = Self {
318 roots,
319 settings,
320 documents,
321 ids: BTreeMap::new(),
322 outside_ids,
323 skipped,
324 };
325 index.rebuild_ids();
326 Ok(index)
327 }
328
329 pub fn skipped_files(&self) -> impl Iterator<Item = (&Path, &str)> {
332 self.skipped
333 .iter()
334 .map(|(path, reason)| (path.as_path(), reason.as_str()))
335 }
336
337 pub fn roots(&self) -> &RootSet {
338 &self.roots
339 }
340
341 pub fn settings(&self) -> &Settings {
342 &self.settings
343 }
344
345 pub fn refresh_path(&mut self, path: &Path) -> Result<(), AgdError> {
346 if path.exists() {
347 if path.extension().is_none_or(|extension| extension != "org") {
348 return Ok(());
349 }
350 let path = self.roots.resolve_read(path)?;
351 if !self.documents.contains_key(&path) && !self.skipped.contains_key(&path) {
352 for locations in self.outside_ids.values_mut() {
353 locations.retain(|location| location != &path);
354 }
355 for id in scan_property_ids(&path) {
356 self.outside_ids.entry(id).or_default().push(path.clone());
357 }
358 self.outside_ids
359 .retain(|_, locations| !locations.is_empty());
360 return Ok(());
361 }
362 match load_document(&path, &self.settings)? {
363 Ok(document) => {
364 self.skipped.remove(&path);
365 self.documents.insert(path, document);
366 }
367 Err(reason) => {
368 self.documents.remove(&path);
369 self.skipped.insert(path, reason.to_string());
370 }
371 }
372 } else {
373 self.documents.remove(path);
374 self.skipped.remove(path);
375 }
376 self.rebuild_ids();
377 Ok(())
378 }
379
380 fn rebuild_ids(&mut self) {
381 self.ids.clear();
382 for (path, document) in &self.documents {
383 for (index, headline) in document.headlines().iter().enumerate() {
384 if let Some(id) = &headline.item.id {
385 self.ids
386 .entry(id.clone())
387 .or_default()
388 .push((path.clone(), index));
389 }
390 }
391 }
392 }
393
394 pub fn documents(&self) -> impl Iterator<Item = &OrgDocument> {
395 self.documents.values()
396 }
397
398 pub fn items(&self) -> impl Iterator<Item = &Item> {
399 self.documents
400 .values()
401 .flat_map(|document| document.headlines().iter().map(|headline| &headline.item))
402 }
403
404 pub fn duplicate_ids(&self) -> impl Iterator<Item = (&str, &[(PathBuf, usize)])> {
405 self.ids
406 .iter()
407 .filter(|(id, locations)| locations.len() + self.outside_id_count(id) > 1)
408 .map(|(id, locations)| (id.as_str(), locations.as_slice()))
409 }
410
411 fn outside_id_count(&self, id: &str) -> usize {
412 self.outside_ids.get(id).map_or(0, Vec::len)
413 }
414
415 fn refuse_duplicate_id(&self, headline: &HeadlineSpan) -> Result<(), AgdError> {
416 let Some(id) = &headline.item.id else {
417 return Ok(());
418 };
419 let count = self.ids.get(id).map_or(0, Vec::len) + self.outside_id_count(id);
420 if count > 1 {
421 return Err(AgdError::new(
422 "duplicate_id",
423 format!("ID '{id}' occurs {count} times under the configured roots"),
424 ));
425 }
426 Ok(())
427 }
428
429 pub fn document(&self, path: &Path) -> Option<&OrgDocument> {
430 self.documents.get(path)
431 }
432
433 pub fn resolve(&self, item_ref: &str) -> Result<(&OrgDocument, &HeadlineSpan), AgdError> {
434 if !item_ref.contains("#sha256:") {
435 let matches = self
436 .ids
437 .get(item_ref)
438 .map(Vec::as_slice)
439 .unwrap_or_default();
440 let outside = self.outside_id_count(item_ref);
441 if matches.len() + outside > 1 {
442 return Err(AgdError::new(
443 "duplicate_id",
444 format!(
445 "ID '{item_ref}' occurs {} times under the configured roots",
446 matches.len() + outside
447 ),
448 ));
449 }
450 return match matches {
451 [] if outside == 1 => Err(AgdError::new(
452 "stale_ref",
453 format!("ID '{item_ref}' is in a file outside the agenda files"),
454 )),
455 [] => Err(AgdError::new(
456 "stale_ref",
457 format!("no item has ID '{item_ref}'"),
458 )),
459 [(path, index)] => Ok((
460 self.documents.get(path).expect("indexed document"),
461 &self
462 .documents
463 .get(path)
464 .expect("indexed document")
465 .headlines()[*index],
466 )),
467 _ => unreachable!("duplicate IDs are rejected above"),
468 };
469 }
470
471 let (location, digest) = item_ref
472 .rsplit_once("#sha256:")
473 .ok_or_else(|| AgdError::new("invalid_ref", format!("invalid item ref: {item_ref}")))?;
474 if digest.len() != 32 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
475 return Err(AgdError::new(
476 "invalid_ref",
477 format!("invalid SHA-256 digest in item ref: {item_ref}"),
478 ));
479 }
480 let (file, line) = location.rsplit_once(':').ok_or_else(|| {
481 AgdError::new(
482 "invalid_ref",
483 format!("item ref has no line hint: {item_ref}"),
484 )
485 })?;
486 let line = line.parse::<usize>().map_err(|_| {
487 AgdError::new(
488 "invalid_ref",
489 format!("invalid line hint in item ref: {item_ref}"),
490 )
491 })?;
492 let requested = Path::new(file);
493 self.roots.resolve_read(requested)?;
494 if let Some(reason) = self.skipped.get(requested) {
495 return Err(AgdError::new("non_utf8", reason.clone()));
496 }
497 let document = self
498 .documents
499 .get(requested)
500 .or_else(|| {
501 requested
502 .canonicalize()
503 .ok()
504 .and_then(|canonical| self.documents.get(&canonical))
505 })
506 .ok_or_else(|| {
507 AgdError::new(
508 "stale_ref",
509 format!(
510 "referenced file is not an agenda file: {}",
511 requested.display()
512 ),
513 )
514 })?;
515 if let Some(headline) = document.headline_at_line(line)
516 && headline.digest == digest
517 {
518 self.refuse_duplicate_id(headline)?;
519 return Ok((document, headline));
520 }
521 let matches: Vec<&HeadlineSpan> = document
522 .headlines()
523 .iter()
524 .filter(|headline| headline.digest == digest)
525 .collect();
526 match matches.as_slice() {
527 [headline] => {
528 self.refuse_duplicate_id(headline)?;
529 Ok((document, headline))
530 }
531 [] => {
532 let mut error = AgdError::new(
533 "stale_ref",
534 format!("headline bytes no longer match ref: {item_ref}"),
535 );
536 if let Some(current) = document.headline_at_line(line) {
537 error = error.with_current(current.item.clone());
538 }
539 Err(error)
540 }
541 _ => Err(AgdError::new(
542 "stale_ref",
543 format!("headline digest is ambiguous in {}", requested.display()),
544 )),
545 }
546 }
547}
548
549#[derive(Clone, Debug)]
550struct RawHeadline {
551 level: usize,
552 line_index: usize,
553 line_span: ByteSpan,
554 section_end: usize,
555 subtree_end: usize,
556 keyword: Option<String>,
557 done: bool,
558 priority: Option<String>,
559 title: String,
560 tags: Vec<String>,
561 planning: Planning,
562 planning_span: Option<ByteSpan>,
563 drawer_span: Option<ByteSpan>,
564 properties: Vec<PropertySpan>,
565 timestamps: Vec<TimestampSpan>,
566 digest: String,
567}
568
569struct ParsedMetadata {
570 planning: Planning,
571 planning_span: Option<ByteSpan>,
572 drawer_span: Option<ByteSpan>,
573 properties: Vec<PropertySpan>,
574 body_start: usize,
575}
576
577#[allow(clippy::too_many_arguments)]
578fn scan_headlines(
579 path: &Path,
580 source: &str,
581 lines: &[SourceLine],
582 todo_active: &[String],
583 todo_done: &[String],
584 file_tags: &[String],
585 file_category: &str,
586 settings: &Settings,
587) -> Result<Vec<HeadlineSpan>, AgdError> {
588 let mut raw = Vec::new();
589 for (line_index, line) in lines.iter().enumerate() {
590 let text = &source[line.content.start..line.content.end];
591 let Some(captures) = HEADLINE_RE.captures(text) else {
592 continue;
593 };
594 let stars = captures.get(1).expect("stars");
595 let content = captures.get(2).expect("headline content");
596 let (keyword, done, priority, title, tags) =
597 parse_headline_content(content.as_str(), todo_active, todo_done);
598 let ParsedMetadata {
599 planning,
600 planning_span,
601 drawer_span,
602 properties,
603 body_start,
604 } = parse_metadata(source, lines, line_index, raw.len())?;
605 let digest = headline_digest(text.as_bytes());
606 raw.push(RawHeadline {
607 level: stars.as_str().len(),
608 line_index,
609 line_span: line.content,
610 section_end: source.len(),
611 subtree_end: source.len(),
612 keyword,
613 done,
614 priority,
615 title,
616 tags,
617 planning,
618 planning_span,
619 drawer_span,
620 properties,
621 timestamps: Vec::new(),
622 digest,
623 });
624 let current = raw.last_mut().expect("pushed headline");
625 current.section_end = body_start;
626 }
627
628 for index in 0..raw.len() {
629 let next_any = raw
630 .get(index + 1)
631 .map_or(source.len(), |next| next.line_span.start);
632 raw[index].section_end = next_any;
633 raw[index].subtree_end = raw[index + 1..]
634 .iter()
635 .find(|next| next.level <= raw[index].level)
636 .map_or(source.len(), |next| next.line_span.start);
637 let timestamp_start = raw[index]
638 .planning_span
639 .map_or(raw[index].line_span.end, |span| span.end);
640 let mut timestamps =
643 scan_timestamps(source, raw[index].line_span.start, raw[index].line_span.end);
644 timestamps.extend(scan_timestamps(source, timestamp_start, next_any));
645 raw[index].timestamps = timestamps;
646 }
647
648 let excluded: BTreeSet<&str> = settings
649 .tag_inheritance_exclusions
650 .iter()
651 .map(String::as_str)
652 .collect();
653 let file_path = path.to_string_lossy().into_owned();
654 let mut result = Vec::with_capacity(raw.len());
655 let mut ancestors: Vec<(usize, String, Vec<String>, String)> = Vec::new();
656 for headline in raw {
657 while ancestors
658 .last()
659 .is_some_and(|ancestor| ancestor.0 >= headline.level)
660 {
661 ancestors.pop();
662 }
663 let mut inherited = BTreeSet::new();
664 for tag in file_tags {
665 if !excluded.contains(tag.as_str()) {
666 inherited.insert(tag.clone());
667 }
668 }
669 for (_, _, tags, _) in &ancestors {
670 for tag in tags {
671 if !excluded.contains(tag.as_str()) {
672 inherited.insert(tag.clone());
673 }
674 }
675 }
676 for own in &headline.tags {
677 inherited.remove(own);
678 }
679 let own_category = headline
680 .properties
681 .iter()
682 .find(|property| property.key.eq_ignore_ascii_case("CATEGORY"))
683 .map(|property| property.value.clone());
684 let category = own_category.unwrap_or_else(|| {
685 ancestors
686 .last()
687 .map(|ancestor| ancestor.3.clone())
688 .unwrap_or_else(|| file_category.to_string())
689 });
690 let id = headline
691 .properties
692 .iter()
693 .find(|property| property.key.eq_ignore_ascii_case("ID"))
694 .map(|property| property.value.clone());
695 let mut olpath: Vec<String> = ancestors
696 .iter()
697 .map(|ancestor| ancestor.1.clone())
698 .collect();
699 olpath.push(headline.title.clone());
700 let item_ref = id.clone().unwrap_or_else(|| {
701 format!(
702 "{}:{}#sha256:{}",
703 path.display(),
704 lines[headline.line_index].number,
705 headline.digest
706 )
707 });
708 let item = Item {
709 schema: ITEM_SCHEMA.to_string(),
710 item_ref,
711 id,
712 file: file_path.clone(),
713 line: lines[headline.line_index].number,
714 olpath,
715 todo: headline.keyword.clone(),
716 done: headline.done,
717 priority: headline.priority.clone(),
718 title: headline.title.clone(),
719 tags: headline.tags.clone(),
720 inherited_tags: inherited.into_iter().collect(),
721 category: category.clone(),
722 planning: headline.planning.clone(),
723 };
724 let metadata_end = headline
725 .drawer_span
726 .or(headline.planning_span)
727 .map_or(headline.line_span.end, |span| span.end);
728 let body_start = line_end_after(source, metadata_end);
729 let body_span = ByteSpan {
730 start: body_start.min(headline.section_end),
731 end: headline.section_end,
732 };
733 ancestors.push((
734 headline.level,
735 headline.title.clone(),
736 headline.tags.clone(),
737 category,
738 ));
739 result.push(HeadlineSpan {
740 item,
741 level: headline.level,
742 line_span: headline.line_span,
743 subtree_span: ByteSpan {
744 start: headline.line_span.start,
745 end: headline.subtree_end,
746 },
747 section_span: ByteSpan {
748 start: headline.line_span.start,
749 end: headline.section_end,
750 },
751 planning_span: headline.planning_span,
752 drawer_span: headline.drawer_span,
753 body_span,
754 properties: headline.properties,
755 timestamps: headline.timestamps,
756 digest: headline.digest,
757 });
758 }
759 Ok(result)
760}
761
762fn parse_headline_content(
763 input: &str,
764 todo_active: &[String],
765 todo_done: &[String],
766) -> (Option<String>, bool, Option<String>, String, Vec<String>) {
767 let mut content = input.trim_end();
768 let tags = TAGS_RE
769 .captures(content)
770 .and_then(|captures| captures.get(1))
771 .map(|matched| {
772 content = content[..matched.start()].trim_end();
773 parse_colon_tags(matched.as_str())
774 })
775 .unwrap_or_default();
776
777 let first_end = content.find(char::is_whitespace).unwrap_or(content.len());
778 let first = &content[..first_end];
779 let (keyword, done) = if todo_active.iter().any(|word| word == first) {
780 content = content[first_end..].trim_start();
781 (Some(first.to_string()), false)
782 } else if todo_done.iter().any(|word| word == first) {
783 content = content[first_end..].trim_start();
784 (Some(first.to_string()), true)
785 } else {
786 (None, false)
787 };
788
789 let priority = PRIORITY_RE.captures(content).and_then(|captures| {
790 let full = captures.get(0)?;
791 let value = captures.get(1)?.as_str().to_string();
792 content = content[full.end()..].trim_start();
793 Some(value)
794 });
795 (keyword, done, priority, content.to_string(), tags)
796}
797
798fn parse_metadata(
799 source: &str,
800 lines: &[SourceLine],
801 headline_line: usize,
802 _headline_count: usize,
803) -> Result<ParsedMetadata, AgdError> {
804 let mut planning = Planning::default();
805 let mut planning_span: Option<ByteSpan> = None;
809 let mut cursor = headline_line + 1;
810 if let Some(line) = lines.get(cursor) {
811 let trimmed = source[line.content.start..line.content.end].trim_start();
812 if is_planning_line(trimmed) {
813 planning_span = Some(line.full);
814 planning.scheduled = labeled_timestamp(trimmed, "SCHEDULED:");
815 planning.deadline = labeled_timestamp(trimmed, "DEADLINE:");
816 planning.closed = labeled_timestamp(trimmed, "CLOSED:");
817 cursor += 1;
818 }
819 }
820
821 let mut properties = Vec::new();
822 let mut drawer_span = None;
823 if let Some(line) = lines.get(cursor) {
824 let text = &source[line.content.start..line.content.end];
825 let trimmed = text.trim_start();
826 if trimmed == ":PROPERTIES:" {
827 let start = line.full.start;
828 cursor += 1;
829 let mut found_end = false;
830 while let Some(property_line) = lines.get(cursor) {
831 let property_text = &source[property_line.content.start..property_line.content.end];
832 let property_trimmed = property_text.trim_start();
833 if property_trimmed == ":END:" {
834 drawer_span = Some(ByteSpan {
835 start,
836 end: property_line.full.end,
837 });
838 cursor += 1;
839 found_end = true;
840 break;
841 }
842 if let Some(captures) = PROPERTY_RE.captures(property_trimmed) {
843 let key = captures.get(1).expect("property key");
844 let value = captures.get(2);
845 properties.push(PropertySpan {
846 key: key.as_str().to_string(),
847 value: value.map_or("", |matched| matched.as_str()).to_string(),
848 line: property_line.full,
849 value_span: value.map_or(
850 ByteSpan {
851 start: property_line.content.end,
852 end: property_line.content.end,
853 },
854 |matched| ByteSpan {
855 start: property_line.content.start + property_text.len()
856 - property_trimmed.len()
857 + matched.start(),
858 end: property_line.content.start + property_text.len()
859 - property_trimmed.len()
860 + matched.end(),
861 },
862 ),
863 });
864 }
865 cursor += 1;
866 }
867 if !found_end {
868 return Err(AgdError::new(
869 "parse_error",
870 format!(
871 "unterminated property drawer after line {}",
872 headline_line + 1
873 ),
874 ));
875 }
876 }
877 }
878 let body_start = lines
879 .get(cursor)
880 .map_or(source.len(), |line| line.full.start);
881 Ok(ParsedMetadata {
882 planning,
883 planning_span,
884 drawer_span,
885 properties,
886 body_start,
887 })
888}
889
890fn source_lines(source: &str) -> Vec<SourceLine> {
891 let mut lines = Vec::new();
892 let mut start = 0;
893 for (index, segment) in source.split_inclusive('\n').enumerate() {
894 let end = start + segment.len();
895 let content_end = if segment.ends_with("\r\n") {
896 end - 2
897 } else if segment.ends_with('\n') {
898 end - 1
899 } else {
900 end
901 };
902 lines.push(SourceLine {
903 number: index + 1,
904 full: ByteSpan { start, end },
905 content: ByteSpan {
906 start,
907 end: content_end,
908 },
909 });
910 start = end;
911 }
912 if source.is_empty() {
913 return lines;
914 }
915 if start < source.len() {
916 lines.push(SourceLine {
917 number: lines.len() + 1,
918 full: ByteSpan {
919 start,
920 end: source.len(),
921 },
922 content: ByteSpan {
923 start,
924 end: source.len(),
925 },
926 });
927 }
928 lines
929}
930
931fn todo_keywords(source: &str, settings: &Settings) -> Vec<TodoKeyword> {
935 let in_file: Vec<Vec<String>> = directives(source, &["TODO", "SEQ_TODO", "TYP_TODO"])
936 .map(|value| value.split_whitespace().map(str::to_string).collect())
937 .collect();
938 let sequences = if in_file.is_empty() {
939 settings.todo_keywords.clone()
940 } else {
941 in_file
942 };
943 let mut keywords = Vec::new();
944 for (index, sequence) in sequences.iter().enumerate() {
945 let separator = sequence.iter().position(|word| word == "|");
946 let last = sequence.len().saturating_sub(1);
947 for (position, raw) in sequence.iter().enumerate() {
948 if raw == "|" {
949 continue;
950 }
951 let (name, markers) = raw
952 .split_once('(')
953 .map_or((raw.as_str(), ""), |(name, rest)| {
954 (name, rest.trim_end_matches(')'))
955 });
956 if name.is_empty() {
957 continue;
958 }
959 let (enter, leave) = markers.split_once('/').unwrap_or((markers, ""));
960 keywords.push(TodoKeyword {
961 name: name.to_string(),
962 done: separator.map_or(position == last, |separator| position > separator),
963 sequence: index,
964 log_enter: log_marker(enter),
965 log_leave: log_marker(leave),
966 });
967 }
968 }
969 keywords
970}
971
972fn log_marker(marker: &str) -> Option<LogKind> {
973 if marker.contains('@') {
974 Some(LogKind::Note)
975 } else if marker.contains('!') {
976 Some(LogKind::Time)
977 } else {
978 None
979 }
980}
981
982fn log_value(value: &str) -> Option<LogKind> {
983 match value {
984 "time" | "t" => Some(LogKind::Time),
985 "note" => Some(LogKind::Note),
986 _ => None,
987 }
988}
989
990fn log_settings(source: &str, settings: &Settings) -> LogSettings {
993 let agenda = settings;
994 let mut logging = LogSettings {
995 done: log_value(&agenda.log_done),
996 repeat: log_value(&agenda.log_repeat),
997 reschedule: log_value(&agenda.log_reschedule),
998 redeadline: log_value(&agenda.log_redeadline),
999 into_drawer: agenda.log_into_drawer,
1000 };
1001 for option in directives(source, &["STARTUP"]).flat_map(str::split_whitespace) {
1002 let option = option.to_ascii_lowercase();
1003 let (kind, name) = if let Some(name) = option.strip_prefix("nolog") {
1004 (None, name)
1005 } else if let Some(name) = option.strip_prefix("lognote") {
1006 (Some(LogKind::Note), name)
1007 } else if let Some(name) = option.strip_prefix("log") {
1008 (Some(LogKind::Time), name)
1009 } else {
1010 continue;
1011 };
1012 match name {
1013 "done" => logging.done = kind,
1014 "repeat" => logging.repeat = kind,
1015 "reschedule" => logging.reschedule = kind,
1016 "redeadline" => logging.redeadline = kind,
1017 "drawer" => logging.into_drawer = kind.is_some(),
1018 _ => {}
1019 }
1020 }
1021 logging
1022}
1023
1024fn directives<'a>(source: &'a str, names: &'a [&str]) -> impl Iterator<Item = &'a str> {
1025 source.lines().filter_map(move |line| {
1026 let (key, value) = line.strip_prefix("#+")?.split_once(':')?;
1027 names
1028 .iter()
1029 .any(|name| key.eq_ignore_ascii_case(name))
1030 .then_some(value.trim())
1031 })
1032}
1033
1034fn directive<'a>(source: &'a str, name: &str) -> Option<&'a str> {
1035 source.lines().find_map(|line| {
1036 let (key, value) = line.strip_prefix("#+")?.split_once(':')?;
1037 key.eq_ignore_ascii_case(name).then_some(value.trim())
1038 })
1039}
1040
1041fn parse_colon_tags(value: &str) -> Vec<String> {
1042 value
1043 .trim()
1044 .trim_matches(':')
1045 .split(':')
1046 .filter(|tag| !tag.is_empty())
1047 .map(str::to_string)
1048 .collect()
1049}
1050
1051fn is_planning_line(line: &str) -> bool {
1054 let line = line.trim_start();
1055 ["SCHEDULED:", "DEADLINE:", "CLOSED:"]
1056 .iter()
1057 .any(|label| line.starts_with(label))
1058}
1059
1060fn labeled_timestamp(line: &str, label: &str) -> Option<PlanningTimestamp> {
1061 let after = line.split_once(label)?.1;
1062 parse_timestamp(after)
1063}
1064
1065pub fn parse_timestamp(input: &str) -> Option<PlanningTimestamp> {
1066 let captures = TIMESTAMP_RE.captures(input)?;
1067 let suffix = captures.get(4).map_or("", |matched| matched.as_str());
1068 let repeater = suffix
1069 .split_whitespace()
1070 .find(|token| token.starts_with('+') || token.starts_with(".+"))
1071 .map(str::to_string);
1072 let warning = suffix
1073 .split_whitespace()
1074 .find(|token| token.starts_with('-'))
1075 .map(str::to_string);
1076 Some(PlanningTimestamp {
1077 date: captures.get(1)?.as_str().to_string(),
1078 time: captures.get(2).map(|matched| matched.as_str().to_string()),
1079 end_time: captures.get(3).map(|matched| matched.as_str().to_string()),
1080 repeater,
1081 warning,
1082 raw: captures.get(0).map(|matched| matched.as_str().to_string()),
1083 })
1084}
1085
1086fn scan_timestamps(source: &str, start: usize, end: usize) -> Vec<TimestampSpan> {
1089 if start >= end || end > source.len() {
1090 return Vec::new();
1091 }
1092 let mut found = Vec::new();
1093 let mut in_verbatim_block = false;
1094 let mut line_start = start;
1095 for line in source[start..end].split_inclusive('\n') {
1096 let offset = line_start;
1097 line_start += line.len();
1098 let trimmed = line.trim();
1099 let lower = trimmed.to_ascii_lowercase();
1100 if in_verbatim_block {
1101 in_verbatim_block = !lower.starts_with("#+end_");
1102 continue;
1103 }
1104 if [
1105 "#+begin_src",
1106 "#+begin_example",
1107 "#+begin_export",
1108 "#+begin_comment",
1109 ]
1110 .iter()
1111 .any(|opening| lower.starts_with(opening))
1112 {
1113 in_verbatim_block = true;
1114 continue;
1115 }
1116 if trimmed == ":" || trimmed.starts_with(": ") {
1117 continue;
1118 }
1119 let masked = LINK_TARGET_RE.replace_all(line, |captures: ®ex::Captures| {
1120 " ".repeat(captures[0].len())
1121 });
1122 for captures in TIMESTAMP_RE.captures_iter(&masked) {
1123 let Some(matched) = captures.get(0) else {
1124 continue;
1125 };
1126 let Some(timestamp) = parse_timestamp(matched.as_str()) else {
1127 continue;
1128 };
1129 found.push(TimestampSpan {
1130 timestamp,
1131 span: ByteSpan {
1132 start: offset + matched.start(),
1133 end: offset + matched.end(),
1134 },
1135 active: matched.as_str().starts_with('<'),
1136 });
1137 }
1138 }
1139 found
1140}
1141
1142fn headline_digest(bytes: &[u8]) -> String {
1143 let digest = Sha256::digest(bytes);
1144 digest[..16]
1145 .iter()
1146 .map(|byte| format!("{byte:02x}"))
1147 .collect()
1148}
1149
1150fn line_end_after(source: &str, offset: usize) -> usize {
1151 if offset >= source.len() {
1152 return source.len();
1153 }
1154 if source.as_bytes().get(offset) == Some(&b'\n') {
1155 offset + 1
1156 } else {
1157 offset
1158 }
1159}
1160
1161#[cfg(test)]
1162mod tests {
1163 use super::*;
1164
1165 fn settings(root: &Path) -> Settings {
1166 Settings {
1167 roots: vec![root.to_string_lossy().into_owned()],
1168 ..Settings::default()
1169 }
1170 }
1171
1172 #[test]
1173 fn indexes_refs_inheritance_and_in_file_todo_without_drift() {
1174 let temp = tempfile::tempdir().unwrap();
1175 let path = temp.path().join("work.org");
1176 let source = "#+TODO: OPEN(o) | SHUT(s!)\r\n#+FILETAGS: :work:\r\n* Parent :project:\r\n** OPEN [#A] Task :report:\r\nSCHEDULED: <2026-10-13 Tue +1w> DEADLINE: <2026-10-16 Fri -5d>\r\n:PROPERTIES:\r\n:ID: exact-id\r\n:CATEGORY: reporting\r\n:END:\r\nbody unchanged\r\n";
1177 std::fs::write(&path, source).unwrap();
1178 let document = OrgDocument::parse(
1179 path.clone(),
1180 std::fs::read(&path).unwrap(),
1181 &settings(temp.path()),
1182 )
1183 .unwrap();
1184 assert_eq!(document.source(), source);
1185 let item = &document.headlines()[1].item;
1186 assert_eq!(item.item_ref, "exact-id");
1187 assert_eq!(item.todo.as_deref(), Some("OPEN"));
1188 assert_eq!(item.priority.as_deref(), Some("A"));
1189 assert_eq!(item.inherited_tags, vec!["project", "work"]);
1190 assert_eq!(item.category, "reporting");
1191 assert_eq!(
1192 item.planning.deadline.as_ref().unwrap().warning.as_deref(),
1193 Some("-5d")
1194 );
1195 }
1196
1197 #[test]
1198 fn digest_ref_relocates_uniquely_and_reports_stale_current() {
1199 let temp = tempfile::tempdir().unwrap();
1200 let path = temp.path().join("tasks.org");
1201 std::fs::write(&path, "* TODO One\n* TODO Two\n").unwrap();
1202 let index = WorkspaceIndex::load(settings(temp.path())).unwrap();
1203 let item_ref = index.items().nth(1).unwrap().item_ref.clone();
1204 assert_eq!(index.resolve(&item_ref).unwrap().1.item.title, "Two");
1205
1206 std::fs::write(&path, "intro\n* TODO One\n* TODO Two\n").unwrap();
1207 let relocated = WorkspaceIndex::load(settings(temp.path())).unwrap();
1208 assert_eq!(relocated.resolve(&item_ref).unwrap().1.item.line, 3);
1209
1210 std::fs::write(&path, "intro\n* TODO One\n* TODO Changed\n").unwrap();
1211 let stale = WorkspaceIndex::load(settings(temp.path())).unwrap();
1212 let error = stale.resolve(&item_ref).unwrap_err();
1213 assert_eq!(error.code(), "stale_ref");
1214 }
1215
1216 #[test]
1217 fn non_utf8_agenda_file_is_skipped_for_reads_and_refused_for_writes() {
1218 let temp = tempfile::tempdir().unwrap();
1219 std::fs::write(temp.path().join("good.org"), "* TODO Readable\n").unwrap();
1220 let bad = temp.path().join("bad.org");
1221 std::fs::write(&bad, b"* TODO Latin-1 \xe9\n").unwrap();
1222 let index = WorkspaceIndex::load(settings(temp.path())).unwrap();
1223 assert_eq!(index.items().count(), 1);
1224 let skipped: Vec<_> = index.skipped_files().collect();
1225 assert_eq!(skipped.len(), 1);
1226 assert!(skipped[0].0.ends_with("bad.org"));
1227 let canonical = bad.canonicalize().unwrap();
1228 let bad_ref = format!(
1229 "{}:1#sha256:00000000000000000000000000000000",
1230 canonical.display()
1231 );
1232 assert_eq!(index.resolve(&bad_ref).unwrap_err().code(), "non_utf8");
1233 }
1234
1235 #[test]
1236 fn ids_in_non_agenda_files_make_targets_ambiguous() {
1237 let temp = tempfile::tempdir().unwrap();
1238 std::fs::write(
1239 temp.path().join("tasks.org"),
1240 "* TODO One\n:PROPERTIES:\n:ID: shared\n:END:\n",
1241 )
1242 .unwrap();
1243 std::fs::create_dir(temp.path().join("archive")).unwrap();
1244 std::fs::write(
1245 temp.path().join("archive/notes.org"),
1246 "* Notes\n:PROPERTIES:\n:id: shared\n:END:\n",
1247 )
1248 .unwrap();
1249 let mut settings = settings(temp.path());
1250 settings.agenda_files = vec!["tasks.org".to_string()];
1251 let index = WorkspaceIndex::load(settings).unwrap();
1252 assert_eq!(index.resolve("shared").unwrap_err().code(), "duplicate_id");
1253 let item_ref = index.items().next().unwrap().item_ref.clone();
1254 assert_eq!(index.resolve(&item_ref).unwrap_err().code(), "duplicate_id");
1255 assert_eq!(index.duplicate_ids().count(), 1);
1256 }
1257
1258 #[test]
1259 fn duplicate_ids_are_not_selectable() {
1260 let temp = tempfile::tempdir().unwrap();
1261 std::fs::write(
1262 temp.path().join("tasks.org"),
1263 "* TODO One\n:PROPERTIES:\n:ID: same\n:END:\n* TODO Two\n:PROPERTIES:\n:ID: same\n:END:\n",
1264 )
1265 .unwrap();
1266 let index = WorkspaceIndex::load(settings(temp.path())).unwrap();
1267 assert_eq!(index.duplicate_ids().count(), 1);
1268 assert_eq!(index.resolve("same").unwrap_err().code(), "duplicate_id");
1269 }
1270}