1use std::collections::HashMap;
2use std::ffi::OsStr;
3use std::fmt;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::string::FromUtf8Error;
7
8use crate::{AgdError, RootSet, Settings};
9use ignore::WalkBuilder;
10use orgize::{Org, ParseConfig, ast::Headline as OrgHeadline, rowan::ast::AstNode};
11use serde::Serialize;
12
13pub const CHECK_SCHEMA: &str = "agd.check/v1";
14
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16pub struct SourceSpan {
17 pub start: usize,
18 pub end: usize,
19}
20
21#[derive(Clone, Debug, Eq, PartialEq)]
22pub struct IndexedHeadline {
23 pub line: usize,
24 pub level: usize,
25 pub span: SourceSpan,
26 pub todo_keyword: Option<SourceSpan>,
27 pub id: Option<SourceSpan>,
28}
29
30pub struct OrgDocument {
31 source: String,
32 org: Org,
33 headlines: Vec<IndexedHeadline>,
34}
35
36impl OrgDocument {
37 pub fn parse(bytes: Vec<u8>) -> Result<Self, DocumentParseError> {
38 let source = String::from_utf8(bytes).map_err(DocumentParseError::InvalidUtf8)?;
39 let org = parse_org(&source);
40 let headlines = index_headlines(&source, &org);
41
42 Ok(Self {
43 source,
44 org,
45 headlines,
46 })
47 }
48
49 pub fn source(&self) -> &str {
50 &self.source
51 }
52
53 pub fn headlines(&self) -> &[IndexedHeadline] {
54 &self.headlines
55 }
56
57 pub fn text(&self, span: SourceSpan) -> &str {
58 &self.source[span.start..span.end]
59 }
60
61 pub fn round_trips_exactly(&self) -> bool {
62 self.org.to_org() == self.source
63 }
64}
65
66#[derive(Debug)]
67pub enum DocumentParseError {
68 InvalidUtf8(FromUtf8Error),
69}
70
71impl fmt::Display for DocumentParseError {
72 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73 match self {
74 Self::InvalidUtf8(_) => formatter.write_str("file is not valid UTF-8"),
75 }
76 }
77}
78
79impl std::error::Error for DocumentParseError {
80 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
81 match self {
82 Self::InvalidUtf8(error) => Some(error),
83 }
84 }
85}
86
87#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
88pub struct CheckReport {
89 pub schema: &'static str,
90 pub files: Vec<CheckFileReport>,
91 pub problems: Vec<CheckProblem>,
92 pub summary: CheckSummary,
93}
94
95impl CheckReport {
96 pub fn has_errors(&self) -> bool {
97 self.summary.errors != 0
98 }
99}
100
101#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
102pub struct CheckFileReport {
103 pub file: String,
104 pub headlines: usize,
105}
106
107#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
108pub struct CheckProblem {
109 pub file: String,
110 pub line: Option<usize>,
111 pub code: &'static str,
112 pub message: String,
113}
114
115#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
116pub struct CheckSummary {
117 pub files: usize,
118 pub headlines: usize,
119 pub errors: usize,
120}
121
122pub fn check_paths(roots: &[PathBuf]) -> CheckReport {
123 let mut paths = Vec::new();
124 let mut problems = Vec::new();
125
126 for root in roots {
127 collect_files(root, &mut paths, &mut problems);
128 }
129 paths.sort_unstable();
130 paths.dedup();
131
132 let mut files = Vec::with_capacity(paths.len());
133 let mut ids: HashMap<String, (String, usize)> = HashMap::new();
134 let mut headline_count = 0;
135
136 for path in paths {
137 let display_path = path.to_string_lossy().into_owned();
138 let bytes = match fs::read(&path) {
139 Ok(bytes) => bytes,
140 Err(error) => {
141 problems.push(CheckProblem {
142 file: display_path,
143 line: None,
144 code: "read_error",
145 message: error.to_string(),
146 });
147 continue;
148 }
149 };
150 let document = match OrgDocument::parse(bytes) {
151 Ok(document) => document,
152 Err(error) => {
153 problems.push(CheckProblem {
154 file: display_path,
155 line: None,
156 code: "invalid_org",
157 message: error.to_string(),
158 });
159 continue;
160 }
161 };
162
163 if !document.round_trips_exactly() {
164 problems.push(CheckProblem {
165 file: display_path.clone(),
166 line: None,
167 code: "byte_drift",
168 message: "orgize did not reproduce the input byte-for-byte".to_owned(),
169 });
170 }
171
172 for headline in document.headlines() {
173 let Some(id_span) = headline.id else {
174 continue;
175 };
176 let id = document.text(id_span);
177 if let Some((first_file, first_line)) = ids.get(id) {
178 problems.push(CheckProblem {
179 file: display_path.clone(),
180 line: Some(headline.line),
181 code: "duplicate_id",
182 message: format!("ID {id:?} first appears at {first_file}:{first_line}"),
183 });
184 } else {
185 ids.insert(id.to_owned(), (display_path.clone(), headline.line));
186 }
187 }
188
189 let headlines = document.headlines().len();
190 headline_count += headlines;
191 files.push(CheckFileReport {
192 file: display_path,
193 headlines,
194 });
195 }
196
197 CheckReport {
198 schema: CHECK_SCHEMA,
199 summary: CheckSummary {
200 files: files.len(),
201 headlines: headline_count,
202 errors: problems.len(),
203 },
204 files,
205 problems,
206 }
207}
208
209pub fn check_settings(settings: &Settings) -> Result<CheckReport, AgdError> {
210 let roots = RootSet::new(&settings.roots, &settings.agenda_files)?;
211 let paths = roots.all_org_files()?;
212 let configured_keywords: std::collections::HashSet<&str> = settings
213 .todo_keywords
214 .iter()
215 .flatten()
216 .filter(|keyword| keyword.as_str() != "|")
217 .filter_map(|keyword| keyword.split('(').next())
218 .collect();
219 let mut files = Vec::with_capacity(paths.len());
220 let mut problems = Vec::new();
221 let mut ids: HashMap<String, (String, usize)> = HashMap::new();
222 let mut headline_count = 0;
223
224 for path in paths {
225 let display_path = path.to_string_lossy().into_owned();
226 let bytes = match fs::read(&path) {
227 Ok(bytes) => bytes,
228 Err(error) => {
229 problems.push(CheckProblem {
230 file: display_path,
231 line: None,
232 code: "read_error",
233 message: error.to_string(),
234 });
235 continue;
236 }
237 };
238 let document = match crate::document::OrgDocument::parse(path, bytes, settings) {
239 Ok(document) => document,
240 Err(error) => {
241 let code = match error.code() {
242 "non_utf8" => "non_utf8",
243 "parse_drift" => "byte_drift",
244 _ => "invalid_org",
245 };
246 problems.push(CheckProblem {
247 file: display_path,
248 line: None,
249 code,
250 message: error.to_string(),
251 });
252 continue;
253 }
254 };
255
256 let effective: std::collections::HashSet<&str> = document
257 .todo_active()
258 .iter()
259 .chain(document.todo_done())
260 .map(String::as_str)
261 .collect();
262 for headline in document.headlines() {
263 if let Some(id) = &headline.item.id {
264 if let Some((first_file, first_line)) = ids.get(id) {
265 problems.push(CheckProblem {
266 file: display_path.clone(),
267 line: Some(headline.item.line),
268 code: "duplicate_id",
269 message: format!("ID {id:?} first appears at {first_file}:{first_line}"),
270 });
271 } else {
272 ids.insert(id.clone(), (display_path.clone(), headline.item.line));
273 }
274 }
275
276 if headline.item.todo.is_none()
277 && let Some(first) = headline.item.title.split_whitespace().next()
278 && configured_keywords.contains(first)
279 && !effective.contains(first)
280 {
281 problems.push(CheckProblem {
282 file: display_path.clone(),
283 line: Some(headline.item.line),
284 code: "unknown_todo_keyword",
285 message: format!(
286 "TODO keyword {first:?} is not enabled by this file's #+TODO settings"
287 ),
288 });
289 }
290 }
291
292 let headlines = document.headlines().len();
293 headline_count += headlines;
294 files.push(CheckFileReport {
295 file: display_path,
296 headlines,
297 });
298 }
299
300 Ok(CheckReport {
301 schema: CHECK_SCHEMA,
302 summary: CheckSummary {
303 files: files.len(),
304 headlines: headline_count,
305 errors: problems.len(),
306 },
307 files,
308 problems,
309 })
310}
311
312fn parse_org(source: &str) -> Org {
313 let Some(todo_keywords) = todo_keywords(source) else {
314 return Org::parse(source);
315 };
316
317 ParseConfig {
318 todo_keywords,
319 ..ParseConfig::default()
320 }
321 .parse(source)
322}
323
324fn todo_keywords(source: &str) -> Option<(Vec<String>, Vec<String>)> {
325 let mut todo = Vec::new();
326 let mut done = Vec::new();
327 let mut found = false;
328
329 for line in source.lines() {
330 let Some(value) =
331 directive_value(line, "TODO").or_else(|| directive_value(line, "SEQ_TODO"))
332 else {
333 continue;
334 };
335 found = true;
336 let sequence_start = todo.len();
337 let mut after_separator = false;
338
339 for token in value.split_ascii_whitespace() {
340 if token == "|" {
341 after_separator = true;
342 continue;
343 }
344 let keyword = token.split_once('(').map_or(token, |(keyword, _)| keyword);
345 if keyword.is_empty() {
346 continue;
347 }
348 if after_separator {
349 done.push(keyword.to_owned());
350 } else {
351 todo.push(keyword.to_owned());
352 }
353 }
354
355 if !after_separator
356 && todo.len() > sequence_start
357 && let Some(last) = todo.pop()
358 {
359 done.push(last);
360 }
361 }
362
363 found.then_some((todo, done))
364}
365
366fn directive_value<'a>(line: &'a str, name: &str) -> Option<&'a str> {
367 let rest = line.strip_prefix("#+")?;
368 let (actual_name, value) = rest.split_once(':')?;
369 actual_name
370 .eq_ignore_ascii_case(name)
371 .then(|| value.trim_ascii())
372}
373
374fn index_headlines(source: &str, org: &Org) -> Vec<IndexedHeadline> {
375 let mut headlines = Vec::new();
376 let mut scanned_to = 0;
377 let mut line = 1;
378
379 for headline in org
380 .document()
381 .syntax()
382 .descendants()
383 .filter_map(OrgHeadline::cast)
384 {
385 let start = usize::from(headline.start());
386 line += source.as_bytes()[scanned_to..start]
387 .iter()
388 .filter(|byte| **byte == b'\n')
389 .count();
390 scanned_to = start;
391
392 let range = headline.text_range();
393 let todo_keyword = headline.todo_keyword().map(|token| SourceSpan {
394 start: usize::from(token.start()),
395 end: usize::from(token.end()),
396 });
397 let id = headline
398 .properties()
399 .and_then(|properties| properties.get("ID"))
400 .map(|token| SourceSpan {
401 start: usize::from(token.start()),
402 end: usize::from(token.end()),
403 });
404
405 headlines.push(IndexedHeadline {
406 line,
407 level: headline.level(),
408 span: SourceSpan {
409 start: usize::from(range.start()),
410 end: usize::from(range.end()),
411 },
412 todo_keyword,
413 id,
414 });
415 }
416
417 headlines
418}
419
420fn collect_files(root: &Path, files: &mut Vec<PathBuf>, problems: &mut Vec<CheckProblem>) {
421 let metadata = match fs::metadata(root) {
422 Ok(metadata) => metadata,
423 Err(error) => {
424 problems.push(CheckProblem {
425 file: root.to_string_lossy().into_owned(),
426 line: None,
427 code: "read_error",
428 message: error.to_string(),
429 });
430 return;
431 }
432 };
433
434 if metadata.is_file() {
435 files.push(root.to_path_buf());
436 return;
437 }
438 if !metadata.is_dir() {
439 problems.push(CheckProblem {
440 file: root.to_string_lossy().into_owned(),
441 line: None,
442 code: "read_error",
443 message: "path is neither a regular file nor a directory".to_owned(),
444 });
445 return;
446 }
447
448 for entry in WalkBuilder::new(root).follow_links(false).build() {
449 match entry {
450 Ok(entry)
451 if entry.file_type().is_some_and(|kind| kind.is_file())
452 && is_org_file(entry.path()) =>
453 {
454 files.push(entry.into_path());
455 }
456 Ok(_) => {}
457 Err(error) => problems.push(CheckProblem {
458 file: root.to_string_lossy().into_owned(),
459 line: None,
460 code: "read_error",
461 message: error.to_string(),
462 }),
463 }
464 }
465}
466
467fn is_org_file(path: &Path) -> bool {
468 path.extension()
469 .and_then(OsStr::to_str)
470 .is_some_and(|extension| extension.eq_ignore_ascii_case("org"))
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 #[test]
478 fn indexes_multiple_in_file_todo_sequences_and_ids() {
479 let source = b"#+TODO: OPEN(o) NEXT | CLOSED(c)\n#+SEQ_TODO: WAIT(w@/!) SOMEDAY | CANCELED(c@/!)\n\n* OPEN Parent\n:PROPERTIES:\n:ID: parent-id\n:END:\n** CANCELED Child\n"
480 .to_vec();
481
482 let document = OrgDocument::parse(source).expect("valid document");
483 let headlines = document.headlines();
484
485 assert_eq!(headlines.len(), 2);
486 assert_eq!((headlines[0].line, headlines[0].level), (4, 1));
487 assert_eq!(
488 headlines[0].todo_keyword.map(|span| document.text(span)),
489 Some("OPEN")
490 );
491 assert_eq!(
492 headlines[0].id.map(|span| document.text(span)),
493 Some("parent-id")
494 );
495 assert_eq!((headlines[1].line, headlines[1].level), (8, 2));
496 assert_eq!(
497 headlines[1].todo_keyword.map(|span| document.text(span)),
498 Some("CANCELED")
499 );
500 }
501
502 #[test]
503 fn treats_the_last_state_as_done_without_a_separator() {
504 let document = OrgDocument::parse(b"#+TODO: OPEN FINISHED\n* FINISHED Task\n".to_vec())
505 .expect("valid document");
506 let headline = document.org.first_node::<OrgHeadline>().expect("headline");
507
508 assert_eq!(headline.todo_type(), Some(orgize::ast::TodoType::Done));
509 }
510
511 #[test]
512 fn preserves_crlf_input_exactly() {
513 let source =
514 b"#+CATEGORY: work\r\n* TODO Task :tag:\r\nSCHEDULED: <2026-10-13 Tue>\r\n".to_vec();
515 let expected = source.clone();
516
517 let document = OrgDocument::parse(source).expect("valid document");
518
519 assert_eq!(document.source().as_bytes(), expected);
520 assert!(document.round_trips_exactly());
521 }
522
523 #[test]
524 fn rejects_non_utf8_input() {
525 let error = OrgDocument::parse(vec![b'*', b' ', 0xff])
526 .err()
527 .expect("parse error");
528
529 assert_eq!(error.to_string(), "file is not valid UTF-8");
530 }
531
532 #[test]
533 fn reports_duplicate_ids_across_org_files() {
534 let directory = tempfile::tempdir().expect("temporary directory");
535 fs::write(
536 directory.path().join("one.org"),
537 "* First\n:PROPERTIES:\n:ID: duplicate\n:END:\n",
538 )
539 .expect("first fixture");
540 fs::write(
541 directory.path().join("two.org"),
542 "* Second\n:PROPERTIES:\n:ID: duplicate\n:END:\n",
543 )
544 .expect("second fixture");
545 fs::write(directory.path().join("ignored.txt"), "* Not org\n").expect("ignored fixture");
546
547 let report = check_paths(&[directory.path().to_path_buf()]);
548
549 assert_eq!(report.summary.files, 2);
550 assert_eq!(report.summary.headlines, 2);
551 assert_eq!(report.summary.errors, 1);
552 assert_eq!(report.problems[0].code, "duplicate_id");
553 assert_eq!(report.problems[0].line, Some(1));
554 assert!(report.problems[0].message.contains("one.org:1"));
555 }
556}