agd_core/document/
org_document.rs1use 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 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 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}