use std::collections::{HashMap, HashSet};
use std::path::Path;
use chrono::Local;
use crate::model::project::Project;
use crate::model::task::{Metadata, Task, TaskState};
use crate::model::track::{SectionKind, Track, TrackNode};
use crate::ops::task_ops::find_max_id_in_track;
#[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,
}
#[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) -> 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(ref pfx) = prefix {
assign_missing_ids(track, track_id, pfx, &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());
}
}
let before_dups = result.duplicates_resolved.len();
resolve_duplicate_ids(project, &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()
}
fn canonical_section(state: TaskState) -> SectionKind {
match state {
TaskState::Parked => SectionKind::Parked,
TaskState::Done => SectionKind::Done,
_ => SectionKind::Backlog,
}
}
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.clone(), *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) -> 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(ref pfx) = prefix {
assign_missing_ids(track, track_id, pfx, &mut result);
}
assign_missing_dates(track, track_id, &mut result);
}
resolve_duplicate_ids(project, &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);
result
}
pub fn generate_active_md(project: &Project) -> String {
let mut lines = Vec::new();
lines.push(format!("# {} — Active Tasks", project.config.project.name));
lines.push(String::new());
lines.push("> Auto-generated by `fr clean`. Do not edit.".to_string());
lines.push(String::new());
for tc in &project.config.tracks {
if tc.state != "active" {
continue;
}
let track = project.tracks.iter().find(|(id, _)| id == &tc.id);
let Some((_, track)) = track else {
continue;
};
lines.push(format!("## {}", track.title));
lines.push(String::new());
let backlog = track.backlog();
if backlog.is_empty() {
lines.push("(empty backlog)".to_string());
} else {
for task in backlog {
let state_char = task.state.checkbox_char();
let id_str = task
.id
.as_ref()
.map(|id| format!("`{}` ", id))
.unwrap_or_default();
let tags_str = if task.tags.is_empty() {
String::new()
} else {
format!(
" {}",
task.tags
.iter()
.map(|t| format!("#{}", t))
.collect::<Vec<_>>()
.join(" ")
)
};
lines.push(format!(
"- [{}] {}{}{}",
state_char, id_str, task.title, tags_str
));
}
}
lines.push(String::new());
}
while lines.last().is_some_and(|l| l.is_empty()) {
lines.pop();
}
lines.join("\n")
}
fn assign_missing_ids(track: &mut Track, track_id: &str, prefix: &str, result: &mut CleanResult) {
let prefix_dash = format!("{}-", prefix);
let mut max = 0usize;
find_max_id_in_track(track, &prefix_dash, &mut max);
for node in &mut track.nodes {
if let TrackNode::Section { tasks, .. } = node {
assign_ids_in_tasks(tasks, track_id, prefix, &prefix_dash, &mut max, result);
}
}
}
fn assign_ids_in_tasks(
tasks: &mut [Task],
track_id: &str,
_prefix: &str,
prefix_dash: &str,
max: &mut usize,
result: &mut CleanResult,
) {
for task in tasks.iter_mut() {
if task.id.is_none() {
*max += 1;
let new_id = format!("{}{:03}", prefix_dash, max);
task.id = Some(new_id.clone());
task.mark_dirty();
result.ids_assigned.push(IdAssignment {
track_id: track_id.to_string(),
assigned_id: new_id,
title: task.title.clone(),
});
}
assign_subtask_ids(task, track_id, result);
}
}
fn assign_subtask_ids(parent: &mut Task, track_id: &str, result: &mut CleanResult) {
let parent_id = match &parent.id {
Some(id) => id.clone(),
None => return, };
let prefix = format!("{}.", parent_id);
let mut max_num: usize = 0;
for sub in parent.subtasks.iter() {
if let Some(ref id) = sub.id
&& let Some(suffix) = id.strip_prefix(&prefix)
&& !suffix.contains('.')
&& let Ok(n) = suffix.parse::<usize>()
{
max_num = max_num.max(n);
}
}
for sub in parent.subtasks.iter_mut() {
if sub.id.is_none() {
max_num += 1;
let sub_id = format!("{}{}", prefix, max_num);
sub.id = Some(sub_id.clone());
sub.mark_dirty();
result.ids_assigned.push(IdAssignment {
track_id: track_id.to_string(),
assigned_id: sub_id,
title: sub.title.clone(),
});
}
assign_subtask_ids(sub, track_id, 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.clone().unwrap_or_default(),
date: today.to_string(),
});
}
assign_dates_in_tasks(&mut task.subtasks, track_id, today, result);
}
}
fn resolve_duplicate_ids(project: &mut Project, 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<(String, String, String)> = 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,
&mut seen_ids,
&mut duplicates,
);
}
}
}
}
if duplicates.is_empty() {
return;
}
let mut reassignments: HashMap<String, Vec<String>> = HashMap::new();
for (old_id, dup_track_id, _title) in &duplicates {
let prefix = project
.config
.ids
.prefixes
.get(dup_track_id.as_str())
.cloned();
let Some(pfx) = prefix else { continue };
let prefix_dash = format!("{}-", pfx);
let track = project
.tracks
.iter()
.find(|(tid, _)| tid == dup_track_id)
.map(|(_, t)| t);
let Some(track) = track else { continue };
let mut max = 0usize;
find_max_id_in_track(track, &prefix_dash, &mut max);
for new_id in reassignments.values().flatten() {
if let Some(n) = new_id
.strip_prefix(&prefix_dash)
.and_then(|s| s.split('.').next())
.and_then(|s| s.parse::<usize>().ok())
.filter(|&n| n > max)
{
max = n;
}
}
let new_id = format!("{}{:03}", prefix_dash, max + 1);
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,
&reassignments,
&mut reassignment_cursors,
&mut seen_in_apply,
result,
);
}
}
}
}
}
fn find_duplicates_in_tasks(
tasks: &[Task],
track_id: &str,
seen: &mut HashSet<String>,
duplicates: &mut Vec<(String, String, String)>,
) {
for task in tasks {
if task.id.as_ref().is_some_and(|id| !seen.insert(id.clone())) {
let id = task.id.as_ref().unwrap();
duplicates.push((id.clone(), track_id.to_string(), task.title.clone()));
}
find_duplicates_in_tasks(&task.subtasks, track_id, seen, duplicates);
}
}
fn apply_duplicate_reassignments(
tasks: &mut [Task],
track_id: &str,
reassignments: &HashMap<String, Vec<String>>,
cursors: &mut HashMap<String, usize>,
seen: &mut HashSet<String>,
result: &mut CleanResult,
) {
for task in tasks.iter_mut() {
if let Some(old_id) = task
.id
.clone()
.filter(|id| reassignments.contains_key(id) && !seen.insert(id.clone()))
{
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(new_id.clone());
task.mark_dirty();
renumber_subtask_ids(task, new_id);
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,
reassignments,
cursors,
seen,
result,
);
}
}
fn renumber_subtask_ids(parent: &mut Task, new_parent_id: &str) {
for (i, sub) in parent.subtasks.iter_mut().enumerate() {
let new_sub_id = format!("{}.{}", new_parent_id, i + 1);
sub.id = Some(new_sub_id.clone());
sub.mark_dirty();
renumber_subtask_ids(sub, &new_sub_id);
}
}
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.clone().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();
let archive_content = {
let lines = crate::parse::serialize_tasks(
&tasks_to_archive
.iter()
.copied()
.cloned()
.collect::<Vec<_>>(),
0,
);
lines.join("\n")
};
if archive_content.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 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.clone().unwrap_or_default(),
title: task.title.clone(),
});
}
}
}
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.clone());
}
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);
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);
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);
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);
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_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);
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);
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);
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);
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);
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);
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);
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);
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);
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_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);
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);
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);
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);
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);
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_generate_active_md() {
let project = make_project(
"\
# Main Track
## Backlog
- [>] `M-001` Active task #core
- [ ] `M-002` Todo task
- [-] `M-003` Blocked task
## Done
",
vec![("main", "M")],
);
let content = generate_active_md(&project);
assert!(content.contains("# test — Active Tasks"));
assert!(content.contains("## Main Track"));
assert!(content.contains("- [>] `M-001` Active task #core"));
assert!(content.contains("- [ ] `M-002` Todo task"));
assert!(content.contains("- [-] `M-003` Blocked task"));
}
#[test]
fn test_generate_active_md_skips_shelved() {
let track = parse_track(
"\
# Shelved
## Backlog
- [ ] `S-001` Hidden task
## Done
",
);
let project = Project {
root: PathBuf::from("/tmp/test"),
frame_dir: PathBuf::from("/tmp/test/frame"),
config: {
let mut cfg = make_config(vec![]);
cfg.tracks = vec![TrackConfig {
id: "shelved".to_string(),
name: "Shelved".to_string(),
state: "shelved".to_string(),
file: "tracks/shelved.md".to_string(),
}];
cfg
},
tracks: vec![("shelved".to_string(), track)],
inbox: None,
};
let content = generate_active_md(&project);
assert!(!content.contains("Hidden task"));
}
#[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);
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);
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);
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);
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_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);
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);
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);
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);
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);
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);
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);
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);
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);
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);
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);
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);
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);
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"));
}
}