use std::sync::{Arc, LazyLock};
use papaya::HashMap as ConcurrentHashMap;
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
use regex::{Captures, Regex};
use serde::{Deserialize, Serialize};
use crate::errors::TaskPatchError;
use crate::wikilink::{
BlockScanner, LineKind, MarkupCursor, indent_width, is_reference_definition,
};
const DONE_STAMP_FORMAT: &str = "%Y-%m-%d %H:%M";
static TASK_LINE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^[ \t]*(?:[-*+]|\d+[.)])[ \t]+\[(?<marker>[ xX>-])\](?:[ \t]+|$)")
.expect("literal task-line regex is valid and cannot fail to compile")
});
static DATE_ANNOTATION: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"@(?<kind>due|done)\((?<value>[^)]*)\)")
.expect("literal date-annotation regex is valid and cannot fail to compile")
});
static DATETIME: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"^(?<y>\d{4})-(?<mo>\d{2})-(?<d>\d{2})(?:[ \t]+(?<h>\d{1,2}):(?<mi>\d{2})(?:[ \t]*(?<ap>[AaPp][Mm]))?)?$",
)
.expect("literal datetime regex is valid and cannot fail to compile")
});
static TAG: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?:^|\s)#(?<tag>[A-Za-z0-9_-]+)")
.expect("literal tag regex is valid and cannot fail to compile")
});
static TRAILING_MOVE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?:^|[ \t])(?<dir>[<>])[ \t]*(?<date>\d{4}-\d{2}-\d{2})[ \t]*$")
.expect("literal trailing-move regex is valid and cannot fail to compile")
});
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TaskStatus {
Open,
Done,
Canceled,
}
impl TaskStatus {
const fn marker_char(self) -> char {
match self {
Self::Open => ' ',
Self::Done => 'x',
Self::Canceled => '-',
}
}
}
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
)]
#[serde(rename_all = "lowercase")]
pub enum TaskPriority {
#[default]
Normal,
High,
Urgent,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TaskKind {
#[default]
Task,
Marker,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Task {
pub line: u32,
pub depth: u8,
pub kind: TaskKind,
pub status: TaskStatus,
pub priority: TaskPriority,
pub text: String,
pub marker_start: Option<u32>,
pub marker_end: Option<u32>,
pub tags: Vec<String>,
pub due: Option<NaiveDateTime>,
pub due_has_time: bool,
pub done: Option<NaiveDateTime>,
pub done_has_time: bool,
pub moved_to: Option<NaiveDate>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Annotations {
pub priority: TaskPriority,
pub tags: Vec<String>,
pub due: Option<NaiveDateTime>,
pub due_has_time: bool,
pub done: Option<NaiveDateTime>,
pub done_has_time: bool,
pub moved_to: Option<NaiveDate>,
}
#[derive(Debug, Clone)]
pub struct MarkerRule {
pattern: Regex,
}
impl MarkerRule {
pub fn new(markers: &[String]) -> Option<Self> {
let mut parts: Vec<&str> = markers
.iter()
.map(String::as_str)
.filter(|m| !m.is_empty())
.collect();
if parts.is_empty() {
return None;
}
parts.sort_by_key(|m| std::cmp::Reverse(m.len()));
let alternation = parts
.iter()
.map(|m| bounded_alternative(m))
.collect::<Vec<_>>()
.join("|");
Regex::new(&format!("(?:{alternation})"))
.ok()
.map(|pattern| Self { pattern })
}
pub fn cached(markers: &[String]) -> Option<Arc<Self>> {
type Compiled = ConcurrentHashMap<Box<[String]>, Option<Arc<MarkerRule>>>;
static COMPILED: LazyLock<Compiled> = LazyLock::new(ConcurrentHashMap::new);
let compiled = COMPILED.pin();
if let Some(hit) = compiled.get(markers) {
return hit.clone();
}
let rule = Self::new(markers).map(Arc::new);
compiled
.get_or_insert_with(markers.into(), || rule.clone())
.clone()
}
pub fn block_initial_match(&self, text: &str) -> Option<usize> {
self.pattern
.find(text)
.filter(|m| m.start() == 0)
.map(|m| m.end())
}
pub fn find_iter<'t>(
&'t self,
text: &'t str,
) -> impl Iterator<Item = std::ops::Range<usize>> + 't {
self.pattern.find_iter(text).map(|m| m.range())
}
pub fn find_in_line(&self, line: &str) -> Option<usize> {
find_marker_outside_markup(line, self).map(|span| span.start)
}
}
fn bounded_alternative(marker: &str) -> String {
let word_ish = |c: char| c.is_alphanumeric() || c == '_';
let mut out = String::with_capacity(marker.len() + 4);
if marker.chars().next().is_some_and(word_ish) {
out.push_str(r"\b");
}
out.push_str(®ex::escape(marker));
if marker.chars().next_back().is_some_and(word_ish) {
out.push_str(r"\b");
}
out
}
fn find_marker_outside_markup(line: &str, rule: &MarkerRule) -> Option<std::ops::Range<usize>> {
if is_reference_definition(line) {
return None;
}
let mut cursor = MarkupCursor::new(line);
rule.find_iter(line).find(|m| !cursor.excludes(m.start))
}
fn utf16_offset(s: &str, byte_offset: usize) -> u32 {
u32::try_from(s[..byte_offset].encode_utf16().count()).unwrap_or(u32::MAX)
}
pub fn parse_task_line(line: &str, line_number: u32) -> Option<Task> {
if !might_be_task(line) {
return None;
}
let (marker_at, raw_text) = match_task_line(line)?;
let status = match line.as_bytes().get(marker_at)? {
b' ' => TaskStatus::Open,
b'x' | b'X' => TaskStatus::Done,
_ => TaskStatus::Canceled,
};
let depth = u8::try_from(indent_width(line) / 2).unwrap_or(u8::MAX);
let (text, annotations) = strip_annotations(raw_text);
Some(Task {
line: line_number,
depth,
kind: TaskKind::Task,
status,
priority: annotations.priority,
text,
marker_start: None,
marker_end: None,
tags: annotations.tags,
due: annotations.due,
due_has_time: annotations.due_has_time,
done: annotations.done,
done_has_time: annotations.done_has_time,
moved_to: annotations.moved_to,
})
}
pub fn parse_marker_line(line: &str, line_number: u32, rule: &MarkerRule) -> Option<Task> {
find_marker_outside_markup(line, rule)?;
let depth = u8::try_from(indent_width(line) / 2).unwrap_or(u8::MAX);
let text = collapse_whitespace(line, |_| true);
let span = find_marker_outside_markup(&text, rule);
Some(Task {
line: line_number,
depth,
kind: TaskKind::Marker,
status: TaskStatus::Open,
priority: TaskPriority::Normal,
marker_start: span.as_ref().map(|s| utf16_offset(&text, s.start)),
marker_end: span.as_ref().map(|s| utf16_offset(&text, s.end)),
text,
tags: Vec::new(),
due: None,
due_has_time: false,
done: None,
done_has_time: false,
moved_to: None,
})
}
pub fn set_marker(line: &str, status: TaskStatus) -> Option<String> {
let (at, _) = match_task_line(line)?;
let mut out = String::with_capacity(line.len());
out.push_str(&line[..at]);
out.push(status.marker_char());
out.push_str(&line[at + 1..]);
Some(out)
}
pub fn set_status(line: &str, status: TaskStatus, now: Option<NaiveDateTime>) -> Option<String> {
let rewritten = set_marker(line, status)?;
let Some(now) = now else {
return Some(rewritten);
};
let (content, terminator) = split_line_terminator(&rewritten);
let updated = match status {
TaskStatus::Done => with_done_stamp(content, now),
TaskStatus::Open | TaskStatus::Canceled => without_done_stamp(content),
};
Some(format!("{updated}{terminator}"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PatchedSource {
pub source: String,
pub text: String,
}
pub fn patch_task_line(
source: &str,
line_number: u32,
expected: &str,
status: TaskStatus,
now: Option<NaiveDateTime>,
) -> Result<PatchedSource, TaskPatchError> {
let span = line_span(source, line_number)
.ok_or(TaskPatchError::LineOutOfRange { line: line_number })?;
let (content, terminator) = split_line_terminator(&source[span.clone()]);
if content != split_line_terminator(expected).0 {
return Err(TaskPatchError::Mismatch { line: line_number });
}
if parse_task_line(content, line_number).is_none() {
return Err(TaskPatchError::NotATask { line: line_number });
}
let text =
set_status(content, status, now).ok_or(TaskPatchError::NotATask { line: line_number })?;
let mut patched = String::with_capacity(source.len() + text.len());
patched.push_str(&source[..span.start]);
patched.push_str(&text);
patched.push_str(terminator);
patched.push_str(&source[span.end..]);
Ok(PatchedSource {
source: patched,
text,
})
}
fn line_span(source: &str, line_number: u32) -> Option<std::ops::Range<usize>> {
let target = usize::try_from(line_number).ok()?.checked_sub(1)?;
let mut start = 0;
for (index, line) in source.split_inclusive('\n').enumerate() {
if index == target {
return Some(start..start + line.len());
}
start += line.len();
}
None
}
fn split_line_terminator(line: &str) -> (&str, &str) {
let content = line.trim_end_matches(['\r', '\n']);
(content, &line[content.len()..])
}
fn with_done_stamp(content: &str, now: NaiveDateTime) -> String {
if done_annotations(content).next().is_some() {
return content.to_string();
}
let trimmed = content.trim_end_matches([' ', '\t']);
format!("{trimmed} @done({})", now.format(DONE_STAMP_FORMAT))
}
fn without_done_stamp(content: &str) -> String {
let mut out = String::with_capacity(content.len());
let mut copied = 0;
for range in done_annotations(content) {
let mut start = range.start;
while start > copied && matches!(content.as_bytes()[start - 1], b' ' | b'\t') {
start -= 1;
}
out.push_str(&content[copied..start]);
copied = range.end;
}
out.push_str(&content[copied..]);
out
}
fn done_annotations(text: &str) -> impl Iterator<Item = std::ops::Range<usize>> + '_ {
DATE_ANNOTATION.captures_iter(text).filter_map(|caps| {
if group(&caps, "kind") != Some("done") {
return None;
}
group(&caps, "value").and_then(parse_datetime)?;
Some(caps.get(0)?.range())
})
}
pub fn scan_source_tasks(source: &str) -> Vec<Task> {
scan_source_tasks_with_markers(source, None)
}
pub fn scan_source_tasks_with_markers(source: &str, markers: Option<&MarkerRule>) -> Vec<Task> {
let skip = frontmatter_line_count(source);
let mut blocks = BlockScanner::new();
source
.lines()
.enumerate()
.skip(skip)
.filter_map(|(index, line)| {
if blocks.classify(line) == LineKind::Code {
return None;
}
let number = u32::try_from(index + 1).unwrap_or(u32::MAX);
parse_task_line(line, number)
.or_else(|| markers.and_then(|rule| parse_marker_line(line, number, rule)))
})
.collect()
}
pub fn strip_annotations(text: &str) -> (String, Annotations) {
let mut annotations = Annotations::default();
let without_dates = strip_date_annotations(text, &mut annotations);
let without_move = strip_trailing_move(&without_dates, &mut annotations);
let without_tags = strip_tags(&without_move, &mut annotations);
let (display, priority) = collapse_taking_priority(&without_tags);
annotations.priority = priority;
(display, annotations)
}
const RUN_BOUNDARY: char = '\0';
const RUN_BOUNDARY_REPLACEMENT: char = '\u{fffd}';
pub fn strip_annotations_across_runs(runs: &[&str]) -> (Vec<String>, Annotations) {
match runs {
[] => (Vec::new(), Annotations::default()),
[only] => {
let (text, annotations) = strip_annotations(only);
(vec![text], annotations)
}
_ => {
let mut joined = String::with_capacity(runs.iter().map(|r| r.len() + 1).sum());
for (index, run) in runs.iter().enumerate() {
if index > 0 {
joined.push(RUN_BOUNDARY);
}
if run.contains(RUN_BOUNDARY) {
joined.extend(run.chars().map(|c| {
if c == RUN_BOUNDARY {
RUN_BOUNDARY_REPLACEMENT
} else {
c
}
}));
} else {
joined.push_str(run);
}
}
let (stripped, annotations) = strip_annotations(&joined);
let mut parts: Vec<String> = stripped.split(RUN_BOUNDARY).map(str::to_string).collect();
if parts.len() != runs.len() {
debug_assert!(false, "run boundaries were not preserved: {stripped:?}");
parts = std::iter::once(stripped.replace(RUN_BOUNDARY, " "))
.chain(std::iter::repeat_n(String::new(), runs.len() - 1))
.collect();
}
(parts, annotations)
}
}
}
fn might_be_task(line: &str) -> bool {
let rest = line.trim_start_matches([' ', '\t']).as_bytes();
if !matches!(rest.first(), Some(b'-' | b'*' | b'+' | b'0'..=b'9')) {
return false;
}
rest.windows(3)
.any(|w| w[0] == b'[' && matches!(w[1], b' ' | b'x' | b'X' | b'>' | b'-') && w[2] == b']')
}
fn group<'h>(caps: &Captures<'h>, name: &str) -> Option<&'h str> {
caps.name(name).map(|m| m.as_str())
}
fn match_task_line(line: &str) -> Option<(usize, &str)> {
if !might_be_task(line) {
return None;
}
let caps = TASK_LINE.captures(line)?;
let marker_at = caps.name("marker")?.start();
let text = &line[caps.get(0)?.end()..];
let text = text.strip_suffix('\n').unwrap_or(text);
if text.contains('\n') {
return None;
}
Some((marker_at, text))
}
fn strip_date_annotations(text: &str, annotations: &mut Annotations) -> String {
if !text.contains('@') {
return text.to_string();
}
DATE_ANNOTATION
.replace_all(text, |caps: &Captures| {
let Some((parsed, has_time)) = group(caps, "value").and_then(parse_datetime) else {
return caps[0].to_string();
};
match group(caps, "kind") {
Some("due") if annotations.due.is_none() => {
annotations.due = Some(parsed);
annotations.due_has_time = has_time;
}
Some("done") if annotations.done.is_none() => {
annotations.done = Some(parsed);
annotations.done_has_time = has_time;
}
_ => {}
}
" ".to_string()
})
.into_owned()
}
fn strip_trailing_move(text: &str, annotations: &mut Annotations) -> String {
let mut rest = text;
while ends_with_digit_ignoring_blanks(rest)
&& let Some(caps) = TRAILING_MOVE.captures(rest)
{
let Some(date) =
group(&caps, "date").and_then(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").ok())
else {
break;
};
if group(&caps, "dir") == Some(">") && annotations.moved_to.is_none() {
annotations.moved_to = Some(date);
}
let Some(whole) = caps.get(0) else { break };
rest = &rest[..whole.start()];
}
rest.to_string()
}
fn ends_with_digit_ignoring_blanks(text: &str) -> bool {
text.as_bytes()
.iter()
.rposition(|byte| !matches!(byte, b' ' | b'\t'))
.is_some_and(|at| text.as_bytes()[at].is_ascii_digit())
}
fn strip_tags(text: &str, annotations: &mut Annotations) -> String {
if !text.contains('#') {
return text.to_string();
}
TAG.replace_all(text, |caps: &Captures| {
if let Some(tag) = group(caps, "tag")
&& !annotations
.tags
.iter()
.any(|seen| seen.eq_ignore_ascii_case(tag))
{
annotations.tags.push(tag.to_string());
}
" ".to_string()
})
.into_owned()
}
fn collapse_whitespace(text: &str, mut keep: impl FnMut(&str) -> bool) -> String {
let mut display = String::with_capacity(text.len());
for token in text.split_whitespace() {
if !keep(token) {
continue;
}
if !display.is_empty() {
display.push(' ');
}
display.push_str(token);
}
display
}
fn collapse_taking_priority(text: &str) -> (String, TaskPriority) {
let mut priority = TaskPriority::Normal;
let display = collapse_whitespace(text, |token| match token {
"!!!" => {
priority = priority.max(TaskPriority::Urgent);
false
}
"!!" => {
priority = priority.max(TaskPriority::High);
false
}
_ => true,
});
(display, priority)
}
fn parse_datetime(value: &str) -> Option<(NaiveDateTime, bool)> {
let caps = DATETIME.captures(value.trim())?;
let date = NaiveDate::from_ymd_opt(
group(&caps, "y")?.parse().ok()?,
group(&caps, "mo")?.parse().ok()?,
group(&caps, "d")?.parse().ok()?,
)?;
let Some(hour_text) = group(&caps, "h") else {
return Some((date.and_time(NaiveTime::MIN), false));
};
let hour: u32 = hour_text.parse().ok()?;
let minute: u32 = group(&caps, "mi")?.parse().ok()?;
let hour24 = match group(&caps, "ap")
.and_then(|ap| ap.as_bytes().first())
.map(|b| b | 0x20)
{
None => hour,
Some(b'a') if (1..=12).contains(&hour) => hour % 12,
Some(_) if (1..=12).contains(&hour) => hour % 12 + 12,
Some(_) => return None,
};
Some((
date.and_time(NaiveTime::from_hms_opt(hour24, minute, 0)?),
true,
))
}
fn frontmatter_line_count(source: &str) -> usize {
let mut lines = source.lines();
if lines.next().map(str::trim_end) != Some("---") {
return 0;
}
lines
.position(|line| matches!(line.trim_end(), "---" | "..."))
.map_or(0, |index| index + 2)
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
fn dt(y: i32, m: u32, d: u32, h: u32, min: u32) -> NaiveDateTime {
NaiveDate::from_ymd_opt(y, m, d)
.and_then(|date| date.and_hms_opt(h, min, 0))
.expect("test datetime is valid")
}
const DEFAULT_MARKERS: &[&str] = &["TK", "TODO", "FIXME", "XXX"];
fn owned(markers: &[&str]) -> Vec<String> {
markers.iter().map(|m| (*m).to_string()).collect()
}
fn rule(markers: &[&str]) -> MarkerRule {
MarkerRule::new(&owned(markers)).expect("a non-empty marker list compiles")
}
fn find(line: &str) -> Option<usize> {
rule(DEFAULT_MARKERS).find_in_line(line)
}
fn parse(line: &str) -> Task {
parse_task_line(line, 1).unwrap_or_else(|| panic!("expected a task from {line:?}"))
}
#[test]
fn open_marker_parses() {
let task = parse("- [ ] open thing");
assert_eq!(task.status, TaskStatus::Open);
assert_eq!(task.text, "open thing");
assert_eq!(task.depth, 0);
assert_eq!(task.line, 1);
}
#[test]
fn lowercase_x_is_done() {
assert_eq!(parse("- [x] finished").status, TaskStatus::Done);
}
#[test]
fn uppercase_x_is_done() {
assert_eq!(parse("- [X] finished").status, TaskStatus::Done);
}
#[test]
fn dash_marker_is_canceled() {
let task = parse("* [-] this task was canceled");
assert_eq!(task.status, TaskStatus::Canceled);
assert_eq!(task.moved_to, None);
}
#[test]
fn angle_marker_is_canceled_with_moved_to() {
let task = parse("* [>] This task was moved to a specific date > 2026-08-04");
assert_eq!(task.status, TaskStatus::Canceled);
assert_eq!(task.text, "This task was moved to a specific date");
assert_eq!(task.moved_to, NaiveDate::from_ymd_opt(2026, 8, 4));
}
#[test]
fn all_bullet_styles_parse() {
for line in [
"- [ ] a",
"* [ ] a",
"+ [ ] a",
"1. [ ] a",
"1) [ ] a",
"42. [ ] a",
] {
assert_eq!(parse(line).text, "a", "bullet style {line:?}");
}
}
#[test]
fn tab_indent_sets_depth() {
assert_eq!(parse("\t- [ ] subtask").depth, 2);
assert_eq!(parse("\t\t- [ ] deeper").depth, 4);
assert_eq!(parse(" - [ ] two spaces").depth, 1);
assert_eq!(parse(" - [ ] four spaces").depth, 2);
}
#[test]
fn depth_saturates_instead_of_overflowing() {
let line = format!("{}- [ ] very deep", " ".repeat(1000));
assert_eq!(parse(&line).depth, u8::MAX);
}
#[test]
fn empty_task_text_is_still_a_task() {
let task = parse("- [ ]");
assert_eq!(task.text, "");
assert_eq!(task.status, TaskStatus::Open);
assert_eq!(parse("- [x] ").text, "");
}
#[test]
fn tab_after_marker_is_accepted() {
assert_eq!(parse("-\t[ ]\tbuy milk").text, "buy milk");
}
#[test]
fn non_tasks_are_rejected() {
for line in [
"- [] x", "- [ x] x", "-[ ] x", "- [y] x", "- [ ]x", "just some prose", "# A heading", "[ ]", "[ ] do a thing", "> [ ] blockquote", "", " ", ] {
assert!(
parse_task_line(line, 1).is_none(),
"expected {line:?} to be rejected"
);
}
}
#[test]
fn a_single_trailing_newline_is_tolerated() {
assert_eq!(parse("- [ ] buy milk\n").text, "buy milk");
assert_eq!(parse("- [ ] buy milk\r\n").text, "buy milk");
assert_eq!(
set_marker("- [ ] buy milk\n", TaskStatus::Done).as_deref(),
Some("- [x] buy milk\n")
);
}
#[test]
fn multi_line_input_is_refused_rather_than_welded_together() {
for input in ["- [ ] first\n- [ ] second", "- [ ] first\nprose\n"] {
assert!(parse_task_line(input, 1).is_none(), "{input:?}");
assert!(set_marker(input, TaskStatus::Done).is_none(), "{input:?}");
}
}
#[test]
fn might_be_task_never_rejects_a_real_task() {
for line in ["- [ ] a", "\t1) [X] b", " * [-] c", "99. [>] d"] {
assert!(might_be_task(line), "{line:?}");
assert!(parse_task_line(line, 1).is_some(), "{line:?}");
}
}
#[test]
fn due_date_only() {
let task = parse("- [ ] pay rent @due(2026-08-05)");
assert_eq!(task.text, "pay rent");
assert_eq!(task.due, Some(dt(2026, 8, 5, 0, 0)));
assert!(!task.due_has_time);
}
#[test]
fn due_with_24_hour_time() {
let task = parse("- [ ] standup @due(2026-08-05 14:30)");
assert_eq!(task.due, Some(dt(2026, 8, 5, 14, 30)));
assert!(task.due_has_time);
}
#[test]
fn due_with_am_pm_time() {
assert_eq!(
parse("- [ ] a @due(2026-08-05 03:00 PM)").due,
Some(dt(2026, 8, 5, 15, 0))
);
assert_eq!(
parse("- [ ] a @due(2026-08-05 3:00pm)").due,
Some(dt(2026, 8, 5, 15, 0))
);
assert_eq!(
parse("- [ ] a @due(2026-08-05 09:15 am)").due,
Some(dt(2026, 8, 5, 9, 15))
);
assert_eq!(
parse("- [ ] a @due(2026-08-05 12:00 AM)").due,
Some(dt(2026, 8, 5, 0, 0))
);
assert_eq!(
parse("- [ ] a @due(2026-08-05 12:00 PM)").due,
Some(dt(2026, 8, 5, 12, 0))
);
}
#[test]
fn done_annotation() {
let task = parse("- [x] shipped @done(2026-08-04 12:11 PM)");
assert_eq!(task.done, Some(dt(2026, 8, 4, 12, 11)));
assert!(task.done_has_time);
assert_eq!(task.due, None);
}
#[test]
fn invalid_dates_are_left_verbatim() {
for (line, expected) in [
("- [ ] a @due(next tuesday)", "a @due(next tuesday)"),
("- [ ] a @due(2026-13-45)", "a @due(2026-13-45)"),
("- [ ] a @due(2026-02-30)", "a @due(2026-02-30)"),
("- [ ] a @due()", "a @due()"),
("- [ ] a @due(2026-08-05 25:00)", "a @due(2026-08-05 25:00)"),
(
"- [ ] a @due(2026-08-05 14:00 PM)",
"a @due(2026-08-05 14:00 PM)",
),
("- [x] a @done(garbage)", "a @done(garbage)"),
] {
let task = parse(line);
assert_eq!(task.text, expected, "for {line:?}");
assert_eq!(task.due, None, "for {line:?}");
assert_eq!(task.done, None, "for {line:?}");
}
}
#[test]
fn first_annotation_of_a_kind_wins_and_all_are_stripped() {
let task = parse("- [ ] a @due(2026-08-05) b @due(2026-09-09) c");
assert_eq!(task.due, Some(dt(2026, 8, 5, 0, 0)));
assert_eq!(task.text, "a b c");
}
#[test]
fn tags_are_extracted_and_stripped() {
let task = parse("- [ ] review #work #urgent-ish #v2_final");
assert_eq!(task.text, "review");
assert_eq!(task.tags, ["work", "urgent-ish", "v2_final"]);
}
#[test]
fn tags_dedupe_case_insensitively_keeping_first_spelling() {
let task = parse("- [ ] a #Work #work #WORK #wOrK b");
assert_eq!(task.tags, ["Work"]);
assert_eq!(task.text, "a b");
}
#[test]
fn url_fragment_is_not_a_tag() {
let task = parse("- [ ] see page.md#anchor for details");
assert!(task.tags.is_empty());
assert_eq!(task.text, "see page.md#anchor for details");
}
#[test]
fn adjacent_hashes_only_yield_the_first_tag() {
let task = parse("- [ ] a #one#two b");
assert_eq!(task.tags, ["one"]);
assert_eq!(task.text, "a #two b");
}
#[test]
fn tag_stops_at_punctuation() {
let task = parse("- [ ] ping #bob, then go");
assert_eq!(task.tags, ["bob"]);
assert_eq!(task.text, "ping , then go");
}
#[test]
fn priorities_are_whitespace_delimited() {
assert_eq!(parse("- [ ] a !! b").priority, TaskPriority::High);
assert_eq!(parse("- [ ] a !!! b").priority, TaskPriority::Urgent);
assert_eq!(parse("- [ ] !! leading").priority, TaskPriority::High);
assert_eq!(parse("- [ ] trailing !!!").priority, TaskPriority::Urgent);
}
#[test]
fn non_delimited_bangs_are_not_priorities() {
for line in ["- [ ] wow!!", "- [ ] a!!b", "- [ ] wow!!!", "- [ ] !!!!"] {
let task = parse(line);
assert_eq!(task.priority, TaskPriority::Normal, "for {line:?}");
assert!(task.text.contains("!!"), "for {line:?}");
}
}
#[test]
fn highest_priority_wins() {
assert_eq!(parse("- [ ] a !! b !!! c").priority, TaskPriority::Urgent);
assert_eq!(parse("- [ ] a !!! b !! c").priority, TaskPriority::Urgent);
assert_eq!(parse("- [ ] a !! b !! c").priority, TaskPriority::High);
}
#[test]
fn priority_markers_are_stripped_from_text() {
assert_eq!(parse("- [ ] a !!! b").text, "a b");
}
#[test]
fn trailing_moved_from_marker_is_discarded() {
let task = parse("- [ ] carried over < 2026-08-01");
assert_eq!(task.text, "carried over");
assert_eq!(task.moved_to, None);
}
#[test]
fn both_trailing_move_markers_on_one_line() {
let task = parse("- [>] shuffled > 2026-08-04 < 2026-08-01");
assert_eq!(task.text, "shuffled");
assert_eq!(task.moved_to, NaiveDate::from_ymd_opt(2026, 8, 4));
}
#[test]
fn mid_line_angle_bracket_is_left_alone() {
for line in [
"- [ ] migrate 1.0 > 2.0 today",
"- [ ] a > b > c",
"- [ ] compare x>y",
] {
let task = parse(line);
assert_eq!(task.moved_to, None, "for {line:?}");
assert!(task.text.contains('>'), "for {line:?}");
}
}
#[test]
fn trailing_move_prefilter_never_rejects_a_match() {
for text in [
"moved > 2026-08-04",
"moved > 2026-08-04 ",
"moved >2026-08-04\t",
"moved < 2026-08-01",
"> 2026-08-04",
"nothing here",
"ends in a digit 42",
"trailing space ",
"",
" ",
"a > b",
] {
assert!(
!TRAILING_MOVE.is_match(text) || ends_with_digit_ignoring_blanks(text),
"prefilter rejected a line the regex matches: {text:?}"
);
}
assert!(!ends_with_digit_ignoring_blanks("nothing here"));
assert!(ends_with_digit_ignoring_blanks("moved > 2026-08-04 "));
}
#[test]
fn trailing_move_needs_a_real_date() {
let task = parse("- [ ] nope > 2026-13-45");
assert_eq!(task.moved_to, None);
assert_eq!(task.text, "nope > 2026-13-45");
}
#[test]
fn trailing_means_after_date_annotations_but_before_tags() {
let with_due = parse("- [ ] moved @due(2026-08-05) > 2026-08-04");
assert_eq!(with_due.moved_to, NaiveDate::from_ymd_opt(2026, 8, 4));
assert_eq!(with_due.text, "moved");
let with_tag = parse("- [ ] moved > 2026-08-04 #work");
assert_eq!(with_tag.moved_to, None);
assert_eq!(with_tag.text, "moved > 2026-08-04");
assert_eq!(with_tag.tags, ["work"]);
}
#[test]
fn every_annotation_on_one_line_in_several_orders() {
let expected_due = Some(dt(2026, 8, 5, 15, 0));
let expected_done = Some(dt(2026, 8, 4, 12, 11));
for line in [
"- [x] ship it !!! #work #ops @due(2026-08-05 03:00 PM) @done(2026-08-04 12:11 PM)",
"- [x] @due(2026-08-05 03:00 PM) ship it #work @done(2026-08-04 12:11 PM) !!! #ops",
"- [x] #work ship it @done(2026-08-04 12:11 PM) !!! #ops @due(2026-08-05 15:00)",
"- [x] !!! #work #ops @done(2026-08-04 12:11 PM) @due(2026-08-05 15:00) ship it",
] {
let task = parse(line);
assert_eq!(task.status, TaskStatus::Done, "for {line:?}");
assert_eq!(task.priority, TaskPriority::Urgent, "for {line:?}");
assert_eq!(task.tags, ["work", "ops"], "for {line:?}");
assert_eq!(task.due, expected_due, "for {line:?}");
assert_eq!(task.done, expected_done, "for {line:?}");
assert!(task.due_has_time && task.done_has_time, "for {line:?}");
assert_eq!(task.text, "ship it", "for {line:?}");
}
}
#[test]
fn whitespace_is_collapsed_in_display_text() {
assert_eq!(parse("- [ ] a b\tc").text, "a b c");
}
#[test]
fn spec_examples_parse_exactly() {
let first = parse("* [ ] this is a task due tomorrow @due(2026-08-05)");
assert_eq!(
first,
Task {
line: 1,
depth: 0,
kind: TaskKind::Task,
status: TaskStatus::Open,
priority: TaskPriority::Normal,
text: "this is a task due tomorrow".to_string(),
marker_start: None,
marker_end: None,
tags: vec![],
due: Some(dt(2026, 8, 5, 0, 0)),
due_has_time: false,
done: None,
done_has_time: false,
moved_to: None,
}
);
let second = parse("* [x] this task was done yesterday @done(2026-08-04 12:11 PM)");
assert_eq!(
second,
Task {
line: 1,
depth: 0,
kind: TaskKind::Task,
status: TaskStatus::Done,
priority: TaskPriority::Normal,
text: "this task was done yesterday".to_string(),
marker_start: None,
marker_end: None,
tags: vec![],
due: None,
due_has_time: false,
done: Some(dt(2026, 8, 4, 12, 11)),
done_has_time: true,
moved_to: None,
}
);
let third = parse("* [ ] this task is urgent !!! #hotlist #work @due(2026-08-04 03:00 PM)");
assert_eq!(
third,
Task {
line: 1,
depth: 0,
kind: TaskKind::Task,
status: TaskStatus::Open,
priority: TaskPriority::Urgent,
text: "this task is urgent".to_string(),
marker_start: None,
marker_end: None,
tags: vec!["hotlist".to_string(), "work".to_string()],
due: Some(dt(2026, 8, 4, 15, 0)),
due_has_time: true,
done: None,
done_has_time: false,
moved_to: None,
}
);
}
#[test]
fn spec_nested_subtasks_are_independent_tasks() {
let source =
"* [ ] parent task\n\t* [ ] broken down subtask 1\n\t* [ ] broken down subtask 2\n";
let tasks = scan_source_tasks(source);
assert_eq!(tasks.len(), 3);
assert_eq!(tasks[0].depth, 0);
assert_eq!(tasks[1].depth, 2);
assert_eq!(tasks[2].depth, 2);
assert_eq!(tasks[1].text, "broken down subtask 1");
}
#[test]
fn set_marker_rewrites_only_the_marker() {
assert_eq!(
set_marker("- [ ] buy milk", TaskStatus::Done).as_deref(),
Some("- [x] buy milk")
);
assert_eq!(
set_marker("- [x] buy milk", TaskStatus::Open).as_deref(),
Some("- [ ] buy milk")
);
assert_eq!(
set_marker("- [ ] buy milk", TaskStatus::Canceled).as_deref(),
Some("- [-] buy milk")
);
}
#[test]
fn set_marker_preserves_exotic_spacing_and_annotations() {
let line =
"\t\t 12) [>] do the thing !!! #work @due(2026-08-05 03:00 PM) > 2026-08-04";
let toggled = set_marker(line, TaskStatus::Done).expect("task line");
assert_eq!(
toggled,
"\t\t 12) [x] do the thing !!! #work @due(2026-08-05 03:00 PM) > 2026-08-04"
);
assert_eq!(toggled.len(), line.len());
}
#[test]
fn set_marker_round_trips() {
let line = " * [ ] a task @due(2026-08-05)";
let done = set_marker(line, TaskStatus::Done).expect("task line");
let back = set_marker(&done, TaskStatus::Open).expect("task line");
assert_eq!(back, line);
}
#[test]
fn set_marker_preserves_a_trailing_carriage_return() {
assert_eq!(
set_marker("- [ ] windows line\r", TaskStatus::Done).as_deref(),
Some("- [x] windows line\r")
);
}
#[test]
fn set_marker_returns_none_for_non_tasks() {
for line in ["# heading", "- not a task", "- [] x", "", "[ ] bare"] {
assert!(set_marker(line, TaskStatus::Done).is_none(), "{line:?}");
}
}
fn stamp() -> NaiveDateTime {
dt(2026, 8, 4, 14, 32)
}
fn stamped(line: &str, status: TaskStatus) -> String {
set_status(line, status, Some(stamp())).unwrap_or_else(|| panic!("task line: {line:?}"))
}
#[test]
fn completing_a_task_appends_a_done_stamp() {
assert_eq!(
stamped("- [ ] write the report !!", TaskStatus::Done),
"- [x] write the report !! @done(2026-08-04 14:32)"
);
}
#[test]
fn a_stamp_round_trips_through_the_parser() {
let task = parse(&stamped("- [ ] ship it", TaskStatus::Done));
assert_eq!(task.status, TaskStatus::Done);
assert_eq!(task.done, Some(stamp()));
assert!(task.done_has_time);
assert_eq!(task.text, "ship it");
}
#[test]
fn stamping_is_idempotent() {
let once = stamped("- [ ] ship it", TaskStatus::Done);
let twice = stamped(&once, TaskStatus::Done);
assert_eq!(twice, once, "a second completion must not stamp again");
assert_eq!(twice.matches("@done(").count(), 1);
}
#[test]
fn an_existing_stamp_is_left_at_the_time_the_author_wrote() {
let line = "- [x] ship it @done(2020-01-02 03:04)";
assert_eq!(stamped(line, TaskStatus::Done), line);
}
#[test]
fn reopening_a_task_removes_the_stamp() {
let done = stamped("- [ ] ship it", TaskStatus::Done);
assert_eq!(stamped(&done, TaskStatus::Open), "- [ ] ship it");
assert_eq!(stamped(&done, TaskStatus::Canceled), "- [-] ship it");
}
#[test]
fn removing_a_stamp_takes_its_leading_whitespace_but_nothing_else() {
assert_eq!(
stamped("- [x] a @done(2026-08-04 14:32) b", TaskStatus::Open),
"- [ ] a b"
);
assert_eq!(
stamped("- [x] a \t@done(2026-08-04 14:32)", TaskStatus::Open),
"- [ ] a"
);
assert_eq!(
stamped(
"\t2) [x] do it !!! #work @due(2026-08-05) @done(2026-08-04 14:32)",
TaskStatus::Open
),
"\t2) [ ] do it !!! #work @due(2026-08-05)"
);
}
#[test]
fn a_line_that_already_ends_in_annotations_keeps_them_all() {
let line = "- [ ] file taxes !!! #work #irs @due(2026-08-05 09:00)";
let done = stamped(line, TaskStatus::Done);
assert_eq!(
done,
"- [x] file taxes !!! #work #irs @due(2026-08-05 09:00) @done(2026-08-04 14:32)"
);
let task = parse(&done);
assert_eq!(task.text, "file taxes");
assert_eq!(task.priority, TaskPriority::Urgent);
assert_eq!(task.tags, ["work", "irs"]);
assert_eq!(task.due, Some(dt(2026, 8, 5, 9, 0)));
assert_eq!(task.done, Some(stamp()));
assert_eq!(stamped(&done, TaskStatus::Open), line);
}
#[test]
fn a_trailing_move_marker_survives_a_stamp() {
let done = stamped("- [ ] shuffled > 2026-08-04", TaskStatus::Done);
assert_eq!(done, "- [x] shuffled > 2026-08-04 @done(2026-08-04 14:32)");
let task = parse(&done);
assert_eq!(task.text, "shuffled");
assert_eq!(task.moved_to, NaiveDate::from_ymd_opt(2026, 8, 4));
}
#[test]
fn stamping_does_not_pile_up_trailing_whitespace() {
assert_eq!(
stamped("- [ ] ship it \t ", TaskStatus::Done),
"- [x] ship it @done(2026-08-04 14:32)"
);
}
#[test]
fn an_unparseable_done_payload_is_prose_not_a_stamp() {
let done = stamped("- [ ] a @done(sometime)", TaskStatus::Done);
assert_eq!(done, "- [x] a @done(sometime) @done(2026-08-04 14:32)");
assert_eq!(
stamped(&done, TaskStatus::Open),
"- [ ] a @done(sometime)",
"only the recognised stamp is removed"
);
}
#[test]
fn a_line_terminator_is_preserved_through_a_stamp() {
for terminator in ["\n", "\r\n", ""] {
let line = format!("- [ ] ship it{terminator}");
assert_eq!(
stamped(&line, TaskStatus::Done),
format!("- [x] ship it @done(2026-08-04 14:32){terminator}"),
"terminator {terminator:?}"
);
let done = format!("- [x] ship it @done(2026-08-04 14:32){terminator}");
assert_eq!(
stamped(&done, TaskStatus::Open),
format!("- [ ] ship it{terminator}"),
"terminator {terminator:?}"
);
}
}
#[test]
fn without_a_clock_set_status_is_exactly_set_marker() {
for line in [
"- [ ] ship it",
"- [x] ship it @done(2026-08-04 14:32)",
"- [ ] a @done(2020-01-01 00:00) b\r\n",
] {
for status in [TaskStatus::Open, TaskStatus::Done, TaskStatus::Canceled] {
assert_eq!(
set_status(line, status, None),
set_marker(line, status),
"{line:?} -> {status:?}"
);
}
}
}
#[test]
fn set_status_returns_none_for_non_tasks() {
for line in ["# heading", "- not a task", "", "[ ] bare"] {
assert!(
set_status(line, TaskStatus::Done, Some(stamp())).is_none(),
"{line:?}"
);
}
}
fn patch(
source: &str,
line: u32,
expected: &str,
status: TaskStatus,
) -> Result<PatchedSource, TaskPatchError> {
patch_task_line(source, line, expected, status, Some(stamp()))
}
#[test]
fn patch_rewrites_only_the_addressed_line() {
let source = "# Notes\n\n- [ ] first\n- [ ] second\n";
let patched = patch(source, 4, "- [ ] second", TaskStatus::Done).expect("patched");
assert_eq!(patched.text, "- [x] second @done(2026-08-04 14:32)");
assert_eq!(
patched.source,
"# Notes\n\n- [ ] first\n- [x] second @done(2026-08-04 14:32)\n"
);
}
#[test]
fn patch_preserves_crlf_terminators() {
let source = "- [ ] first\r\n- [ ] second\r\n";
let patched = patch(source, 1, "- [ ] first", TaskStatus::Done).expect("patched");
assert_eq!(
patched.source,
"- [x] first @done(2026-08-04 14:32)\r\n- [ ] second\r\n"
);
assert_eq!(patched.text, "- [x] first @done(2026-08-04 14:32)");
}
#[test]
fn patch_neither_adds_nor_removes_a_trailing_newline() {
let source = "- [ ] last line has no newline";
let patched = patch(source, 1, source, TaskStatus::Canceled).expect("patched");
assert_eq!(patched.source, "- [-] last line has no newline");
assert!(!patched.source.ends_with('\n'));
let terminated = "- [ ] terminated\n";
let patched =
patch(terminated, 1, "- [ ] terminated", TaskStatus::Canceled).expect("patched");
assert_eq!(patched.source, "- [-] terminated\n");
}
#[test]
fn patch_accepts_expected_with_or_without_its_terminator() {
let source = "- [ ] ship it\r\n";
for expected in ["- [ ] ship it", "- [ ] ship it\n", "- [ ] ship it\r\n"] {
assert!(
patch(source, 1, expected, TaskStatus::Done).is_ok(),
"{expected:?}"
);
}
}
#[test]
fn patch_rejects_a_line_that_changed_underneath_the_client() {
let source = "- [ ] the text changed\n";
assert_eq!(
patch(source, 1, "- [ ] what the client saw", TaskStatus::Done),
Err(TaskPatchError::Mismatch { line: 1 })
);
assert_eq!(
patch(source, 1, "- [ ] the text changed", TaskStatus::Done),
Err(TaskPatchError::Mismatch { line: 1 })
);
}
#[test]
fn patch_rejects_a_line_number_the_file_does_not_have() {
let source = "- [ ] only line\n";
assert_eq!(
patch(source, 2, "", TaskStatus::Done),
Err(TaskPatchError::LineOutOfRange { line: 2 })
);
assert_eq!(
patch(source, 0, "- [ ] only line", TaskStatus::Done),
Err(TaskPatchError::LineOutOfRange { line: 0 })
);
assert_eq!(
patch("", 1, "", TaskStatus::Done),
Err(TaskPatchError::LineOutOfRange { line: 1 })
);
}
#[test]
fn patch_rejects_a_line_that_is_not_a_task() {
let source = "# Notes\n- [ ] a task\n";
assert_eq!(
patch(source, 1, "# Notes", TaskStatus::Done),
Err(TaskPatchError::NotATask { line: 1 })
);
}
#[test]
fn patch_addresses_the_same_lines_the_scanner_reports() {
let source = concat!(
"---\ntitle: T\n---\n\n", "- [ ] first\n", "\n```\n- [ ] fake\n```\n", "- [ ] second\n", );
for task in scan_source_tasks(source) {
let line = source
.lines()
.nth(usize::try_from(task.line).expect("fits") - 1)
.expect("line exists");
let patched = patch(source, task.line, line, TaskStatus::Done).expect("patched");
assert!(patched.source.contains("- [ ] fake"), "code stayed code");
assert_eq!(
patched.source.matches("@done(").count(),
1,
"exactly one line was touched"
);
}
}
#[test]
fn line_span_covers_every_line_including_its_terminator() {
let source = "a\r\nbb\nccc";
assert_eq!(line_span(source, 1).map(|r| &source[r]), Some("a\r\n"));
assert_eq!(line_span(source, 2).map(|r| &source[r]), Some("bb\n"));
assert_eq!(line_span(source, 3).map(|r| &source[r]), Some("ccc"));
assert_eq!(line_span(source, 4), None);
}
#[test]
fn scan_reports_one_based_line_numbers() {
let source = "# Title\n\n- [ ] first\n- [x] second\n";
let tasks = scan_source_tasks(source);
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0].line, 3);
assert_eq!(tasks[1].line, 4);
}
#[test]
fn scan_skips_backtick_fenced_code() {
let source = "- [ ] real\n\n```\n- [ ] fake\n```\n\n- [x] also real\n";
let tasks = scan_source_tasks(source);
assert_eq!(tasks.iter().map(|t| t.line).collect::<Vec<_>>(), vec![1, 7]);
}
#[test]
fn scan_skips_tilde_fenced_code_with_info_string() {
let source = "~~~markdown\n- [ ] fake\n~~~\n- [ ] real\n";
let tasks = scan_source_tasks(source);
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].line, 4);
}
#[test]
fn scan_respects_longer_closing_fence_rule() {
let source = "````\n- [ ] fake\n```\n- [ ] still fake\n````\n- [ ] real\n";
let tasks = scan_source_tasks(source);
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].text, "real");
}
#[test]
fn scan_skips_fenced_code_with_an_info_string() {
let source = "```rust\n// - [ ] fake\n- [ ] fake too\n```\n- [ ] real\n";
let tasks = scan_source_tasks(source);
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].line, 5);
}
#[test]
fn scan_treats_an_unclosed_fence_as_running_to_end_of_file() {
let source = "```\n- [ ] fake\n- [ ] also fake\n";
assert!(scan_source_tasks(source).is_empty());
}
#[test]
fn scan_skips_indented_code_blocks() {
let source = "Some prose.\n\n - [ ] fake\n\n- [ ] real\n";
let tasks = scan_source_tasks(source);
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].line, 5);
}
#[test]
fn scan_skips_tab_indented_code_blocks() {
let source = "Some prose.\n\n\t- [ ] fake\n\n- [ ] real\n";
let tasks = scan_source_tasks(source);
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].text, "real");
}
#[test]
fn indented_code_inside_a_list_item_is_a_known_false_positive() {
let source = "- item\n\n - [ ] this really is code\n";
let tasks = scan_source_tasks(source);
assert_eq!(tasks.len(), 1, "known limitation: code inside a list item");
assert_eq!(tasks[0].text, "this really is code");
}
#[test]
fn scan_skips_yaml_frontmatter() {
let source = "---\ntitle: Notes\nchecklist: \"- [ ] fake\"\n---\n\n- [ ] real\n";
let tasks = scan_source_tasks(source);
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].line, 6);
assert_eq!(tasks[0].text, "real");
}
#[test]
fn scan_skips_frontmatter_closed_with_dots() {
let source = "---\nfake: \"- [ ] no\"\n...\n- [ ] real\n";
let tasks = scan_source_tasks(source);
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].line, 4);
}
#[test]
fn unclosed_frontmatter_opener_is_treated_as_a_thematic_break() {
let source = "---\n\n- [ ] real\n";
let tasks = scan_source_tasks(source);
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].line, 3);
}
#[test]
fn a_later_triple_dash_is_not_frontmatter() {
let source = "# Title\n\n---\n\n- [ ] real\n";
let tasks = scan_source_tasks(source);
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].line, 5);
}
#[test]
fn scan_line_numbers_survive_multi_line_blocks() {
let source = concat!(
"---\n", "title: T\n", "---\n", "\n", "- [ ] first\n", "\n", "```js\n", "// - [ ] fake\n", "\n", "```\n", "\n", " indented\n", " - [ ] fake\n", "\n", "- [x] second\n", );
let tasks = scan_source_tasks(source);
assert_eq!(
tasks.iter().map(|t| t.line).collect::<Vec<_>>(),
vec![5, 15]
);
assert_eq!(tasks[1].status, TaskStatus::Done);
}
#[test]
fn scan_handles_crlf_line_endings() {
let source = "- [ ] first\r\n```\r\n- [ ] fake\r\n```\r\n- [x] second\r\n";
let tasks = scan_source_tasks(source);
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0].text, "first");
assert_eq!(tasks[1].text, "second");
assert_eq!(tasks[1].line, 5);
}
#[test]
fn scan_of_empty_source_is_empty() {
assert!(scan_source_tasks("").is_empty());
assert!(scan_source_tasks("\n\n\n").is_empty());
}
#[test]
fn scan_finds_tasks_inside_a_blockquote_fence_correctly() {
let source = "> ```\n> - [ ] fake\n> ```\n- [ ] real\n";
let tasks = scan_source_tasks(source);
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].text, "real");
}
fn across(runs: &[&str]) -> (Vec<String>, Annotations) {
strip_annotations_across_runs(runs)
}
#[test]
fn runs_keep_the_whitespace_that_separates_inline_formatting() {
let (runs, ann) = across(&["fix ", "this", " #bug"]);
assert_eq!(runs, ["fix ", "this", ""]);
assert_eq!(ann.tags, ["bug"]);
}
#[test]
fn a_single_run_matches_strip_annotations_exactly() {
let (runs, ann) = across(&["ship it !!! #work @due(2026-08-05)"]);
let (text, expected) = strip_annotations("ship it !!! #work @due(2026-08-05)");
assert_eq!(runs, [text]);
assert_eq!(ann, expected);
}
#[test]
fn an_empty_run_list_yields_nothing() {
let (runs, ann) = across(&[]);
assert!(runs.is_empty());
assert_eq!(ann, Annotations::default());
}
#[test]
fn every_annotation_kind_is_found_across_run_boundaries() {
let (runs, ann) = across(&[
"do ",
"the",
" thing !! #work @due(2026-08-05) > 2026-08-04",
]);
assert_eq!(runs, ["do ", "the", " thing"]);
assert_eq!(ann.priority, TaskPriority::High);
assert_eq!(ann.tags, ["work"]);
assert_eq!(ann.due, Some(dt(2026, 8, 5, 0, 0)));
assert_eq!(ann.moved_to, NaiveDate::from_ymd_opt(2026, 8, 4));
}
#[test]
fn a_run_boundary_is_not_whitespace_so_it_cannot_invent_a_tag() {
let (runs, ann) = across(&["", "a", "#work"]);
assert!(ann.tags.is_empty());
assert_eq!(runs.concat(), "a#work");
}
#[test]
fn a_literal_boundary_character_cannot_desynchronise_the_split() {
let (runs, ann) = across(&["a\u{0}b ", "c", " #t"]);
assert_eq!(runs.len(), 3, "run count must be preserved");
assert_eq!(runs, ["a\u{fffd}b ", "c", ""]);
assert_eq!(ann.tags, ["t"]);
}
#[test]
fn status_and_priority_serialize_lowercase() {
let task = parse("- [x] a !!! @due(2026-08-05 15:00)");
let json = serde_json::to_value(&task).expect("task serializes");
assert_eq!(json["status"], "done");
assert_eq!(json["priority"], "urgent");
assert_eq!(json["due"], "2026-08-05T15:00:00");
assert_eq!(json["moved_to"], serde_json::Value::Null);
}
#[test]
fn status_and_priority_deserialize_lowercase() {
let status: TaskStatus = serde_json::from_str("\"canceled\"").expect("status");
assert_eq!(status, TaskStatus::Canceled);
let priority: TaskPriority = serde_json::from_str("\"high\"").expect("priority");
assert_eq!(priority, TaskPriority::High);
}
#[test]
fn the_default_markers_match_at_the_start_of_a_block() {
let rule = rule(DEFAULT_MARKERS);
assert_eq!(rule.block_initial_match("TK"), Some(2));
assert_eq!(rule.block_initial_match("TK rewrite this"), Some(2));
assert_eq!(rule.block_initial_match("TODO foo"), Some(4));
assert_eq!(rule.block_initial_match("FIXME(name)"), Some(5));
assert_eq!(rule.block_initial_match("XXX:"), Some(3));
assert_eq!(rule.block_initial_match("TKTK"), None);
assert_eq!(rule.block_initial_match("TODOs"), None);
assert_eq!(rule.block_initial_match("Tk"), None);
assert_eq!(rule.block_initial_match("todo"), None);
assert_eq!(rule.block_initial_match("Tomato"), None);
}
#[test]
fn a_block_initial_match_must_be_at_the_very_start() {
let rule = rule(DEFAULT_MARKERS);
assert_eq!(rule.block_initial_match("see TODO here"), None);
}
#[test]
fn an_empty_marker_list_compiles_to_no_rule() {
assert!(MarkerRule::new(&[]).is_none());
assert!(MarkerRule::new(&["".to_string()]).is_none());
}
#[test]
fn caching_hands_back_the_same_rule_for_the_same_markers() {
let markers = owned(DEFAULT_MARKERS);
let first = MarkerRule::cached(&markers).expect("the default markers compile");
let second = MarkerRule::cached(&markers).expect("the default markers compile");
assert!(Arc::ptr_eq(&first, &second));
}
#[test]
fn caching_keys_on_the_marker_list_not_the_process() {
let custom = MarkerRule::cached(&owned(&["NOTE"])).expect("one marker compiles");
let default = MarkerRule::cached(&owned(DEFAULT_MARKERS)).expect("defaults compile");
assert!(custom.find_in_line("NOTE check this").is_some());
assert!(custom.find_in_line("TODO check this").is_none());
assert!(default.find_in_line("TODO check this").is_some());
}
#[test]
fn caching_remembers_a_marker_list_that_compiles_to_nothing() {
assert!(MarkerRule::cached(&[]).is_none());
assert!(MarkerRule::cached(&[]).is_none());
}
#[test]
fn regex_metacharacters_in_a_marker_are_matched_literally() {
let rule = rule(&["FOO(", "BAR"]);
assert_eq!(rule.block_initial_match("BAR foo"), Some(3));
assert_eq!(rule.block_initial_match("FOO(bar)"), Some(4));
assert_eq!(rule.block_initial_match("Tomato"), None);
}
#[test]
fn a_marker_ending_in_punctuation_does_not_require_a_word_after_it() {
let rule = rule(&["TODO:"]);
let line = "see TODO: here";
assert_eq!(rule.find_in_line(line), line.find("TODO:"));
assert_eq!(rule.block_initial_match("TODO: here"), Some(5));
}
#[test]
fn a_marker_starting_with_punctuation_does_not_require_a_word_before_it() {
let rule = rule(&["@todo"]);
assert_eq!(rule.find_in_line(" @todo "), Some(1));
assert_eq!(rule.find_in_line("x@todo"), Some(1));
}
#[test]
fn the_longest_marker_wins_regardless_of_configuration_order() {
for order in [["TODO", "TODOMAYBE"], ["TODOMAYBE", "TODO"]] {
let rule = rule(&order);
assert_eq!(
rule.block_initial_match("TODOMAYBE later"),
Some(9),
"for {order:?}"
);
}
}
#[test]
fn a_marker_embedded_in_prose_is_found() {
let line = "The market fell 10% (source: TK).";
assert_eq!(find(line), line.find("TK"));
}
#[test]
fn a_marker_inside_an_inline_code_span_is_not_found() {
assert_eq!(find("run `TODO` later"), None);
}
#[test]
fn a_marker_after_an_inline_code_span_is_found() {
let line = "run `x` then TODO";
assert_eq!(find(line), line.find("TODO"));
}
#[test]
fn a_longer_backtick_span_hides_a_shorter_run_inside_it() {
assert_eq!(find("``a ` TODO ` b``"), None);
}
#[test]
fn an_unmatched_backtick_does_not_swallow_the_rest_of_the_line() {
let line = "a ` b TODO";
assert_eq!(find(line), line.find("TODO"));
}
#[test]
fn a_marker_inside_a_wikilink_target_is_not_found() {
assert_eq!(find("see [[TODO list]] here"), None);
}
#[test]
fn a_marker_in_a_wikilink_alias_is_found() {
let line = "[[page|TODO fix]]";
assert_eq!(find(line), line.find("TODO"));
}
#[test]
fn an_unclosed_double_bracket_does_not_hide_a_later_marker() {
let line = "[[page and TODO";
assert_eq!(find(line), line.find("TODO"));
}
#[test]
fn a_marker_in_a_link_destination_is_not_found() {
assert_eq!(find("[link](/TODO/page)"), None);
}
#[test]
fn a_marker_in_a_link_title_is_not_found() {
assert_eq!(find("[link](/page \"TODO\")"), None);
}
#[test]
fn a_marker_in_link_text_is_found() {
let line = "[TODO fix](/page)";
assert_eq!(find(line), line.find("TODO"));
}
#[test]
fn a_marker_in_image_alt_text_is_found_but_not_in_its_destination() {
let line = "";
assert_eq!(find(line), line.find("TODO"));
assert_eq!(find(""), None);
}
#[test]
fn a_link_destination_with_balanced_parentheses_is_skipped_whole() {
let line = "[x](/a(TODO)b) TODO";
assert_eq!(find(line), line.rfind("TODO"));
}
#[test]
fn an_unclosed_link_destination_does_not_hide_a_later_marker() {
let line = "[x](/a and TODO";
assert_eq!(find(line), line.find("TODO"));
}
#[test]
fn a_marker_inside_an_autolink_is_not_found() {
assert_eq!(find("<https://example.com/TODO>"), None);
}
#[test]
fn a_marker_inside_an_inline_html_tag_is_not_found() {
assert_eq!(find("a <!--TODO--> b"), None);
let line = "<kbd>TODO</kbd>";
assert_eq!(find(line), line.find("TODO"));
}
#[test]
fn a_less_than_sign_in_prose_is_not_mistaken_for_a_tag() {
let line = "a < b TODO > c";
assert_eq!(find(line), line.find("TODO"));
}
#[test]
fn a_marker_in_a_bare_url_is_found_the_way_the_renderer_sees_it() {
let line = "see https://example.com/TODO now";
assert_eq!(find(line), line.find("TODO"));
}
#[test]
fn a_reference_link_definition_never_yields_a_marker() {
assert_eq!(find("[TODO]: /some/path"), None);
assert_eq!(find("[label]: /TODO/path \"TODO\""), None);
assert_eq!(find(" [label]: /TODO/path"), None);
}
#[test]
fn a_footnote_definition_is_not_a_reference_definition() {
let line = "[^1]: TODO fix this";
assert_eq!(find(line), line.find("TODO"));
}
#[test]
fn the_first_qualifying_marker_wins_when_an_excluded_one_comes_first() {
let line = "`TODO` then FIXME";
assert_eq!(find(line), line.find("FIXME"));
}
fn marker(line: &str) -> Task {
parse_marker_line(line, 1, &rule(DEFAULT_MARKERS))
.unwrap_or_else(|| panic!("expected a marker from {line:?}"))
}
#[test]
fn a_marker_line_becomes_an_open_normal_priority_pseudo_task() {
let found = marker("The market fell 10% (source: TK).");
assert_eq!(found.kind, TaskKind::Marker);
assert_eq!(found.status, TaskStatus::Open);
assert_eq!(found.priority, TaskPriority::Normal);
assert_eq!(found.line, 1);
assert!(found.tags.is_empty());
assert_eq!(found.due, None);
assert_eq!(found.done, None);
assert_eq!(found.moved_to, None);
assert!(!found.due_has_time && !found.done_has_time);
let rule = rule(DEFAULT_MARKERS);
assert!(parse_marker_line("ordinary prose", 1, &rule).is_none());
assert!(parse_marker_line("call `TODO()` later", 1, &rule).is_none());
}
#[test]
fn marker_text_is_the_whole_line_whitespace_collapsed() {
assert_eq!(
marker(" TK check this\tagain ").text,
"TK check this again"
);
assert_eq!(
marker("The market fell 10% (source: TK).").text,
"The market fell 10% (source: TK)."
);
}
#[test]
fn marker_text_keeps_tags_priority_and_date_annotations_verbatim() {
let found = marker("TODO cross-link #docs !! before @due(2026-08-05)");
assert_eq!(
found.text,
"TODO cross-link #docs !! before @due(2026-08-05)"
);
assert!(found.tags.is_empty());
assert_eq!(found.priority, TaskPriority::Normal);
assert_eq!(found.due, None);
}
fn marker_word(found: &Task) -> String {
let (start, end) = (
found.marker_start.expect("a marker carries a span") as usize,
found.marker_end.expect("a marker carries a span") as usize,
);
let units: Vec<u16> = found.text.encode_utf16().collect();
String::from_utf16(&units[start..end]).expect("the span is on a boundary")
}
#[test]
fn a_marker_span_covers_the_marker_word_in_the_display_text() {
let found = marker("The market fell 10% (source: TK).");
assert_eq!((found.marker_start, found.marker_end), (Some(29), Some(31)));
assert_eq!(marker_word(&found), "TK");
}
#[test]
fn a_marker_span_is_in_utf16_units_so_non_ascii_before_it_does_not_shift_it() {
let found = marker("café costs 5€ — TODO check");
assert_eq!(marker_word(&found), "TODO");
assert_eq!((found.marker_start, found.marker_end), (Some(16), Some(20)));
assert_ne!(
found.marker_start.unwrap() as usize,
found.text.find("TODO").unwrap()
);
}
#[test]
fn a_checkbox_task_carries_no_marker_span() {
let task = parse_task_line("- [ ] TODO looks like a marker but is a task", 1).unwrap();
assert_eq!(task.marker_start, None);
assert_eq!(task.marker_end, None);
}
#[test]
fn a_marker_span_survives_whitespace_collapsing() {
let found = marker("foo TODO bar");
assert_eq!(found.text, "foo TODO bar");
assert_eq!((found.marker_start, found.marker_end), (Some(4), Some(8)));
assert_eq!(marker_word(&found), "TODO");
}
#[test]
fn a_marker_span_points_at_the_occurrence_the_rule_accepted() {
let found = marker("Set `TODO` in config and TK fix it");
assert_eq!(marker_word(&found), "TK");
assert_eq!((found.marker_start, found.marker_end), (Some(25), Some(27)));
}
#[test]
fn marker_depth_comes_from_indentation_like_a_task() {
assert_eq!(marker("TK top level").depth, 0);
assert_eq!(marker(" TK two spaces").depth, 1);
assert_eq!(marker(" TK four spaces").depth, 2);
assert_eq!(marker("\tTK one tab").depth, 2);
assert_eq!(marker("- TK inside a bullet").depth, 0);
}
#[test]
fn a_marker_line_cannot_be_patched() {
let source = "notes\nTODO: write this up\n";
let error = patch_task_line(
source,
2,
"TODO: write this up",
TaskStatus::Done,
Some(dt(2026, 8, 4, 14, 32)),
)
.expect_err("a marker line is not patchable");
assert_eq!(error, TaskPatchError::NotATask { line: 2 });
assert!(set_marker("TODO: write this up", TaskStatus::Done).is_none());
assert!(set_status("TODO: write this up", TaskStatus::Done, None).is_none());
}
fn scan_with_markers(source: &str) -> Vec<Task> {
scan_source_tasks_with_markers(source, Some(&rule(DEFAULT_MARKERS)))
}
#[test]
fn a_checkbox_line_that_also_says_todo_is_one_task_not_two() {
let found = scan_with_markers("- [ ] TODO: ship it\n");
assert_eq!(found.len(), 1, "checkbox wins, and yields one entry");
assert_eq!(found[0].kind, TaskKind::Task);
assert_eq!(found[0].text, "TODO: ship it");
assert_eq!(found[0].status, TaskStatus::Open);
}
#[test]
fn scanning_without_a_marker_rule_matches_the_old_behaviour() {
let source = concat!(
"# TODO list\n",
"\n",
"- [ ] real task\n",
"The market fell 10% (source: TK).\n",
"- [x] FIXME later\n",
);
assert_eq!(
scan_source_tasks(source),
scan_source_tasks_with_markers(source, None)
);
assert!(scan_with_markers(source).len() > scan_source_tasks(source).len());
}
#[test]
fn markers_and_tasks_come_back_in_source_order() {
let source = concat!(
"TK intro needs a source\n", "\n", "- [ ] real task\n", "prose with a FIXME here\n", "- [x] done thing\n", );
let found = scan_with_markers(source);
assert_eq!(
found.iter().map(|t| (t.line, t.kind)).collect::<Vec<_>>(),
vec![
(1, TaskKind::Marker),
(3, TaskKind::Task),
(4, TaskKind::Marker),
(5, TaskKind::Task),
]
);
}
#[test]
fn markers_are_skipped_inside_fenced_code() {
let source =
"TK real\n\n```rust\n// TODO: fake\nlet x = 1; // FIXME fake\n```\n\nXXX also real\n";
let found = scan_with_markers(source);
assert_eq!(found.iter().map(|t| t.line).collect::<Vec<_>>(), vec![1, 8]);
let indented = "Some prose.\n\n TODO: fake\n\nTK real\n";
assert_eq!(
scan_with_markers(indented)
.iter()
.map(|t| t.line)
.collect::<Vec<_>>(),
vec![5]
);
}
#[test]
fn markers_are_skipped_inside_yaml_frontmatter() {
let source = "---\ntitle: TODO pick a title\nstatus: TK\n---\n\nTK real\n";
let found = scan_with_markers(source);
assert_eq!(found.len(), 1);
assert_eq!(found[0].line, 6);
assert_eq!(found[0].text, "TK real");
}
fn taskish_text() -> impl Strategy<Value = String> {
proptest::collection::vec(
prop::sample::select(vec![
"a", "b", " ", "\t", "#", "@", "!", "(", ")", "[", "]", "-", "*", "+", ">", "<",
"x", "X", "due", "done", "2026", "08", "05", "12", ":", ".", "0", "1", "PM", "am",
]),
0..40,
)
.prop_map(|parts| parts.concat())
}
fn markerish_line() -> impl Strategy<Value = String> {
prop_oneof![
".*",
proptest::collection::vec(
prop::sample::select(vec![
"a", " ", "`", "[", "]", "(", ")", "<", ">", "|", "\"", "\\", ":", "!", "TK",
"TODO", "FIXME", "XXX", "todo", "é",
]),
0..40,
)
.prop_map(|parts| parts.concat()),
]
}
proptest! {
#[test]
fn a_returned_marker_offset_always_lands_on_a_marker(line in markerish_line()) {
if let Some(at) = rule(DEFAULT_MARKERS).find_in_line(&line) {
prop_assert!(
line.is_char_boundary(at),
"offset {} splits a character in {:?}", at, line
);
prop_assert!(
DEFAULT_MARKERS.iter().any(|m| line[at..].starts_with(m)),
"offset {} in {:?} is not a marker", at, line
);
}
}
#[test]
fn a_marker_wrapped_in_a_code_span_is_never_found(
prefix in "[a-z ]{0,20}",
suffix in "[a-z ]{0,20}",
) {
let line = format!("{prefix}`x TODO y`{suffix}");
prop_assert_eq!(rule(DEFAULT_MARKERS).find_in_line(&line), None);
}
#[test]
fn finding_a_marker_never_panics_on_arbitrary_input(line in markerish_line()) {
let rule = rule(DEFAULT_MARKERS);
let _ = rule.find_in_line(&line);
let _ = rule.block_initial_match(&line);
let _ = rule.find_iter(&line).count();
}
#[test]
fn scanning_with_markers_never_panics_on_arbitrary_input(s in ".*") {
let rule = rule(DEFAULT_MARKERS);
let _ = scan_source_tasks_with_markers(&s, Some(&rule));
let _ = parse_marker_line(&s, 1, &rule);
}
#[test]
fn every_source_line_yields_at_most_one_entry(source in markerish_line()) {
let with_newlines = source.replace(' ', "\n");
let found = scan_source_tasks_with_markers(
&with_newlines,
Some(&rule(DEFAULT_MARKERS)),
);
for pair in found.windows(2) {
prop_assert!(
pair[0].line < pair[1].line,
"line {} repeated or out of order in {:?}",
pair[1].line,
with_newlines
);
}
}
#[test]
fn parse_task_line_never_panics_on_arbitrary_input(s in ".*") {
let _ = parse_task_line(&s, 1);
let _ = set_marker(&s, TaskStatus::Done);
let _ = scan_source_tasks(&s);
}
#[test]
fn parse_task_line_never_panics_on_taskish_input(s in taskish_text()) {
let _ = parse_task_line(&s, 1);
let _ = set_marker(&s, TaskStatus::Done);
}
#[test]
fn display_text_keeps_no_recognised_annotation(s in taskish_text()) {
let line = format!("- [ ] {s}");
if let Some(task) = parse_task_line(&line, 1) {
for caps in DATE_ANNOTATION.captures_iter(&task.text) {
let value = group(&caps, "value").unwrap_or("");
prop_assert!(
parse_datetime(value).is_none(),
"recognised annotation survived in {:?}",
task.text
);
}
prop_assert!(
!task.text.split_whitespace().any(|t| t == "!!" || t == "!!!"),
"standalone priority marker survived in {:?}",
task.text
);
prop_assert_eq!(task.text.trim(), &task.text, "text is not trimmed");
}
}
#[test]
fn constructed_annotations_are_always_stripped(
body in "[a-z ]{0,24}",
year in 2000i32..2100,
month in 1u32..=12,
day in 1u32..=28,
hour in 0u32..24,
minute in 0u32..60,
urgent in any::<bool>(),
tag in "[A-Za-z][A-Za-z0-9_-]{0,10}",
) {
let bangs = if urgent { "!!!" } else { "!!" };
let line = format!(
"- [ ] {body} @due({year:04}-{month:02}-{day:02} {hour:02}:{minute:02}) {bangs} #{tag}"
);
let task = parse_task_line(&line, 1).expect("constructed line is a task");
prop_assert!(!task.text.contains("@due("));
prop_assert!(!task.text.contains('#'));
prop_assert!(!task.text.split_whitespace().any(|t| t == bangs));
prop_assert_eq!(task.tags, vec![tag]);
prop_assert_eq!(
task.priority,
if urgent { TaskPriority::Urgent } else { TaskPriority::High }
);
prop_assert_eq!(task.due, Some(dt(year, month, day, hour, minute)));
prop_assert!(task.due_has_time);
}
#[test]
fn set_marker_is_a_one_byte_edit_that_round_trips(
indent in "[ \t]{0,6}",
bullet in prop::sample::select(vec!["-", "*", "+", "1.", "7)"]),
gap in "[ \t]{1,3}",
marker in prop::sample::select(vec![' ', 'x', 'X', '-', '>']),
body in "[a-zA-Z0-9 #@!()-]{0,40}",
status in prop::sample::select(vec![
TaskStatus::Open,
TaskStatus::Done,
TaskStatus::Canceled,
]),
) {
let line = format!("{indent}{bullet}{gap}[{marker}]{gap}{body}");
let original = parse_task_line(&line, 1).expect("constructed line is a task");
let rewritten = set_marker(&line, status).expect("constructed line is a task");
prop_assert_eq!(rewritten.len(), line.len(), "marker edit changed the length");
prop_assert_eq!(
rewritten.bytes().zip(line.bytes()).filter(|(a, b)| a != b).count() <= 1,
true,
"more than the marker byte changed"
);
let reparsed = parse_task_line(&rewritten, 1).expect("rewritten line is still a task");
prop_assert_eq!(reparsed.status, status);
prop_assert_eq!(&reparsed.text, &original.text);
prop_assert_eq!(&reparsed.tags, &original.tags);
prop_assert_eq!(reparsed.depth, original.depth);
prop_assert_eq!(reparsed.priority, original.priority);
prop_assert_eq!(reparsed.due, original.due);
}
#[test]
fn stamping_and_unstamping_restores_the_original_line(
indent in "[ \t]{0,4}",
bullet in prop::sample::select(vec!["-", "*", "+", "1.", "7)"]),
body in "[a-zA-Z0-9#@!>< -]{0,40}",
terminator in prop::sample::select(vec!["", "\n", "\r\n"]),
reopen_as in prop::sample::select(vec![TaskStatus::Open, TaskStatus::Canceled]),
) {
let content = format!("{indent}{bullet} [ ] {body}");
let line = format!("{}{terminator}", content.trim_end());
prop_assume!(parse_task_line(&line, 1).is_some());
prop_assume!(done_annotations(&line).next().is_none());
let now = dt(2026, 8, 4, 14, 32);
let done = set_status(&line, TaskStatus::Done, Some(now)).expect("task line");
prop_assert_eq!(
parse_task_line(&done, 1).and_then(|t| t.done),
Some(now),
"the stamp must be readable back out of {:?}", done
);
let reopened = set_status(&done, reopen_as, Some(now)).expect("task line");
let expected = set_marker(&line, reopen_as).expect("task line");
prop_assert_eq!(reopened, expected);
}
#[test]
fn trailing_move_prefilter_is_sound(s in taskish_text()) {
prop_assert!(
!TRAILING_MOVE.is_match(&s) || ends_with_digit_ignoring_blanks(&s),
"prefilter rejected a match in {s:?}"
);
}
#[test]
fn splicing_runs_preserves_their_count(
runs in proptest::collection::vec(taskish_text(), 0..6)
) {
let borrowed: Vec<&str> = runs.iter().map(String::as_str).collect();
let (out, _) = strip_annotations_across_runs(&borrowed);
prop_assert_eq!(out.len(), runs.len());
}
#[test]
fn splicing_agrees_with_single_string_stripping_on_plain_text(
words in proptest::collection::vec("[a-z]{1,6}", 1..6),
split_at in 0usize..6,
) {
let joined = words.join(" ");
let at = split_at.min(words.len());
let (head, tail) = words.split_at(at);
let runs = [
format!("{} ", head.join(" ")),
tail.join(" "),
];
let borrowed: Vec<&str> = runs.iter().map(String::as_str).collect();
let (out, _) = strip_annotations_across_runs(&borrowed);
let collapsed: String = out.concat().split_whitespace().collect::<Vec<_>>().join(" ");
let (single, _) = strip_annotations(&joined);
prop_assert_eq!(collapsed, single);
}
#[test]
fn scanned_line_numbers_address_the_right_line(source in taskish_text()) {
let with_newlines = source.replace('.', "\n");
let lines: Vec<&str> = with_newlines.lines().collect();
for task in scan_source_tasks(&with_newlines) {
let index = usize::try_from(task.line).unwrap_or(usize::MAX);
let Some(line) = index.checked_sub(1).and_then(|i| lines.get(i)) else {
return Err(TestCaseError::fail("line number out of range"));
};
let reparsed = parse_task_line(line, task.line);
prop_assert_eq!(reparsed.as_ref(), Some(&task));
}
}
}
}