use std::fs;
use std::path::PathBuf;
use frame::model::{
Archive, Inbox, InboxItem, Metadata, SectionKind, Task, TaskState, Track, TrackNode,
};
use frame::parse::{
LineEnding, parse_archive, parse_inbox, parse_track, serialize_archive, serialize_inbox,
serialize_track,
};
use proptest::prelude::*;
#[derive(Debug, PartialEq, Eq)]
struct TrackShape {
title: String,
sections: Vec<(SectionKind, Vec<Task>)>,
}
fn track_shape(track: &Track) -> TrackShape {
TrackShape {
title: track.title.clone(),
sections: track
.nodes
.iter()
.filter_map(|node| match node {
TrackNode::Section { kind, tasks, .. } => Some((*kind, tasks.clone())),
TrackNode::Literal(_) => None,
})
.collect(),
}
}
#[derive(Debug, PartialEq, Eq, Clone)]
struct TaskIdentity {
section: SectionKind,
id: Option<String>,
title: String,
}
fn task_identities(track: &Track) -> Vec<TaskIdentity> {
fn walk(section: SectionKind, tasks: &[Task], out: &mut Vec<TaskIdentity>) {
for task in tasks {
out.push(TaskIdentity {
section,
id: task.id.as_deref().map(str::to_string),
title: task.title.clone(),
});
walk(section, &task.subtasks, out);
}
}
let mut out = Vec::new();
for node in &track.nodes {
if let TrackNode::Section { kind, tasks, .. } = node {
walk(*kind, tasks, &mut out);
}
}
out
}
fn metadata_entries(track: &Track) -> Vec<Metadata> {
fn walk(tasks: &[Task], out: &mut Vec<Metadata>) {
for task in tasks {
out.extend(task.metadata.iter().cloned());
walk(&task.subtasks, out);
}
}
let mut out = Vec::new();
for node in &track.nodes {
if let TrackNode::Section { tasks, .. } = node {
walk(tasks, &mut out);
}
}
out
}
fn stranded_lines(track: &Track) -> Vec<String> {
fn walk(tasks: &[Task], out: &mut Vec<String>) {
for task in tasks {
out.extend(task.leading_lines.iter().cloned());
walk(&task.subtasks, out);
}
}
let mut out = Vec::new();
for node in &track.nodes {
if let TrackNode::Section { tasks, .. } = node {
walk(tasks, &mut out);
}
}
out
}
fn archive_identities(archive: &Archive) -> Vec<(Option<String>, String)> {
fn walk(tasks: &[Task], out: &mut Vec<(Option<String>, String)>) {
for task in tasks {
out.push((task.id.as_deref().map(str::to_string), task.title.clone()));
walk(&task.subtasks, out);
}
}
let mut out = Vec::new();
walk(&archive.tasks, &mut out);
out
}
fn archive_metadata(archive: &Archive) -> Vec<Metadata> {
fn walk(tasks: &[Task], out: &mut Vec<Metadata>) {
for task in tasks {
out.extend(task.metadata.iter().cloned());
walk(&task.subtasks, out);
}
}
let mut out = Vec::new();
walk(&archive.tasks, &mut out);
out
}
#[derive(Debug, PartialEq, Eq)]
struct ItemShape {
title: String,
tags: Vec<String>,
body: Option<String>,
}
fn inbox_shape(inbox: &Inbox) -> Vec<ItemShape> {
inbox
.items
.iter()
.map(|item| ItemShape {
title: item.title.clone(),
tags: item.tags.clone(),
body: item.body.clone(),
})
.collect()
}
fn dirty_task(task: &mut Task) {
task.dirty = true;
task.source_text = None;
for sub in &mut task.subtasks {
dirty_task(sub);
}
}
fn dirty_track(track: &mut Track) {
for node in &mut track.nodes {
if let TrackNode::Section { tasks, .. } = node {
for task in tasks.iter_mut() {
dirty_task(task);
}
}
}
}
fn dirty_inbox(inbox: &mut Inbox) {
for item in &mut inbox.items {
item.dirty = true;
item.source_text = None;
}
}
fn canon_track(source: &str) -> String {
let mut track = parse_track(source);
dirty_track(&mut track);
serialize_track(&track)
}
fn canon_inbox(source: &str) -> String {
let (mut inbox, _) = parse_inbox(source);
dirty_inbox(&mut inbox);
serialize_inbox(&inbox)
}
fn canon_archive(source: &str) -> String {
let mut archive = parse_archive(source);
for task in &mut archive.tasks {
dirty_task(task);
}
serialize_archive(&archive)
}
const MAX_REWRITES_TO_SETTLE: usize = 4;
fn settle(source: &str, rewrite: fn(&str) -> String) -> Result<(String, usize), (String, String)> {
let mut current = rewrite(source);
for pass in 1..=MAX_REWRITES_TO_SETTLE {
let next = rewrite(¤t);
if next == current {
return Ok((current, pass));
}
current = next;
}
Err((current.clone(), rewrite(¤t)))
}
fn fixture_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
}
fn fixture_sources() -> Vec<String> {
let mut out = Vec::new();
for entry in fs::read_dir(fixture_dir()).expect("fixtures dir readable") {
let path = entry.expect("readable dir entry").path();
if path.extension().is_some_and(|e| e == "md") {
out.push(fs::read_to_string(&path).expect("fixture readable"));
}
}
assert!(!out.is_empty(), "no .md fixtures found");
let crlf: Vec<String> = out.iter().map(|s| s.replace('\n', "\r\n")).collect();
out.extend(crlf);
out
}
const INTERESTING_LINES: &[&str] = &[
"",
" ",
" ",
"\t",
"# Title",
"## Backlog",
"## Parked",
"## Done",
"> a description",
"- [ ] plain task",
"- [x] `T-001` done task",
"- [>] `T-a1` active #tag",
"- [~] parked",
"- [-] blocked",
"- [ ] `T-002` café ünïcode §§§ title",
" - added: 2025-05-14",
" - resolved: 2025-05-14",
" - dep: T-001, T-002",
" - ref: src/a.rs",
" - spec: doc/s.md#x",
" - note: single line",
" - note:",
" - note:\n ```rust\n let x = 1;\n ```",
" - note:\n ```lace\n let x = perform Ask()",
" - note:\n § multi-byte at the slice boundary\n more note text",
" - note:\n deeper body\n deeper still",
" - note:\n body text",
"- [ ] `T-003` trailing",
"not a task line at all",
"```",
"§",
];
const DEEP_CONTENT_LINES: &[&str] = &[
" ```",
" ```rust",
" ```lace",
" § multi-byte at the slice boundary",
" body text",
" deeper body",
" deeper still",
" - [ ] `T-003.1` subtask",
" - [ ] `T-003.1.1` deep subtask",
" - [ ] `T-003.1.1.1` past MAX_DEPTH",
" §§ dedented mid-character",
];
fn arb_soup() -> impl Strategy<Value = String> {
let pool: Vec<&'static str> = INTERESTING_LINES
.iter()
.chain(DEEP_CONTENT_LINES)
.copied()
.collect();
(
prop::collection::vec(prop::sample::select(pool).prop_map(str::to_string), 0..40),
prop::bool::ANY,
)
.prop_map(|(lines, crlf)| lines.join(if crlf { "\r\n" } else { "\n" }))
}
#[derive(Debug, Clone)]
enum Mutation {
Delete,
Duplicate,
Truncate,
Indent(usize),
Dedent,
Replace(usize),
AppendMultibyte,
}
fn arb_mutation() -> impl Strategy<Value = Mutation> {
prop_oneof![
Just(Mutation::Delete),
Just(Mutation::Duplicate),
Just(Mutation::Truncate),
(1usize..8).prop_map(Mutation::Indent),
Just(Mutation::Dedent),
(0usize..INTERESTING_LINES.len()).prop_map(Mutation::Replace),
Just(Mutation::AppendMultibyte),
]
}
fn mutate(source: &str, idx: usize, mutation: &Mutation) -> String {
let mut lines: Vec<String> = source.split('\n').map(str::to_string).collect();
if lines.is_empty() {
return source.to_string();
}
let i = idx % lines.len();
match mutation {
Mutation::Delete => {
lines.remove(i);
}
Mutation::Duplicate => {
lines.insert(i, lines[i].clone());
}
Mutation::Truncate => {
let line = &lines[i];
let cut = line
.char_indices()
.nth(line.chars().count() / 2)
.map(|(b, _)| b)
.unwrap_or(0);
lines[i] = line[..cut].to_string();
}
Mutation::Indent(n) => {
lines[i] = format!("{}{}", " ".repeat(*n), lines[i]);
}
Mutation::Dedent => {
lines[i] = lines[i].trim_start().to_string();
}
Mutation::Replace(n) => {
lines[i] = INTERESTING_LINES[*n].to_string();
}
Mutation::AppendMultibyte => {
lines[i] = format!("{}§é→", lines[i]);
}
}
lines.join("\n")
}
fn arb_title() -> impl Strategy<Value = String> {
prop::sample::select(
[
"plain title",
"café ünïcode",
"§ symbol lead",
"with - dashes",
"with: a colon",
"trailing punctuation!",
"a",
]
.as_slice(),
)
.prop_map(str::to_string)
}
fn arb_tags() -> impl Strategy<Value = Vec<String>> {
prop::collection::vec(
prop::sample::select(["cc", "bug", "design", "cc-added"].as_slice())
.prop_map(str::to_string),
0..3,
)
}
fn arb_note() -> impl Strategy<Value = String> {
prop::collection::vec(
prop::sample::select(
[
"a note line",
"```rust",
"let x = 1;",
"```",
"§ unicode in a note",
"- not a task, just prose",
]
.as_slice(),
)
.prop_map(str::to_string),
1..5,
)
.prop_map(|lines| lines.join("\n"))
}
fn arb_metadata() -> impl Strategy<Value = Vec<Metadata>> {
(
prop::option::of(Just("2025-05-14".to_string())),
prop::option::of(Just("2025-06-01".to_string())),
prop::option::of(prop::collection::vec(
prop::sample::select(["T-001", "T-002", "EFF-a3"].as_slice()).prop_map(str::to_string),
1..3,
)),
prop::option::of(prop::collection::vec(
prop::sample::select(
[
"src/a.rs",
"doc/b.md",
"src/c.rs:42 — the fix site (first red)",
]
.as_slice(),
)
.prop_map(str::to_string),
1..3,
)),
prop::option::of(prop::collection::vec(
prop::sample::select(["doc/spec.md#section", "doc/other spec.md"].as_slice())
.prop_map(str::to_string),
1..3,
)),
prop::option::of(arb_note()),
)
.prop_map(|(added, resolved, dep, refs, spec, note)| {
let mut out = Vec::new();
if let Some(v) = added {
out.push(Metadata::Added(v));
}
if let Some(v) = resolved {
out.push(Metadata::Resolved(v));
}
if let Some(v) = dep {
out.push(Metadata::Dep(v));
}
if let Some(v) = refs {
out.push(Metadata::Ref(v));
}
if let Some(v) = spec {
out.push(Metadata::Spec(v));
}
if let Some(v) = note {
out.push(Metadata::Note(v));
}
out
})
}
fn arb_leaf(depth: usize) -> impl Strategy<Value = Task> {
(
prop::sample::select(
[
TaskState::Todo,
TaskState::Active,
TaskState::Blocked,
TaskState::Done,
TaskState::Parked,
]
.as_slice(),
),
prop::option::of(prop::sample::select(
["T-001", "T-002", "EFF-a14", "T-001.1"].as_slice(),
)),
arb_title(),
arb_tags(),
arb_metadata(),
)
.prop_map(move |(state, id, title, tags, metadata)| {
let mut task = Task::new(state, id.map(Into::into), title);
task.tags = tags;
task.metadata = metadata;
task.depth = depth;
task
})
}
fn arb_task_tree() -> impl Strategy<Value = Task> {
(
arb_leaf(0),
prop::collection::vec(
(arb_leaf(1), prop::collection::vec(arb_leaf(2), 0..2)),
0..3,
),
)
.prop_map(|(mut root, children)| {
root.subtasks = children
.into_iter()
.map(|(mut child, grandchildren)| {
child.subtasks = grandchildren;
child
})
.collect();
root
})
}
fn section(kind: SectionKind, tasks: Vec<Task>, last: bool) -> TrackNode {
let header = match kind {
SectionKind::Backlog => "## Backlog",
SectionKind::Parked => "## Parked",
SectionKind::Done => "## Done",
};
TrackNode::Section {
kind,
header_lines: vec![header.to_string(), String::new()],
tasks,
trailing_lines: if last {
Vec::new()
} else {
vec![String::new()]
},
}
}
fn arb_eol() -> impl Strategy<Value = LineEnding> {
prop_oneof![Just(LineEnding::Lf), Just(LineEnding::Crlf)]
}
fn arb_track_model() -> impl Strategy<Value = Track> {
(
prop::collection::vec(arb_task_tree(), 0..3),
prop::collection::vec(arb_task_tree(), 0..2),
prop::collection::vec(arb_task_tree(), 0..2),
arb_eol(),
)
.prop_map(|(backlog, parked, done, eol)| Track {
title: "Generated Track".to_string(),
description: None,
nodes: vec![
TrackNode::Literal(vec!["# Generated Track".to_string(), String::new()]),
section(SectionKind::Backlog, backlog, false),
section(SectionKind::Parked, parked, false),
section(SectionKind::Done, done, true),
],
eol,
})
}
fn arb_archive_model() -> impl Strategy<Value = Archive> {
(
prop::collection::vec(arb_task_tree(), 0..4),
prop::option::of(prop::sample::select(
["<!-- archived 2025 -->", "---", "see also: notes.md"].as_slice(),
)),
arb_eol(),
)
.prop_map(|(tasks, tail, eol)| Archive {
header: vec!["# Archive — generated".to_string(), String::new()],
trailing: match tail {
Some(line) if !tasks.is_empty() => vec![String::new(), line.to_string()],
_ => Vec::new(),
},
tasks,
eol,
})
}
fn arb_inbox_model() -> impl Strategy<Value = Inbox> {
(
prop::collection::vec(
(
arb_title(),
arb_tags(),
prop::option::of(prop::collection::vec(
prop::sample::select(
[
"body line",
"more detail here",
"§ unicode body",
"```lace",
"let x = 1",
"```",
]
.as_slice(),
)
.prop_map(str::to_string),
1..4,
)),
),
0..4,
),
arb_eol(),
)
.prop_map(|(items, eol)| Inbox {
header_lines: vec!["# Inbox".to_string(), String::new()],
items: items
.into_iter()
.map(|(title, tags, body)| {
let mut item = InboxItem::new(title);
item.tags = tags;
item.body = body.map(|lines| lines.join("\n"));
item
})
.collect(),
eol,
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn p1_track_parse_never_panics(source in arb_soup()) {
let track = parse_track(&source);
let _ = serialize_track(&track);
}
#[test]
fn p1_archive_parse_never_panics(source in arb_soup()) {
let archive = parse_archive(&source);
let _ = serialize_archive(&archive);
}
#[test]
fn p1_inbox_parse_never_panics(source in arb_soup()) {
let (inbox, _) = parse_inbox(&source);
let _ = serialize_inbox(&inbox);
}
#[test]
fn p1_mutated_fixtures_never_panic(
which in 0usize..64,
idx in 0usize..512,
mutation in arb_mutation(),
) {
let corpus = fixture_sources();
let source = &corpus[which % corpus.len()];
let damaged = mutate(source, idx, &mutation);
let track = parse_track(&damaged);
let _ = serialize_track(&track);
let (inbox, _) = parse_inbox(&damaged);
let _ = serialize_inbox(&inbox);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(128))]
#[test]
fn p2_track_canonical_rewrite_preserves_content(
which in 0usize..64,
idx in 0usize..512,
mutation in arb_mutation(),
) {
let corpus = fixture_sources();
let source = &corpus[which % corpus.len()];
let damaged = mutate(source, idx, &mutation);
let before = parse_track(&damaged);
let rewritten = canon_track(&damaged);
let after = parse_track(&rewritten);
let after_tasks = task_identities(&after);
for task in task_identities(&before) {
prop_assert!(
after_tasks.contains(&task),
"task lost by the rewrite: {:?}\nsurvivors: {:?}",
task,
after_tasks
);
}
let after_meta = metadata_entries(&after);
for meta in metadata_entries(&before) {
prop_assert!(
after_meta.contains(&meta),
"metadata lost by the rewrite: {:?}\nsurvivors: {:?}",
meta,
after_meta
);
}
for stranded in stranded_lines(&before) {
prop_assert!(
rewritten.lines().any(|l| l.trim() == stranded.trim()),
"stranded line lost by the rewrite: {:?}\nrewritten: {:?}",
stranded,
rewritten
);
}
}
#[test]
fn p2_archive_canonical_rewrite_preserves_content(
which in 0usize..64,
idx in 0usize..512,
mutation in arb_mutation(),
) {
let corpus = fixture_sources();
let source = &corpus[which % corpus.len()];
let damaged = mutate(source, idx, &mutation);
let before = parse_archive(&damaged);
let rewritten = canon_archive(&damaged);
let after = parse_archive(&rewritten);
let after_tasks = archive_identities(&after);
for task in archive_identities(&before) {
prop_assert!(
after_tasks.contains(&task),
"archived task lost by the rewrite: {:?}\nsurvivors: {:?}",
task,
after_tasks
);
}
let after_meta = archive_metadata(&after);
for meta in archive_metadata(&before) {
prop_assert!(
after_meta.contains(&meta),
"metadata lost by the rewrite: {:?}\nsurvivors: {:?}",
meta,
after_meta
);
}
for line in before.header.iter().chain(before.trailing.iter()) {
prop_assert!(
line.trim().is_empty() || rewritten.lines().any(|l| l == line),
"carried line lost by the rewrite: {:?}\nrewritten: {:?}",
line,
rewritten
);
}
}
#[test]
fn p2_inbox_canonical_rewrite_preserves_content(
which in 0usize..64,
idx in 0usize..512,
mutation in arb_mutation(),
) {
let corpus = fixture_sources();
let source = &corpus[which % corpus.len()];
let damaged = mutate(source, idx, &mutation);
let (before, _) = parse_inbox(&damaged);
let rewritten = canon_inbox(&damaged);
let (after, _) = parse_inbox(&rewritten);
prop_assert_eq!(inbox_shape(&before), inbox_shape(&after));
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(128))]
#[test]
fn p3_track_model_survives_a_round_trip(model in arb_track_model()) {
let text = serialize_track(&model);
let parsed = parse_track(&text);
prop_assert_eq!(track_shape(&model), track_shape(&parsed));
}
#[test]
fn p3_no_task_is_lost(model in arb_track_model()) {
fn count(tasks: &[Task]) -> usize {
tasks.iter().map(|t| 1 + count(&t.subtasks)).sum()
}
let total = |t: &Track| -> usize {
t.nodes
.iter()
.filter_map(|n| match n {
TrackNode::Section { tasks, .. } => Some(count(tasks)),
TrackNode::Literal(_) => None,
})
.sum()
};
let text = serialize_track(&model);
let parsed = parse_track(&text);
prop_assert_eq!(total(&model), total(&parsed));
}
#[test]
fn p3_inbox_model_survives_a_round_trip(model in arb_inbox_model()) {
let text = serialize_inbox(&model);
let (parsed, _) = parse_inbox(&text);
prop_assert_eq!(inbox_shape(&model), inbox_shape(&parsed));
}
#[test]
fn p3_archive_model_survives_a_round_trip(model in arb_archive_model()) {
let text = serialize_archive(&model);
let parsed = parse_archive(&text);
prop_assert_eq!(archive_identities(&model), archive_identities(&parsed));
prop_assert_eq!(archive_metadata(&model), archive_metadata(&parsed));
prop_assert_eq!(model.eol, parsed.eol);
prop_assert_eq!(&text, &serialize_archive(&parsed));
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn p4_track_rewrites_converge(source in arb_soup()) {
if let Err((last, next)) = settle(&source, canon_track) {
return Err(TestCaseError::fail(format!(
"never settled in {MAX_REWRITES_TO_SETTLE} rewrites\nlast: {last:?}\nnext: {next:?}"
)));
}
}
#[test]
fn p4_archive_rewrites_converge(source in arb_soup()) {
if let Err((last, next)) = settle(&source, canon_archive) {
return Err(TestCaseError::fail(format!(
"never settled in {MAX_REWRITES_TO_SETTLE} rewrites\nlast: {last:?}\nnext: {next:?}"
)));
}
}
#[test]
fn p4_inbox_rewrites_converge(source in arb_soup()) {
if let Err((last, next)) = settle(&source, canon_inbox) {
return Err(TestCaseError::fail(format!(
"never settled in {MAX_REWRITES_TO_SETTLE} rewrites\nlast: {last:?}\nnext: {next:?}"
)));
}
}
#[test]
fn p4_mutated_fixture_rewrites_converge(
which in 0usize..64,
idx in 0usize..512,
mutation in arb_mutation(),
) {
let corpus = fixture_sources();
let source = &corpus[which % corpus.len()];
let damaged = mutate(source, idx, &mutation);
if let Err((last, next)) = settle(&damaged, canon_track) {
return Err(TestCaseError::fail(format!(
"never settled in {MAX_REWRITES_TO_SETTLE} rewrites\nlast: {last:?}\nnext: {next:?}"
)));
}
}
}
fn nonblank_lines(source: &str) -> Vec<&str> {
source.lines().filter(|l| !l.trim().is_empty()).collect()
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(512))]
#[test]
fn p5_a_plain_write_keeps_every_line(source in arb_soup()) {
let written = serialize_track(&parse_track(&source));
prop_assert_eq!(nonblank_lines(&source), nonblank_lines(&written));
}
#[test]
fn p5_an_archive_write_keeps_every_line(source in arb_soup()) {
let written = serialize_archive(&parse_archive(&source));
prop_assert_eq!(nonblank_lines(&source), nonblank_lines(&written));
}
#[test]
fn p5_an_archive_write_keeps_every_line_in_a_damaged_fixture(
which in 0usize..64,
idx in 0usize..512,
mutation in arb_mutation(),
) {
let corpus = fixture_sources();
let source = &corpus[which % corpus.len()];
let damaged = mutate(source, idx, &mutation);
let written = serialize_archive(&parse_archive(&damaged));
prop_assert_eq!(nonblank_lines(&damaged), nonblank_lines(&written));
}
#[test]
fn p5_a_plain_write_keeps_every_line_in_a_damaged_fixture(
which in 0usize..64,
idx in 0usize..512,
mutation in arb_mutation(),
) {
let corpus = fixture_sources();
let source = &corpus[which % corpus.len()];
let damaged = mutate(source, idx, &mutation);
let written = serialize_track(&parse_track(&damaged));
prop_assert_eq!(nonblank_lines(&damaged), nonblank_lines(&written));
}
#[test]
fn p5_an_inbox_write_keeps_every_line(source in arb_soup()) {
let written = serialize_inbox(&parse_inbox(&source).0);
prop_assert_eq!(nonblank_lines(&source), nonblank_lines(&written));
}
#[test]
fn p5_an_inbox_write_keeps_every_line_in_a_damaged_fixture(
which in 0usize..64,
idx in 0usize..512,
mutation in arb_mutation(),
) {
let corpus = fixture_sources();
let source = &corpus[which % corpus.len()];
let damaged = mutate(source, idx, &mutation);
let written = serialize_inbox(&parse_inbox(&damaged).0);
prop_assert_eq!(nonblank_lines(&damaged), nonblank_lines(&written));
}
#[test]
fn nothing_is_ever_dropped(source in arb_soup()) {
let (_, dropped) = parse_inbox(&source);
prop_assert!(dropped.is_empty(), "dropped {dropped:?} from {source:?}");
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn p6_a_write_keeps_the_line_ending(source in arb_soup()) {
let written = serialize_track(&parse_track(&source));
prop_assert_eq!(
LineEnding::detect(&source),
LineEnding::detect(&written),
"source: {:?}\nwritten: {:?}", source, written
);
}
#[test]
fn p6_an_inbox_write_keeps_the_line_ending(source in arb_soup()) {
let written = serialize_inbox(&parse_inbox(&source).0);
prop_assert_eq!(
LineEnding::detect(&source),
LineEnding::detect(&written),
"source: {:?}\nwritten: {:?}", source, written
);
}
#[test]
fn p6_an_archive_write_keeps_the_line_ending(source in arb_soup()) {
let written = serialize_archive(&parse_archive(&source));
prop_assert_eq!(
LineEnding::detect(&source),
LineEnding::detect(&written),
"source: {:?}\nwritten: {:?}", source, written
);
}
#[test]
fn p6_the_line_ending_is_a_fixpoint(source in arb_soup()) {
let once = serialize_track(&parse_track(&source));
let twice = serialize_track(&parse_track(&once));
prop_assert_eq!(once, twice);
}
#[test]
fn p6_the_archive_line_ending_is_a_fixpoint(source in arb_soup()) {
let once = serialize_archive(&parse_archive(&source));
let twice = serialize_archive(&parse_archive(&once));
prop_assert_eq!(once, twice);
}
}
#[test]
fn unclosed_fence_does_not_swallow_the_rest_of_the_file() {
let source = "\
# Track
## Backlog
- [ ] `T-001` Has an unclosed fence
- note:
```rust
let x = 1;
- [ ] `T-002` Sibling that must survive
## Done
- [x] `T-003` Done task that must survive";
let track = parse_track(source);
assert_eq!(track.backlog().len(), 2, "sibling task was swallowed");
assert_eq!(track.done().len(), 1, "done task was swallowed");
assert_eq!(track.backlog()[1].id.as_deref(), Some("T-002"));
assert_eq!(track.done()[0].id.as_deref(), Some("T-003"));
}
#[test]
fn orphaned_task_lines_survive_a_write() {
let cases: &[(&str, &str, &str)] = &[
(
"orphan directly after the section header",
"# Track\n\n## Backlog\n\n - [ ] `T-001` Orphan",
"T-001",
),
(
"orphan with no blank line after the header",
"# Track\n\n## Backlog\n - [ ] `T-001` Orphan",
"T-001",
),
(
"orphan followed by another section",
"# Track\n\n## Backlog\n\n - [ ] `T-001` Orphan\n\n## Done\n\n- [x] `T-002` Done",
"T-001",
),
(
"nesting one level past MAX_DEPTH, at the end",
"# Track\n\n## Backlog\n\n- [ ] `A` a\n - [ ] `B` b\n - [ ] `C` c\n - [ ] `D` d",
"`D`",
),
(
"nesting past MAX_DEPTH with a task following at top level",
"# Track\n\n## Backlog\n\n- [ ] `A` a\n - [ ] `B` b\n - [ ] `C` c\n - [ ] `D` d\n- [ ] `E` e",
"`D`",
),
];
for (label, source, needle) in cases {
let clean = serialize_track(&parse_track(source));
assert!(
clean.contains(needle),
"{label}: {needle} deleted by a verbatim write: {clean:?}"
);
let once = canon_track(source);
assert!(
once.contains(needle),
"{label}: {needle} deleted by a canonical rewrite: {once:?}"
);
let twice = canon_track(&once);
assert_eq!(once, twice, "{label}: canonical form is not stable");
}
}
#[test]
fn recovering_an_orphan_does_not_truncate_the_section() {
let source = "\
# Track
## Backlog
- [ ] `A` a
- [ ] `B` b
- [ ] `C` c
- [ ] `D` d
- [ ] `E` e";
let rewritten = canon_track(source);
for id in ["`A`", "`B`", "`C`", "`D`", "`E`"] {
assert!(rewritten.contains(id), "{id} lost: {rewritten:?}");
}
}
#[test]
fn deep_content_after_metadata_survives_a_write() {
let source = "\
# Track
## Backlog
- [ ] `T-001` Task
- added: 2025-05-14
stray deep content";
let rewritten = canon_track(source);
assert!(
rewritten.contains("stray deep content"),
"deep content deleted by a canonical rewrite: {rewritten:?}"
);
}
#[test]
fn single_line_note_keeps_surrounding_whitespace() {
let source = "\
# Track
## Backlog
- [ ] `T-001` Task
- note:
indented one-liner";
let once = canon_track(source);
let twice = canon_track(&once);
assert_eq!(once, twice, "note indentation changed on the second write");
let parsed = parse_track(&once);
let Some(Metadata::Note(note)) = parsed.backlog()[0].metadata.first() else {
panic!("expected a note, got {:?}", parsed.backlog()[0].metadata);
};
assert_eq!(
note, " indented one-liner",
"leading whitespace was dropped"
);
}
#[test]
fn multibyte_at_the_indent_boundary_does_not_panic() {
let source = "\
# Track
## Backlog
- [ ] `T-001` Task
- note:
§ünïcode at the slice boundary
§§ dedented mid-character
- [ ] `T-002` Sibling
";
let track = parse_track(source);
let _ = serialize_track(&track);
assert_eq!(track.backlog().len(), 2);
}
#[test]
fn a_stray_line_between_two_done_tasks_survives_a_write() {
let source = "\
# Main
## Done
- [x] `MAI-012` Sharded map lowering
- added: 2026-07-01
- resolved: 2026-07-20
**Shape.** A sharded `||> Vec.map` whose callback produces a per-row output.
- [x] `MAI-013` Unrelated finished work
- resolved: 2026-07-21
";
let plain = serialize_track(&parse_track(source));
assert_eq!(plain, source, "a plain write changed the file at all");
let rewritten = canon_track(source);
assert!(
rewritten.contains("**Shape.**"),
"stray line deleted by a canonical rewrite: {rewritten:?}"
);
assert_eq!(
rewritten,
canon_track(&rewritten),
"the recovered line did not settle"
);
}
#[test]
fn a_stray_line_under_a_subtask_survives_a_write() {
let source = "\
# Track
## Backlog
- [ ] `T-001` Parent
- [ ] `T-001.1` Subtask
stranded under the subtask
- [ ] `T-002` Sibling
";
assert_eq!(serialize_track(&parse_track(source)), source);
assert!(canon_track(source).contains("stranded under the subtask"));
}
#[test]
fn a_crlf_track_comes_back_crlf() {
let source = "# Main\r\n\r\n## Backlog\r\n\r\n\
- [ ] `M-001` One\r\n - added: 2025-05-01\r\n\r\n## Done\r\n";
let written = serialize_track(&parse_track(source));
assert_eq!(
written, source,
"a CRLF file must survive a write unchanged"
);
}
#[test]
fn a_crlf_inbox_comes_back_crlf() {
let source = "# Inbox\r\n\r\n- captured thing #tag\r\n";
let written = serialize_inbox(&parse_inbox(source).0);
assert_eq!(written, source);
}
#[test]
fn a_mostly_lf_file_stays_lf() {
let source = "# Main\n\n## Backlog\r\n\n- [ ] `M-001` One\n\n## Done\n";
let written = serialize_track(&parse_track(source));
assert!(
!written.contains('\r'),
"the majority ending wins, and it is LF here: {written:?}"
);
}
#[test]
fn a_dirtied_crlf_track_still_writes_crlf() {
let source = "# Main\r\n\r\n## Backlog\r\n\r\n- [ ] `M-001` One\r\n\r\n## Done\r\n";
let mut track = parse_track(source);
dirty_track(&mut track);
let written = serialize_track(&track);
assert_eq!(
LineEnding::detect(&written),
LineEnding::Crlf,
"{written:?}"
);
assert!(
!written.contains("\n\n\r"),
"no stray bare newlines: {written:?}"
);
}
#[test]
fn a_write_leaves_exactly_one_terminal_newline() {
for source in [
"# T\n\n## Backlog\n\n- [ ] `T-001` One",
"# T\n\n## Backlog\n\n- [ ] `T-001` One\n",
"# T\n\n## Backlog\n\n- [ ] `T-001` One\n\n\n",
] {
let once = canon_track(source);
let twice = canon_track(&once);
assert_eq!(
once, twice,
"a second write must change nothing: {source:?}"
);
assert!(once.ends_with('\n'), "{once:?}");
}
let padded = "# T\n\n## Backlog\n\n- [ ] `T-001` One\n\n\n";
assert_eq!(
canon_track(padded).matches('\n').count(),
padded.matches('\n').count(),
"trailing blank lines must survive a write intact"
);
}
#[test]
fn an_inbox_write_leaves_exactly_one_terminal_newline() {
for source in [
"# Inbox\n\n- one",
"# Inbox\n\n- one\n",
"# Inbox\n\n- one\n\n",
] {
let once = canon_inbox(source);
let twice = canon_inbox(&once);
assert_eq!(
once, twice,
"a second write must change nothing: {source:?}"
);
assert!(once.ends_with('\n'), "{once:?}");
}
}
#[test]
fn a_stray_line_above_the_first_task_survives_a_write() {
let source = "\
## Backlog
- added: 2025-05-14
- [ ] plain task
";
assert_eq!(serialize_track(&parse_track(source)), source);
assert!(canon_track(source).contains("- added: 2025-05-14"));
}
#[test]
fn a_stranded_run_settles_in_one_write() {
for source in [
"## Backlog\n- [ ] plain task\n\n ```\n\n- [ ] plain task\n",
"## Backlog\n- [ ] plain task\n\n ```\n\n - added: 2025-05-14\n- [ ] plain task\n",
] {
let once = serialize_track(&parse_track(source));
assert_eq!(
once,
serialize_track(&parse_track(&once)),
"a second write changed the file again: {source:?}"
);
assert!(
once.contains("```"),
"the stranded line itself must survive: {once:?}"
);
}
}