use serde::Serialize;
use std::path::Path;
use crate::model::project::Project;
use crate::model::task::{Metadata, Task};
use crate::model::track::TrackNode;
use crate::ops::check::{CheckResult, CheckWarning};
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type")]
pub enum Repair {
#[serde(rename = "close_note_fence")]
CloseNoteFence {
track_id: String,
task_id: Option<String>,
title: String,
fence: String,
closer: String,
},
#[serde(rename = "close_inbox_fence")]
CloseInboxFence {
index: usize,
title: String,
fence: String,
closer: String,
},
#[serde(rename = "add_gitignore_pattern")]
AddGitignorePattern {
pattern: String,
},
#[serde(rename = "dedupe_archived_task")]
DedupeArchivedTask {
task_id: String,
total: usize,
archives: Vec<String>,
},
#[serde(rename = "remove_frontier_backup")]
RemoveFrontierBackup { path: String },
#[serde(rename = "renumber_subtask")]
RenumberSubtask {
track_id: String,
task_id: String,
parent_id: String,
},
#[serde(rename = "move_task_to_section")]
MoveTaskToSection {
track_id: String,
task_id: String,
from: crate::model::track::SectionKind,
to: crate::model::track::SectionKind,
},
#[serde(rename = "clear_inflight_marker")]
ClearInflightMarker { operation: String, command: String },
}
pub fn section_name(kind: crate::model::track::SectionKind) -> &'static str {
use crate::model::track::SectionKind;
match kind {
SectionKind::Backlog => "## Backlog",
SectionKind::Parked => "## Parked",
SectionKind::Done => "## Done",
}
}
impl Repair {
pub fn destructive(&self) -> bool {
match self {
Repair::CloseNoteFence { .. }
| Repair::CloseInboxFence { .. }
| Repair::MoveTaskToSection { .. }
| Repair::AddGitignorePattern { .. } => false,
Repair::DedupeArchivedTask { .. }
| Repair::RemoveFrontierBackup { .. }
| Repair::ClearInflightMarker { .. }
| Repair::RenumberSubtask { .. } => true,
}
}
pub fn describe(&self) -> String {
match self {
Repair::CloseNoteFence {
track_id,
task_id,
title,
fence,
..
} => {
let who = task_id.clone().unwrap_or_else(|| format!("\"{title}\""));
format!("[{track_id}] {who}: close note fence opened by `{fence}`")
}
Repair::CloseInboxFence {
index,
title,
fence,
..
} => {
format!("inbox {index} \"{title}\": close body fence opened by `{fence}`")
}
Repair::AddGitignorePattern { pattern } => {
format!(".gitignore: add `{pattern}` (covers every working-copy-local frame file)")
}
Repair::DedupeArchivedTask {
task_id,
total,
archives,
} => {
format!(
"{task_id}: delete {} duplicate archive cop{} ({}), keeping one",
total - 1,
if *total == 2 { "y" } else { "ies" },
archives.join(", ")
)
}
Repair::RemoveFrontierBackup { path } => {
format!("delete stale frontier backup {path}")
}
Repair::RenumberSubtask {
track_id,
task_id,
parent_id,
} => {
format!(
"[{track_id}] {task_id}: renumber under its parent {parent_id} \
(its id does not extend the parent's); deps follow"
)
}
Repair::MoveTaskToSection {
track_id,
task_id,
from,
to,
} => {
format!(
"[{track_id}] {task_id}: move from {} to {} (its state belongs there)",
section_name(*from),
section_name(*to)
)
}
Repair::ClearInflightMarker { command, .. } => {
format!(
"clear the in-flight marker for `{command}` (recovery could not complete it)"
)
}
}
}
}
#[derive(Debug, Default, Serialize)]
pub struct FixResult {
pub applied: Vec<Repair>,
pub skipped: Vec<SkippedRepair>,
#[serde(skip)]
pub also_touched: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct SkippedRepair {
pub repair: Repair,
pub reason: String,
}
pub fn plan(check: &CheckResult) -> Vec<Repair> {
let mut plan = Vec::new();
for warning in &check.warnings {
match warning {
CheckWarning::UnclosedNoteFence {
track_id,
task_id,
title,
fence,
} => plan.push(Repair::CloseNoteFence {
track_id: track_id.clone(),
task_id: task_id.clone(),
title: title.clone(),
closer: closer_for(fence),
fence: fence.clone(),
}),
CheckWarning::UnclosedInboxFence {
index,
title,
fence,
} => plan.push(Repair::CloseInboxFence {
index: *index,
title: title.clone(),
closer: closer_for(fence),
fence: fence.clone(),
}),
CheckWarning::LocalFileCommitted {
path,
tracked: false,
} => {
let pattern = gitignore_pattern_for_reported(path);
if !plan.iter().any(
|r| matches!(r, Repair::AddGitignorePattern { pattern: p } if *p == pattern),
) {
plan.push(Repair::AddGitignorePattern { pattern });
}
}
CheckWarning::DuplicateArchivedId {
task_id,
total,
archives,
} => plan.push(Repair::DedupeArchivedTask {
task_id: task_id.clone(),
total: *total,
archives: archives.clone(),
}),
CheckWarning::ChildIdNotUnderParent {
track_id,
task_id,
parent_id,
} => plan.push(Repair::RenumberSubtask {
track_id: track_id.clone(),
task_id: task_id.clone(),
parent_id: parent_id.clone(),
}),
CheckWarning::TaskInWrongSection {
track_id,
task_id,
expected,
actual,
} => plan.push(Repair::MoveTaskToSection {
track_id: track_id.clone(),
task_id: task_id.clone(),
from: *actual,
to: *expected,
}),
CheckWarning::IdFrontierWasReset { path } => {
plan.push(Repair::RemoveFrontierBackup { path: path.clone() })
}
CheckWarning::InterruptedOperation {
operation, command, ..
} => plan.push(Repair::ClearInflightMarker {
operation: operation.clone(),
command: command.clone(),
}),
_ => {}
}
}
plan
}
fn gitignore_pattern_for_reported(reported_path: &str) -> String {
let dir = reported_path
.rsplit_once('/')
.map(|(dir, _)| dir)
.unwrap_or("frame");
crate::io::project_io::gitignore_pattern_for(dir)
}
fn closer_for(opening_fence: &str) -> String {
let ticks = opening_fence.chars().take_while(|c| *c == '`').count();
"`".repeat(ticks.max(3))
}
pub fn apply(project: &mut Project, plan: &[Repair]) -> FixResult {
let mut result = FixResult::default();
for repair in plan {
match repair {
Repair::CloseNoteFence {
track_id,
task_id,
title,
closer,
..
} => match apply_note_fence(project, track_id, task_id.as_deref(), title, closer) {
Ok(()) => result.applied.push(repair.clone()),
Err(reason) => result.skipped.push(SkippedRepair {
repair: repair.clone(),
reason,
}),
},
Repair::CloseInboxFence {
index,
title,
closer,
..
} => match apply_inbox_fence(project, *index, title, closer) {
Ok(()) => result.applied.push(repair.clone()),
Err(reason) => result.skipped.push(SkippedRepair {
repair: repair.clone(),
reason,
}),
},
Repair::MoveTaskToSection {
track_id,
task_id,
from,
to,
} => {
let moved = project
.tracks
.iter_mut()
.find(|(id, _)| id == track_id)
.map(|(_, track)| (track,))
.and_then(|(track,)| {
let now = crate::ops::task_ops::top_level_section(track, task_id)?;
Some(
now == *to
|| crate::ops::task_ops::move_task_between_sections(
track, task_id, now, *to,
)
.is_some(),
)
});
match moved {
Some(true) => result.applied.push(repair.clone()),
_ => result.skipped.push(SkippedRepair {
repair: repair.clone(),
reason: format!(
"{task_id} is no longer a top-level task in {}",
section_name(*from)
),
}),
}
}
Repair::AddGitignorePattern { pattern } => {
match crate::io::git::repo_paths(&project.frame_dir) {
Some(paths) => {
match crate::io::project_io::append_gitignore_entry(
&paths.toplevel,
pattern,
) {
Ok(()) => result.applied.push(repair.clone()),
Err(e) => result.skipped.push(SkippedRepair {
repair: repair.clone(),
reason: e.to_string(),
}),
}
}
None => result.skipped.push(SkippedRepair {
repair: repair.clone(),
reason: "not a git repository".to_string(),
}),
}
}
Repair::DedupeArchivedTask {
task_id, archives, ..
} => match dedupe_archived(&project.frame_dir, task_id, archives) {
Ok(()) => result.applied.push(repair.clone()),
Err(reason) => result.skipped.push(SkippedRepair {
repair: repair.clone(),
reason,
}),
},
Repair::RenumberSubtask {
track_id,
task_id,
parent_id,
} => match apply_renumber_subtask(project, track_id, task_id, parent_id) {
Ok(touched) => {
result.also_touched.extend(touched);
result.applied.push(repair.clone())
}
Err(reason) => result.skipped.push(SkippedRepair {
repair: repair.clone(),
reason,
}),
},
Repair::ClearInflightMarker { .. } => {
match crate::io::inflight::clear(&project.frame_dir) {
Ok(()) => result.applied.push(repair.clone()),
Err(e) => result.skipped.push(SkippedRepair {
repair: repair.clone(),
reason: e.to_string(),
}),
}
}
Repair::RemoveFrontierBackup { path } => {
match std::fs::remove_file(path) {
Ok(()) => result.applied.push(repair.clone()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
result.applied.push(repair.clone())
}
Err(e) => result.skipped.push(SkippedRepair {
repair: repair.clone(),
reason: e.to_string(),
}),
}
}
}
}
result
}
fn dedupe_archived(frame_dir: &Path, task_id: &str, archives: &[String]) -> Result<(), String> {
let mut seen = false;
for rel in archives {
let path = frame_dir.join(rel);
let content = std::fs::read_to_string(&path)
.map_err(|e| format!("could not read {}: {e}", path.display()))?;
let lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
let start = lines
.iter()
.position(|l| l.starts_with("- ["))
.unwrap_or(lines.len());
let (tasks, _) = crate::parse::parse_tasks(&lines, start, 0, 0);
let mut removed = Vec::new();
let kept: Vec<Task> = tasks
.into_iter()
.filter(|task| {
if task.id.as_deref() != Some(task_id) {
return true;
}
if !seen {
seen = true;
return true;
}
removed.push(task.clone());
false
})
.collect();
if removed.is_empty() {
continue;
}
for task in &removed {
crate::io::recovery::log_recovery(
frame_dir,
crate::io::recovery::RecoveryEntry {
timestamp: chrono::Utc::now(),
category: crate::io::recovery::RecoveryCategory::Delete,
description: format!("duplicate archive copy of {task_id} removed"),
fields: vec![
("Archive".to_string(), rel.clone()),
("Task".to_string(), task.title.clone()),
],
body: task.source_text.clone().unwrap_or_default().join("\n"),
},
);
}
let mut out = lines[..start].join("\n");
if !out.is_empty() {
out.push('\n');
}
out.push_str(&crate::parse::serialize_tasks(&kept, 0).join("\n"));
out.push('\n');
crate::io::recovery::atomic_write(&path, out.as_bytes())
.map_err(|e| format!("could not write {}: {e}", path.display()))?;
}
if seen {
Ok(())
} else {
Err(format!("{task_id} no longer appears in the archives"))
}
}
fn apply_renumber_subtask(
project: &mut Project,
track_id: &str,
task_id: &str,
parent_id: &str,
) -> Result<Vec<String>, String> {
use crate::model::task_id::TaskId;
use crate::ops::task_ops;
let track = project
.tracks
.iter()
.find(|(id, _)| id == track_id)
.map(|(_, t)| t)
.ok_or_else(|| format!("track '{track_id}' not found"))?;
let parent = task_ops::find_task_in_track(track, parent_id)
.ok_or_else(|| format!("parent '{parent_id}' not found"))?;
let parent_task_id = parent
.id
.as_ref()
.filter(|id| id.is_structured())
.ok_or_else(|| format!("parent '{parent_id}' has no structured id"))?;
let current = parent
.subtasks
.iter()
.find_map(|sub| sub.id.as_ref().filter(|id| id.as_str() == task_id))
.ok_or_else(|| format!("'{task_id}' is no longer a subtask of '{parent_id}'"))?;
if current.is_child_of(parent_task_id) {
return Err(format!("'{task_id}' already extends '{parent_id}'"));
}
let token = current.leaf_token().cloned();
let number = task_ops::next_child_number(parent, token.as_ref()) as u32;
let new_id = TaskId::child_of(parent_task_id, number, token.as_ref());
let track = project
.tracks
.iter_mut()
.find(|(id, _)| id == track_id)
.map(|(_, t)| t)
.expect("track was found immutably a moment ago");
let task = task_ops::find_task_mut_in_track(track, task_id)
.expect("task was found immutably a moment ago");
let mappings = task_ops::rekey_subtree(task, new_id.as_str(), token.as_ref());
for (old, new) in &mappings {
task_ops::update_dep_references(&mut project.tracks, old, new);
}
Ok(project
.tracks
.iter()
.filter(|(_, t)| task_ops::track_has_dirty_task(t))
.map(|(id, _)| id.clone())
.collect())
}
fn apply_note_fence(
project: &mut Project,
track_id: &str,
task_id: Option<&str>,
title: &str,
closer: &str,
) -> Result<(), String> {
let track = project
.tracks
.iter_mut()
.find(|(id, _)| id == track_id)
.map(|(_, t)| t)
.ok_or_else(|| format!("track '{track_id}' not found"))?;
match task_id {
Some(id) => {
let task = crate::ops::task_ops::find_task_mut_in_track(track, id)
.ok_or_else(|| format!("task '{id}' not found"))?;
if close_open_fence(task, closer) {
Ok(())
} else {
Err("note no longer has an unclosed fence".to_string())
}
}
None => {
for node in &mut track.nodes {
if let TrackNode::Section { tasks, .. } = node
&& close_first_open_fence_by_title(tasks, title, closer)
{
return Ok(());
}
}
Err(format!("no task \"{title}\" with an unclosed fence"))
}
}
}
fn close_open_fence(task: &mut Task, closer: &str) -> bool {
let mut closed = false;
for meta in &mut task.metadata {
if let Metadata::Note(body) = meta
&& crate::ops::check::unclosed_fence(body).is_some()
{
body.push('\n');
body.push_str(closer);
closed = true;
break;
}
}
if closed {
task.dirty = true;
}
closed
}
fn close_first_open_fence_by_title(tasks: &mut [Task], title: &str, closer: &str) -> bool {
for task in tasks.iter_mut() {
if task.title == title && close_open_fence(task, closer) {
return true;
}
if close_first_open_fence_by_title(&mut task.subtasks, title, closer) {
return true;
}
}
false
}
fn apply_inbox_fence(
project: &mut Project,
index: usize,
title: &str,
closer: &str,
) -> Result<(), String> {
let inbox = project
.inbox
.as_mut()
.ok_or_else(|| "no inbox".to_string())?;
let item = inbox
.items
.get_mut(index.saturating_sub(1))
.ok_or_else(|| format!("inbox item {index} not found"))?;
if item.title != title {
return Err(format!("inbox item {index} is no longer \"{title}\""));
}
let body = item
.body
.as_mut()
.ok_or_else(|| format!("inbox item {index} has no body"))?;
if crate::ops::check::unclosed_fence(body).is_none() {
return Err("body no longer has an unclosed fence".to_string());
}
body.push('\n');
body.push_str(closer);
item.dirty = true;
Ok(())
}
pub fn tracks_touched(result: &FixResult) -> Vec<String> {
let mut out: Vec<String> = result
.applied
.iter()
.filter_map(|r| match r {
Repair::CloseNoteFence { track_id, .. }
| Repair::RenumberSubtask { track_id, .. }
| Repair::MoveTaskToSection { track_id, .. } => Some(track_id.clone()),
_ => None,
})
.collect();
out.extend(result.also_touched.iter().cloned());
out.sort();
out.dedup();
out
}
pub fn inbox_touched(result: &FixResult) -> bool {
result
.applied
.iter()
.any(|r| matches!(r, Repair::CloseInboxFence { .. }))
}
pub fn destructive_count(plan: &[Repair]) -> usize {
plan.iter().filter(|r| r.destructive()).count()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ops::check::CheckError;
fn result_with(warnings: Vec<CheckWarning>) -> CheckResult {
CheckResult {
errors: Vec::new(),
warnings,
..Default::default()
}
}
#[test]
fn closer_matches_the_opening_run_length() {
assert_eq!(closer_for("```"), "```");
assert_eq!(closer_for("```rust"), "```");
assert_eq!(closer_for("````"), "````");
assert_eq!(closer_for("`````lace"), "`````");
assert_eq!(closer_for(""), "```");
}
#[test]
fn plan_covers_exactly_the_repairable_warnings() {
let plan = plan(&result_with(vec![
CheckWarning::UnclosedNoteFence {
track_id: "t".into(),
task_id: Some("T-1".into()),
title: "task".into(),
fence: "```rust".into(),
},
CheckWarning::UnclosedInboxFence {
index: 1,
title: "item".into(),
fence: "```".into(),
},
CheckWarning::LocalFileCommitted {
path: "frame/.actor".into(),
tracked: false,
},
CheckWarning::DuplicateArchivedId {
task_id: "T-9".into(),
total: 2,
archives: vec!["archive/t.md".into()],
},
CheckWarning::IdFrontierWasReset {
path: "/x/frame-ids.toml.bak".into(),
},
]));
assert_eq!(plan.len(), 5);
}
#[test]
fn plan_ignores_warnings_with_no_safe_repair() {
let plan = plan(&result_with(vec![
CheckWarning::LocalFileCommitted {
path: "frame/.actor".into(),
tracked: true,
},
CheckWarning::IdReissuedAfterArchive {
task_id: "T-1".into(),
tracks: vec!["t".into()],
archives: vec!["archive/t.md".into()],
},
CheckWarning::ActorNameCollision {
name: "host".into(),
tokens: vec!["a".into(), "b".into()],
},
CheckWarning::LostTask {
track_id: "t".into(),
task_id: "T-2".into(),
},
CheckWarning::IdFrontierUnreadable {
path: "/x".into(),
detail: "bad".into(),
},
CheckWarning::MissingId {
track_id: "t".into(),
title: "x".into(),
},
CheckWarning::MissingAddedDate {
track_id: "t".into(),
task_id: "T-3".into(),
},
CheckWarning::MissingResolvedDate {
track_id: "t".into(),
task_id: "T-4".into(),
},
CheckWarning::StrandedLine {
track_id: "t".into(),
before_task_id: Some("T-6".into()),
before_title: "task".into(),
line: "**Shape.** prose that lost its indent".into(),
},
]));
assert!(
plan.is_empty(),
"expected no repairs, got: {:?}",
plan.iter().map(Repair::describe).collect::<Vec<_>>()
);
}
#[test]
fn plan_ignores_errors() {
let mut check = result_with(Vec::new());
check.errors.push(CheckError::DuplicateId {
task_id: "T-1".into(),
track_ids: vec!["a".into(), "b".into()],
});
assert!(plan(&check).is_empty());
}
#[test]
fn only_deleting_repairs_are_counted_for_confirmation() {
let additive = vec![
Repair::CloseNoteFence {
track_id: "t".into(),
task_id: None,
title: "x".into(),
fence: "```".into(),
closer: "```".into(),
},
Repair::AddGitignorePattern {
pattern: "frame/.*".into(),
},
];
assert_eq!(destructive_count(&additive), 0);
let mut mixed = additive;
mixed.push(Repair::RemoveFrontierBackup { path: "/x".into() });
mixed.push(Repair::DedupeArchivedTask {
task_id: "T-1".into(),
total: 3,
archives: vec!["archive/t.md".into()],
});
assert_eq!(destructive_count(&mixed), 2);
}
#[test]
fn close_open_fence_appends_and_dirties() {
let mut task = Task::new(
crate::model::task::TaskState::Todo,
Some("T-1".into()),
"t".into(),
);
task.metadata
.push(Metadata::Note("Example:\n```rust\nlet x = 1;".into()));
task.dirty = false;
assert!(close_open_fence(&mut task, "```"));
assert!(task.dirty);
let Some(Metadata::Note(body)) = task.metadata.first() else {
panic!("note missing");
};
assert!(body.ends_with("\n```"));
assert!(
crate::ops::check::unclosed_fence(body).is_none(),
"fence should now be balanced"
);
assert!(!close_open_fence(&mut task, "```"));
}
#[test]
fn dedupe_reads_the_archive_shape_clean_actually_writes() {
let tmp = tempfile::TempDir::new().unwrap();
let frame_dir = tmp.path().join("frame");
std::fs::create_dir_all(frame_dir.join("archive")).unwrap();
let path = frame_dir.join("archive").join("main.md");
std::fs::write(
&path,
"# Archive — main\n\n\
- [x] `M-900` Twice\n - resolved: 2026-01-01\n\
- [x] `M-900` Twice\n - resolved: 2026-01-01\n\
- [x] `M-901` Once\n - resolved: 2026-01-02\n",
)
.unwrap();
dedupe_archived(&frame_dir, "M-900", &["archive/main.md".to_string()]).unwrap();
let after = std::fs::read_to_string(&path).unwrap();
assert_eq!(after.matches("`M-900`").count(), 1, "{after}");
assert!(
after.contains("`M-901`"),
"untouched task survives: {after}"
);
assert!(
after.starts_with("# Archive — main\n\n"),
"header carried verbatim: {after}"
);
dedupe_archived(&frame_dir, "M-900", &["archive/main.md".to_string()]).unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
after,
"a second run must change nothing"
);
}
fn project_with(tracks: Vec<(&str, &str)>) -> Project {
use crate::model::config::{
AgentConfig, CleanConfig, IdConfig, ProjectConfig, ProjectInfo, TrackConfig, UiConfig,
};
Project {
root: std::path::PathBuf::from("/tmp/fix-test"),
frame_dir: std::path::PathBuf::from("/tmp/fix-test/frame"),
config: ProjectConfig {
project: ProjectInfo {
name: "test".to_string(),
},
agent: AgentConfig::default(),
tracks: tracks
.iter()
.map(|(id, _)| TrackConfig {
id: id.to_string(),
name: id.to_string(),
state: "active".to_string(),
file: format!("tracks/{id}.md"),
})
.collect(),
clean: CleanConfig::default(),
ids: IdConfig {
prefixes: indexmap::IndexMap::new(),
},
ui: UiConfig::default(),
},
tracks: tracks
.into_iter()
.map(|(id, src)| (id.to_string(), crate::parse::parse_track(src)))
.collect(),
inbox: None,
}
}
fn fix_all(project: &mut Project) -> FixResult {
let plan = plan(&crate::ops::check::check_project(project));
apply(project, &plan)
}
#[test]
fn a_misparented_subtask_is_planned_for_renumbering() {
let plan = plan(&result_with(vec![CheckWarning::ChildIdNotUnderParent {
track_id: "main".into(),
task_id: "M-007".into(),
parent_id: "M-001".into(),
}]));
assert_eq!(plan.len(), 1);
assert!(matches!(plan[0], Repair::RenumberSubtask { .. }));
assert_eq!(destructive_count(&plan), 1);
}
#[test]
fn renumbering_puts_the_subtask_under_its_parent() {
let mut project = project_with(vec![(
"main",
"\
# Main
## Backlog
- [ ] `M-001` Parent
- [ ] `M-001.1` Sibling
- [ ] `M-007` Escaped
## Done
",
)]);
let result = fix_all(&mut project);
assert_eq!(result.applied.len(), 1);
assert!(result.skipped.is_empty());
let subs = &project.tracks[0].1.backlog()[0].subtasks;
assert_eq!(subs[1].id.as_deref(), Some("M-001.2"));
assert!(subs[1].dirty);
assert_eq!(tracks_touched(&result), vec!["main".to_string()]);
assert!(fix_all(&mut project).applied.is_empty());
}
#[test]
fn renumbering_carries_descendants_and_their_deps() {
let mut project = project_with(vec![
(
"main",
"\
# Main
## Backlog
- [ ] `M-001` Parent
- [ ] `M-007` Escaped
- [ ] `M-007.1` Child of the escapee
## Done
",
),
(
"other",
"\
# Other
## Backlog
- [ ] `O-001` Waiting
- dep: M-007.1
## Done
",
),
]);
let result = fix_all(&mut project);
assert_eq!(result.applied.len(), 1);
let escaped = &project.tracks[0].1.backlog()[0].subtasks[0];
assert_eq!(escaped.id.as_deref(), Some("M-001.1"));
assert_eq!(escaped.subtasks[0].id.as_deref(), Some("M-001.1.1"));
let waiting = &project.tracks[1].1.backlog()[0];
assert!(
waiting
.metadata
.iter()
.any(|m| matches!(m, Metadata::Dep(d) if d == &vec!["M-001.1.1".to_string()])),
"dep should follow the rekey: {:?}",
waiting.metadata
);
assert_eq!(
tracks_touched(&result),
vec!["main".to_string(), "other".to_string()]
);
}
#[test]
fn renumbering_keeps_the_id_in_its_own_namespace() {
let mut project = project_with(vec![(
"main",
"\
# Main
## Backlog
- [ ] `M-001` Parent
- [ ] `M-001.1` Ours
- [ ] `M-b12` Theirs, escaped
## Done
",
)]);
fix_all(&mut project);
let subs = &project.tracks[0].1.backlog()[0].subtasks;
assert_eq!(subs[1].id.as_deref(), Some("M-001.b1"));
}
#[test]
fn a_repair_whose_finding_went_away_is_skipped() {
let mut project = project_with(vec![(
"main",
"\
# Main
## Backlog
- [ ] `M-001` Parent
- [ ] `M-001.1` Already fine
## Done
",
)]);
let stale = vec![Repair::RenumberSubtask {
track_id: "main".into(),
task_id: "M-001.1".into(),
parent_id: "M-001".into(),
}];
let result = apply(&mut project, &stale);
assert!(result.applied.is_empty());
assert_eq!(result.skipped.len(), 1);
assert!(
result.skipped[0].reason.contains("already extends"),
"reason: {}",
result.skipped[0].reason
);
}
}