Skip to main content

agd_core/document/
org_document.rs

1//! `OrgDocument`: one org file parsed from its bytes, keeping the source
2//! exactly, with its headlines and the TODO keywords, logging, priorities, file
3//! tags and category its directives give.
4//!
5//! It refuses a file that is not UTF-8 (`invalid_utf8`) or that orgize would
6//! not round-trip byte for byte (`parse_failed`), so every span points into bytes
7//! that are exactly the file's.
8
9use std::path::{Path, PathBuf};
10
11use orgize::ParseConfig;
12
13use crate::{AgdError, ErrorCode, Settings};
14
15use super::directive::{
16    LogSettings, PriorityRange, TodoKeyword, directive, log_settings, priority_range, todo_keywords,
17};
18use super::headline::{HeadlineSpan, parse_colon_tags, scan_headlines};
19use super::source::source_lines;
20
21#[derive(Clone, Debug)]
22pub struct OrgDocument {
23    path: PathBuf,
24    source: String,
25    pub(super) headlines: Vec<HeadlineSpan>,
26    todo_active: Vec<String>,
27    todo_done: Vec<String>,
28    todo_keywords: Vec<TodoKeyword>,
29    logging: LogSettings,
30    priorities: PriorityRange,
31}
32
33impl OrgDocument {
34    pub fn parse(path: PathBuf, bytes: Vec<u8>, settings: &Settings) -> Result<Self, AgdError> {
35        let source = String::from_utf8(bytes).map_err(|error| {
36            AgdError::with_source(
37                ErrorCode::InvalidUtf8,
38                format!("org file is not UTF-8: {}", path.display()),
39                error,
40            )
41        })?;
42        let lines = source_lines(&source);
43        let todo_keywords = todo_keywords(&source, settings);
44        let todo_active: Vec<String> = todo_keywords
45            .iter()
46            .filter(|keyword| !keyword.done)
47            .map(|keyword| keyword.name.clone())
48            .collect();
49        let todo_done: Vec<String> = todo_keywords
50            .iter()
51            .filter(|keyword| keyword.done)
52            .map(|keyword| keyword.name.clone())
53            .collect();
54        let logging = log_settings(&source, settings);
55        let priorities = priority_range(&source, settings);
56        let file_tags = directive(&source, "FILETAGS")
57            .map(parse_colon_tags)
58            .unwrap_or_default();
59        let file_category = directive(&source, "CATEGORY")
60            .map(str::trim)
61            .filter(|value| !value.is_empty())
62            .map(str::to_string)
63            .unwrap_or_else(|| {
64                path.file_stem()
65                    .and_then(|name| name.to_str())
66                    .unwrap_or("org")
67                    .to_string()
68            });
69
70        let parse_config = ParseConfig {
71            todo_keywords: (todo_active.clone(), todo_done.clone()),
72            ..Default::default()
73        };
74        let parsed = parse_config.parse(&source);
75        if parsed.to_org() != source {
76            return Err(AgdError::new(
77                ErrorCode::ParseFailed,
78                format!(
79                    "orgize changed bytes during a no-op round trip: {}",
80                    path.display()
81                ),
82            ));
83        }
84
85        let raw = scan_headlines(
86            &path,
87            &source,
88            &lines,
89            &todo_active,
90            &todo_done,
91            &file_tags,
92            &file_category,
93            settings,
94        )?;
95        Ok(Self {
96            path,
97            source,
98            headlines: raw,
99            todo_active,
100            todo_done,
101            todo_keywords,
102            logging,
103            priorities,
104        })
105    }
106
107    pub fn path(&self) -> &Path {
108        &self.path
109    }
110
111    pub fn source(&self) -> &str {
112        &self.source
113    }
114
115    pub fn headlines(&self) -> &[HeadlineSpan] {
116        &self.headlines
117    }
118
119    pub fn headline_at_line(&self, line: usize) -> Option<&HeadlineSpan> {
120        self.headlines
121            .iter()
122            .find(|headline| headline.item.line == line)
123    }
124
125    pub fn todo_active(&self) -> &[String] {
126        &self.todo_active
127    }
128
129    pub fn todo_done(&self) -> &[String] {
130        &self.todo_done
131    }
132
133    pub fn todo_keywords(&self) -> &[TodoKeyword] {
134        &self.todo_keywords
135    }
136
137    pub fn todo_keyword(&self, name: &str) -> Option<&TodoKeyword> {
138        self.todo_keywords
139            .iter()
140            .find(|keyword| keyword.name == name)
141    }
142
143    pub fn logging(&self) -> LogSettings {
144        self.logging
145    }
146
147    /// This file's priority range: `#+PRIORITIES`, else the configured one.
148    pub fn priorities(&self) -> PriorityRange {
149        self.priorities
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::config::test_support::settings;
157
158    #[test]
159    fn indexes_refs_inheritance_and_in_file_todo_without_drift() {
160        let temp = tempfile::tempdir().unwrap();
161        let path = temp.path().join("work.org");
162        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";
163        std::fs::write(&path, source).unwrap();
164        let document = OrgDocument::parse(
165            path.clone(),
166            std::fs::read(&path).unwrap(),
167            &settings(temp.path()),
168        )
169        .unwrap();
170        assert_eq!(document.source(), source);
171        let item = &document.headlines()[1].item;
172        assert_eq!(item.item_ref, "exact-id");
173        assert_eq!(item.todo.as_deref(), Some("OPEN"));
174        assert_eq!(item.priority.as_deref(), Some("A"));
175        // File tags first, then the parent's, as Org lists them.
176        assert_eq!(item.inherited_tags, vec!["work", "project"]);
177        assert_eq!(item.category, "reporting");
178        assert_eq!(
179            item.planning.deadline.as_ref().unwrap().warning.as_deref(),
180            Some("-5d")
181        );
182    }
183}