use std::collections::BTreeSet;
use std::path::Path;
use frame::io::actors::IdScope;
use frame::io::project_io;
use frame::model::project::Project;
use frame::model::task::TaskState;
use frame::ops::ids::Mint;
use frame::ops::task_ops::{self, InsertPosition};
use frame::ops::track_ops;
use frame::ops::{check, clean, fix, inbox_ops};
use frame::parse::parse_archive;
use proptest::prelude::*;
#[path = "support/tree_checks.rs"]
mod tree_checks;
use tree_checks::{all_text, present, tasks_of, unsettled, walk};
const TRACK_A: &str = "\
# Alpha
> A track with awkward corners.
## Backlog
- [ ] `A-001` First task #core
- added: 2026-01-01
a stray line between two tasks
- [ ] `A-002` Second task
- added: 2026-01-01
- note: a note that says something
- [ ] `A-002.1` A subtask
- added: 2026-01-01
content indented past its metadata
- [ ] `A-002.2` A second subtask
- added: 2026-01-01
- dep: A-001
- [ ] `A-003` Third task
- added: 2026-01-01
- dep: A-002.1
## Parked
- [~] `A-010` Parked idea
- added: 2026-01-01
## Done
- [x] `A-020` Already finished
- added: 2026-01-01
- resolved: 2026-01-02
";
const TRACK_B: &str = "\
# Beta
## Backlog
- [ ] `B-001` Beta work
- added: 2026-01-01
- dep: A-002.1
## Done
";
const INBOX: &str = "\
# Inbox
- something captured earlier #idea
a stray line between two inbox items
- another thought
";
const PROJECT_TOML: &str = "\
[project]
name = \"conservation\"
[clean]
auto_clean = true
done_threshold = 2
done_retain = 0
[[tracks]]
id = \"alpha\"
name = \"Alpha\"
state = \"active\"
file = \"tracks/alpha.md\"
[[tracks]]
id = \"beta\"
name = \"Beta\"
state = \"active\"
file = \"tracks/beta.md\"
[ids.prefixes]
alpha = \"A\"
beta = \"B\"
";
fn build_project(root: &Path) {
let frame_dir = root.join("frame");
std::fs::create_dir_all(frame_dir.join("tracks")).unwrap();
std::fs::write(frame_dir.join(".actor"), "null\n").unwrap();
std::fs::write(frame_dir.join("project.toml"), PROJECT_TOML).unwrap();
std::fs::write(frame_dir.join("tracks/alpha.md"), TRACK_A).unwrap();
std::fs::write(frame_dir.join("tracks/beta.md"), TRACK_B).unwrap();
std::fs::write(frame_dir.join("inbox.md"), INBOX).unwrap();
}
const UNOWNED_LINES: &[&str] = &[
"a stray line between two tasks",
"content indented past its metadata",
"a stray line between two inbox items",
];
fn resolved_dep_pairs(frame_dir: &Path) -> BTreeSet<(String, String)> {
let tasks = tree_checks::all_tasks(frame_dir);
let mut by_id: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for task in &tasks {
if let Some(id) = &task.id {
by_id
.entry(id.to_string())
.or_insert_with(|| task.title.clone());
}
}
let mut out = BTreeSet::new();
for task in &tasks {
if task.title.trim().is_empty() {
continue;
}
for dep in frame::ops::deps::task_deps(task) {
if let Some(target) = by_id.get(&dep)
&& !target.trim().is_empty()
{
out.insert((task.title.clone(), target.clone()));
}
}
}
out
}
#[derive(Debug, Clone)]
enum Op {
AddTask {
track: usize,
title: String,
},
SetState {
task: usize,
state: TaskState,
},
SetNote {
task: usize,
text: String,
},
EditTitle {
task: usize,
title: String,
},
AddTag {
task: usize,
tag: String,
},
Delete {
task: usize,
},
Capture {
text: String,
},
Triage {
item: usize,
track: usize,
},
Clean,
CheckFix,
RenamePrefix {
track: usize,
},
AddDep {
from: usize,
to: usize,
},
MoveToTrack {
task: usize,
track: usize,
},
}
fn arb_title() -> impl Strategy<Value = String> {
prop::sample::select(
[
"a plain new task",
"task with §unicode",
"task: with punctuation",
"a much longer title than the others so wrapping has something to do",
]
.as_slice(),
)
.prop_map(str::to_string)
}
fn unique(title: &str, step: usize) -> String {
format!("{title} {step}")
}
fn arb_op() -> impl Strategy<Value = Op> {
prop_oneof![
4 => (0usize..2, arb_title()).prop_map(|(track, title)| Op::AddTask { track, title }),
4 => (0usize..12, prop::sample::select(
[TaskState::Todo, TaskState::Active, TaskState::Done, TaskState::Parked, TaskState::Blocked].as_slice(),
))
.prop_map(|(task, state)| Op::SetState { task, state }),
3 => (0usize..12, prop::sample::select(
["a note", "a note\nwith two lines", "note with §unicode"].as_slice(),
))
.prop_map(|(task, text)| Op::SetNote { task, text: text.to_string() }),
2 => (0usize..12, arb_title()).prop_map(|(task, title)| Op::EditTitle { task, title }),
2 => (0usize..12, prop::sample::select(["bug", "core", "later"].as_slice()))
.prop_map(|(task, tag)| Op::AddTag { task, tag: tag.to_string() }),
1 => (0usize..12).prop_map(|task| Op::Delete { task }),
2 => arb_title().prop_map(|text| Op::Capture { text }),
2 => (0usize..4, 0usize..2).prop_map(|(item, track)| Op::Triage { item, track }),
3 => Just(Op::Clean),
2 => Just(Op::CheckFix),
1 => (0usize..2).prop_map(|track| Op::RenamePrefix { track }),
3 => (0usize..12, 0usize..12).prop_map(|(from, to)| Op::AddDep { from, to }),
3 => (0usize..12, 0usize..2).prop_map(|(task, track)| Op::MoveToTrack { task, track }),
]
}
#[derive(Default)]
struct Licensed {
titles: BTreeSet<String>,
ids: BTreeSet<String>,
}
fn addressable(project: &Project) -> Vec<(String, String)> {
let mut out = Vec::new();
for (track_id, track) in &project.tracks {
for task in tasks_of(track) {
if let Some(id) = &task.id {
out.push((track_id.clone(), id.to_string()));
}
}
}
out
}
fn save_all(project: &Project) {
for (track_id, track) in &project.tracks {
let file = project
.config
.tracks
.iter()
.find(|t| &t.id == track_id)
.map(|t| t.file.clone())
.unwrap_or_else(|| format!("tracks/{track_id}.md"));
project_io::save_track(&project.frame_dir, &file, track).unwrap();
}
if let Some(inbox) = &project.inbox {
project_io::save_inbox(&project.frame_dir, inbox).unwrap();
}
}
fn apply_op(project: &mut Project, op: &Op, step: usize) -> Licensed {
let mut licensed = Licensed::default();
let frame_dir = project.frame_dir.clone();
match op {
Op::AddTask { track, title } => {
let idx = track % project.tracks.len();
let (track_id, prefix) = {
let (id, _) = &project.tracks[idx];
let prefix = project.config.ids.prefixes.get(id).cloned();
(id.clone(), prefix)
};
let Some(prefix) = prefix else {
return licensed;
};
let mint = Mint::new(&frame_dir, &track_id, &prefix, None);
let (_, track) = &mut project.tracks[idx];
let _ = task_ops::add_task(track, unique(title, step), InsertPosition::Bottom, mint);
}
Op::SetState { task, state } => {
let all = addressable(project);
if all.is_empty() {
return licensed;
}
let (track_id, id) = all[task % all.len()].clone();
if let Some((_, track)) = project.tracks.iter_mut().find(|(t, _)| *t == track_id)
&& let Some(t) = task_ops::find_task_mut_in_track(track, &id)
{
task_ops::set_state(t, *state);
}
clean::reconcile_sections(project);
}
Op::SetNote { task, text } => {
let all = addressable(project);
if all.is_empty() {
return licensed;
}
let (track_id, id) = all[task % all.len()].clone();
if let Some((_, track)) = project.tracks.iter_mut().find(|(t, _)| *t == track_id) {
let _ = task_ops::set_note(track, &id, text.clone());
}
}
Op::EditTitle { task, title } => {
let all = addressable(project);
if all.is_empty() {
return licensed;
}
let (track_id, id) = all[task % all.len()].clone();
if let Some((_, track)) = project.tracks.iter_mut().find(|(t, _)| *t == track_id) {
if let Some(t) = task_ops::find_task_in_track(track, &id) {
licensed.titles.insert(t.title.clone());
}
let _ = task_ops::edit_title(track, &id, unique(title, step));
}
}
Op::AddTag { task, tag } => {
let all = addressable(project);
if all.is_empty() {
return licensed;
}
let (track_id, id) = all[task % all.len()].clone();
if let Some((_, track)) = project.tracks.iter_mut().find(|(t, _)| *t == track_id) {
let _ = task_ops::add_tag(track, &id, tag);
}
}
Op::Delete { task } => {
let all = addressable(project);
if all.is_empty() {
return licensed;
}
let (track_id, id) = all[task % all.len()].clone();
if let Some((_, track)) = project.tracks.iter_mut().find(|(t, _)| *t == track_id) {
if let Some(t) = task_ops::find_task_in_track(track, &id) {
let mut subtree = Vec::new();
walk(t, &mut subtree);
for s in subtree {
licensed.titles.insert(s.title.clone());
if let Some(sid) = &s.id {
licensed.ids.insert(sid.to_string());
}
}
}
let _ = task_ops::delete_task(track, &id);
}
}
Op::Capture { text } => {
if let Some(inbox) = project.inbox.as_mut() {
inbox_ops::add_inbox_item(inbox, unique(text, step), Vec::new(), None);
}
}
Op::Triage { item, track } => {
let idx = track % project.tracks.len();
let (track_id, prefix) = {
let (id, _) = &project.tracks[idx];
(id.clone(), project.config.ids.prefixes.get(id).cloned())
};
let Some(prefix) = prefix else {
return licensed;
};
let Some(inbox) = project.inbox.as_mut() else {
return licensed;
};
if inbox.items.is_empty() {
return licensed;
}
let i = item % inbox.items.len();
let mint = Mint::new(&frame_dir, &track_id, &prefix, None);
let (_, track) = &mut project.tracks[idx];
let _ = inbox_ops::triage(inbox, i, track, InsertPosition::Bottom, mint);
}
Op::Clean => {
clean::clean_project(project, IdScope::Mint(None));
}
Op::CheckFix => {
let plan = fix::plan(&check::check_project(project));
fix::apply(project, &plan);
}
Op::RenamePrefix { track } => {
let idx = track % project.tracks.len();
let track_id = project.tracks[idx].0.clone();
let Some(old_prefix) = project.config.ids.prefixes.get(&track_id).cloned() else {
return licensed;
};
let new_prefix = format!("{old_prefix}X");
for task in tasks_of(&project.tracks[idx].1) {
if let Some(id) = &task.id {
licensed.ids.insert(id.to_string());
}
}
let archive_path = frame_dir.join("archive").join(format!("{track_id}.md"));
if let Ok(text) = std::fs::read_to_string(&archive_path) {
for task in parse_archive(&text).tasks {
let mut flat = Vec::new();
walk(&task, &mut flat);
for t in flat {
if let Some(id) = &t.id {
licensed.ids.insert(id.to_string());
}
}
}
}
let mut tracks = std::mem::take(&mut project.tracks);
let renamed = track_ops::rename_track_prefix(
&mut project.config,
&mut tracks,
&track_id,
&old_prefix,
&new_prefix,
);
project.tracks = tracks;
if renamed.is_ok() {
let _ = track_ops::rename_archive_prefix(
&frame_dir,
&track_id,
&old_prefix,
&new_prefix,
);
let _ = frame::io::config_io::write_config_from_struct(&frame_dir, &project.config);
}
}
Op::AddDep { from, to } => {
let all = addressable(project);
if all.len() < 2 {
return licensed;
}
let (track_id, id) = all[from % all.len()].clone();
let (_, dep_id) = all[to % all.len()].clone();
if dep_id == id
|| dep_id.starts_with(&format!("{id}."))
|| id.starts_with(&format!("{dep_id}."))
{
return licensed;
}
let snapshot = project.tracks.clone();
if let Some((_, track)) = project.tracks.iter_mut().find(|(t, _)| *t == track_id) {
let _ = task_ops::add_dep(track, &id, &dep_id, &snapshot);
}
}
Op::MoveToTrack { task, track } => {
let all = addressable(project);
if all.is_empty() || project.tracks.len() < 2 {
return licensed;
}
let (source_id, id) = all[task % all.len()].clone();
let target_idx = track % project.tracks.len();
let target_id = project.tracks[target_idx].0.clone();
if target_id == source_id {
return licensed;
}
let Some(prefix) = project.config.ids.prefixes.get(&target_id).cloned() else {
return licensed;
};
let source_idx = project
.tracks
.iter()
.position(|(t, _)| *t == source_id)
.expect("source track was just addressed");
if !task_ops::is_top_level_in_section(
&project.tracks[source_idx].1,
&id,
frame::model::SectionKind::Backlog,
) && !task_ops::is_top_level_in_section(
&project.tracks[source_idx].1,
&id,
frame::model::SectionKind::Parked,
) && !task_ops::is_top_level_in_section(
&project.tracks[source_idx].1,
&id,
frame::model::SectionKind::Done,
) {
return licensed;
}
let mint = Mint::new(&frame_dir, &target_id, &prefix, None);
let (left, right) = if source_idx < target_idx {
let (l, r) = project.tracks.split_at_mut(target_idx);
(&mut l[source_idx].1, &mut r[0].1)
} else {
let (l, r) = project.tracks.split_at_mut(source_idx);
(&mut r[0].1, &mut l[target_idx].1)
};
let (source_track, target_track) = if source_idx < target_idx {
(left, right)
} else {
(right, left)
};
let moved = task_ops::move_task_to_track(
source_track,
target_track,
&id,
InsertPosition::Bottom,
mint,
);
if let Ok(moved) = moved {
for (old, _) in &moved.id_mappings {
licensed.ids.insert(old.clone());
}
task_ops::apply_id_map_to_deps(&mut project.tracks, &moved.id_mappings);
}
}
}
save_all(project);
licensed
}
fn assert_settled(frame_dir: &Path, step: usize, op: &Op) -> Result<(), TestCaseError> {
match unsettled(frame_dir) {
Some(detail) => Err(TestCaseError::fail(format!(
"step {step} ({op:?}): {detail}"
))),
None => Ok(()),
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(96))]
#[test]
fn p7_an_operation_sequence_conserves_content(ops in prop::collection::vec(arb_op(), 1..9)) {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
build_project(root);
let frame_dir = root.join("frame");
let (mut titles, mut ids) = present(&frame_dir);
let mut expected_unowned: Vec<&str> = UNOWNED_LINES.to_vec();
for (step, op) in ops.iter().enumerate() {
let before_pairs = resolved_dep_pairs(&frame_dir);
let mut project = project_io::load_project(root).expect("project loads");
let licensed = apply_op(&mut project, op, step);
for t in &licensed.titles {
titles.remove(t);
}
for i in &licensed.ids {
ids.remove(i);
}
let (now_titles, now_ids) = present(&frame_dir);
let lost_titles: Vec<_> = titles.difference(&now_titles).cloned().collect();
prop_assert!(
lost_titles.is_empty(),
"step {step} ({op:?}) lost titles: {lost_titles:?}"
);
let lost_ids: Vec<_> = ids.difference(&now_ids).cloned().collect();
prop_assert!(
lost_ids.is_empty(),
"step {step} ({op:?}) lost ids: {lost_ids:?}"
);
let text = all_text(&frame_dir);
if matches!(op, Op::SetNote { .. }) {
expected_unowned.retain(|line| text.contains(line));
} else {
for line in &expected_unowned {
prop_assert!(
text.contains(line),
"step {step} ({op:?}) dropped a line frame does not own: {line:?}"
);
}
}
let broken: Vec<_> = before_pairs
.difference(&resolved_dep_pairs(&frame_dir))
.filter(|(from, to)| {
!licensed.titles.contains(from) && !licensed.titles.contains(to)
})
.cloned()
.collect();
prop_assert!(
broken.is_empty(),
"step {step} ({op:?}) broke these dependencies (dependent, dependency): {broken:?}"
);
assert_settled(&frame_dir, step, op)?;
titles = now_titles;
ids = now_ids;
}
}
}
#[test]
fn clean_does_not_drop_a_line_it_does_not_understand() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
build_project(root);
let frame_dir = root.join("frame");
let mut project = project_io::load_project(root).unwrap();
clean::clean_project(&mut project, IdScope::Mint(None));
save_all(&project);
let text = all_text(&frame_dir);
for line in UNOWNED_LINES {
assert!(text.contains(line), "clean dropped {line:?}:\n{text}");
}
}
#[test]
fn a_cleaned_task_moves_to_the_archive_rather_than_away() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
build_project(root);
let frame_dir = root.join("frame");
let path = frame_dir.join("tracks/alpha.md");
let mut body = std::fs::read_to_string(&path).unwrap();
body.push_str(
"- [x] `A-021` Second finished\n - added: 2026-01-01\n - resolved: 2026-01-02\n\
- [x] `A-022` Third finished\n - added: 2026-01-01\n - resolved: 2026-01-02\n",
);
std::fs::write(&path, body).unwrap();
let before = present(&frame_dir);
let mut project = project_io::load_project(root).unwrap();
clean::clean_project(&mut project, IdScope::Mint(None));
save_all(&project);
let after = present(&frame_dir);
assert!(
before.0.is_subset(&after.0),
"titles left the project: {:?}",
before.0.difference(&after.0).collect::<Vec<_>>()
);
assert!(
before.1.is_subset(&after.1),
"ids left the project: {:?}",
before.1.difference(&after.1).collect::<Vec<_>>()
);
assert!(
frame_dir.join("archive/alpha.md").exists(),
"and the archive is where they went"
);
}
#[test]
fn deep_content_survives_a_section_move() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
build_project(root);
let frame_dir = root.join("frame");
let path = frame_dir.join("tracks/alpha.md");
let body = std::fs::read_to_string(&path).unwrap().replace(
" - [ ] `A-002.1` A subtask\n - added: 2026-01-01\n",
" - [ ] `A-002.1` A subtask\n - added: 2026-01-01\n deep content\n",
);
std::fs::write(&path, body).unwrap();
let mut project = project_io::load_project(root).unwrap();
apply_op(
&mut project,
&Op::SetNote {
task: 0,
text: "a note\nwith two lines".into(),
},
0,
);
let mut project = project_io::load_project(root).unwrap();
apply_op(
&mut project,
&Op::SetState {
task: 1,
state: TaskState::Done,
},
0,
);
let mut project = project_io::load_project(root).unwrap();
apply_op(
&mut project,
&Op::SetNote {
task: 0,
text: "a note".into(),
},
0,
);
assert!(
all_text(&frame_dir).contains("deep content"),
"an edit to A-001 must not delete content that belonged to A-002.1"
);
}
#[test]
fn a_note_absorbs_unowned_content_under_its_own_task() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
build_project(root);
let frame_dir = root.join("frame");
let mut project = project_io::load_project(root).unwrap();
apply_op(
&mut project,
&Op::SetNote {
task: 2, text: "a note\nwith two lines".into(),
},
0,
);
assert!(all_text(&frame_dir).contains("content indented past its metadata"));
let project = project_io::load_project(root).unwrap();
let sub = task_ops::find_task_in_track(&project.tracks[0].1, "A-002.1").unwrap();
assert!(
sub.trailing_lines.is_empty(),
"the note claimed it, so it is no longer unowned: {:?}",
sub.trailing_lines
);
assert!(
sub.metadata.iter().any(|m| matches!(
m,
frame::model::task::Metadata::Note(n) if n.contains("content indented")
)),
"and it is note text now: {:?}",
sub.metadata
);
}