1use super::open_board;
9#[cfg(feature = "sqlite")]
10use crate::backlog::BacklogItem;
11use crate::backlog::ItemId;
12use crate::config::Config;
13use crate::error::Result;
14#[cfg(feature = "sqlite")]
15use crate::sprint::Sprint;
16use crate::sprint::SprintId;
17use crate::storage::{Backend, parse_frontmatter};
18use inspect::inspect_board;
19use repair::{apply_safe_fixes, repair_duplicate_item_ids};
20use std::collections::HashSet;
21use std::future::Future;
22use std::path::{Path, PathBuf};
23
24#[cfg(test)]
26use inspect::{analyze_records, analyze_sprints, graph_cycles, read_issued_history};
27
28mod inspect;
29mod repair;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
33pub enum DoctorIssueKind {
34 DanglingDependency,
35 DanglingParent,
36 DanglingSprint,
37 ParentCycle,
38 DependencyCycle,
39 DuplicateId,
40 IssuedId,
41 InvalidStatus,
42 RankAnomaly,
43 Collision,
44 MalformedRecord,
45 Filename,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct DoctorIssue {
51 pub kind: DoctorIssueKind,
52 pub location: String,
53 pub detail: String,
54 pub repair: String,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct DoctorFix {
60 pub description: String,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct DoctorReport {
66 pub issues: Vec<DoctorIssue>,
67 pub fixes: Vec<DoctorFix>,
68}
69
70pub async fn doctor(project_dir: &Path, fix: bool) -> Result<DoctorReport> {
72 if fix {
73 let (board_dir, backend, config, _lock) = super::open_board_locked(project_dir).await?;
74 run_doctor(&board_dir, &backend, &config, true).await
75 } else {
76 let (board_dir, backend, config) = open_board(project_dir).await?;
77 run_doctor(&board_dir, &backend, &config, false).await
78 }
79}
80
81async fn run_doctor(
82 board_dir: &Path,
83 backend: &Backend,
84 config: &Config,
85 fix: bool,
86) -> Result<DoctorReport> {
87 run_doctor_with(board_dir, backend, fix, || {
88 inspect_board(board_dir, backend, config)
89 })
90 .await
91}
92
93async fn run_doctor_with<F, Fut>(
96 board_dir: &Path,
97 backend: &Backend,
98 fix: bool,
99 mut inspect: F,
100) -> Result<DoctorReport>
101where
102 F: FnMut() -> Fut,
103 Fut: Future<Output = Result<Inspection>>,
104{
105 let initial = inspect().await?;
106 if !fix {
107 return Ok(DoctorReport {
108 issues: initial.issues,
109 fixes: Vec::new(),
110 });
111 }
112 let mut fixes = repair_duplicate_item_ids(board_dir, &initial).await?;
116 let inspection = if fixes.is_empty() {
117 initial
118 } else {
119 inspect().await?
120 };
121 let safe_fixes = apply_safe_fixes(board_dir, &inspection).await?;
122 let board_changed_after_inspection = !safe_fixes.is_empty();
123 fixes.extend(safe_fixes);
124 if fixes.is_empty() {
125 return Ok(DoctorReport {
126 issues: inspection.issues,
127 fixes,
128 });
129 }
130 backend.commit("pinto: doctor --fix").await?;
131 let final_state = if board_changed_after_inspection {
134 inspect().await?
135 } else {
136 inspection
137 };
138 Ok(DoctorReport {
139 issues: final_state.issues,
140 fixes,
141 })
142}
143
144#[derive(Debug)]
145struct Inspection {
146 records: Vec<RawItemRecord>,
147 issues: Vec<DoctorIssue>,
148 issued: IssuedHistory,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152enum RecordArea {
153 Tasks,
154 Archive,
155 #[cfg(feature = "sqlite")]
156 Database,
157}
158
159impl RecordArea {
160 fn directory(self, board_dir: &Path) -> Option<PathBuf> {
161 match self {
162 Self::Tasks => Some(board_dir.join("tasks")),
163 Self::Archive => Some(board_dir.join("archive")),
164 #[cfg(feature = "sqlite")]
165 Self::Database => None,
166 }
167 }
168}
169
170#[derive(Debug, Clone)]
171enum RawField<T> {
172 Missing,
173 Invalid(String),
174 Present(T),
175}
176
177impl<T> RawField<T> {
178 fn as_ref(&self) -> Option<&T> {
179 match self {
180 Self::Present(value) => Some(value),
181 Self::Missing | Self::Invalid(_) => None,
182 }
183 }
184}
185
186#[derive(Debug, Clone)]
187struct RawItemRecord {
188 path: PathBuf,
189 area: RecordArea,
190 document: Option<String>,
191 filename: Option<String>,
192 frontmatter_error: Option<String>,
193 id: RawField<String>,
194 status: RawField<String>,
195 rank: RawField<String>,
196 title: RawField<String>,
197 sprint: RawField<String>,
198 parent: RawField<String>,
199 depends_on: RawField<Vec<String>>,
200}
201
202impl RawItemRecord {
203 fn from_document(path: PathBuf, area: RecordArea, text: String) -> Self {
204 let filename = path
205 .file_name()
206 .and_then(|name| name.to_str())
207 .map(str::to_string);
208 let missing = || Self {
209 path: path.clone(),
210 area,
211 document: Some(text.clone()),
212 filename: filename.clone(),
213 frontmatter_error: None,
214 id: RawField::Missing,
215 status: RawField::Missing,
216 rank: RawField::Missing,
217 title: RawField::Missing,
218 sprint: RawField::Missing,
219 parent: RawField::Missing,
220 depends_on: RawField::Missing,
221 };
222 let Some((front, _body)) = parse_frontmatter(&text) else {
223 let mut record = missing();
224 record.frontmatter_error = Some("missing frontmatter delimiter".to_string());
225 return record;
226 };
227 let value = match toml::from_str::<toml::Value>(front) {
228 Ok(value) => value,
229 Err(error) => {
230 let mut record = missing();
231 record.frontmatter_error = Some(error.to_string());
232 return record;
233 }
234 };
235 let Some(table) = value.as_table() else {
236 let mut record = missing();
237 record.frontmatter_error = Some("frontmatter must be a TOML table".to_string());
238 return record;
239 };
240 Self {
241 path,
242 area,
243 document: Some(text),
244 filename,
245 frontmatter_error: None,
246 id: string_field(table, "id"),
247 status: string_field(table, "status"),
248 rank: string_field(table, "rank"),
249 title: string_field(table, "title"),
250 sprint: string_field(table, "sprint"),
251 parent: string_field(table, "parent"),
252 depends_on: string_list_field(table, "depends_on"),
253 }
254 }
255
256 #[cfg(feature = "sqlite")]
257 fn from_item(board_dir: &Path, item: BacklogItem) -> Self {
258 Self {
259 path: board_dir
260 .join("board.sqlite3#items")
261 .join(item.id.to_string()),
262 area: RecordArea::Database,
263 document: None,
264 filename: None,
265 frontmatter_error: None,
266 id: RawField::Present(item.id.to_string()),
267 status: RawField::Present(item.status.as_str().to_string()),
268 rank: RawField::Present(item.rank.as_str().to_string()),
269 title: RawField::Present(item.title.clone()),
270 sprint: item.sprint.map_or(RawField::Missing, RawField::Present),
271 parent: item
272 .parent
273 .map_or(RawField::Missing, |id| RawField::Present(id.to_string())),
274 depends_on: RawField::Present(
275 item.depends_on
276 .into_iter()
277 .map(|id| id.to_string())
278 .collect(),
279 ),
280 }
281 }
282
283 fn valid_id(&self) -> Option<ItemId> {
284 self.id.as_ref()?.parse().ok()
285 }
286
287 fn location(&self) -> String {
288 self.path.display().to_string()
289 }
290}
291
292#[derive(Debug, Clone)]
293struct RawSprintRecord {
294 path: PathBuf,
295 frontmatter_error: Option<String>,
296 id: RawField<String>,
297 state: RawField<String>,
298}
299
300impl RawSprintRecord {
301 fn from_document(path: PathBuf, text: String) -> Self {
302 let missing = || Self {
303 path: path.clone(),
304 frontmatter_error: None,
305 id: RawField::Missing,
306 state: RawField::Missing,
307 };
308 let Some((front, _goal)) = parse_frontmatter(&text) else {
309 let mut record = missing();
310 record.frontmatter_error = Some("missing frontmatter delimiter".to_string());
311 return record;
312 };
313 let value = match toml::from_str::<toml::Value>(front) {
314 Ok(value) => value,
315 Err(error) => {
316 let mut record = missing();
317 record.frontmatter_error = Some(error.to_string());
318 return record;
319 }
320 };
321 let Some(table) = value.as_table() else {
322 let mut record = missing();
323 record.frontmatter_error = Some("frontmatter must be a TOML table".to_string());
324 return record;
325 };
326 Self {
327 path,
328 frontmatter_error: None,
329 id: string_field(table, "id"),
330 state: string_field(table, "state"),
331 }
332 }
333
334 #[cfg(feature = "sqlite")]
335 fn from_sprint(sprint: Sprint) -> Self {
336 Self {
337 path: PathBuf::from(format!("board.sqlite3#sprints/{}", sprint.id)),
338 frontmatter_error: None,
339 id: RawField::Present(sprint.id.to_string()),
340 state: RawField::Present(sprint.state.to_string()),
341 }
342 }
343
344 fn valid_id(&self) -> Option<SprintId> {
345 self.id.as_ref()?.parse().ok()
346 }
347
348 fn location(&self) -> String {
349 self.path.display().to_string()
350 }
351}
352
353#[derive(Debug, Default)]
354struct IssuedHistory {
355 path: PathBuf,
356 ids: HashSet<ItemId>,
357 invalid: Vec<(usize, String)>,
358 duplicates: Vec<(usize, String)>,
359}
360
361fn string_field(table: &toml::map::Map<String, toml::Value>, name: &str) -> RawField<String> {
362 match table.get(name) {
363 None => RawField::Missing,
364 Some(value) => value.as_str().map_or_else(
365 || RawField::Invalid(format!("field `{name}` must be a string")),
366 |value| RawField::Present(value.to_string()),
367 ),
368 }
369}
370
371fn string_list_field(
372 table: &toml::map::Map<String, toml::Value>,
373 name: &str,
374) -> RawField<Vec<String>> {
375 match table.get(name) {
376 None => RawField::Missing,
377 Some(value) => {
378 let Some(values) = value.as_array() else {
379 return RawField::Invalid(format!("field `{name}` must be an array of strings"));
380 };
381 let mut output = Vec::with_capacity(values.len());
382 for value in values {
383 let Some(value) = value.as_str() else {
384 return RawField::Invalid(format!("field `{name}` must contain only strings"));
385 };
386 output.push(value.to_string());
387 }
388 RawField::Present(output)
389 }
390 }
391}
392
393#[cfg(test)]
394mod tests;