Skip to main content

org_core/
check.rs

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}