Skip to main content

agd_core/
check.rs

1//! `agd setup check`: a read-only report ([`CheckReport`], schema
2//! `agd.check/v1`) on the org files under the roots and on the configuration:
3//! files that cannot be read or parsed, files the Org parser would not
4//! reproduce byte for byte, repeated `:ID:`s, TODO keywords a file uses
5//! without enabling them, and interrupted writes.
6//!
7//! It parses each file as the index does ([`IndexedDocument`](crate::IndexedDocument))
8//! and never writes. Its problem `code`s are [`ErrorCode`](crate::ErrorCode)s,
9//! the same code an agd error gives for the same failure: `io_failed`,
10//! `walk_failed`, `invalid_utf8`, `parse_failed`, `id_conflict`,
11//! `invalid_todo`, `invalid_path` and `journal_conflict`.
12
13use std::collections::HashMap;
14use std::fs;
15
16use crate::{AgdError, CHECK_SCHEMA, ErrorCode, JournalRecord, RootSet, Settings};
17use serde::Serialize;
18
19#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
20pub struct CheckReport {
21    pub schema: &'static str,
22    pub files: Vec<CheckFileReport>,
23    pub problems: Vec<CheckProblem>,
24    pub summary: CheckSummary,
25}
26
27impl CheckReport {
28    pub fn has_errors(&self) -> bool {
29        self.summary.errors != 0
30    }
31
32    /// Adds problems found outside the files, such as journal conflicts.
33    pub fn add_problems(&mut self, problems: Vec<CheckProblem>) {
34        self.summary.errors += problems.len();
35        self.problems.extend(problems);
36    }
37}
38
39/// One problem per interrupted write whose file changed since (see
40/// `WriteEngine::journal_conflicts`), naming the command that settles it.
41pub fn journal_problems(conflicts: &[JournalRecord]) -> Vec<CheckProblem> {
42    conflicts
43        .iter()
44        .map(|record| CheckProblem {
45            file: record.file.clone(),
46            line: None,
47            code: ErrorCode::JournalConflict.as_str(),
48            message: format!(
49                "an agd write was interrupted (journal entry {}) and the file changed since; writes to it are refused until `agd setup check --resolve` keeps it as it is",
50                record.id
51            ),
52        })
53        .collect()
54}
55
56#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
57pub struct CheckFileReport {
58    pub file: String,
59    pub headlines: usize,
60}
61
62#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
63pub struct CheckProblem {
64    pub file: String,
65    pub line: Option<usize>,
66    pub code: &'static str,
67    pub message: String,
68}
69
70#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
71pub struct CheckSummary {
72    pub files: usize,
73    pub headlines: usize,
74    pub errors: usize,
75}
76
77pub fn check_settings(settings: &Settings) -> Result<CheckReport, AgdError> {
78    let roots = RootSet::new(&settings.roots, &settings.agenda_files)?;
79    let org_files = roots.all_org_files()?;
80    let paths = org_files.files;
81    // The configured keywords, read as a file without #+TODO lines reads them.
82    let configured_keywords: std::collections::HashSet<String> =
83        crate::document::todo_keywords("", settings)
84            .into_iter()
85            .map(|keyword| keyword.name)
86            .collect();
87    let mut files = Vec::with_capacity(paths.len());
88    let mut problems: Vec<CheckProblem> = org_files
89        .skipped
90        .into_iter()
91        .map(|(path, reason)| CheckProblem {
92            file: path.to_string_lossy().into_owned(),
93            line: None,
94            code: reason.code(),
95            message: reason.message(),
96        })
97        .collect();
98    let mut ids: HashMap<String, (String, usize)> = HashMap::new();
99    let mut headline_count = 0;
100
101    for path in paths {
102        let display_path = path.to_string_lossy().into_owned();
103        let bytes = match fs::read(&path) {
104            Ok(bytes) => bytes,
105            Err(error) => {
106                problems.push(CheckProblem {
107                    file: display_path,
108                    line: None,
109                    code: ErrorCode::IoFailed.as_str(),
110                    message: error.to_string(),
111                });
112                continue;
113            }
114        };
115        let document = match crate::document::OrgDocument::parse(path, bytes, settings) {
116            Ok(document) => document,
117            Err(error) => {
118                problems.push(CheckProblem {
119                    file: display_path,
120                    line: None,
121                    code: error.code().as_str(),
122                    message: error.to_string(),
123                });
124                continue;
125            }
126        };
127
128        if let Some(value) = crate::document::ignored_priorities_line(document.source()) {
129            let line = document.source().lines().position(|line| {
130                line.get(..13)
131                    .is_some_and(|key| key.eq_ignore_ascii_case("#+PRIORITIES:"))
132            });
133            problems.push(CheckProblem {
134                file: display_path.clone(),
135                line: line.map(|line| line + 1),
136                code: ErrorCode::InvalidPriority.as_str(),
137                message: format!(
138                    "agd ignores '#+PRIORITIES: {value}' and uses the configured {}: it takes three letters A–Z, highest, lowest and default; numeric priorities are not supported",
139                    document.priorities().describe()
140                ),
141            });
142        }
143
144        let effective: std::collections::HashSet<&str> = document
145            .todo_active()
146            .iter()
147            .chain(document.todo_done())
148            .map(String::as_str)
149            .collect();
150        for headline in document.headlines() {
151            if let Some(id) = &headline.item.id {
152                if let Some((first_file, first_line)) = ids.get(id) {
153                    problems.push(CheckProblem {
154                        file: display_path.clone(),
155                        line: Some(headline.item.line),
156                        code: ErrorCode::IdConflict.as_str(),
157                        message: format!("ID '{id}' first appears at {first_file}:{first_line}"),
158                    });
159                } else {
160                    ids.insert(id.clone(), (display_path.clone(), headline.item.line));
161                }
162            }
163
164            if headline.item.todo.is_none()
165                && let Some(first) = headline.item.title.split_whitespace().next()
166                && configured_keywords.contains(first)
167                && !effective.contains(first)
168            {
169                problems.push(CheckProblem {
170                    file: display_path.clone(),
171                    line: Some(headline.item.line),
172                    code: ErrorCode::InvalidTodo.as_str(),
173                    message: format!(
174                        "TODO keyword '{first}' is not enabled by this file's #+TODO settings"
175                    ),
176                });
177            }
178        }
179
180        let headlines = document.headlines().len();
181        headline_count += headlines;
182        files.push(CheckFileReport {
183            file: display_path,
184            headlines,
185        });
186    }
187
188    Ok(CheckReport {
189        schema: CHECK_SCHEMA,
190        summary: CheckSummary {
191            files: files.len(),
192            headlines: headline_count,
193            errors: problems.len(),
194        },
195        files,
196        problems,
197    })
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn settings_check_reports_a_priorities_line_agd_ignores() {
206        let directory = tempfile::tempdir().expect("temporary directory");
207        fs::write(
208            directory.path().join("tasks.org"),
209            "#+TITLE: Tasks\n#+PRIORITIES: 1 5 3\n* TODO [#2] Kept\n",
210        )
211        .expect("fixture");
212        let settings = Settings {
213            roots: vec![directory.path().to_string_lossy().into_owned()],
214            ..Settings::default()
215        };
216        let report = check_settings(&settings).expect("check runs");
217        assert_eq!(report.problems.len(), 1, "{:?}", report.problems);
218        let problem = &report.problems[0];
219        assert_eq!((problem.code, problem.line), ("invalid_priority", Some(2)));
220        assert!(
221            problem
222                .message
223                .starts_with("agd ignores '#+PRIORITIES: 1 5 3' and uses the configured A–C"),
224            "{}",
225            problem.message
226        );
227    }
228
229    #[cfg(unix)]
230    #[test]
231    fn settings_check_reports_symlinks_that_leave_the_roots() {
232        let directory = tempfile::tempdir().expect("temporary directory");
233        fs::write(directory.path().join("tasks.org"), "* TODO Kept\n").expect("fixture");
234        let outside = tempfile::tempdir().expect("outside directory");
235        std::os::unix::fs::symlink(outside.path(), directory.path().join("elsewhere"))
236            .expect("symlink");
237        let settings = Settings {
238            roots: vec![directory.path().to_string_lossy().into_owned()],
239            ..Settings::default()
240        };
241
242        let report = check_settings(&settings).expect("check runs");
243
244        assert_eq!(report.summary.files, 1);
245        assert_eq!(report.problems.len(), 1);
246        assert_eq!(report.problems[0].code, "invalid_path");
247        assert!(report.problems[0].file.ends_with("elsewhere"));
248    }
249}