Skip to main content

org_core/
document.rs

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/// How a state change or planning change is recorded, as in Org's `!` (time)
86/// and `@` (note) markers and the org-log-* variables.
87#[derive(Clone, Copy, Debug, Eq, PartialEq)]
88pub enum LogKind {
89    Time,
90    Note,
91}
92
93/// One TODO keyword with the logging markers from `KEYWORD(key!/@)`.
94#[derive(Clone, Debug, Eq, PartialEq)]
95pub struct TodoKeyword {
96    pub name: String,
97    pub done: bool,
98    /// Index of the sequence it belongs to; repeaters reset to its first keyword.
99    pub sequence: usize,
100    pub log_enter: Option<LogKind>,
101    pub log_leave: Option<LogKind>,
102}
103
104/// Effective logging for one file: configuration overridden by `#+STARTUP`.
105#[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    // `:ID:` values found in org files under the roots that are not agenda
121    // files, so an ID shared with an archive or notes file is still ambiguous.
122    outside_ids: BTreeMap<String, Vec<PathBuf>>,
123    skipped: BTreeMap<PathBuf, String>,
124}
125
126/// Reads and parses one agenda file. A non-UTF-8 file is returned as a
127/// skip reason instead of an error so that one unreadable file never hides
128/// the rest of the agenda.
129fn 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    /// Agenda files left out of the index, with the reason (currently only
330    /// non-UTF-8 content).
331    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        // Org lists active timestamps on the headline line itself, e.g.
641        // `* Birthday <1993-10-16 Sat +1y>`, but never the planning line.
642        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    // Org reads planning only from the line right after the headline; a
806    // second SCHEDULED/DEADLINE line or one after a blank line is plain text
807    // with an ordinary timestamp.
808    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
931/// TODO keywords from every `#+TODO:`/`#+SEQ_TODO:`/`#+TYP_TODO:` line, or
932/// the configured sequences. A sequence without `|` makes its last keyword
933/// the done state, as in Org.
934fn 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
990/// org-log-* settings for one file, with `#+STARTUP:` options such as
991/// `logrepeat`, `lognotereschedule` or `nologdrawer` applied on top.
992fn 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
1051// A planning line starts with one of the keywords; `: SCHEDULED: <…>` is
1052// fixed-width text, not planning.
1053fn 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
1086// Org ignores timestamps in src/example/export/comment blocks, in
1087// fixed-width lines (`: text`) and inside link targets.
1088fn 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: &regex::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}