Skip to main content

pinto/service/
doctor.rs

1//! Board integrity diagnostics and conservative recovery.
2//!
3//! The parent module holds the public report types, the inspect/fix/report entry points, and the
4//! raw-record data model shared by both submodules. Board reading and analysis live in `inspect`;
5//! the conservative, mechanical repairs live in `repair`. The raw-record structs stay here so both
6//! submodules can read their private fields without any visibility widening.
7
8use 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// Inspection helpers the test module drives directly; re-imported so `use super::*` resolves them.
25#[cfg(test)]
26use inspect::{analyze_records, analyze_sprints, graph_cycles, read_issued_history};
27
28mod inspect;
29mod repair;
30
31/// Category of an integrity issue.
32#[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/// One actionable board-integrity finding.
49#[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/// One safe mechanical change applied by doctor --fix.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct DoctorFix {
60    pub description: String,
61}
62
63/// Result of a board integrity scan.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct DoctorReport {
66    pub issues: Vec<DoctorIssue>,
67    pub fixes: Vec<DoctorFix>,
68}
69
70/// Inspect a board and optionally apply safe, mechanical repairs.
71pub 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
93/// Drive the inspect/fix/report flow with an injected inspection step so tests
94/// can observe exactly how many full inspections one doctor run performs.
95async 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    // Renumber duplicate IDs first, then re-inspect so the filename and issued-history repairs
113    // see the post-renumber board (for example, a surviving copy whose filename still needs to
114    // be normalized). Only pay for the extra inspection when a duplicate was actually repaired.
115    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    // The report must describe the post-fix board, so re-inspect only when the
132    // safe-fix stage changed the board after the latest inspection.
133    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;