use std::collections::{HashMap, HashSet};
use std::path::Path;
use chrono::Local;
use crate::io::actors::IdScope;
use crate::model::project::Project;
use crate::model::task::{Metadata, Task, TaskState};
use crate::model::task_id::{TaskId, Token};
use crate::model::track::{SectionKind, Track, TrackNode};
use crate::ops::ids::Mint;
use crate::ops::task_ops::renumber_subtasks;
#[derive(Debug, Default)]
pub struct CleanResult {
pub ids_assigned: Vec<IdAssignment>,
pub dates_assigned: Vec<DateAssignment>,
pub duplicates_resolved: Vec<DuplicateResolution>,
pub tasks_archived: Vec<ArchiveRecord>,
pub dangling_deps: Vec<DanglingDep>,
pub broken_refs: Vec<BrokenRef>,
pub sections_reconciled: Vec<SectionReconcile>,
pub suggestions: Vec<Suggestion>,
}
#[derive(Debug, Clone)]
pub struct IdAssignment {
pub track_id: String,
pub assigned_id: String,
pub title: String,
}
#[derive(Debug, Clone)]
pub struct DateAssignment {
pub track_id: String,
pub task_id: String,
pub date: String,
pub kind: DateKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DateKind {
Added,
Resolved,
}
impl DateKind {
pub fn key(self) -> &'static str {
match self {
DateKind::Added => "added",
DateKind::Resolved => "resolved",
}
}
}
#[derive(Debug, Clone)]
pub struct DuplicateResolution {
pub track_id: String,
pub original_id: String,
pub new_id: String,
pub title: String,
}
#[derive(Debug, Clone)]
pub struct ArchiveRecord {
pub track_id: String,
pub task_id: String,
pub title: String,
}
#[derive(Debug, Clone)]
pub struct DanglingDep {
pub track_id: String,
pub task_id: String,
pub dep_id: String,
}
#[derive(Debug, Clone)]
pub struct BrokenRef {
pub track_id: String,
pub task_id: String,
pub path: String,
pub kind: RefKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RefKind {
Ref,
Spec,
}
#[derive(Debug, Clone)]
pub struct SectionReconcile {
pub track_id: String,
pub task_id: String,
pub from: SectionKind,
pub to: SectionKind,
}
#[derive(Debug, Clone)]
pub struct Suggestion {
pub track_id: String,
pub task_id: String,
pub kind: SuggestionKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SuggestionKind {
AllSubtasksDone,
}
pub fn ensure_ids_and_dates(project: &mut Project, scope: IdScope) -> Vec<String> {
let mut result = CleanResult::default();
let mut modified = HashSet::new();
for (track_id, track) in &mut project.tracks {
let before_ids = result.ids_assigned.len();
let before_dates = result.dates_assigned.len();
let prefix = project.config.ids.prefixes.get(track_id.as_str()).cloned();
if let (Some(pfx), IdScope::Mint(ns)) = (&prefix, &scope) {
let mint = Mint::new(&project.frame_dir, track_id, pfx, ns.as_ref());
assign_missing_ids(track, track_id, mint, &mut result);
}
assign_missing_dates(track, track_id, &mut result);
if result.ids_assigned.len() > before_ids || result.dates_assigned.len() > before_dates {
modified.insert(track_id.clone());
}
}
if let IdScope::Mint(ns) = &scope {
let before_dups = result.duplicates_resolved.len();
resolve_duplicate_ids(project, ns.as_ref(), &mut result);
for dup in &result.duplicates_resolved[before_dups..] {
modified.insert(dup.track_id.clone());
}
}
for (track_id, track) in &mut project.tracks {
if reconcile_sections_for_track(track, track_id, &mut result) {
modified.insert(track_id.clone());
}
}
modified.into_iter().collect()
}
use crate::ops::task_ops::canonical_section;
fn reconcile_sections_for_track(
track: &mut Track,
track_id: &str,
result: &mut CleanResult,
) -> bool {
let mut moves: Vec<(String, SectionKind, SectionKind)> = Vec::new();
for node in &track.nodes {
if let TrackNode::Section { kind, tasks, .. } = node {
for task in tasks {
let target = canonical_section(task.state);
if target != *kind
&& let Some(ref id) = task.id
{
moves.push((id.to_string(), *kind, target));
}
}
}
}
if moves.is_empty() {
return false;
}
for (task_id, from, to) in &moves {
crate::ops::task_ops::move_task_between_sections(track, task_id, *from, *to);
result.sections_reconciled.push(SectionReconcile {
track_id: track_id.to_string(),
task_id: task_id.clone(),
from: *from,
to: *to,
});
}
true
}
pub fn reconcile_sections(project: &mut Project) -> Vec<String> {
let mut result = CleanResult::default();
let mut modified = Vec::new();
for (track_id, track) in &mut project.tracks {
if reconcile_sections_for_track(track, track_id, &mut result) {
modified.push(track_id.clone());
}
}
modified
}
pub fn clean_project(project: &mut Project, scope: IdScope) -> CleanResult {
let mut result = CleanResult::default();
for (track_id, track) in &mut project.tracks {
let prefix = project.config.ids.prefixes.get(track_id.as_str()).cloned();
if let (Some(pfx), IdScope::Mint(ns)) = (&prefix, &scope) {
let mint = Mint::new(&project.frame_dir, track_id, pfx, ns.as_ref());
assign_missing_ids(track, track_id, mint, &mut result);
}
assign_missing_dates(track, track_id, &mut result);
}
if let IdScope::Mint(ns) = &scope {
resolve_duplicate_ids(project, ns.as_ref(), &mut result);
}
for (track_id, track) in &mut project.tracks {
reconcile_sections_for_track(track, track_id, &mut result);
}
let all_task_ids = collect_all_task_ids(project);
for (track_id, track) in &mut project.tracks {
validate_deps(track, track_id, &all_task_ids, &mut result);
validate_refs(track, track_id, &project.root, &mut result);
collect_suggestions(track, track_id, &mut result);
}
archive_done_tasks(project, &mut result);
for (track_id, track) in &mut project.tracks {
assign_missing_resolved_dates(track, track_id, &mut result);
}
result
}
fn assign_missing_ids(track: &mut Track, track_id: &str, mint: Mint<'_>, result: &mut CleanResult) {
let needed = count_missing_top_level_ids(track);
let mut max = if needed > 0 {
(mint.next_n(track, needed) - 1) as usize
} else {
0
};
let (prefix, token) = (mint.prefix(), mint.token());
for node in &mut track.nodes {
if let TrackNode::Section { tasks, .. } = node {
assign_ids_in_tasks(tasks, track_id, prefix, token, &mut max, result);
}
}
}
fn count_missing_top_level_ids(track: &Track) -> u32 {
let mut n = 0;
for node in &track.nodes {
if let TrackNode::Section { tasks, .. } = node {
n += tasks.iter().filter(|t| t.id.is_none()).count() as u32;
}
}
n
}
fn assign_ids_in_tasks(
tasks: &mut [Task],
track_id: &str,
prefix: &str,
token: Option<&Token>,
max: &mut usize,
result: &mut CleanResult,
) {
for task in tasks.iter_mut() {
if task.id.is_none() {
*max += 1;
let new_id = TaskId::with_number(prefix, *max as u32, token);
task.id = Some(new_id.clone());
task.mark_dirty();
result.ids_assigned.push(IdAssignment {
track_id: track_id.to_string(),
assigned_id: new_id.to_string(),
title: task.title.clone(),
});
}
assign_subtask_ids(task, track_id, token, result);
}
}
fn assign_subtask_ids(
parent: &mut Task,
track_id: &str,
token: Option<&Token>,
result: &mut CleanResult,
) {
let parent_id = match &parent.id {
Some(id) => id.clone(),
None => return, };
let mut max_num: u32 = 0;
for sub in parent.subtasks.iter() {
if let Some(n) = sub
.id
.as_ref()
.and_then(|id| id.child_number_of(&parent_id, token))
{
max_num = max_num.max(n);
}
}
for sub in parent.subtasks.iter_mut() {
if sub.id.is_none() {
max_num += 1;
let sub_id = TaskId::child_of(&parent_id, max_num, token);
sub.id = Some(sub_id.clone());
sub.mark_dirty();
result.ids_assigned.push(IdAssignment {
track_id: track_id.to_string(),
assigned_id: sub_id.to_string(),
title: sub.title.clone(),
});
}
assign_subtask_ids(sub, track_id, token, result);
}
}
fn assign_missing_dates(track: &mut Track, track_id: &str, result: &mut CleanResult) {
let today = today_str();
for node in &mut track.nodes {
if let TrackNode::Section { tasks, .. } = node {
assign_dates_in_tasks(tasks, track_id, &today, result);
}
}
}
fn assign_dates_in_tasks(
tasks: &mut [Task],
track_id: &str,
today: &str,
result: &mut CleanResult,
) {
for task in tasks.iter_mut() {
let has_added = task
.metadata
.iter()
.any(|m| matches!(m, Metadata::Added(_)));
if !has_added {
task.metadata.insert(0, Metadata::Added(today.to_string()));
task.mark_dirty();
result.dates_assigned.push(DateAssignment {
track_id: track_id.to_string(),
task_id: task.id.as_ref().map(|i| i.to_string()).unwrap_or_default(),
date: today.to_string(),
kind: DateKind::Added,
});
}
assign_dates_in_tasks(&mut task.subtasks, track_id, today, result);
}
}
fn assign_missing_resolved_dates(track: &mut Track, track_id: &str, result: &mut CleanResult) {
let today = today_str();
for node in &mut track.nodes {
if let TrackNode::Section { tasks, .. } = node {
assign_resolved_in_tasks(tasks, track_id, &today, result);
}
}
}
fn assign_resolved_in_tasks(
tasks: &mut [Task],
track_id: &str,
today: &str,
result: &mut CleanResult,
) {
for task in tasks.iter_mut() {
if task.state == TaskState::Done
&& !task
.metadata
.iter()
.any(|m| matches!(m, Metadata::Resolved(_)))
{
task.metadata.push(Metadata::Resolved(today.to_string()));
task.mark_dirty();
result.dates_assigned.push(DateAssignment {
track_id: track_id.to_string(),
task_id: task.id.as_ref().map(|i| i.to_string()).unwrap_or_default(),
date: today.to_string(),
kind: DateKind::Resolved,
});
}
assign_resolved_in_tasks(&mut task.subtasks, track_id, today, result);
}
}
struct Duplicate {
old_id: String,
track_id: String,
parent_id: Option<String>,
}
fn resolve_duplicate_ids(project: &mut Project, token: Option<&Token>, result: &mut CleanResult) {
let track_order: Vec<String> = project
.config
.tracks
.iter()
.map(|tc| tc.id.clone())
.collect();
let mut seen_ids: HashSet<String> = HashSet::new();
let mut duplicates: Vec<Duplicate> = Vec::new();
for config_track_id in &track_order {
if let Some((_, track)) = project
.tracks
.iter()
.find(|(tid, _)| tid == config_track_id)
{
for node in &track.nodes {
if let TrackNode::Section { tasks, .. } = node {
find_duplicates_in_tasks(
tasks,
config_track_id,
None,
&mut seen_ids,
&mut duplicates,
);
}
}
}
}
if duplicates.is_empty() {
return;
}
let mut reassignments: HashMap<String, Vec<String>> = HashMap::new();
let mut staged_children: HashMap<String, u32> = HashMap::new();
for dup in &duplicates {
let Duplicate {
old_id,
track_id: dup_track_id,
parent_id,
} = dup;
let prefix = project
.config
.ids
.prefixes
.get(dup_track_id.as_str())
.cloned();
let Some(pfx) = prefix else { continue };
let track = project
.tracks
.iter()
.find(|(tid, _)| tid == dup_track_id)
.map(|(_, t)| t);
let Some(track) = track else { continue };
let new_id = match parent_id
.as_deref()
.and_then(|pid| next_child_id_under(track, pid, token, &mut staged_children))
{
Some(child_id) => child_id,
None => {
let staged = reassignments
.values()
.flatten()
.filter_map(|new_id| TaskId::parse(new_id).top_level_number(&pfx, token))
.max()
.unwrap_or(0);
let mint = Mint::new(&project.frame_dir, dup_track_id, &pfx, token);
TaskId::with_number(&pfx, mint.next_above(track, staged), token).to_string()
}
};
reassignments
.entry(old_id.clone())
.or_default()
.push(new_id);
}
let mut reassignment_cursors: HashMap<String, usize> = HashMap::new();
let mut seen_in_apply: HashSet<String> = HashSet::new();
for config_track_id in &track_order {
if let Some((_, track)) = project
.tracks
.iter_mut()
.find(|(tid, _)| tid == config_track_id)
{
for node in &mut track.nodes {
if let TrackNode::Section { tasks, .. } = node {
apply_duplicate_reassignments(
tasks,
config_track_id,
token,
&reassignments,
&mut reassignment_cursors,
&mut seen_in_apply,
result,
);
}
}
}
}
}
fn next_child_id_under(
track: &Track,
parent_id: &str,
token: Option<&Token>,
staged: &mut HashMap<String, u32>,
) -> Option<String> {
let parent = crate::ops::task_ops::find_task_in_track(track, parent_id)?;
let parent_task_id = parent.id.as_ref().filter(|id| id.is_structured())?;
let scanned = crate::ops::task_ops::next_child_number(parent, token) as u32;
let slot = staged.entry(parent_id.to_string()).or_insert(0);
let number = scanned.max(*slot + 1);
*slot = number;
Some(TaskId::child_of(parent_task_id, number, token).to_string())
}
fn find_duplicates_in_tasks(
tasks: &[Task],
track_id: &str,
parent_id: Option<&str>,
seen: &mut HashSet<String>,
duplicates: &mut Vec<Duplicate>,
) {
for task in tasks {
if task
.id
.as_ref()
.is_some_and(|id| !seen.insert(id.to_string()))
{
let id = task.id.as_ref().unwrap();
duplicates.push(Duplicate {
old_id: id.to_string(),
track_id: track_id.to_string(),
parent_id: parent_id.map(str::to_string),
});
}
find_duplicates_in_tasks(
&task.subtasks,
track_id,
task.id.as_deref(),
seen,
duplicates,
);
}
}
#[allow(clippy::too_many_arguments)]
fn apply_duplicate_reassignments(
tasks: &mut [Task],
track_id: &str,
token: Option<&Token>,
reassignments: &HashMap<String, Vec<String>>,
cursors: &mut HashMap<String, usize>,
seen: &mut HashSet<String>,
result: &mut CleanResult,
) {
for task in tasks.iter_mut() {
let dup_old: Option<String> = task
.id
.as_ref()
.map(|id| id.to_string())
.filter(|id| reassignments.contains_key(id) && !seen.insert(id.clone()));
if let Some(old_id) = dup_old {
let cursor = cursors.entry(old_id.clone()).or_insert(0);
if let Some(new_id) = reassignments.get(&old_id).and_then(|ids| ids.get(*cursor)) {
task.id = Some(TaskId::parse(new_id));
task.mark_dirty();
renumber_subtasks(task, new_id, token);
result.duplicates_resolved.push(DuplicateResolution {
track_id: track_id.to_string(),
original_id: old_id.clone(),
new_id: new_id.clone(),
title: task.title.clone(),
});
*cursor += 1;
}
}
apply_duplicate_reassignments(
&mut task.subtasks,
track_id,
token,
reassignments,
cursors,
seen,
result,
);
}
}
fn validate_deps(
track: &Track,
track_id: &str,
all_ids: &HashSet<String>,
result: &mut CleanResult,
) {
for node in &track.nodes {
if let TrackNode::Section { tasks, .. } = node {
validate_deps_in_tasks(tasks, track_id, all_ids, result);
}
}
}
fn validate_deps_in_tasks(
tasks: &[Task],
track_id: &str,
all_ids: &HashSet<String>,
result: &mut CleanResult,
) {
for task in tasks {
let task_id = task.id.as_deref().unwrap_or("");
for meta in &task.metadata {
if let Metadata::Dep(deps) = meta {
for dep_id in deps {
if !all_ids.contains(dep_id) {
result.dangling_deps.push(DanglingDep {
track_id: track_id.to_string(),
task_id: task_id.to_string(),
dep_id: dep_id.clone(),
});
}
}
}
}
validate_deps_in_tasks(&task.subtasks, track_id, all_ids, result);
}
}
fn validate_refs(track: &Track, track_id: &str, project_root: &Path, result: &mut CleanResult) {
for node in &track.nodes {
if let TrackNode::Section { tasks, .. } = node {
validate_refs_in_tasks(tasks, track_id, project_root, result);
}
}
}
fn validate_refs_in_tasks(
tasks: &[Task],
track_id: &str,
project_root: &Path,
result: &mut CleanResult,
) {
for task in tasks {
let task_id = task.id.as_deref().unwrap_or("");
for meta in &task.metadata {
match meta {
Metadata::Ref(refs) => {
for r in refs {
if !path_exists(project_root, r) {
result.broken_refs.push(BrokenRef {
track_id: track_id.to_string(),
task_id: task_id.to_string(),
path: r.clone(),
kind: RefKind::Ref,
});
}
}
}
Metadata::Spec(spec) => {
let file_path = spec.split('#').next().unwrap_or(spec);
if !path_exists(project_root, file_path) {
result.broken_refs.push(BrokenRef {
track_id: track_id.to_string(),
task_id: task_id.to_string(),
path: spec.clone(),
kind: RefKind::Spec,
});
}
}
_ => {}
}
}
validate_refs_in_tasks(&task.subtasks, track_id, project_root, result);
}
}
fn path_exists(project_root: &Path, relative_path: &str) -> bool {
project_root.join(relative_path).exists()
}
fn collect_suggestions(track: &Track, track_id: &str, result: &mut CleanResult) {
for node in &track.nodes {
if let TrackNode::Section { tasks, .. } = node {
collect_suggestions_in_tasks(tasks, track_id, result);
}
}
}
fn collect_suggestions_in_tasks(tasks: &[Task], track_id: &str, result: &mut CleanResult) {
for task in tasks {
if !task.subtasks.is_empty()
&& task.state != TaskState::Done
&& task.subtasks.iter().all(|s| s.state == TaskState::Done)
{
result.suggestions.push(Suggestion {
track_id: track_id.to_string(),
task_id: task.id.as_ref().map(|i| i.to_string()).unwrap_or_default(),
kind: SuggestionKind::AllSubtasksDone,
});
}
collect_suggestions_in_tasks(&task.subtasks, track_id, result);
}
}
fn archive_done_tasks(project: &mut Project, result: &mut CleanResult) {
if !project.config.clean.archive_per_track {
return;
}
let threshold = project.config.clean.done_threshold;
let retain = project.config.clean.done_retain;
for (track_id, track) in &mut project.tracks {
let done_tasks = track.section_tasks(SectionKind::Done);
let done_task_count = done_tasks.len();
if done_task_count <= threshold {
continue;
}
if retain >= done_task_count {
continue;
}
let mut indexed: Vec<(usize, String)> = done_tasks
.iter()
.enumerate()
.map(|(i, task)| {
let resolved = task
.metadata
.iter()
.find_map(|m| {
if let Metadata::Resolved(d) = m {
Some(d.clone())
} else {
None
}
})
.unwrap_or_default();
(i, resolved)
})
.collect();
indexed.sort_by(|a, b| b.1.cmp(&a.1));
let retain_indices: HashSet<usize> = indexed.iter().take(retain).map(|(i, _)| *i).collect();
let tasks_to_archive: Vec<&Task> = done_tasks
.iter()
.enumerate()
.filter(|(i, _)| !retain_indices.contains(i))
.map(|(_, t)| t)
.collect();
if tasks_to_archive.is_empty() {
continue;
}
let archive_path = project
.frame_dir
.join("archive")
.join(format!("{}.md", track_id));
if let Some(parent) = archive_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let existing = std::fs::read_to_string(&archive_path).unwrap_or_default();
let already_archived = archived_task_ids(&existing);
let (fresh, duplicates): (Vec<&Task>, Vec<&Task>) =
tasks_to_archive.iter().partition(|task| {
task.id
.as_ref()
.is_none_or(|id| !already_archived.contains(id.as_str()))
});
for task in &duplicates {
let id = task.id.as_ref().map(|i| i.to_string()).unwrap_or_default();
crate::io::recovery::log_recovery(
&project.frame_dir,
crate::io::recovery::RecoveryEntry {
timestamp: chrono::Utc::now(),
category: crate::io::recovery::RecoveryCategory::Conflict,
description: format!(
"{} was already in archive/{}.md — live copy removed from the track, not appended again",
id, track_id
),
fields: vec![
("track".to_string(), track_id.clone()),
("task".to_string(), id),
],
body: crate::parse::serialize_tasks(&[(*task).clone()], 0).join("\n"),
},
);
}
let archive_content =
crate::parse::serialize_tasks(&fresh.iter().copied().cloned().collect::<Vec<_>>(), 0)
.join("\n");
if !archive_content.is_empty() {
let new_content = if existing.is_empty() {
format!("# Archive — {}\n\n{}", track_id, archive_content)
} else {
format!("{}\n{}", existing.trim_end(), archive_content)
};
if crate::io::recovery::atomic_write(&archive_path, new_content.as_bytes()).is_err() {
eprintln!(
"warning: could not write archive for {}, skipping",
track_id
);
continue;
}
}
let archived = extract_done_tasks_except(track, &retain_indices);
for task in &archived {
result.tasks_archived.push(ArchiveRecord {
track_id: track_id.clone(),
task_id: task.id.as_ref().map(|i| i.to_string()).unwrap_or_default(),
title: task.title.clone(),
});
}
}
}
fn archived_task_ids(existing: &str) -> HashSet<String> {
existing
.lines()
.filter_map(|line| {
let trimmed = line.trim_start();
if !trimmed.starts_with("- [") {
return None;
}
let (_, after) = trimmed.split_once('`')?;
let (id, _) = after.split_once('`')?;
(!id.is_empty()).then(|| id.to_string())
})
.collect()
}
fn extract_done_tasks_except(track: &mut Track, retain_indices: &HashSet<usize>) -> Vec<Task> {
for node in &mut track.nodes {
if let TrackNode::Section {
kind: SectionKind::Done,
tasks,
..
} = node
{
let mut archived = Vec::new();
let mut retained = Vec::new();
for (i, task) in std::mem::take(tasks).into_iter().enumerate() {
if retain_indices.contains(&i) {
retained.push(task);
} else {
archived.push(task);
}
}
*tasks = retained;
return archived;
}
}
Vec::new()
}
fn today_str() -> String {
Local::now().format("%Y-%m-%d").to_string()
}
fn collect_all_task_ids(project: &Project) -> HashSet<String> {
let mut ids = HashSet::new();
for (_, track) in &project.tracks {
for node in &track.nodes {
if let TrackNode::Section { tasks, .. } = node {
collect_ids_from_tasks(tasks, &mut ids);
}
}
}
ids
}
fn collect_ids_from_tasks(tasks: &[Task], ids: &mut HashSet<String>) {
for task in tasks {
if let Some(ref id) = task.id {
ids.insert(id.to_string());
}
collect_ids_from_tasks(&task.subtasks, ids);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::config::{
AgentConfig, CleanConfig, IdConfig, ProjectConfig, ProjectInfo, TrackConfig, UiConfig,
};
use crate::parse::parse_track;
use indexmap::IndexMap;
use std::path::PathBuf;
use tempfile::TempDir;
fn make_config(prefixes: Vec<(&str, &str)>) -> ProjectConfig {
let mut prefix_map = IndexMap::new();
for (k, v) in &prefixes {
prefix_map.insert(k.to_string(), v.to_string());
}
ProjectConfig {
project: ProjectInfo {
name: "test".to_string(),
},
agent: AgentConfig::default(),
tracks: vec![TrackConfig {
id: "main".to_string(),
name: "Main".to_string(),
state: "active".to_string(),
file: "tracks/main.md".to_string(),
}],
clean: CleanConfig::default(),
ids: IdConfig {
prefixes: prefix_map,
},
ui: UiConfig::default(),
}
}
fn make_project(track_src: &str, prefixes: Vec<(&str, &str)>) -> Project {
let track = parse_track(track_src);
Project {
root: PathBuf::from("/tmp/test"),
frame_dir: PathBuf::from("/tmp/test/frame"),
config: make_config(prefixes),
tracks: vec![("main".to_string(), track)],
inbox: None,
}
}
#[test]
fn test_assign_missing_ids() {
let mut project = make_project(
"\
# Main
## Backlog
- [ ] `M-001` Has ID
- [ ] Missing ID task
- [ ] Another missing
## Done
",
vec![("main", "M")],
);
let result = clean_project(&mut project, IdScope::Mint(None));
assert_eq!(result.ids_assigned.len(), 2);
assert_eq!(result.ids_assigned[0].assigned_id, "M-002");
assert_eq!(result.ids_assigned[0].title, "Missing ID task");
assert_eq!(result.ids_assigned[1].assigned_id, "M-003");
let backlog = project.tracks[0].1.backlog();
assert_eq!(backlog[1].id.as_deref(), Some("M-002"));
assert_eq!(backlog[2].id.as_deref(), Some("M-003"));
assert!(backlog[1].dirty);
}
#[test]
fn test_assign_missing_ids_no_prefix() {
let mut project = make_project(
"\
# Main
## Backlog
- [ ] No prefix configured
## Done
",
vec![], );
let result = clean_project(&mut project, IdScope::Mint(None));
assert!(result.ids_assigned.is_empty());
}
#[test]
fn test_assign_subtask_ids() {
let mut project = make_project(
"\
# Main
## Backlog
- [ ] `M-001` Parent
- [ ] Sub without ID
- [ ] `M-001.2` Has ID
## Done
",
vec![("main", "M")],
);
let result = clean_project(&mut project, IdScope::Mint(None));
let sub_assignments: Vec<_> = result
.ids_assigned
.iter()
.filter(|a| a.assigned_id.contains('.'))
.collect();
assert_eq!(sub_assignments.len(), 1);
assert_eq!(sub_assignments[0].assigned_id, "M-001.3");
}
#[test]
fn test_assign_missing_dates() {
let mut project = make_project(
"\
# Main
## Backlog
- [ ] `M-001` Has date
- added: 2025-05-01
- [ ] `M-002` Missing date
## Done
",
vec![("main", "M")],
);
let result = clean_project(&mut project, IdScope::Mint(None));
assert_eq!(result.dates_assigned.len(), 1);
assert_eq!(result.dates_assigned[0].task_id, "M-002");
let backlog = project.tracks[0].1.backlog();
assert!(
backlog[1]
.metadata
.iter()
.any(|m| matches!(m, Metadata::Added(_)))
);
}
#[test]
fn test_assigns_missing_resolved_date() {
let mut project = make_project(
"\
# Main
## Done
- [x] `M-001` Has a resolved date
- added: 2025-05-01
- resolved: 2025-05-02
- [x] `M-002` Ticked done by hand, no resolved date
- added: 2025-05-01
",
vec![("main", "M")],
);
let result = clean_project(&mut project, IdScope::Mint(None));
let resolved: Vec<_> = result
.dates_assigned
.iter()
.filter(|d| d.kind == DateKind::Resolved)
.collect();
assert_eq!(resolved.len(), 1, "only the dateless done task");
assert_eq!(resolved[0].task_id, "M-002");
let done = project.tracks[0].1.done();
assert!(
done[1]
.metadata
.iter()
.any(|m| matches!(m, Metadata::Resolved(_)))
);
}
#[test]
fn test_resolved_date_is_not_assigned_to_unfinished_tasks() {
let mut project = make_project(
"\
# Main
## Backlog
- [ ] `M-001` Todo
- added: 2025-05-01
- [~] `M-002` Parked
- added: 2025-05-01
",
vec![("main", "M")],
);
let result = clean_project(&mut project, IdScope::Mint(None));
assert!(
!result
.dates_assigned
.iter()
.any(|d| d.kind == DateKind::Resolved),
"only done tasks get a resolved date"
);
}
#[test]
fn test_missing_resolved_date_still_archives_first() {
let root = PathBuf::from("/tmp/test-resolved-order");
let track = parse_track(
"\
# Main
## Done
- [x] `M-001` Dateless — must archive first
- added: 2025-01-01
- [x] `M-002` Older
- added: 2025-01-02
- resolved: 2025-05-01
- [x] `M-003` Newest
- added: 2025-01-03
- resolved: 2025-05-20
",
);
let mut config = make_config(vec![("main", "M")]);
config.clean.done_threshold = 1;
config.clean.done_retain = 2;
let mut project = Project {
root: root.clone(),
frame_dir: root.join("frame"),
config,
tracks: vec![("main".to_string(), track)],
inbox: None,
};
let result = clean_project(&mut project, IdScope::Mint(None));
let archived: Vec<&str> = result
.tasks_archived
.iter()
.map(|a| a.task_id.as_str())
.collect();
assert_eq!(
archived,
vec!["M-001"],
"the dateless task ranks oldest and is archived, not stamped with today"
);
let retained: Vec<&str> = project.tracks[0]
.1
.done()
.iter()
.filter_map(|t| t.id.as_deref())
.collect();
assert_eq!(retained, vec!["M-002", "M-003"]);
}
#[test]
fn test_no_duplicate_dates() {
let mut project = make_project(
"\
# Main
## Backlog
- [ ] `M-001` Already has date
- added: 2025-01-01
## Done
",
vec![("main", "M")],
);
let result = clean_project(&mut project, IdScope::Mint(None));
assert!(result.dates_assigned.is_empty());
}
#[test]
fn test_dangling_deps() {
let mut project = make_project(
"\
# Main
## Backlog
- [ ] `M-001` Task with good dep
- dep: M-002
- [ ] `M-002` Target task
- [ ] `M-003` Task with bad dep
- dep: NONEXIST-999
## Done
",
vec![("main", "M")],
);
let result = clean_project(&mut project, IdScope::Mint(None));
assert_eq!(result.dangling_deps.len(), 1);
assert_eq!(result.dangling_deps[0].task_id, "M-003");
assert_eq!(result.dangling_deps[0].dep_id, "NONEXIST-999");
}
#[test]
fn test_cross_track_deps_valid() {
let track_a = parse_track(
"\
# Track A
## Backlog
- [ ] `A-001` Task A
- dep: B-001
## Done
",
);
let track_b = parse_track(
"\
# Track B
## Backlog
- [ ] `B-001` Task B
## Done
",
);
let mut project = Project {
root: PathBuf::from("/tmp/test"),
frame_dir: PathBuf::from("/tmp/test/frame"),
config: {
let mut cfg = make_config(vec![("a", "A"), ("b", "B")]);
cfg.tracks = vec![
TrackConfig {
id: "a".to_string(),
name: "A".to_string(),
state: "active".to_string(),
file: "tracks/a.md".to_string(),
},
TrackConfig {
id: "b".to_string(),
name: "B".to_string(),
state: "active".to_string(),
file: "tracks/b.md".to_string(),
},
];
cfg
},
tracks: vec![("a".to_string(), track_a), ("b".to_string(), track_b)],
inbox: None,
};
let result = clean_project(&mut project, IdScope::Mint(None));
assert!(result.dangling_deps.is_empty());
}
#[test]
fn test_broken_refs() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("frame/tracks")).unwrap();
std::fs::write(root.join("existing.md"), "hi").unwrap();
let track = parse_track(
"\
# Main
## Backlog
- [ ] `M-001` Task with refs
- ref: existing.md
- ref: missing.md
- spec: also_missing.md#section
## Done
",
);
let mut project = Project {
root: root.to_path_buf(),
frame_dir: root.join("frame"),
config: make_config(vec![("main", "M")]),
tracks: vec![("main".to_string(), track)],
inbox: None,
};
let result = clean_project(&mut project, IdScope::Mint(None));
assert_eq!(result.broken_refs.len(), 2);
assert_eq!(result.broken_refs[0].path, "missing.md");
assert_eq!(result.broken_refs[0].kind, RefKind::Ref);
assert_eq!(result.broken_refs[1].path, "also_missing.md#section");
assert_eq!(result.broken_refs[1].kind, RefKind::Spec);
}
#[test]
fn test_valid_refs() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("frame/tracks")).unwrap();
std::fs::create_dir_all(root.join("doc")).unwrap();
std::fs::write(root.join("doc/spec.md"), "spec").unwrap();
let track = parse_track(
"\
# Main
## Backlog
- [ ] `M-001` Task with valid ref
- spec: doc/spec.md#section
## Done
",
);
let mut project = Project {
root: root.to_path_buf(),
frame_dir: root.join("frame"),
config: make_config(vec![("main", "M")]),
tracks: vec![("main".to_string(), track)],
inbox: None,
};
let result = clean_project(&mut project, IdScope::Mint(None));
assert!(result.broken_refs.is_empty());
}
#[test]
fn test_suggest_parent_done_when_all_subtasks_done() {
let mut project = make_project(
"\
# Main
## Backlog
- [ ] `M-001` Parent with all done subs
- [x] `M-001.1` Sub one
- resolved: 2025-05-10
- [x] `M-001.2` Sub two
- resolved: 2025-05-11
- [ ] `M-002` Parent with mixed subs
- [x] `M-002.1` Done sub
- [ ] `M-002.2` Todo sub
## Done
",
vec![("main", "M")],
);
let result = clean_project(&mut project, IdScope::Mint(None));
assert_eq!(result.suggestions.len(), 1);
assert_eq!(result.suggestions[0].task_id, "M-001");
assert_eq!(result.suggestions[0].kind, SuggestionKind::AllSubtasksDone);
}
#[test]
fn test_no_suggestion_for_already_done_parent() {
let mut project = make_project(
"\
# Main
## Backlog
## Done
- [x] `M-001` Already done parent
- resolved: 2025-05-10
- [x] `M-001.1` Sub one
- [x] `M-001.2` Sub two
",
vec![("main", "M")],
);
let result = clean_project(&mut project, IdScope::Mint(None));
assert!(result.suggestions.is_empty());
}
#[test]
fn test_no_suggestion_for_leaf_tasks() {
let mut project = make_project(
"\
# Main
## Backlog
- [ ] `M-001` Leaf task with no subtasks
## Done
",
vec![("main", "M")],
);
let result = clean_project(&mut project, IdScope::Mint(None));
assert!(result.suggestions.is_empty());
}
#[test]
fn test_archive_done_past_threshold() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("frame/tracks")).unwrap();
let mut done_lines = String::new();
for i in 0..100 {
done_lines.push_str(&format!(
"- [x] `M-{:03}` Done task {}\n - added: 2025-01-01\n - resolved: 2025-05-{:02}\n",
i, i, (i % 28) + 1
));
}
let src = format!(
"\
# Main
## Backlog
- [ ] `M-200` Active task
## Done
{}",
done_lines.trim_end()
);
let track = parse_track(&src);
let mut config = make_config(vec![("main", "M")]);
config.clean.done_threshold = 10; config.clean.done_retain = 0;
let mut project = Project {
root: root.to_path_buf(),
frame_dir: root.join("frame"),
config,
tracks: vec![("main".to_string(), track)],
inbox: None,
};
let result = clean_project(&mut project, IdScope::Mint(None));
assert_eq!(result.tasks_archived.len(), 100);
let done = project.tracks[0].1.done();
assert!(done.is_empty());
let archive_path = root.join("frame/archive/main.md");
assert!(archive_path.exists());
}
#[test]
fn test_archive_does_not_duplicate_an_already_archived_task() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("frame/archive")).unwrap();
std::fs::write(
root.join("frame/archive/main.md"),
"# Archive \u{2014} main\n\n- [x] `M-001` First\n - resolved: 2025-05-01\n",
)
.unwrap();
let track = parse_track(
"\
# Main
## Backlog
## Done
- [x] `M-001` First
- added: 2025-01-01
- resolved: 2025-05-01
- [x] `M-002` Second
- added: 2025-01-02
- resolved: 2025-05-02
",
);
let mut config = make_config(vec![("main", "M")]);
config.clean.done_threshold = 1;
config.clean.done_retain = 0;
let mut project = Project {
root: root.to_path_buf(),
frame_dir: root.join("frame"),
config,
tracks: vec![("main".to_string(), track)],
inbox: None,
};
clean_project(&mut project, IdScope::Mint(None));
let archive = std::fs::read_to_string(root.join("frame/archive/main.md")).unwrap();
assert_eq!(
archive.matches("`M-001`").count(),
1,
"M-001 was appended twice:\n{archive}"
);
assert_eq!(
archive.matches("`M-002`").count(),
1,
"M-002 should be archived once:\n{archive}"
);
assert!(project.tracks[0].1.done().is_empty());
let log = std::fs::read_to_string(root.join("frame/.recovery.log")).unwrap();
assert!(log.contains("M-001"), "recovery log should hold it:\n{log}");
assert!(log.contains("already in archive/main.md"), "{log}");
}
#[test]
fn test_archive_all_duplicates_still_clears_done() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("frame/archive")).unwrap();
let original = "# Archive \u{2014} main\n\n- [x] `M-001` First\n - resolved: 2025-05-01\n";
std::fs::write(root.join("frame/archive/main.md"), original).unwrap();
let track = parse_track(
"\
# Main
## Backlog
## Done
- [x] `M-001` First
- added: 2025-01-01
- resolved: 2025-05-01
",
);
let mut config = make_config(vec![("main", "M")]);
config.clean.done_threshold = 0;
config.clean.done_retain = 0;
let mut project = Project {
root: root.to_path_buf(),
frame_dir: root.join("frame"),
config,
tracks: vec![("main".to_string(), track)],
inbox: None,
};
clean_project(&mut project, IdScope::Mint(None));
assert_eq!(
std::fs::read_to_string(root.join("frame/archive/main.md")).unwrap(),
original,
"archive should be untouched when there is nothing new to append"
);
assert!(project.tracks[0].1.done().is_empty());
}
#[test]
fn test_archived_task_ids_reads_task_lines_only() {
let ids = archived_task_ids(
"\
# Archive \u{2014} main
- [x] `M-001` First
- note:
A note mentioning `M-999` in prose, and a fake `- [x] `M-998`` line.
- [x] `M-001.1` Subtask
- [x] `M-a7` Another namespace
",
);
assert!(ids.contains("M-001"));
assert!(ids.contains("M-001.1"), "subtask lines count too");
assert!(ids.contains("M-a7"));
assert!(!ids.contains("M-999"), "prose is not a task line");
assert_eq!(ids.len(), 3, "{ids:?}");
}
#[test]
fn test_no_archive_under_threshold() {
let mut project = make_project(
"\
# Main
## Backlog
## Done
- [x] `M-001` One done task
- resolved: 2025-05-10
",
vec![("main", "M")],
);
let result = clean_project(&mut project, IdScope::Mint(None));
assert!(result.tasks_archived.is_empty());
}
#[test]
fn test_archive_threshold_counts_tasks_not_lines() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("frame/tracks")).unwrap();
let src = "\
# Main
## Backlog
- [ ] `M-100` Active task
## Done
- [x] `M-001` Task one
- added: 2025-01-01
- resolved: 2025-05-01
- note:
A long multi-line note that spans
several lines to inflate the line count
well beyond what a simple task would use.
- [x] `M-002` Task two
- added: 2025-01-02
- resolved: 2025-05-02
- note:
Another verbose note here
with multiple lines
- [x] `M-003` Task three
- added: 2025-01-03
- resolved: 2025-05-03
- spec: doc/spec.md
- ref: doc/ref1.md, doc/ref2.md
- note: Short note
- [x] `M-004` Task four
- added: 2025-01-04
- resolved: 2025-05-04
- [x] `M-005` Task five
- added: 2025-01-05
- resolved: 2025-05-05
";
let track = parse_track(src);
let mut config = make_config(vec![("main", "M")]);
config.clean.done_threshold = 5;
let mut project = Project {
root: root.to_path_buf(),
frame_dir: root.join("frame"),
config,
tracks: vec![("main".to_string(), track)],
inbox: None,
};
let result = clean_project(&mut project, IdScope::Mint(None));
assert!(result.tasks_archived.is_empty());
assert_eq!(project.tracks[0].1.done().len(), 5);
}
#[test]
fn test_archive_triggers_above_task_threshold() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("frame/tracks")).unwrap();
let src = "\
# Main
## Backlog
- [ ] `M-100` Active task
## Done
- [x] `M-001` Task one
- added: 2025-01-01
- resolved: 2025-05-01
- [x] `M-002` Task two
- added: 2025-01-02
- resolved: 2025-05-02
- [x] `M-003` Task three
- added: 2025-01-03
- resolved: 2025-05-03
";
let track = parse_track(src);
let mut config = make_config(vec![("main", "M")]);
config.clean.done_threshold = 2; config.clean.done_retain = 0;
let mut project = Project {
root: root.to_path_buf(),
frame_dir: root.join("frame"),
config,
tracks: vec![("main".to_string(), track)],
inbox: None,
};
let result = clean_project(&mut project, IdScope::Mint(None));
assert_eq!(result.tasks_archived.len(), 3);
assert!(project.tracks[0].1.done().is_empty());
}
#[test]
fn test_archive_retains_most_recent() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("frame/tracks")).unwrap();
let src = "\
# Main
## Backlog
- [ ] `M-100` Active task
## Done
- [x] `M-001` Oldest task
- added: 2025-01-01
- resolved: 2025-05-01
- [x] `M-002` No resolved date
- added: 2025-01-02
- [x] `M-003` Middle task
- added: 2025-01-03
- resolved: 2025-05-10
- [x] `M-004` Most recent
- added: 2025-01-04
- resolved: 2025-05-20
- [x] `M-005` Second most recent
- added: 2025-01-05
- resolved: 2025-05-15
";
let track = parse_track(src);
let mut config = make_config(vec![("main", "M")]);
config.clean.done_threshold = 2; config.clean.done_retain = 2;
let mut project = Project {
root: root.to_path_buf(),
frame_dir: root.join("frame"),
config,
tracks: vec![("main".to_string(), track)],
inbox: None,
};
let result = clean_project(&mut project, IdScope::Mint(None));
assert_eq!(result.tasks_archived.len(), 3);
let done = project.tracks[0].1.done();
assert_eq!(done.len(), 2);
let retained_ids: Vec<&str> = done.iter().filter_map(|t| t.id.as_deref()).collect();
assert!(retained_ids.contains(&"M-004"));
assert!(retained_ids.contains(&"M-005"));
let archived_ids: Vec<&str> = result
.tasks_archived
.iter()
.map(|a| a.task_id.as_str())
.collect();
assert!(archived_ids.contains(&"M-001"));
assert!(archived_ids.contains(&"M-002"));
assert!(archived_ids.contains(&"M-003"));
}
#[test]
fn test_archive_retain_exceeds_count() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("frame/tracks")).unwrap();
let src = "\
# Main
## Backlog
- [ ] `M-100` Active task
## Done
- [x] `M-001` Task one
- added: 2025-01-01
- resolved: 2025-05-01
- [x] `M-002` Task two
- added: 2025-01-02
- resolved: 2025-05-02
- [x] `M-003` Task three
- added: 2025-01-03
- resolved: 2025-05-03
";
let track = parse_track(src);
let mut config = make_config(vec![("main", "M")]);
config.clean.done_threshold = 2; config.clean.done_retain = 5;
let mut project = Project {
root: root.to_path_buf(),
frame_dir: root.join("frame"),
config,
tracks: vec![("main".to_string(), track)],
inbox: None,
};
let result = clean_project(&mut project, IdScope::Mint(None));
assert!(result.tasks_archived.is_empty());
assert_eq!(project.tracks[0].1.done().len(), 3);
let archive_path = root.join("frame/archive/main.md");
assert!(!archive_path.exists());
}
#[test]
fn test_resolve_duplicate_ids_cross_track() {
let track_a = parse_track(
"\
# Track A
## Backlog
- [ ] `DUP-001` First occurrence in A
- added: 2025-05-01
## Done
",
);
let track_b = parse_track(
"\
# Track B
## Backlog
- [ ] `DUP-001` Duplicate in B
- added: 2025-05-02
## Done
",
);
let mut project = Project {
root: PathBuf::from("/tmp/test"),
frame_dir: PathBuf::from("/tmp/test/frame"),
config: {
let mut cfg = make_config(vec![("a", "A"), ("b", "B")]);
cfg.tracks = vec![
TrackConfig {
id: "a".to_string(),
name: "A".to_string(),
state: "active".to_string(),
file: "tracks/a.md".to_string(),
},
TrackConfig {
id: "b".to_string(),
name: "B".to_string(),
state: "active".to_string(),
file: "tracks/b.md".to_string(),
},
];
cfg
},
tracks: vec![("a".to_string(), track_a), ("b".to_string(), track_b)],
inbox: None,
};
let result = clean_project(&mut project, IdScope::Mint(None));
assert_eq!(result.duplicates_resolved.len(), 1);
assert_eq!(result.duplicates_resolved[0].track_id, "b");
assert_eq!(result.duplicates_resolved[0].original_id, "DUP-001");
assert_eq!(result.duplicates_resolved[0].title, "Duplicate in B");
let a_backlog = project.tracks[0].1.backlog();
assert_eq!(a_backlog[0].id.as_deref(), Some("DUP-001"));
let b_backlog = project.tracks[1].1.backlog();
assert_eq!(b_backlog[0].id.as_deref(), Some("B-001"));
}
#[test]
fn test_resolve_duplicate_ids_within_track() {
let mut project = make_project(
"\
# Main
## Backlog
- [ ] `M-001` First occurrence
- added: 2025-05-01
- [ ] `M-001` Duplicate in same track
- added: 2025-05-02
## Done
",
vec![("main", "M")],
);
let result = clean_project(&mut project, IdScope::Mint(None));
assert_eq!(result.duplicates_resolved.len(), 1);
assert_eq!(result.duplicates_resolved[0].original_id, "M-001");
assert_eq!(
result.duplicates_resolved[0].title,
"Duplicate in same track"
);
let backlog = project.tracks[0].1.backlog();
assert_eq!(backlog[0].id.as_deref(), Some("M-001"));
assert_eq!(backlog[1].id.as_deref(), Some("M-002"));
}
#[test]
fn test_resolve_duplicate_ids_track_order_precedence() {
let track_a = parse_track(
"\
# Track A
## Backlog
- [ ] `X-001` In track A
- added: 2025-05-01
## Done
",
);
let track_b = parse_track(
"\
# Track B
## Backlog
- [ ] `X-001` In track B
- added: 2025-05-02
## Done
",
);
let mut project = Project {
root: PathBuf::from("/tmp/test"),
frame_dir: PathBuf::from("/tmp/test/frame"),
config: {
let mut cfg = make_config(vec![("a", "A"), ("b", "B")]);
cfg.tracks = vec![
TrackConfig {
id: "b".to_string(),
name: "B".to_string(),
state: "active".to_string(),
file: "tracks/b.md".to_string(),
},
TrackConfig {
id: "a".to_string(),
name: "A".to_string(),
state: "active".to_string(),
file: "tracks/a.md".to_string(),
},
];
cfg
},
tracks: vec![("a".to_string(), track_a), ("b".to_string(), track_b)],
inbox: None,
};
let result = clean_project(&mut project, IdScope::Mint(None));
assert_eq!(result.duplicates_resolved.len(), 1);
assert_eq!(result.duplicates_resolved[0].track_id, "a");
assert_eq!(result.duplicates_resolved[0].original_id, "X-001");
let a_backlog = project
.tracks
.iter()
.find(|(id, _)| id == "a")
.unwrap()
.1
.backlog();
assert_eq!(a_backlog[0].id.as_deref(), Some("A-001"));
let b_backlog = project
.tracks
.iter()
.find(|(id, _)| id == "b")
.unwrap()
.1
.backlog();
assert_eq!(b_backlog[0].id.as_deref(), Some("X-001"));
}
#[test]
fn test_resolve_duplicate_ids_renumbers_subtasks() {
let mut project = make_project(
"\
# Main
## Backlog
- [ ] `M-001` First
- added: 2025-05-01
- [ ] `M-001` Duplicate parent with subtasks
- added: 2025-05-02
- [ ] `M-001.1` Sub one
- added: 2025-05-02
- [ ] `M-001.2` Sub two
- added: 2025-05-02
## Done
",
vec![("main", "M")],
);
let result = clean_project(&mut project, IdScope::Mint(None));
assert_eq!(result.duplicates_resolved.len(), 1);
let backlog = project.tracks[0].1.backlog();
assert_eq!(backlog[0].id.as_deref(), Some("M-001"));
assert_eq!(backlog[1].id.as_deref(), Some("M-002"));
assert_eq!(backlog[1].subtasks[0].id.as_deref(), Some("M-002.1"));
assert_eq!(backlog[1].subtasks[1].id.as_deref(), Some("M-002.2"));
}
#[test]
fn test_resolve_duplicate_subtask_renumbers_under_its_parent() {
let mut project = make_project(
"\
# Main
## Backlog
- [ ] `M-001` Parent
- added: 2025-05-01
- [ ] `M-001.4` Mine
- added: 2025-05-01
- [ ] `M-001.4` Theirs
- added: 2025-05-01
## Done
",
vec![("main", "M")],
);
let result = clean_project(&mut project, IdScope::Mint(None));
assert_eq!(result.duplicates_resolved.len(), 1);
assert_eq!(result.duplicates_resolved[0].original_id, "M-001.4");
assert_eq!(result.duplicates_resolved[0].new_id, "M-001.5");
let backlog = project.tracks[0].1.backlog();
assert_eq!(backlog.len(), 1, "no task was promoted to top level");
assert_eq!(backlog[0].subtasks[0].id.as_deref(), Some("M-001.4"));
assert_eq!(backlog[0].subtasks[1].id.as_deref(), Some("M-001.5"));
}
#[test]
fn test_resolve_duplicate_subtasks_stage_within_one_parent() {
let mut project = make_project(
"\
# Main
## Backlog
- [ ] `M-001` Parent
- added: 2025-05-01
- [ ] `M-001.1` Original
- added: 2025-05-01
- [ ] `M-001.1` Copy one
- added: 2025-05-01
- [ ] `M-001.1` Copy two
- added: 2025-05-01
## Done
",
vec![("main", "M")],
);
clean_project(&mut project, IdScope::Mint(None));
let subs = &project.tracks[0].1.backlog()[0].subtasks;
let ids: Vec<_> = subs.iter().filter_map(|s| s.id.as_deref()).collect();
assert_eq!(ids, vec!["M-001.1", "M-001.2", "M-001.3"]);
}
#[test]
fn test_resolve_duplicate_grandchild_renumbers_under_its_own_parent() {
let mut project = make_project(
"\
# Main
## Backlog
- [ ] `M-001` Parent
- added: 2025-05-01
- [ ] `M-001.1` Child
- added: 2025-05-01
- [ ] `M-001.1.2` Grandchild
- added: 2025-05-01
- [ ] `M-001.1.2` Grandchild twin
- added: 2025-05-01
## Done
",
vec![("main", "M")],
);
clean_project(&mut project, IdScope::Mint(None));
let grandkids = &project.tracks[0].1.backlog()[0].subtasks[0].subtasks;
assert_eq!(grandkids[0].id.as_deref(), Some("M-001.1.2"));
assert_eq!(grandkids[1].id.as_deref(), Some("M-001.1.3"));
}
#[test]
fn test_resolve_duplicate_subtask_rekeys_descendants() {
let mut project = make_project(
"\
# Main
## Backlog
- [ ] `M-001` Parent
- added: 2025-05-01
- [ ] `M-001.1` Original
- added: 2025-05-01
- [ ] `M-001.1` Twin
- added: 2025-05-01
- [ ] `M-001.1.1` Twin's child
- added: 2025-05-01
## Done
",
vec![("main", "M")],
);
clean_project(&mut project, IdScope::Mint(None));
let twin = &project.tracks[0].1.backlog()[0].subtasks[1];
assert_eq!(twin.id.as_deref(), Some("M-001.2"));
assert_eq!(twin.subtasks[0].id.as_deref(), Some("M-001.2.1"));
}
#[test]
fn test_resolve_duplicate_subtask_in_token_namespace() {
let mut project = make_project(
"\
# Main
## Backlog
- [ ] `M-001` Parent
- added: 2025-05-01
- [ ] `M-001.1` Original
- added: 2025-05-01
- [ ] `M-001.1` Twin
- added: 2025-05-01
## Done
",
vec![("main", "M")],
);
let token = Token::new("b").unwrap();
clean_project(&mut project, IdScope::Mint(Some(token)));
let subs = &project.tracks[0].1.backlog()[0].subtasks;
assert_eq!(subs[0].id.as_deref(), Some("M-001.1"));
assert_eq!(subs[1].id.as_deref(), Some("M-001.b1"));
}
#[test]
fn test_resolve_duplicate_subtask_leaves_no_check_finding() {
let mut project = make_project(
"\
# Main
## Backlog
- [ ] `M-001` Parent
- added: 2025-05-01
- [ ] `M-001.4` Mine
- added: 2025-05-01
- [ ] `M-001.4` Theirs
- added: 2025-05-01
## Done
",
vec![("main", "M")],
);
clean_project(&mut project, IdScope::Mint(None));
let check = crate::ops::check::check_project(&project);
assert!(
!check
.errors
.iter()
.any(|e| matches!(e, crate::ops::check::CheckError::DuplicateId { .. })),
"duplicate survived: {:?}",
check.errors
);
assert!(
!check.warnings.iter().any(|w| matches!(
w,
crate::ops::check::CheckWarning::ChildIdNotUnderParent { .. }
)),
"resolution misparented a subtask: {:?}",
check.warnings
);
}
#[test]
fn test_no_duplicates_no_changes() {
let mut project = make_project(
"\
# Main
## Backlog
- [ ] `M-001` Task one
- added: 2025-05-01
- [ ] `M-002` Task two
- added: 2025-05-01
## Done
",
vec![("main", "M")],
);
let result = clean_project(&mut project, IdScope::Mint(None));
assert!(result.duplicates_resolved.is_empty());
}
#[test]
fn test_clean_assigns_ids_then_validates_deps() {
let mut project = make_project(
"\
# Main
## Backlog
- [ ] `M-001` Task one
- dep: M-002
- [ ] `M-002` Task two
## Done
",
vec![("main", "M")],
);
let result = clean_project(&mut project, IdScope::Mint(None));
assert!(result.dangling_deps.is_empty());
}
#[test]
fn test_clean_full_run() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("frame/tracks")).unwrap();
std::fs::write(root.join("doc.md"), "doc").unwrap();
let track = parse_track(
"\
# Main
## Backlog
- [ ] `M-001` Has everything
- added: 2025-05-01
- dep: M-002
- ref: doc.md
- [ ] Missing ID and date
- [ ] `M-002` Second task
## Done
",
);
let mut project = Project {
root: root.to_path_buf(),
frame_dir: root.join("frame"),
config: make_config(vec![("main", "M")]),
tracks: vec![("main".to_string(), track)],
inbox: None,
};
let result = clean_project(&mut project, IdScope::Mint(None));
assert_eq!(result.ids_assigned.len(), 1);
assert_eq!(result.ids_assigned[0].title, "Missing ID and date");
assert!(!result.dates_assigned.is_empty());
assert!(result.dangling_deps.is_empty());
assert!(result.broken_refs.is_empty());
}
#[test]
fn test_ensure_ids_and_dates_basic() {
let mut project = make_project(
"\
# Main
## Backlog
- [ ] `M-001` Has ID and date
- added: 2025-05-01
- [ ] Missing everything
## Done
",
vec![("main", "M")],
);
let modified = ensure_ids_and_dates(&mut project, IdScope::Mint(None));
assert_eq!(modified, vec!["main".to_string()]);
let backlog = project.tracks[0].1.backlog();
assert_eq!(backlog[1].id.as_deref(), Some("M-002"));
assert!(
backlog[1]
.metadata
.iter()
.any(|m| matches!(m, Metadata::Added(_)))
);
}
#[test]
fn test_ensure_ids_and_dates_no_changes() {
let mut project = make_project(
"\
# Main
## Backlog
- [ ] `M-001` All good
- added: 2025-05-01
- [ ] `M-002` Also good
- added: 2025-05-02
## Done
",
vec![("main", "M")],
);
let modified = ensure_ids_and_dates(&mut project, IdScope::Mint(None));
assert!(modified.is_empty());
}
#[test]
fn test_ensure_ids_and_dates_no_prefix() {
let mut project = make_project(
"\
# Main
## Backlog
- [ ] No prefix configured
## Done
",
vec![], );
let modified = ensure_ids_and_dates(&mut project, IdScope::Mint(None));
assert_eq!(modified, vec!["main".to_string()]);
let backlog = project.tracks[0].1.backlog();
assert!(backlog[0].id.is_none());
assert!(
backlog[0]
.metadata
.iter()
.any(|m| matches!(m, Metadata::Added(_)))
);
}
#[test]
fn test_ensure_ids_and_dates_resolves_duplicates() {
let mut project = make_project(
"\
# Main
## Backlog
- [ ] `M-001` First occurrence
- added: 2025-05-01
- [ ] `M-001` Duplicate
- added: 2025-05-02
## Done
",
vec![("main", "M")],
);
let modified = ensure_ids_and_dates(&mut project, IdScope::Mint(None));
assert!(modified.contains(&"main".to_string()));
let backlog = project.tracks[0].1.backlog();
assert_eq!(backlog[0].id.as_deref(), Some("M-001"));
assert_eq!(backlog[1].id.as_deref(), Some("M-002"));
}
#[test]
fn test_reconcile_parked_task_in_backlog() {
let mut project = make_project(
"\
# Main
## Backlog
- [ ] `M-001` Normal task
- added: 2025-05-01
- [~] `M-002` Should be in Parked
- added: 2025-05-02
## Done
",
vec![("main", "M")],
);
let result = clean_project(&mut project, IdScope::Mint(None));
assert_eq!(result.sections_reconciled.len(), 1);
assert_eq!(result.sections_reconciled[0].task_id, "M-002");
assert_eq!(result.sections_reconciled[0].from, SectionKind::Backlog);
assert_eq!(result.sections_reconciled[0].to, SectionKind::Parked);
assert_eq!(project.tracks[0].1.parked().len(), 1);
assert_eq!(project.tracks[0].1.parked()[0].id.as_deref(), Some("M-002"));
assert_eq!(project.tracks[0].1.backlog().len(), 1);
}
#[test]
fn test_reconcile_done_task_in_backlog() {
let mut project = make_project(
"\
# Main
## Backlog
- [x] `M-001` Done but stuck in Backlog
- added: 2025-05-01
- resolved: 2025-05-10
## Done
",
vec![("main", "M")],
);
let result = clean_project(&mut project, IdScope::Mint(None));
assert_eq!(result.sections_reconciled.len(), 1);
assert_eq!(result.sections_reconciled[0].to, SectionKind::Done);
assert_eq!(project.tracks[0].1.done().len(), 1);
assert!(project.tracks[0].1.backlog().is_empty());
}
#[test]
fn test_reconcile_unparked_task_in_parked() {
let mut project = make_project(
"\
# Main
## Backlog
## Parked
- [ ] `M-001` Unparked but stuck in Parked section
- added: 2025-05-01
## Done
",
vec![("main", "M")],
);
let result = clean_project(&mut project, IdScope::Mint(None));
assert_eq!(result.sections_reconciled.len(), 1);
assert_eq!(result.sections_reconciled[0].from, SectionKind::Parked);
assert_eq!(result.sections_reconciled[0].to, SectionKind::Backlog);
assert_eq!(project.tracks[0].1.backlog().len(), 1);
assert!(project.tracks[0].1.parked().is_empty());
}
#[test]
fn test_reconcile_no_changes_when_correct() {
let mut project = make_project(
"\
# Main
## Backlog
- [ ] `M-001` Normal task
- added: 2025-05-01
## Parked
- [~] `M-002` Correctly parked
- added: 2025-05-02
## Done
- [x] `M-003` Correctly done
- added: 2025-05-03
- resolved: 2025-05-10
",
vec![("main", "M")],
);
let result = clean_project(&mut project, IdScope::Mint(None));
assert!(result.sections_reconciled.is_empty());
}
#[test]
fn test_reconcile_via_ensure_ids_and_dates() {
let mut project = make_project(
"\
# Main
## Backlog
- [~] `M-001` Parked in wrong section
- added: 2025-05-01
## Done
",
vec![("main", "M")],
);
let modified = ensure_ids_and_dates(&mut project, IdScope::Mint(None));
assert!(modified.contains(&"main".to_string()));
assert_eq!(project.tracks[0].1.parked().len(), 1);
assert!(project.tracks[0].1.backlog().is_empty());
}
#[test]
fn test_assign_subtask_ids_after_deletion() {
let track = parse_track(
"\
# Test
## Backlog
- [ ] `T-001` Parent
- [ ] `T-001.1` Sub 1
- [ ] `T-001.2` Sub 2
- [ ] `T-001.4` Sub 4
- [ ] New subtask without ID
## Done",
);
let config = make_config(vec![("main", "T")]);
let root = TempDir::new().unwrap();
let mut project = Project {
config,
root: root.path().to_path_buf(),
frame_dir: root.path().join("frame"),
tracks: vec![("main".to_string(), track)],
inbox: None,
};
let modified = ensure_ids_and_dates(&mut project, IdScope::Mint(None));
assert!(modified.contains(&"main".to_string()));
let parent =
crate::ops::task_ops::find_task_in_track(&project.tracks[0].1, "T-001").unwrap();
let new_sub = &parent.subtasks[3];
assert_eq!(new_sub.id.as_deref(), Some("T-001.5"));
}
#[test]
fn test_clean_assigns_missing_ids_in_token_namespace() {
let token = Token::new("a").unwrap();
let mut project = make_project(
"\
# Main
## Backlog
- [ ] `M-001` Has null ID
- [ ] Missing ID task
- [ ] Missing subtask ID
## Done
",
vec![("main", "M")],
);
let result = clean_project(&mut project, IdScope::Mint(Some(token.clone())));
let assigned: Vec<&str> = result
.ids_assigned
.iter()
.map(|a| a.assigned_id.as_str())
.collect();
assert_eq!(assigned, vec!["M-a1", "M-a1.a1"]);
assert_eq!(
project.tracks[0].1.backlog()[0].id.as_deref(),
Some("M-001")
);
}
#[test]
fn test_clean_resolves_duplicates_in_token_namespace() {
let token = Token::new("a").unwrap();
let mut project = make_project(
"\
# Main
## Backlog
- [ ] `M-001` First occurrence
- added: 2025-05-01
- [ ] `M-001` Duplicate
- added: 2025-05-02
## Done
",
vec![("main", "M")],
);
clean_project(&mut project, IdScope::Mint(Some(token.clone())));
let backlog = project.tracks[0].1.backlog();
assert_eq!(backlog[0].id.as_deref(), Some("M-001"));
assert_eq!(backlog[1].id.as_deref(), Some("M-a1"));
}
#[test]
fn test_clean_archival_unchanged_by_token() {
let src = "\
# Main
## Backlog
- [ ] `M-100` Active task
## Done
- [x] `M-001` Task one
- added: 2025-01-01
- resolved: 2025-05-01
- [x] `M-002` Task two
- added: 2025-01-02
- resolved: 2025-05-02
- [x] `M-003` Task three
- added: 2025-01-03
- resolved: 2025-05-03
";
let archived_count = |scope: IdScope| {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("frame/tracks")).unwrap();
let mut config = make_config(vec![("main", "M")]);
config.clean.done_threshold = 2;
config.clean.done_retain = 0;
let mut project = Project {
root: root.to_path_buf(),
frame_dir: root.join("frame"),
config,
tracks: vec![("main".to_string(), parse_track(src))],
inbox: None,
};
clean_project(&mut project, scope).tasks_archived.len()
};
assert_eq!(archived_count(IdScope::Mint(None)), 3);
assert_eq!(archived_count(IdScope::Mint(Token::new("a"))), 3);
assert_eq!(archived_count(IdScope::Unclaimed), 3);
}
fn project_with_idless_task() -> Project {
make_project(
"\
# Main
## Backlog
- [ ] `M-001` Has an ID
- [ ] Missing an ID
## Done
",
vec![("main", "M")],
)
}
#[test]
fn test_unclaimed_passive_skips_id_assignment() {
let mut project = project_with_idless_task();
let modified = ensure_ids_and_dates(&mut project, IdScope::Unclaimed);
let backlog = project.tracks[0].1.backlog();
assert!(
backlog[1].id.is_none(),
"unclaimed clone must not mint an ID"
);
assert!(
backlog[1]
.metadata
.iter()
.any(|m| matches!(m, Metadata::Added(_)))
);
assert!(modified.contains(&"main".to_string()));
}
#[test]
fn test_null_creator_passive_mints_null() {
let mut project = project_with_idless_task();
ensure_ids_and_dates(&mut project, IdScope::Mint(None));
assert_eq!(
project.tracks[0].1.backlog()[1].id.as_deref(),
Some("M-002")
);
}
#[test]
fn test_tokened_passive_mints_in_namespace() {
let mut project = project_with_idless_task();
ensure_ids_and_dates(&mut project, IdScope::Mint(Token::new("a")));
assert_eq!(project.tracks[0].1.backlog()[1].id.as_deref(), Some("M-a1"));
}
#[test]
fn test_unclaimed_clean_skips_minting_but_archives() {
let mut project = project_with_idless_task();
let result = clean_project(&mut project, IdScope::Unclaimed);
assert!(result.ids_assigned.is_empty());
assert!(project.tracks[0].1.backlog()[1].id.is_none());
}
#[test]
fn test_clean_keeps_a_stray_line_on_an_untouched_task() {
let source = "\
# Main
## Done
- [x] `M-001` Sharded map lowering
- added: 2026-07-01
- resolved: 2026-07-20
**Shape.** A sharded map whose callback produces a per-row output.
- [x] `M-002` Needs a resolved date
- added: 2026-07-02
";
let mut project = make_project(source, vec![("main", "M")]);
let result = clean_project(&mut project, IdScope::Mint(None));
assert!(
result
.dates_assigned
.iter()
.any(|d| d.task_id == "M-002" && d.kind == DateKind::Resolved),
"expected clean to fill M-002's resolved date: {:?}",
result.dates_assigned
);
let written = crate::parse::serialize_track(&project.tracks[0].1);
assert!(
written.contains("**Shape.** A sharded map whose callback produces a per-row output."),
"clean deleted a line from an untouched task: {written}"
);
}
#[test]
fn test_archive_carries_a_stranded_line() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("frame/tracks")).unwrap();
let src = "\
# Main
## Backlog
- [ ] `M-200` Active task
## Done
- [x] `M-001` Sharded map lowering
- added: 2026-07-01
- resolved: 2026-07-20
**Shape.** A sharded map whose callback produces a per-row output.
- [x] `M-002` Unrelated finished work
- added: 2026-07-02
- resolved: 2026-07-21
";
let mut config = make_config(vec![("main", "M")]);
config.clean.done_threshold = 1;
config.clean.done_retain = 0;
let mut project = Project {
root: root.to_path_buf(),
frame_dir: root.join("frame"),
config,
tracks: vec![("main".to_string(), parse_track(src))],
inbox: None,
};
let result = clean_project(&mut project, IdScope::Mint(None));
assert_eq!(result.tasks_archived.len(), 2);
let archive = std::fs::read_to_string(root.join("frame/archive/main.md")).unwrap();
assert!(
archive.contains("**Shape.** A sharded map whose callback produces a per-row output."),
"archiving dropped the stranded line: {archive}"
);
let track = crate::parse::serialize_track(&project.tracks[0].1);
assert!(
!track.contains("**Shape."),
"the line was left behind in the track as well: {track}"
);
}
}