Skip to main content

pinto/service/
doctor.rs

1//! Board integrity diagnostics and conservative recovery.
2
3use super::open_board;
4#[cfg(feature = "sqlite")]
5use crate::backlog::BacklogItem;
6use crate::backlog::ItemId;
7use crate::config::{Config, StorageBackend};
8use crate::error::{Error, Result};
9use crate::rank::Rank;
10#[cfg(feature = "sqlite")]
11use crate::sprint::Sprint;
12use crate::sprint::{SprintId, SprintState};
13use crate::storage::{Backend, item_issued_ids_path, parse_frontmatter, record_issued_id};
14#[cfg(feature = "sqlite")]
15use crate::storage::{BacklogItemRepository, SprintRepository};
16use rayon::prelude::*;
17use std::collections::{BTreeMap, BTreeSet, HashSet};
18use std::path::{Path, PathBuf};
19use tokio::fs;
20use tokio::task::JoinSet;
21
22/// Category of an integrity issue.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
24pub enum DoctorIssueKind {
25    DanglingDependency,
26    DanglingParent,
27    DanglingSprint,
28    ParentCycle,
29    DependencyCycle,
30    DuplicateId,
31    IssuedId,
32    InvalidStatus,
33    RankAnomaly,
34    Collision,
35    MalformedRecord,
36    Filename,
37}
38
39/// One actionable board-integrity finding.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct DoctorIssue {
42    pub kind: DoctorIssueKind,
43    pub location: String,
44    pub detail: String,
45    pub repair: String,
46}
47
48/// One safe mechanical change applied by doctor --fix.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct DoctorFix {
51    pub description: String,
52}
53
54/// Result of a board integrity scan.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct DoctorReport {
57    pub issues: Vec<DoctorIssue>,
58    pub fixes: Vec<DoctorFix>,
59}
60
61/// Inspect a board and optionally apply safe, mechanical repairs.
62pub async fn doctor(project_dir: &Path, fix: bool) -> Result<DoctorReport> {
63    if fix {
64        let (board_dir, backend, config, _lock) = super::open_board_locked(project_dir).await?;
65        run_doctor(&board_dir, &backend, &config, true).await
66    } else {
67        let (board_dir, backend, config) = open_board(project_dir).await?;
68        run_doctor(&board_dir, &backend, &config, false).await
69    }
70}
71
72async fn run_doctor(
73    board_dir: &Path,
74    backend: &Backend,
75    config: &Config,
76    fix: bool,
77) -> Result<DoctorReport> {
78    let initial = inspect_board(board_dir, backend, config).await?;
79    let fixes = if fix {
80        apply_safe_fixes(board_dir, &initial).await?
81    } else {
82        Vec::new()
83    };
84    if !fixes.is_empty() {
85        backend.commit("pinto: doctor --fix").await?;
86    }
87    let final_state = inspect_board(board_dir, backend, config).await?;
88    Ok(DoctorReport {
89        issues: final_state.issues,
90        fixes,
91    })
92}
93
94#[derive(Debug)]
95struct Inspection {
96    records: Vec<RawItemRecord>,
97    issues: Vec<DoctorIssue>,
98    issued: IssuedHistory,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102enum RecordArea {
103    Tasks,
104    Archive,
105    #[cfg(feature = "sqlite")]
106    Database,
107}
108
109impl RecordArea {
110    fn directory(self, board_dir: &Path) -> Option<PathBuf> {
111        match self {
112            Self::Tasks => Some(board_dir.join("tasks")),
113            Self::Archive => Some(board_dir.join("archive")),
114            #[cfg(feature = "sqlite")]
115            Self::Database => None,
116        }
117    }
118}
119
120#[derive(Debug, Clone)]
121enum RawField<T> {
122    Missing,
123    Invalid(String),
124    Present(T),
125}
126
127impl<T> RawField<T> {
128    fn as_ref(&self) -> Option<&T> {
129        match self {
130            Self::Present(value) => Some(value),
131            Self::Missing | Self::Invalid(_) => None,
132        }
133    }
134}
135
136#[derive(Debug, Clone)]
137struct RawItemRecord {
138    path: PathBuf,
139    area: RecordArea,
140    filename: Option<String>,
141    frontmatter_error: Option<String>,
142    id: RawField<String>,
143    status: RawField<String>,
144    rank: RawField<String>,
145    title: RawField<String>,
146    sprint: RawField<String>,
147    parent: RawField<String>,
148    depends_on: RawField<Vec<String>>,
149}
150
151impl RawItemRecord {
152    fn from_document(path: PathBuf, area: RecordArea, text: String) -> Self {
153        let filename = path
154            .file_name()
155            .and_then(|name| name.to_str())
156            .map(str::to_string);
157        let missing = || Self {
158            path: path.clone(),
159            area,
160            filename: filename.clone(),
161            frontmatter_error: None,
162            id: RawField::Missing,
163            status: RawField::Missing,
164            rank: RawField::Missing,
165            title: RawField::Missing,
166            sprint: RawField::Missing,
167            parent: RawField::Missing,
168            depends_on: RawField::Missing,
169        };
170        let Some((front, _body)) = parse_frontmatter(&text) else {
171            let mut record = missing();
172            record.frontmatter_error = Some("missing frontmatter delimiter".to_string());
173            return record;
174        };
175        let value = match toml::from_str::<toml::Value>(front) {
176            Ok(value) => value,
177            Err(error) => {
178                let mut record = missing();
179                record.frontmatter_error = Some(error.to_string());
180                return record;
181            }
182        };
183        let Some(table) = value.as_table() else {
184            let mut record = missing();
185            record.frontmatter_error = Some("frontmatter must be a TOML table".to_string());
186            return record;
187        };
188        Self {
189            path,
190            area,
191            filename,
192            frontmatter_error: None,
193            id: string_field(table, "id"),
194            status: string_field(table, "status"),
195            rank: string_field(table, "rank"),
196            title: string_field(table, "title"),
197            sprint: string_field(table, "sprint"),
198            parent: string_field(table, "parent"),
199            depends_on: string_list_field(table, "depends_on"),
200        }
201    }
202
203    #[cfg(feature = "sqlite")]
204    fn from_item(board_dir: &Path, item: BacklogItem) -> Self {
205        Self {
206            path: board_dir
207                .join("board.sqlite3#items")
208                .join(item.id.to_string()),
209            area: RecordArea::Database,
210            filename: None,
211            frontmatter_error: None,
212            id: RawField::Present(item.id.to_string()),
213            status: RawField::Present(item.status.as_str().to_string()),
214            rank: RawField::Present(item.rank.as_str().to_string()),
215            title: RawField::Present(item.title.clone()),
216            sprint: item.sprint.map_or(RawField::Missing, RawField::Present),
217            parent: item
218                .parent
219                .map_or(RawField::Missing, |id| RawField::Present(id.to_string())),
220            depends_on: RawField::Present(
221                item.depends_on
222                    .into_iter()
223                    .map(|id| id.to_string())
224                    .collect(),
225            ),
226        }
227    }
228
229    fn valid_id(&self) -> Option<ItemId> {
230        self.id.as_ref()?.parse().ok()
231    }
232
233    fn location(&self) -> String {
234        self.path.display().to_string()
235    }
236}
237
238#[derive(Debug, Clone)]
239struct RawSprintRecord {
240    path: PathBuf,
241    frontmatter_error: Option<String>,
242    id: RawField<String>,
243    state: RawField<String>,
244}
245
246impl RawSprintRecord {
247    fn from_document(path: PathBuf, text: String) -> Self {
248        let missing = || Self {
249            path: path.clone(),
250            frontmatter_error: None,
251            id: RawField::Missing,
252            state: RawField::Missing,
253        };
254        let Some((front, _goal)) = parse_frontmatter(&text) else {
255            let mut record = missing();
256            record.frontmatter_error = Some("missing frontmatter delimiter".to_string());
257            return record;
258        };
259        let value = match toml::from_str::<toml::Value>(front) {
260            Ok(value) => value,
261            Err(error) => {
262                let mut record = missing();
263                record.frontmatter_error = Some(error.to_string());
264                return record;
265            }
266        };
267        let Some(table) = value.as_table() else {
268            let mut record = missing();
269            record.frontmatter_error = Some("frontmatter must be a TOML table".to_string());
270            return record;
271        };
272        Self {
273            path,
274            frontmatter_error: None,
275            id: string_field(table, "id"),
276            state: string_field(table, "state"),
277        }
278    }
279
280    #[cfg(feature = "sqlite")]
281    fn from_sprint(sprint: Sprint) -> Self {
282        Self {
283            path: PathBuf::from(format!("board.sqlite3#sprints/{}", sprint.id)),
284            frontmatter_error: None,
285            id: RawField::Present(sprint.id.to_string()),
286            state: RawField::Present(sprint.state.to_string()),
287        }
288    }
289
290    fn valid_id(&self) -> Option<SprintId> {
291        self.id.as_ref()?.parse().ok()
292    }
293
294    fn location(&self) -> String {
295        self.path.display().to_string()
296    }
297}
298
299#[derive(Debug, Default)]
300struct IssuedHistory {
301    path: PathBuf,
302    ids: HashSet<ItemId>,
303    invalid: Vec<(usize, String)>,
304    duplicates: Vec<(usize, String)>,
305}
306
307async fn inspect_board(
308    board_dir: &Path,
309    _backend: &Backend,
310    config: &Config,
311) -> Result<Inspection> {
312    let (records, sprints) = match config.storage.backend {
313        StorageBackend::File | StorageBackend::Git => inspect_file_storage(board_dir).await?,
314        #[cfg(feature = "sqlite")]
315        StorageBackend::Sqlite => inspect_sqlite_storage(board_dir, _backend).await?,
316    };
317    let issued = read_issued_history(board_dir).await?;
318    let mut issues = analyze_sprints(&sprints);
319    issues.extend(analyze_records(&records, &sprints, config));
320    issues.extend(analyze_issued(&records, &issued));
321    issues.sort_by(|left, right| {
322        left.kind
323            .cmp(&right.kind)
324            .then_with(|| left.location.cmp(&right.location))
325            .then_with(|| left.detail.cmp(&right.detail))
326    });
327    Ok(Inspection {
328        records,
329        issues,
330        issued,
331    })
332}
333
334async fn inspect_file_storage(
335    board_dir: &Path,
336) -> Result<(Vec<RawItemRecord>, Vec<RawSprintRecord>)> {
337    let tasks_dir = board_dir.join("tasks");
338    let archive_dir = board_dir.join("archive");
339    let sprints_dir = board_dir.join("sprints");
340    let (active, archived, sprint_documents) = tokio::try_join!(
341        read_documents(&tasks_dir),
342        read_documents(&archive_dir),
343        read_documents(&sprints_dir),
344    )?;
345    let mut records = active
346        .into_par_iter()
347        .map(|(path, text)| RawItemRecord::from_document(path, RecordArea::Tasks, text))
348        .chain(
349            archived
350                .into_par_iter()
351                .map(|(path, text)| RawItemRecord::from_document(path, RecordArea::Archive, text)),
352        )
353        .collect::<Vec<_>>();
354    records.sort_by(|left, right| left.path.cmp(&right.path));
355    let mut sprints = sprint_documents
356        .into_par_iter()
357        .map(|(path, text)| RawSprintRecord::from_document(path, text))
358        .collect::<Vec<_>>();
359    sprints.sort_by(|left, right| left.path.cmp(&right.path));
360    Ok((records, sprints))
361}
362
363#[cfg(feature = "sqlite")]
364async fn inspect_sqlite_storage(
365    board_dir: &Path,
366    backend: &Backend,
367) -> Result<(Vec<RawItemRecord>, Vec<RawSprintRecord>)> {
368    let items = BacklogItemRepository::list(backend).await?;
369    let sprints = SprintRepository::list(backend).await?;
370    let records = items
371        .into_iter()
372        .map(|item| RawItemRecord::from_item(board_dir, item))
373        .collect();
374    let sprints = sprints
375        .into_iter()
376        .map(RawSprintRecord::from_sprint)
377        .collect();
378    Ok((records, sprints))
379}
380
381async fn read_documents(dir: &Path) -> Result<Vec<(PathBuf, String)>> {
382    let mut entries = match fs::read_dir(dir).await {
383        Ok(entries) => entries,
384        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
385        Err(error) => return Err(Error::io(dir, &error)),
386    };
387    let mut paths = Vec::new();
388    while let Some(entry) = entries
389        .next_entry()
390        .await
391        .map_err(|error| Error::io(dir, &error))?
392    {
393        let path = entry.path();
394        if path.extension().and_then(|extension| extension.to_str()) == Some("md") {
395            paths.push(path);
396        }
397    }
398    paths.sort();
399    let mut reads = JoinSet::new();
400    for path in paths {
401        reads.spawn(async move {
402            fs::read_to_string(&path)
403                .await
404                .map(|text| (path.clone(), text))
405                .map_err(|error| Error::io(&path, &error))
406        });
407    }
408    let mut documents = Vec::new();
409    while let Some(result) = reads.join_next().await {
410        documents.push(result.map_err(Error::task)??);
411    }
412    documents.sort_by(|left, right| left.0.cmp(&right.0));
413    Ok(documents)
414}
415
416async fn read_issued_history(board_dir: &Path) -> Result<IssuedHistory> {
417    let path = item_issued_ids_path(board_dir);
418    let text = match fs::read_to_string(&path).await {
419        Ok(text) => text,
420        Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
421        Err(error) => return Err(Error::io(&path, &error)),
422    };
423    let mut history = IssuedHistory {
424        path,
425        ..IssuedHistory::default()
426    };
427    for (line_number, line) in text.lines().enumerate() {
428        let value = line.trim();
429        if value.is_empty() {
430            continue;
431        }
432        match value.parse::<ItemId>() {
433            Ok(id) if !history.ids.insert(id.clone()) => {
434                history.duplicates.push((line_number + 1, id.to_string()));
435            }
436            Ok(_) => {}
437            Err(error) => history
438                .invalid
439                .push((line_number + 1, format!("{value:?}: {error}"))),
440        }
441    }
442    Ok(history)
443}
444
445fn make_issue(
446    kind: DoctorIssueKind,
447    location: impl Into<String>,
448    detail: impl Into<String>,
449    repair: impl Into<String>,
450) -> DoctorIssue {
451    DoctorIssue {
452        kind,
453        location: location.into(),
454        detail: detail.into(),
455        repair: repair.into(),
456    }
457}
458
459fn analyze_sprints(sprints: &[RawSprintRecord]) -> Vec<DoctorIssue> {
460    let mut issues = Vec::new();
461    let mut ids: BTreeMap<String, Vec<&RawSprintRecord>> = BTreeMap::new();
462
463    for sprint in sprints {
464        if let Some(error) = &sprint.frontmatter_error {
465            issues.push(make_issue(
466                DoctorIssueKind::MalformedRecord,
467                sprint.location(),
468                format!("sprint frontmatter is invalid: {error}"),
469                "restore valid TOML frontmatter with the required sprint fields",
470            ));
471            continue;
472        }
473
474        let Some(raw_id) = sprint.id.as_ref() else {
475            issues.push(make_issue(
476                DoctorIssueKind::MalformedRecord,
477                sprint.location(),
478                "required sprint field id is missing",
479                "restore the sprint ID in frontmatter and keep the filename aligned",
480            ));
481            continue;
482        };
483        let Ok(id) = raw_id.parse::<SprintId>() else {
484            issues.push(make_issue(
485                DoctorIssueKind::MalformedRecord,
486                sprint.location(),
487                format!("sprint ID is invalid: {raw_id:?}"),
488                "replace the sprint ID with a path-safe non-empty value",
489            ));
490            continue;
491        };
492        ids.entry(id.to_string()).or_default().push(sprint);
493
494        match &sprint.state {
495            RawField::Present(state) => {
496                if state.parse::<SprintState>().is_err() {
497                    issues.push(make_issue(
498                        DoctorIssueKind::InvalidStatus,
499                        sprint.location(),
500                        format!("sprint state is invalid: {state:?}"),
501                        "set state to planned, active, or closed",
502                    ));
503                }
504            }
505            RawField::Missing => issues.push(make_issue(
506                DoctorIssueKind::InvalidStatus,
507                sprint.location(),
508                "required sprint field state is missing",
509                "set state to planned, active, or closed",
510            )),
511            RawField::Invalid(error) => issues.push(make_issue(
512                DoctorIssueKind::InvalidStatus,
513                sprint.location(),
514                error.clone(),
515                "set state to a string: planned, active, or closed",
516            )),
517        }
518    }
519
520    for (id, records) in ids {
521        if records.len() > 1 {
522            let locations = records
523                .iter()
524                .map(|record| record.location())
525                .collect::<Vec<_>>()
526                .join(", ");
527            for record in records {
528                issues.push(make_issue(
529                    DoctorIssueKind::DuplicateId,
530                    record.location(),
531                    format!("sprint ID {id} is also present at {locations}"),
532                    "keep one record for the ID and rename or remove the duplicate manually",
533                ));
534            }
535        }
536    }
537    issues
538}
539
540fn analyze_records(
541    records: &[RawItemRecord],
542    sprints: &[RawSprintRecord],
543    config: &Config,
544) -> Vec<DoctorIssue> {
545    let mut issues = Vec::new();
546    let valid_statuses = config.columns.iter().collect::<HashSet<_>>();
547    let valid_sprints = sprints
548        .iter()
549        .filter_map(RawSprintRecord::valid_id)
550        .map(|id| id.to_string())
551        .collect::<BTreeSet<_>>();
552    let mut ids: BTreeMap<String, Vec<&RawItemRecord>> = BTreeMap::new();
553    let mut parent_edges: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
554    let mut dependency_edges: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
555    let mut parent_references = Vec::new();
556    let mut dependency_references = Vec::new();
557    let mut sprint_references = Vec::new();
558    let mut rank_scopes: BTreeMap<(String, String, String), Vec<&RawItemRecord>> = BTreeMap::new();
559    let mut filenames: BTreeMap<String, Vec<&RawItemRecord>> = BTreeMap::new();
560
561    for record in records {
562        if let Some(error) = &record.frontmatter_error {
563            issues.push(make_issue(
564                DoctorIssueKind::MalformedRecord,
565                record.location(),
566                format!("item frontmatter is invalid: {error}"),
567                "restore valid TOML frontmatter with the required item fields",
568            ));
569            continue;
570        }
571
572        let id = match &record.id {
573            RawField::Present(raw) => match raw.parse::<ItemId>() {
574                Ok(id) => {
575                    ids.entry(id.to_string()).or_default().push(record);
576                    Some(id)
577                }
578                Err(_) => {
579                    issues.push(make_issue(
580                        DoctorIssueKind::IssuedId,
581                        record.location(),
582                        format!("item ID is invalid: {raw:?}"),
583                        "replace the frontmatter ID with a valid PREFIX-NUMBER ID",
584                    ));
585                    None
586                }
587            },
588            RawField::Missing => {
589                issues.push(make_issue(
590                    DoctorIssueKind::MalformedRecord,
591                    record.location(),
592                    "required item field id is missing",
593                    "restore the item ID in frontmatter and keep the filename aligned",
594                ));
595                None
596            }
597            RawField::Invalid(error) => {
598                issues.push(make_issue(
599                    DoctorIssueKind::IssuedId,
600                    record.location(),
601                    error.clone(),
602                    "set id to a string in PREFIX-NUMBER form",
603                ));
604                None
605            }
606        };
607
608        match &record.title {
609            RawField::Present(title) if !title.trim().is_empty() => {}
610            RawField::Present(_) => issues.push(make_issue(
611                DoctorIssueKind::MalformedRecord,
612                record.location(),
613                "item title must not be empty",
614                "restore a non-empty title in frontmatter",
615            )),
616            RawField::Missing => issues.push(make_issue(
617                DoctorIssueKind::MalformedRecord,
618                record.location(),
619                "required item field title is missing",
620                "restore a non-empty title in frontmatter",
621            )),
622            RawField::Invalid(error) => issues.push(make_issue(
623                DoctorIssueKind::MalformedRecord,
624                record.location(),
625                error.clone(),
626                "set title to a string in frontmatter",
627            )),
628        }
629
630        match &record.status {
631            RawField::Present(status) if valid_statuses.contains(status) => {}
632            RawField::Present(status) => issues.push(make_issue(
633                DoctorIssueKind::InvalidStatus,
634                record.location(),
635                format!("item status {status:?} is not a configured workflow column"),
636                "set status to one of the columns in config.toml",
637            )),
638            RawField::Missing => issues.push(make_issue(
639                DoctorIssueKind::InvalidStatus,
640                record.location(),
641                "required item field status is missing",
642                "set status to one of the columns in config.toml",
643            )),
644            RawField::Invalid(error) => issues.push(make_issue(
645                DoctorIssueKind::InvalidStatus,
646                record.location(),
647                error.clone(),
648                "set status to a string matching a configured workflow column",
649            )),
650        }
651
652        let valid_rank = match &record.rank {
653            RawField::Present(rank) => match Rank::parse(rank) {
654                Ok(_) => Some(rank.as_str()),
655                Err(_) => {
656                    issues.push(make_issue(
657                        DoctorIssueKind::RankAnomaly,
658                        record.location(),
659                        format!("rank is not in normal form: {rank:?}"),
660                        "set rank to a non-empty base-36 rank without a trailing zero",
661                    ));
662                    None
663                }
664            },
665            RawField::Missing => {
666                issues.push(make_issue(
667                    DoctorIssueKind::RankAnomaly,
668                    record.location(),
669                    "required item field rank is missing",
670                    "set rank with pinto reorder or pinto rebalance",
671                ));
672                None
673            }
674            RawField::Invalid(error) => {
675                issues.push(make_issue(
676                    DoctorIssueKind::RankAnomaly,
677                    record.location(),
678                    error.clone(),
679                    "set rank to a string in normal base-36 form",
680                ));
681                None
682            }
683        };
684
685        if let Some(id) = &id {
686            if let Some(filename) = record.filename.as_deref()
687                && filename != format!("{id}.md")
688            {
689                issues.push(make_issue(
690                    DoctorIssueKind::Filename,
691                    record.location(),
692                    format!("filename does not match frontmatter ID {id}"),
693                    "rename the record to the canonical ID.md filename",
694                ));
695            }
696
697            if record.area == RecordArea::Tasks
698                && let (RawField::Present(status), Some(rank)) = (&record.status, valid_rank)
699                && valid_statuses.contains(status)
700            {
701                let parent = match &record.parent {
702                    RawField::Present(parent) => parent.clone(),
703                    _ => String::new(),
704                };
705                rank_scopes
706                    .entry((status.clone(), parent, rank.to_string()))
707                    .or_default()
708                    .push(record);
709            }
710
711            match &record.parent {
712                RawField::Missing => {}
713                RawField::Present(parent) => match parent.parse::<ItemId>() {
714                    Ok(parent) => {
715                        let child = id.to_string();
716                        let parent = parent.to_string();
717                        parent_edges
718                            .entry(child.clone())
719                            .or_default()
720                            .insert(parent.clone());
721                        parent_references.push((record, parent, child));
722                    }
723                    Err(_) => issues.push(make_issue(
724                        DoctorIssueKind::DanglingParent,
725                        record.location(),
726                        format!("parent reference is invalid: {parent:?}"),
727                        "remove the parent field or set it to an existing PBI ID",
728                    )),
729                },
730                RawField::Invalid(error) => issues.push(make_issue(
731                    DoctorIssueKind::DanglingParent,
732                    record.location(),
733                    error.clone(),
734                    "remove the parent field or set it to a string PBI ID",
735                )),
736            }
737
738            match &record.depends_on {
739                RawField::Missing => {}
740                RawField::Present(dependencies) => {
741                    for dependency in dependencies {
742                        match dependency.parse::<ItemId>() {
743                            Ok(dependency) => {
744                                let source = id.to_string();
745                                let dependency = dependency.to_string();
746                                dependency_edges
747                                    .entry(source.clone())
748                                    .or_default()
749                                    .insert(dependency.clone());
750                                dependency_references.push((record, dependency, source));
751                            }
752                            Err(_) => issues.push(make_issue(
753                                DoctorIssueKind::DanglingDependency,
754                                record.location(),
755                                format!("dependency reference is invalid: {dependency:?}"),
756                                "remove the dependency or set it to an existing PBI ID",
757                            )),
758                        }
759                    }
760                }
761                RawField::Invalid(error) => issues.push(make_issue(
762                    DoctorIssueKind::DanglingDependency,
763                    record.location(),
764                    error.clone(),
765                    "remove depends_on or set it to an array of PBI IDs",
766                )),
767            }
768
769            match &record.sprint {
770                RawField::Missing => {}
771                RawField::Present(sprint) => match sprint.parse::<SprintId>() {
772                    Ok(sprint) => sprint_references.push((record, sprint.to_string())),
773                    Err(_) => issues.push(make_issue(
774                        DoctorIssueKind::DanglingSprint,
775                        record.location(),
776                        format!("sprint reference is invalid: {sprint:?}"),
777                        "remove the sprint field or set it to an existing sprint ID",
778                    )),
779                },
780                RawField::Invalid(error) => issues.push(make_issue(
781                    DoctorIssueKind::DanglingSprint,
782                    record.location(),
783                    error.clone(),
784                    "remove the sprint field or set it to a string sprint ID",
785                )),
786            }
787        }
788
789        if let Some(filename) = &record.filename {
790            filenames.entry(filename.clone()).or_default().push(record);
791        }
792    }
793
794    for (id, records_with_id) in &ids {
795        if records_with_id.len() > 1 {
796            let locations = records_with_id
797                .iter()
798                .map(|record| record.location())
799                .collect::<Vec<_>>()
800                .join(", ");
801            for record in records_with_id {
802                issues.push(make_issue(
803                    DoctorIssueKind::DuplicateId,
804                    record.location(),
805                    format!("item ID {id} is also present at {locations}"),
806                    "keep one record for the ID and rename or remove the duplicate manually",
807                ));
808            }
809        }
810    }
811
812    let valid_ids = ids.keys().cloned().collect::<BTreeSet<_>>();
813    for (record, target, source) in parent_references {
814        if !valid_ids.contains(&target) {
815            issues.push(make_issue(
816                DoctorIssueKind::DanglingParent,
817                record.location(),
818                format!("item {source} refers to missing parent {target}"),
819                "remove the parent field or set it to an existing PBI ID",
820            ));
821        }
822    }
823    for (record, target, source) in dependency_references {
824        if !valid_ids.contains(&target) {
825            issues.push(make_issue(
826                DoctorIssueKind::DanglingDependency,
827                record.location(),
828                format!("item {source} depends on missing item {target}"),
829                "remove the dependency or set it to an existing PBI ID",
830            ));
831        }
832    }
833    for (record, target) in sprint_references {
834        if !valid_sprints.contains(&target) {
835            issues.push(make_issue(
836                DoctorIssueKind::DanglingSprint,
837                record.location(),
838                format!("item refers to missing sprint {target}"),
839                "remove the sprint field or set it to an existing sprint ID",
840            ));
841        }
842    }
843
844    for ((status, parent, rank), records_in_scope) in rank_scopes {
845        if records_in_scope.len() > 1 {
846            for record in records_in_scope {
847                issues.push(make_issue(
848                    DoctorIssueKind::RankAnomaly,
849                    record.location(),
850                    format!(
851                        "rank {rank:?} is duplicated in status {status:?} and parent scope {parent:?}"
852                    ),
853                    "run pinto rebalance for the affected workflow scope",
854                ));
855            }
856        }
857    }
858
859    for (filename, records_with_name) in filenames {
860        let has_tasks = records_with_name
861            .iter()
862            .any(|record| record.area == RecordArea::Tasks);
863        let has_archive = records_with_name
864            .iter()
865            .any(|record| record.area == RecordArea::Archive);
866        if has_tasks && has_archive {
867            for record in records_with_name {
868                issues.push(make_issue(
869                    DoctorIssueKind::Collision,
870                    record.location(),
871                    format!("filename {filename:?} exists in both tasks and archive"),
872                    "keep one copy and move or remove the other record manually",
873                ));
874            }
875        }
876    }
877
878    for cycle in graph_cycles(&parent_edges) {
879        issues.push(make_issue(
880            DoctorIssueKind::ParentCycle,
881            cycle.join(" -> "),
882            format!("parent relationship cycle: {}", cycle.join(" -> ")),
883            "remove or change one parent field in the cycle manually",
884        ));
885    }
886    for cycle in graph_cycles(&dependency_edges) {
887        issues.push(make_issue(
888            DoctorIssueKind::DependencyCycle,
889            cycle.join(" -> "),
890            format!("dependency relationship cycle: {}", cycle.join(" -> ")),
891            "remove or change one dependency in the cycle manually",
892        ));
893    }
894
895    issues
896}
897
898fn analyze_issued(records: &[RawItemRecord], issued: &IssuedHistory) -> Vec<DoctorIssue> {
899    let mut issues = Vec::new();
900    for (line, detail) in &issued.invalid {
901        issues.push(make_issue(
902            DoctorIssueKind::IssuedId,
903            format!("{}:{line}", issued.path.display()),
904            format!("issued ID is invalid: {detail}"),
905            "remove the invalid line from issued_ids",
906        ));
907    }
908    for (line, id) in &issued.duplicates {
909        issues.push(make_issue(
910            DoctorIssueKind::IssuedId,
911            format!("{}:{line}", issued.path.display()),
912            format!("issued ID {id} is duplicated"),
913            "remove the duplicate line from issued_ids",
914        ));
915    }
916
917    let mut seen = BTreeMap::new();
918    for record in records {
919        if let Some(id) = record.valid_id() {
920            seen.entry(id.to_string()).or_insert_with(|| (id, record));
921        }
922    }
923    for (id, (id_value, record)) in seen {
924        if !issued.ids.contains(&id_value) {
925            issues.push(make_issue(
926                DoctorIssueKind::IssuedId,
927                record.location(),
928                format!("item ID {id} is missing from issued_ids"),
929                "append the existing item ID to issued_ids or run pinto doctor --fix",
930            ));
931        }
932    }
933    issues
934}
935
936fn graph_cycles(edges: &BTreeMap<String, BTreeSet<String>>) -> Vec<Vec<String>> {
937    let mut state = BTreeMap::new();
938    let mut stack = Vec::new();
939    let mut seen_cycles = HashSet::new();
940    let mut cycles = Vec::new();
941    for node in edges.keys() {
942        visit_graph(
943            node,
944            edges,
945            &mut state,
946            &mut stack,
947            &mut seen_cycles,
948            &mut cycles,
949        );
950    }
951    cycles.sort();
952    cycles
953}
954
955fn visit_graph(
956    node: &str,
957    edges: &BTreeMap<String, BTreeSet<String>>,
958    state: &mut BTreeMap<String, u8>,
959    stack: &mut Vec<String>,
960    seen_cycles: &mut HashSet<String>,
961    cycles: &mut Vec<Vec<String>>,
962) {
963    if state.get(node).copied().unwrap_or(0) == 2 {
964        return;
965    }
966    state.insert(node.to_string(), 1);
967    stack.push(node.to_string());
968    if let Some(targets) = edges.get(node) {
969        for target in targets {
970            match state.get(target).copied().unwrap_or(0) {
971                0 => visit_graph(target, edges, state, stack, seen_cycles, cycles),
972                1 => {
973                    if let Some(start) = stack.iter().position(|value| value == target) {
974                        let mut cycle = stack[start..].to_vec();
975                        cycle.sort();
976                        let key = cycle.join(",");
977                        if seen_cycles.insert(key) {
978                            cycles.push(cycle);
979                        }
980                    }
981                }
982                _ => {}
983            }
984        }
985    }
986    stack.pop();
987    state.insert(node.to_string(), 2);
988}
989
990async fn apply_safe_fixes(board_dir: &Path, inspection: &Inspection) -> Result<Vec<DoctorFix>> {
991    let mut fixes = Vec::new();
992    let mut id_counts = BTreeMap::<String, usize>::new();
993    for record in &inspection.records {
994        if let Some(id) = record.valid_id() {
995            *id_counts.entry(id.to_string()).or_default() += 1;
996        }
997    }
998
999    for record in &inspection.records {
1000        let Some(id) = record.valid_id() else {
1001            continue;
1002        };
1003        if id_counts.get(&id.to_string()) != Some(&1)
1004            || record.filename.as_deref() == Some(&format!("{id}.md"))
1005        {
1006            continue;
1007        }
1008        let Some(directory) = record.area.directory(board_dir) else {
1009            continue;
1010        };
1011        let destination = directory.join(format!("{id}.md"));
1012        if fs::try_exists(&destination)
1013            .await
1014            .map_err(|error| Error::io(&destination, &error))?
1015        {
1016            continue;
1017        }
1018        let other_area = match record.area {
1019            RecordArea::Tasks => RecordArea::Archive,
1020            RecordArea::Archive => RecordArea::Tasks,
1021            #[cfg(feature = "sqlite")]
1022            RecordArea::Database => continue,
1023        };
1024        let other_destination = other_area
1025            .directory(board_dir)
1026            .map(|directory| directory.join(format!("{id}.md")));
1027        if let Some(other_destination) = other_destination
1028            && fs::try_exists(&other_destination)
1029                .await
1030                .map_err(|error| Error::io(&other_destination, &error))?
1031        {
1032            continue;
1033        }
1034        fs::rename(&record.path, &destination)
1035            .await
1036            .map_err(|error| Error::io(&record.path, &error))?;
1037        fixes.push(DoctorFix {
1038            description: format!(
1039                "renamed {} to {}",
1040                record.path.display(),
1041                destination.display()
1042            ),
1043        });
1044    }
1045
1046    let mut recorded = BTreeSet::new();
1047    for record in &inspection.records {
1048        let Some(id) = record.valid_id() else {
1049            continue;
1050        };
1051        if !recorded.insert(id.to_string()) || inspection.issued.ids.contains(&id) {
1052            continue;
1053        }
1054        record_issued_id(board_dir, &id).await?;
1055        fixes.push(DoctorFix {
1056            description: format!("recorded {id} in {}", inspection.issued.path.display()),
1057        });
1058    }
1059    Ok(fixes)
1060}
1061
1062fn string_field(table: &toml::map::Map<String, toml::Value>, name: &str) -> RawField<String> {
1063    match table.get(name) {
1064        None => RawField::Missing,
1065        Some(value) => value.as_str().map_or_else(
1066            || RawField::Invalid(format!("field `{name}` must be a string")),
1067            |value| RawField::Present(value.to_string()),
1068        ),
1069    }
1070}
1071
1072fn string_list_field(
1073    table: &toml::map::Map<String, toml::Value>,
1074    name: &str,
1075) -> RawField<Vec<String>> {
1076    match table.get(name) {
1077        None => RawField::Missing,
1078        Some(value) => {
1079            let Some(values) = value.as_array() else {
1080                return RawField::Invalid(format!("field `{name}` must be an array of strings"));
1081            };
1082            let mut output = Vec::with_capacity(values.len());
1083            for value in values {
1084                let Some(value) = value.as_str() else {
1085                    return RawField::Invalid(format!("field `{name}` must contain only strings"));
1086                };
1087                output.push(value.to_string());
1088            }
1089            RawField::Present(output)
1090        }
1091    }
1092}
1093
1094#[cfg(test)]
1095mod tests {
1096    use super::*;
1097    use tempfile::TempDir;
1098
1099    fn item_record(
1100        path: impl Into<PathBuf>,
1101        area: RecordArea,
1102        fields: &str,
1103    ) -> (RawItemRecord, String) {
1104        let path = path.into();
1105        let text = format!("+++\n{fields}\n+++\n");
1106        (RawItemRecord::from_document(path, area, text.clone()), text)
1107    }
1108
1109    fn sprint_record(path: impl Into<PathBuf>, fields: &str) -> RawSprintRecord {
1110        let path = path.into();
1111        RawSprintRecord::from_document(path, format!("+++\n{fields}\n+++\n"))
1112    }
1113
1114    fn has_issue_kind(issues: &[DoctorIssue], kind: DoctorIssueKind) -> bool {
1115        issues.iter().any(|issue| issue.kind == kind)
1116    }
1117
1118    #[test]
1119    fn doctor_classifies_malformed_documents_and_relationships() {
1120        let malformed = RawItemRecord::from_document(
1121            PathBuf::from("broken.md"),
1122            RecordArea::Tasks,
1123            "not frontmatter".to_string(),
1124        );
1125        let invalid_toml = RawItemRecord::from_document(
1126            PathBuf::from("invalid-toml.md"),
1127            RecordArea::Tasks,
1128            "+++\nid = [\n+++\n".to_string(),
1129        );
1130        let non_table = RawItemRecord::from_document(
1131            PathBuf::from("non-table.md"),
1132            RecordArea::Tasks,
1133            "+++\n[\"not a table\"]\n+++\n".to_string(),
1134        );
1135        let (missing_fields, _) = item_record("T-2.md", RecordArea::Tasks, "id = \"T-2\"");
1136        let (invalid_fields, _) = item_record(
1137            "T-3.md",
1138            RecordArea::Tasks,
1139            "id = \"T-3\"\ntitle = 3\nstatus = 4\nrank = 5\nsprint = 6\nparent = 7\ndepends_on = [8]",
1140        );
1141        let (dangling, _) = item_record(
1142            "T-4.md",
1143            RecordArea::Tasks,
1144            "id = \"T-4\"\ntitle = \" \"\nstatus = \"unknown\"\nrank = \"i\"\nsprint = \"S-missing\"\nparent = \"T-99\"\ndepends_on = [\"T-99\", \"not-an-id\"]",
1145        );
1146        let (invalid_id, _) = item_record(
1147            "invalid.md",
1148            RecordArea::Tasks,
1149            "id = \"not/an/id\"\ntitle = \"Invalid ID\"\nstatus = \"todo\"\nrank = \"j\"",
1150        );
1151        let (missing_id, _) = item_record(
1152            "missing-id.md",
1153            RecordArea::Tasks,
1154            "title = \"Missing ID\"\nstatus = \"todo\"\nrank = \"k\"",
1155        );
1156        let (cycle_one, _) = item_record(
1157            "wrong-name.md",
1158            RecordArea::Tasks,
1159            "id = \"T-6\"\ntitle = \"Cycle one\"\nstatus = \"todo\"\nrank = \"l\"\nparent = \"T-7\"\ndepends_on = [\"T-7\"]",
1160        );
1161        let (cycle_two, _) = item_record(
1162            "T-7.md",
1163            RecordArea::Tasks,
1164            "id = \"T-7\"\ntitle = \"Cycle two\"\nstatus = \"todo\"\nrank = \"m\"\nparent = \"T-6\"\ndepends_on = [\"T-6\"]",
1165        );
1166        let (rank_one, _) = item_record(
1167            "T-8.md",
1168            RecordArea::Tasks,
1169            "id = \"T-8\"\ntitle = \"Rank one\"\nstatus = \"todo\"\nrank = \"n\"",
1170        );
1171        let (rank_two, _) = item_record(
1172            "T-9.md",
1173            RecordArea::Tasks,
1174            "id = \"T-9\"\ntitle = \"Rank two\"\nstatus = \"todo\"\nrank = \"n\"",
1175        );
1176        let (duplicate_task, _) = item_record(
1177            "T-5.md",
1178            RecordArea::Tasks,
1179            "id = \"T-5\"\ntitle = \"Task copy\"\nstatus = \"todo\"\nrank = \"o\"",
1180        );
1181        let (duplicate_archive, _) = item_record(
1182            "T-5.md",
1183            RecordArea::Archive,
1184            "id = \"T-5\"\ntitle = \"Archive copy\"\nstatus = \"todo\"\nrank = \"p\"",
1185        );
1186
1187        let records = vec![
1188            malformed,
1189            invalid_toml,
1190            non_table,
1191            missing_fields,
1192            invalid_fields,
1193            dangling,
1194            invalid_id,
1195            missing_id,
1196            cycle_one,
1197            cycle_two,
1198            rank_one,
1199            rank_two,
1200            duplicate_task,
1201            duplicate_archive,
1202        ];
1203        let sprints = vec![
1204            RawSprintRecord::from_document(
1205                PathBuf::from("broken-sprint.md"),
1206                "not frontmatter".to_string(),
1207            ),
1208            sprint_record("invalid-sprint.toml", "id = ["),
1209            sprint_record("non-table-sprint.md", "[\"not a table\"]"),
1210            sprint_record("missing-sprint-id.md", "state = \"planned\""),
1211            sprint_record(
1212                "invalid-sprint-id.md",
1213                "id = \"bad/id\"\nstate = \"planned\"",
1214            ),
1215            sprint_record("invalid-state.md", "id = \"S-2\"\nstate = \"broken\""),
1216            sprint_record("missing-state.md", "id = \"S-3\""),
1217            sprint_record("typed-state.md", "id = \"S-4\"\nstate = 4"),
1218            sprint_record("S-1.md", "id = \"S-1\"\nstate = \"planned\""),
1219            sprint_record("duplicate-sprint.md", "id = \"S-1\"\nstate = \"closed\""),
1220        ];
1221
1222        let mut config = Config::default();
1223        let sprint_issues = analyze_sprints(&sprints);
1224        let item_issues = analyze_records(&records, &sprints, &config);
1225
1226        for kind in [
1227            DoctorIssueKind::MalformedRecord,
1228            DoctorIssueKind::InvalidStatus,
1229            DoctorIssueKind::DuplicateId,
1230        ] {
1231            assert!(
1232                has_issue_kind(&sprint_issues, kind),
1233                "missing Sprint issue {kind:?}"
1234            );
1235        }
1236        for kind in [
1237            DoctorIssueKind::DanglingDependency,
1238            DoctorIssueKind::DanglingParent,
1239            DoctorIssueKind::DanglingSprint,
1240            DoctorIssueKind::ParentCycle,
1241            DoctorIssueKind::DependencyCycle,
1242            DoctorIssueKind::DuplicateId,
1243            DoctorIssueKind::IssuedId,
1244            DoctorIssueKind::InvalidStatus,
1245            DoctorIssueKind::RankAnomaly,
1246            DoctorIssueKind::Collision,
1247            DoctorIssueKind::MalformedRecord,
1248            DoctorIssueKind::Filename,
1249        ] {
1250            assert!(
1251                has_issue_kind(&item_issues, kind),
1252                "missing item issue {kind:?}"
1253            );
1254        }
1255
1256        // Keep this explicit so the test also exercises the normal workflow lookup used by the
1257        // rank and status checks rather than relying only on Config's default shape.
1258        config.columns = vec!["todo".to_string(), "done".to_string()];
1259        assert!(has_issue_kind(
1260            &analyze_records(&records, &sprints, &config),
1261            DoctorIssueKind::InvalidStatus
1262        ));
1263    }
1264
1265    #[tokio::test]
1266    async fn doctor_safe_fixes_rename_only_unambiguous_records() {
1267        let dir = TempDir::new().expect("temp dir");
1268        let board_dir = dir.path().join(".pinto");
1269        let tasks_dir = board_dir.join("tasks");
1270        let archive_dir = board_dir.join("archive");
1271        fs::create_dir_all(&tasks_dir)
1272            .await
1273            .expect("tasks directory");
1274        fs::create_dir_all(&archive_dir)
1275            .await
1276            .expect("archive directory");
1277
1278        let (rename_active, rename_active_text) = item_record(
1279            tasks_dir.join("renamed.md"),
1280            RecordArea::Tasks,
1281            "id = \"T-1\"\ntitle = \"Rename active\"\nstatus = \"todo\"\nrank = \"i\"",
1282        );
1283        let (rename_archive, rename_archive_text) = item_record(
1284            archive_dir.join("archived.md"),
1285            RecordArea::Archive,
1286            "id = \"T-2\"\ntitle = \"Rename archive\"\nstatus = \"todo\"\nrank = \"j\"",
1287        );
1288        let (destination_exists, destination_exists_text) = item_record(
1289            tasks_dir.join("source.md"),
1290            RecordArea::Tasks,
1291            "id = \"T-3\"\ntitle = \"Destination exists\"\nstatus = \"todo\"\nrank = \"k\"",
1292        );
1293        let (other_area_exists, other_area_exists_text) = item_record(
1294            archive_dir.join("source.md"),
1295            RecordArea::Archive,
1296            "id = \"T-4\"\ntitle = \"Other area exists\"\nstatus = \"todo\"\nrank = \"l\"",
1297        );
1298        let (duplicate_one, duplicate_one_text) = item_record(
1299            tasks_dir.join("duplicate-one.md"),
1300            RecordArea::Tasks,
1301            "id = \"T-5\"\ntitle = \"Duplicate one\"\nstatus = \"todo\"\nrank = \"m\"",
1302        );
1303        let (duplicate_two, duplicate_two_text) = item_record(
1304            archive_dir.join("duplicate-two.md"),
1305            RecordArea::Archive,
1306            "id = \"T-5\"\ntitle = \"Duplicate two\"\nstatus = \"todo\"\nrank = \"n\"",
1307        );
1308        let (already_named, already_named_text) = item_record(
1309            tasks_dir.join("T-6.md"),
1310            RecordArea::Tasks,
1311            "id = \"T-6\"\ntitle = \"Already named\"\nstatus = \"todo\"\nrank = \"o\"",
1312        );
1313        let (invalid_id, invalid_id_text) = item_record(
1314            tasks_dir.join("invalid.md"),
1315            RecordArea::Tasks,
1316            "id = \"not-an-id\"\ntitle = \"Invalid ID\"\nstatus = \"todo\"\nrank = \"p\"",
1317        );
1318        let fixtures = [
1319            (rename_active, rename_active_text),
1320            (rename_archive, rename_archive_text),
1321            (destination_exists, destination_exists_text),
1322            (other_area_exists, other_area_exists_text),
1323            (duplicate_one, duplicate_one_text),
1324            (duplicate_two, duplicate_two_text),
1325            (already_named, already_named_text),
1326            (invalid_id, invalid_id_text),
1327        ];
1328        for (record, text) in &fixtures {
1329            fs::write(&record.path, text).await.expect("write fixture");
1330        }
1331        fs::write(tasks_dir.join("T-3.md"), "existing destination")
1332            .await
1333            .expect("write active destination");
1334        fs::write(tasks_dir.join("T-4.md"), "existing other-area destination")
1335            .await
1336            .expect("write other-area destination");
1337
1338        let inspection = Inspection {
1339            records: fixtures.iter().map(|(record, _)| record.clone()).collect(),
1340            issues: Vec::new(),
1341            issued: IssuedHistory {
1342                path: board_dir.join("issued_ids"),
1343                ids: HashSet::from([ItemId::new("T", 6)]),
1344                invalid: Vec::new(),
1345                duplicates: Vec::new(),
1346            },
1347        };
1348        let fixes = apply_safe_fixes(&board_dir, &inspection)
1349            .await
1350            .expect("safe fixes succeed");
1351
1352        assert!(tasks_dir.join("T-1.md").is_file());
1353        assert!(!tasks_dir.join("renamed.md").exists());
1354        assert!(archive_dir.join("T-2.md").is_file());
1355        assert!(!archive_dir.join("archived.md").exists());
1356        assert!(
1357            fixes
1358                .iter()
1359                .filter(|fix| fix.description.starts_with("renamed "))
1360                .count()
1361                == 2
1362        );
1363        let history = fs::read_to_string(board_dir.join("issued_ids"))
1364            .await
1365            .expect("issued history");
1366        for id in ["T-1", "T-2", "T-3", "T-4", "T-5"] {
1367            assert!(history.lines().any(|line| line == id), "missing {id}");
1368        }
1369        assert!(!history.lines().any(|line| line == "T-6"));
1370    }
1371
1372    #[tokio::test]
1373    async fn doctor_reads_and_classifies_issued_id_history() {
1374        let dir = TempDir::new().expect("temp dir");
1375        let board_dir = dir.path().join(".pinto");
1376        fs::create_dir_all(&board_dir)
1377            .await
1378            .expect("board directory");
1379        fs::write(item_issued_ids_path(&board_dir), "\nT-1\nT-1\nnot-an-id\n")
1380            .await
1381            .expect("issued history");
1382
1383        let history = read_issued_history(&board_dir)
1384            .await
1385            .expect("read issued history");
1386        assert_eq!(history.ids, HashSet::from([ItemId::new("T", 1)]));
1387        assert_eq!(history.duplicates, vec![(3, "T-1".to_string())]);
1388        assert_eq!(history.invalid.len(), 1);
1389    }
1390
1391    #[cfg(feature = "sqlite")]
1392    #[tokio::test]
1393    async fn doctor_inspects_sqlite_records_through_the_backend() {
1394        use crate::storage::{BacklogItemRepository, SprintRepository, SqliteRepository};
1395        use chrono::Utc;
1396
1397        let dir = TempDir::new().expect("temp dir");
1398        crate::service::init_board(dir.path())
1399            .await
1400            .expect("initialize board");
1401        let board_dir = dir.path().join(".pinto");
1402        let repository = SqliteRepository::new(board_dir.clone());
1403        let now = Utc::now();
1404        let item = BacklogItem::new(
1405            ItemId::new("T", 1),
1406            "SQLite item",
1407            crate::backlog::Status::new("todo"),
1408            Rank::parse("i").expect("rank"),
1409            now,
1410        )
1411        .expect("item");
1412        let sprint =
1413            Sprint::new(SprintId::new("S-1").expect("sprint ID"), "Sprint", now).expect("sprint");
1414        BacklogItemRepository::save(&repository, &item)
1415            .await
1416            .expect("save item");
1417        SprintRepository::save(&repository, &sprint)
1418            .await
1419            .expect("save sprint");
1420        record_issued_id(&board_dir, &item.id)
1421            .await
1422            .expect("record issued ID");
1423
1424        let mut config = Config::default();
1425        config.storage.backend = StorageBackend::Sqlite;
1426        let backend = Backend::Sqlite(repository);
1427        let inspection = inspect_board(&board_dir, &backend, &config)
1428            .await
1429            .expect("inspect SQLite board");
1430        assert_eq!(inspection.records.len(), 1);
1431        assert!(
1432            inspection.issues.is_empty(),
1433            "issues: {:?}",
1434            inspection.issues
1435        );
1436    }
1437
1438    #[test]
1439    fn graph_cycles_are_reported_once_with_stable_members() {
1440        let edges = BTreeMap::from([
1441            (
1442                "T-1".to_string(),
1443                BTreeSet::from(["T-2".to_string(), "T-3".to_string()]),
1444            ),
1445            ("T-2".to_string(), BTreeSet::from(["T-1".to_string()])),
1446            ("T-3".to_string(), BTreeSet::from(["T-1".to_string()])),
1447        ]);
1448
1449        assert_eq!(
1450            graph_cycles(&edges),
1451            vec![
1452                vec!["T-1".to_string(), "T-2".to_string()],
1453                vec!["T-1".to_string(), "T-3".to_string()],
1454            ]
1455        );
1456    }
1457
1458    #[tokio::test]
1459    async fn service_scan_keeps_diagnosing_after_malformed_record() {
1460        let dir = TempDir::new().expect("temp dir");
1461        crate::service::init_board(dir.path())
1462            .await
1463            .expect("initialize board");
1464        let path = dir.path().join(".pinto/tasks/broken.md");
1465        fs::write(&path, "this is not frontmatter")
1466            .await
1467            .expect("write malformed item");
1468
1469        let report = doctor(dir.path(), false).await.expect("scan board");
1470
1471        assert!(report.issues.iter().any(|issue| {
1472            issue.kind == DoctorIssueKind::MalformedRecord
1473                && std::path::Path::new(&issue.location)
1474                    .file_name()
1475                    .and_then(|name| name.to_str())
1476                    == path.file_name().and_then(|name| name.to_str())
1477        }));
1478    }
1479}