1use 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#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct DoctorFix {
51 pub description: String,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct DoctorReport {
57 pub issues: Vec<DoctorIssue>,
58 pub fixes: Vec<DoctorFix>,
59}
60
61pub 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 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}