use super::{DoctorFix, Inspection, RawItemRecord, RecordArea};
use crate::backlog::{BacklogItem, ItemId};
use crate::error::{Error, Result};
use crate::storage::{atomic_write, item_from_markdown, item_to_markdown, record_issued_id};
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use tokio::fs;
pub(super) async fn apply_safe_fixes(
board_dir: &std::path::Path,
inspection: &Inspection,
) -> Result<Vec<DoctorFix>> {
let mut fixes = Vec::new();
let mut id_counts = BTreeMap::<String, usize>::new();
for record in &inspection.records {
if let Some(id) = record.valid_id() {
*id_counts.entry(id.to_string()).or_default() += 1;
}
}
for record in &inspection.records {
let Some(id) = record.valid_id() else {
continue;
};
if id_counts.get(&id.to_string()) != Some(&1)
|| record.filename.as_deref() == Some(&format!("{id}.md"))
{
continue;
}
let Some(directory) = record.area.directory(board_dir) else {
continue;
};
let destination = directory.join(format!("{id}.md"));
if fs::try_exists(&destination)
.await
.map_err(|error| Error::io(&destination, &error))?
{
continue;
}
let other_area = match record.area {
RecordArea::Tasks => RecordArea::Archive,
RecordArea::Archive => RecordArea::Tasks,
#[cfg(feature = "sqlite")]
RecordArea::Database => continue,
};
let other_destination = other_area
.directory(board_dir)
.map(|directory| directory.join(format!("{id}.md")));
if let Some(other_destination) = other_destination
&& fs::try_exists(&other_destination)
.await
.map_err(|error| Error::io(&other_destination, &error))?
{
continue;
}
fs::rename(&record.path, &destination)
.await
.map_err(|error| Error::io(&record.path, &error))?;
fixes.push(DoctorFix {
description: format!(
"renamed {} to {}",
record.path.display(),
destination.display()
),
});
}
let mut recorded = BTreeSet::new();
for record in &inspection.records {
let Some(id) = record.valid_id() else {
continue;
};
if !recorded.insert(id.to_string()) || inspection.issued.ids.contains(&id) {
continue;
}
record_issued_id(board_dir, &id).await?;
fixes.push(DoctorFix {
description: format!("recorded {id} in {}", inspection.issued.path.display()),
});
}
Ok(fixes)
}
#[derive(Debug)]
struct DuplicateRepair {
source: PathBuf,
area: RecordArea,
old_id: ItemId,
new_id: ItemId,
occurrence: usize,
item: BacklogItem,
}
#[derive(Debug, Default)]
struct DuplicateRepairPlan {
repairs: Vec<DuplicateRepair>,
lineages: BTreeMap<String, Vec<ItemId>>,
}
pub(super) async fn repair_duplicate_item_ids(
board_dir: &std::path::Path,
inspection: &Inspection,
) -> Result<Vec<DoctorFix>> {
let DuplicateRepairPlan { repairs, lineages } = plan_duplicate_item_repairs(inspection)?;
let mut fixes = Vec::with_capacity(repairs.len());
for mut repair in repairs {
repair.item.id = repair.new_id.clone();
if let Some(parent) = repair.item.parent.as_mut()
&& let Some(ids) = lineages.get(&parent.to_string())
&& let Some(replacement) = ids.get(repair.occurrence)
{
*parent = replacement.clone();
}
for dependency in &mut repair.item.depends_on {
if let Some(ids) = lineages.get(&dependency.to_string())
&& let Some(replacement) = ids.get(repair.occurrence)
{
*dependency = replacement.clone();
}
}
let Some(directory) = repair.area.directory(board_dir) else {
continue;
};
let destination = directory.join(format!("{}.md", repair.new_id));
let text = item_to_markdown(&repair.item)?;
atomic_write(&destination, &text).await?;
fs::remove_file(&repair.source)
.await
.map_err(|error| Error::io(&repair.source, &error))?;
record_issued_id(board_dir, &repair.new_id).await?;
fixes.push(DoctorFix {
description: format!(
"renumbered {} as {}: {} -> {}",
repair.old_id,
repair.new_id,
repair.source.display(),
destination.display()
),
});
}
Ok(fixes)
}
fn plan_duplicate_item_repairs(inspection: &Inspection) -> Result<DuplicateRepairPlan> {
let mut groups = BTreeMap::<String, Vec<&RawItemRecord>>::new();
let mut maximum_by_prefix = BTreeMap::<String, u32>::new();
for record in &inspection.records {
if let Some(id) = record.valid_id() {
maximum_by_prefix
.entry(id.prefix().to_string())
.and_modify(|maximum| *maximum = (*maximum).max(id.number()))
.or_insert(id.number());
groups.entry(id.to_string()).or_default().push(record);
}
if let Some(filename) = record
.filename
.as_deref()
.and_then(|filename| filename.strip_suffix(".md"))
.and_then(|stem| stem.parse::<ItemId>().ok())
{
maximum_by_prefix
.entry(filename.prefix().to_string())
.and_modify(|maximum| *maximum = (*maximum).max(filename.number()))
.or_insert(filename.number());
}
}
for id in &inspection.issued.ids {
maximum_by_prefix
.entry(id.prefix().to_string())
.and_modify(|maximum| *maximum = (*maximum).max(id.number()))
.or_insert(id.number());
}
let mut duplicate_groups = groups
.into_iter()
.filter_map(|(id, records)| {
(records.len() > 1)
.then(|| id.parse::<ItemId>().ok().map(|id| (id, records)))
.flatten()
})
.collect::<Vec<_>>();
duplicate_groups.sort_by(|(left, _), (right, _)| {
(left.prefix(), left.number()).cmp(&(right.prefix(), right.number()))
});
let mut repairs = Vec::new();
let mut lineages = BTreeMap::new();
for (old_id, mut records) in duplicate_groups {
records.sort_by(|left, right| {
record_area_priority(left.area)
.cmp(&record_area_priority(right.area))
.then_with(|| left.path.cmp(&right.path))
});
let parsed = records
.iter()
.map(|record| {
let document = record.document.as_deref()?;
item_from_markdown(document, &record.path).ok()
})
.collect::<Option<Vec<_>>>();
let Some(parsed) = parsed else {
continue;
};
let mut ids = vec![old_id.clone()];
let mut group_repairs = Vec::new();
for (occurrence, (record, item)) in records.iter().zip(parsed).enumerate().skip(1) {
let maximum = maximum_by_prefix
.entry(old_id.prefix().to_string())
.or_default();
*maximum = maximum
.checked_add(1)
.ok_or_else(|| Error::InvalidItemId(old_id.to_string()))?;
let new_id = ItemId::try_new(old_id.prefix(), *maximum)?;
ids.push(new_id.clone());
group_repairs.push(DuplicateRepair {
source: record.path.clone(),
area: record.area,
old_id: old_id.clone(),
new_id,
occurrence,
item,
});
}
lineages.insert(old_id.to_string(), ids);
repairs.extend(group_repairs);
}
Ok(DuplicateRepairPlan { repairs, lineages })
}
fn record_area_priority(area: RecordArea) -> u8 {
match area {
RecordArea::Tasks => 0,
RecordArea::Archive => 1,
#[cfg(feature = "sqlite")]
RecordArea::Database => 2,
}
}