use super::open_board;
#[cfg(feature = "sqlite")]
use crate::backlog::BacklogItem;
use crate::backlog::ItemId;
use crate::config::Config;
use crate::error::Result;
#[cfg(feature = "sqlite")]
use crate::sprint::Sprint;
use crate::sprint::SprintId;
use crate::storage::{Backend, parse_frontmatter};
use inspect::inspect_board;
use repair::{apply_safe_fixes, repair_duplicate_item_ids};
use std::collections::HashSet;
use std::future::Future;
use std::path::{Path, PathBuf};
#[cfg(test)]
use inspect::{analyze_records, analyze_sprints, graph_cycles, read_issued_history};
mod inspect;
mod repair;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum DoctorIssueKind {
DanglingDependency,
DanglingParent,
DanglingSprint,
ParentCycle,
DependencyCycle,
DuplicateId,
IssuedId,
InvalidStatus,
RankAnomaly,
Collision,
MalformedRecord,
Filename,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DoctorIssue {
pub kind: DoctorIssueKind,
pub location: String,
pub detail: String,
pub repair: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DoctorFix {
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DoctorReport {
pub issues: Vec<DoctorIssue>,
pub fixes: Vec<DoctorFix>,
}
pub async fn doctor(project_dir: &Path, fix: bool) -> Result<DoctorReport> {
if fix {
let (board_dir, backend, config, _lock) = super::open_board_locked(project_dir).await?;
run_doctor(&board_dir, &backend, &config, true).await
} else {
let (board_dir, backend, config) = open_board(project_dir).await?;
run_doctor(&board_dir, &backend, &config, false).await
}
}
async fn run_doctor(
board_dir: &Path,
backend: &Backend,
config: &Config,
fix: bool,
) -> Result<DoctorReport> {
run_doctor_with(board_dir, backend, fix, || {
inspect_board(board_dir, backend, config)
})
.await
}
async fn run_doctor_with<F, Fut>(
board_dir: &Path,
backend: &Backend,
fix: bool,
mut inspect: F,
) -> Result<DoctorReport>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<Inspection>>,
{
let initial = inspect().await?;
if !fix {
return Ok(DoctorReport {
issues: initial.issues,
fixes: Vec::new(),
});
}
let mut fixes = repair_duplicate_item_ids(board_dir, &initial).await?;
let inspection = if fixes.is_empty() {
initial
} else {
inspect().await?
};
let safe_fixes = apply_safe_fixes(board_dir, &inspection).await?;
let board_changed_after_inspection = !safe_fixes.is_empty();
fixes.extend(safe_fixes);
if fixes.is_empty() {
return Ok(DoctorReport {
issues: inspection.issues,
fixes,
});
}
backend.commit("pinto: doctor --fix").await?;
let final_state = if board_changed_after_inspection {
inspect().await?
} else {
inspection
};
Ok(DoctorReport {
issues: final_state.issues,
fixes,
})
}
#[derive(Debug)]
struct Inspection {
records: Vec<RawItemRecord>,
issues: Vec<DoctorIssue>,
issued: IssuedHistory,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RecordArea {
Tasks,
Archive,
#[cfg(feature = "sqlite")]
Database,
}
impl RecordArea {
fn directory(self, board_dir: &Path) -> Option<PathBuf> {
match self {
Self::Tasks => Some(board_dir.join("tasks")),
Self::Archive => Some(board_dir.join("archive")),
#[cfg(feature = "sqlite")]
Self::Database => None,
}
}
}
#[derive(Debug, Clone)]
enum RawField<T> {
Missing,
Invalid(String),
Present(T),
}
impl<T> RawField<T> {
fn as_ref(&self) -> Option<&T> {
match self {
Self::Present(value) => Some(value),
Self::Missing | Self::Invalid(_) => None,
}
}
}
#[derive(Debug, Clone)]
struct RawItemRecord {
path: PathBuf,
area: RecordArea,
document: Option<String>,
filename: Option<String>,
frontmatter_error: Option<String>,
id: RawField<String>,
status: RawField<String>,
rank: RawField<String>,
title: RawField<String>,
sprint: RawField<String>,
parent: RawField<String>,
depends_on: RawField<Vec<String>>,
}
impl RawItemRecord {
fn from_document(path: PathBuf, area: RecordArea, text: String) -> Self {
let filename = path
.file_name()
.and_then(|name| name.to_str())
.map(str::to_string);
let missing = || Self {
path: path.clone(),
area,
document: Some(text.clone()),
filename: filename.clone(),
frontmatter_error: None,
id: RawField::Missing,
status: RawField::Missing,
rank: RawField::Missing,
title: RawField::Missing,
sprint: RawField::Missing,
parent: RawField::Missing,
depends_on: RawField::Missing,
};
let Some((front, _body)) = parse_frontmatter(&text) else {
let mut record = missing();
record.frontmatter_error = Some("missing frontmatter delimiter".to_string());
return record;
};
let value = match toml::from_str::<toml::Value>(front) {
Ok(value) => value,
Err(error) => {
let mut record = missing();
record.frontmatter_error = Some(error.to_string());
return record;
}
};
let Some(table) = value.as_table() else {
let mut record = missing();
record.frontmatter_error = Some("frontmatter must be a TOML table".to_string());
return record;
};
Self {
path,
area,
document: Some(text),
filename,
frontmatter_error: None,
id: string_field(table, "id"),
status: string_field(table, "status"),
rank: string_field(table, "rank"),
title: string_field(table, "title"),
sprint: string_field(table, "sprint"),
parent: string_field(table, "parent"),
depends_on: string_list_field(table, "depends_on"),
}
}
#[cfg(feature = "sqlite")]
fn from_item(board_dir: &Path, item: BacklogItem) -> Self {
Self {
path: board_dir
.join("board.sqlite3#items")
.join(item.id.to_string()),
area: RecordArea::Database,
document: None,
filename: None,
frontmatter_error: None,
id: RawField::Present(item.id.to_string()),
status: RawField::Present(item.status.as_str().to_string()),
rank: RawField::Present(item.rank.as_str().to_string()),
title: RawField::Present(item.title.clone()),
sprint: item.sprint.map_or(RawField::Missing, RawField::Present),
parent: item
.parent
.map_or(RawField::Missing, |id| RawField::Present(id.to_string())),
depends_on: RawField::Present(
item.depends_on
.into_iter()
.map(|id| id.to_string())
.collect(),
),
}
}
fn valid_id(&self) -> Option<ItemId> {
self.id.as_ref()?.parse().ok()
}
fn location(&self) -> String {
self.path.display().to_string()
}
}
#[derive(Debug, Clone)]
struct RawSprintRecord {
path: PathBuf,
frontmatter_error: Option<String>,
id: RawField<String>,
state: RawField<String>,
}
impl RawSprintRecord {
fn from_document(path: PathBuf, text: String) -> Self {
let missing = || Self {
path: path.clone(),
frontmatter_error: None,
id: RawField::Missing,
state: RawField::Missing,
};
let Some((front, _goal)) = parse_frontmatter(&text) else {
let mut record = missing();
record.frontmatter_error = Some("missing frontmatter delimiter".to_string());
return record;
};
let value = match toml::from_str::<toml::Value>(front) {
Ok(value) => value,
Err(error) => {
let mut record = missing();
record.frontmatter_error = Some(error.to_string());
return record;
}
};
let Some(table) = value.as_table() else {
let mut record = missing();
record.frontmatter_error = Some("frontmatter must be a TOML table".to_string());
return record;
};
Self {
path,
frontmatter_error: None,
id: string_field(table, "id"),
state: string_field(table, "state"),
}
}
#[cfg(feature = "sqlite")]
fn from_sprint(sprint: Sprint) -> Self {
Self {
path: PathBuf::from(format!("board.sqlite3#sprints/{}", sprint.id)),
frontmatter_error: None,
id: RawField::Present(sprint.id.to_string()),
state: RawField::Present(sprint.state.to_string()),
}
}
fn valid_id(&self) -> Option<SprintId> {
self.id.as_ref()?.parse().ok()
}
fn location(&self) -> String {
self.path.display().to_string()
}
}
#[derive(Debug, Default)]
struct IssuedHistory {
path: PathBuf,
ids: HashSet<ItemId>,
invalid: Vec<(usize, String)>,
duplicates: Vec<(usize, String)>,
}
fn string_field(table: &toml::map::Map<String, toml::Value>, name: &str) -> RawField<String> {
match table.get(name) {
None => RawField::Missing,
Some(value) => value.as_str().map_or_else(
|| RawField::Invalid(format!("field `{name}` must be a string")),
|value| RawField::Present(value.to_string()),
),
}
}
fn string_list_field(
table: &toml::map::Map<String, toml::Value>,
name: &str,
) -> RawField<Vec<String>> {
match table.get(name) {
None => RawField::Missing,
Some(value) => {
let Some(values) = value.as_array() else {
return RawField::Invalid(format!("field `{name}` must be an array of strings"));
};
let mut output = Vec::with_capacity(values.len());
for value in values {
let Some(value) = value.as_str() else {
return RawField::Invalid(format!("field `{name}` must contain only strings"));
};
output.push(value.to_string());
}
RawField::Present(output)
}
}
}
#[cfg(test)]
mod tests;