1use 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 pub fn add_problems(&mut self, problems: Vec<CheckProblem>) {
34 self.summary.errors += problems.len();
35 self.problems.extend(problems);
36 }
37}
38
39pub 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 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}