1use std::sync::{Arc, LazyLock};
69
70use papaya::HashMap as ConcurrentHashMap;
71
72use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
73use regex::{Captures, Regex};
74use serde::{Deserialize, Serialize};
75
76use crate::errors::TaskPatchError;
77use crate::wikilink::{
78 BlockScanner, LineKind, MarkupCursor, indent_width, is_reference_definition,
79};
80
81const DONE_STAMP_FORMAT: &str = "%Y-%m-%d %H:%M";
87
88static TASK_LINE: LazyLock<Regex> = LazyLock::new(|| {
103 Regex::new(r"^[ \t]*(?:[-*+]|\d+[.)])[ \t]+\[(?<marker>[ xX>-])\](?:[ \t]+|$)")
104 .expect("literal task-line regex is valid and cannot fail to compile")
105});
106
107static DATE_ANNOTATION: LazyLock<Regex> = LazyLock::new(|| {
110 Regex::new(r"@(?<kind>due|done)\((?<value>[^)]*)\)")
111 .expect("literal date-annotation regex is valid and cannot fail to compile")
112});
113
114static DATETIME: LazyLock<Regex> = LazyLock::new(|| {
117 Regex::new(
118 r"^(?<y>\d{4})-(?<mo>\d{2})-(?<d>\d{2})(?:[ \t]+(?<h>\d{1,2}):(?<mi>\d{2})(?:[ \t]*(?<ap>[AaPp][Mm]))?)?$",
119 )
120 .expect("literal datetime regex is valid and cannot fail to compile")
121});
122
123static TAG: LazyLock<Regex> = LazyLock::new(|| {
126 Regex::new(r"(?:^|\s)#(?<tag>[A-Za-z0-9_-]+)")
127 .expect("literal tag regex is valid and cannot fail to compile")
128});
129
130static TRAILING_MOVE: LazyLock<Regex> = LazyLock::new(|| {
134 Regex::new(r"(?:^|[ \t])(?<dir>[<>])[ \t]*(?<date>\d{4}-\d{2}-\d{2})[ \t]*$")
135 .expect("literal trailing-move regex is valid and cannot fail to compile")
136});
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
143#[serde(rename_all = "lowercase")]
144pub enum TaskStatus {
145 Open,
147 Done,
149 Canceled,
151}
152
153impl TaskStatus {
154 const fn marker_char(self) -> char {
160 match self {
161 Self::Open => ' ',
162 Self::Done => 'x',
163 Self::Canceled => '-',
164 }
165 }
166}
167
168#[derive(
174 Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
175)]
176#[serde(rename_all = "lowercase")]
177pub enum TaskPriority {
178 #[default]
180 Normal,
181 High,
183 Urgent,
185}
186
187#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
195#[serde(rename_all = "lowercase")]
196pub enum TaskKind {
197 #[default]
199 Task,
200 Marker,
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
206pub struct Task {
207 pub line: u32,
209 pub depth: u8,
211 pub kind: TaskKind,
213 pub status: TaskStatus,
215 pub priority: TaskPriority,
217 pub text: String,
219 pub marker_start: Option<u32>,
227 pub marker_end: Option<u32>,
229 pub tags: Vec<String>,
231 pub due: Option<NaiveDateTime>,
233 pub due_has_time: bool,
235 pub done: Option<NaiveDateTime>,
237 pub done_has_time: bool,
239 pub moved_to: Option<NaiveDate>,
241}
242
243#[derive(Debug, Clone, Default, PartialEq, Eq)]
245pub struct Annotations {
246 pub priority: TaskPriority,
248 pub tags: Vec<String>,
250 pub due: Option<NaiveDateTime>,
252 pub due_has_time: bool,
254 pub done: Option<NaiveDateTime>,
256 pub done_has_time: bool,
258 pub moved_to: Option<NaiveDate>,
260}
261
262#[derive(Debug, Clone)]
301pub struct MarkerRule {
302 pattern: Regex,
303}
304
305impl MarkerRule {
306 pub fn new(markers: &[String]) -> Option<Self> {
316 let mut parts: Vec<&str> = markers
317 .iter()
318 .map(String::as_str)
319 .filter(|m| !m.is_empty())
320 .collect();
321 if parts.is_empty() {
322 return None;
323 }
324 parts.sort_by_key(|m| std::cmp::Reverse(m.len()));
326
327 let alternation = parts
328 .iter()
329 .map(|m| bounded_alternative(m))
330 .collect::<Vec<_>>()
331 .join("|");
332 Regex::new(&format!("(?:{alternation})"))
336 .ok()
337 .map(|pattern| Self { pattern })
338 }
339
340 pub fn cached(markers: &[String]) -> Option<Arc<Self>> {
363 type Compiled = ConcurrentHashMap<Box<[String]>, Option<Arc<MarkerRule>>>;
366
367 static COMPILED: LazyLock<Compiled> = LazyLock::new(ConcurrentHashMap::new);
368
369 let compiled = COMPILED.pin();
370 if let Some(hit) = compiled.get(markers) {
371 return hit.clone();
372 }
373 let rule = Self::new(markers).map(Arc::new);
376 compiled
377 .get_or_insert_with(markers.into(), || rule.clone())
378 .clone()
379 }
380
381 pub fn block_initial_match(&self, text: &str) -> Option<usize> {
387 self.pattern
392 .find(text)
393 .filter(|m| m.start() == 0)
394 .map(|m| m.end())
395 }
396
397 pub fn find_iter<'t>(
399 &'t self,
400 text: &'t str,
401 ) -> impl Iterator<Item = std::ops::Range<usize>> + 't {
402 self.pattern.find_iter(text).map(|m| m.range())
403 }
404
405 pub fn find_in_line(&self, line: &str) -> Option<usize> {
412 find_marker_outside_markup(line, self).map(|span| span.start)
413 }
414}
415
416fn bounded_alternative(marker: &str) -> String {
420 let word_ish = |c: char| c.is_alphanumeric() || c == '_';
421 let mut out = String::with_capacity(marker.len() + 4);
422
423 if marker.chars().next().is_some_and(word_ish) {
424 out.push_str(r"\b");
425 }
426 out.push_str(®ex::escape(marker));
427 if marker.chars().next_back().is_some_and(word_ish) {
428 out.push_str(r"\b");
429 }
430 out
431}
432
433fn find_marker_outside_markup(line: &str, rule: &MarkerRule) -> Option<std::ops::Range<usize>> {
453 if is_reference_definition(line) {
456 return None;
457 }
458 let mut cursor = MarkupCursor::new(line);
459 rule.find_iter(line).find(|m| !cursor.excludes(m.start))
462}
463
464fn utf16_offset(s: &str, byte_offset: usize) -> u32 {
473 u32::try_from(s[..byte_offset].encode_utf16().count()).unwrap_or(u32::MAX)
474}
475
476pub fn parse_task_line(line: &str, line_number: u32) -> Option<Task> {
506 if !might_be_task(line) {
507 return None;
508 }
509 let (marker_at, raw_text) = match_task_line(line)?;
510 let status = match line.as_bytes().get(marker_at)? {
511 b' ' => TaskStatus::Open,
512 b'x' | b'X' => TaskStatus::Done,
513 _ => TaskStatus::Canceled,
515 };
516 let depth = u8::try_from(indent_width(line) / 2).unwrap_or(u8::MAX);
519 let (text, annotations) = strip_annotations(raw_text);
520
521 Some(Task {
522 line: line_number,
523 depth,
524 kind: TaskKind::Task,
525 status,
526 priority: annotations.priority,
527 text,
528 marker_start: None,
530 marker_end: None,
531 tags: annotations.tags,
532 due: annotations.due,
533 due_has_time: annotations.due_has_time,
534 done: annotations.done,
535 done_has_time: annotations.done_has_time,
536 moved_to: annotations.moved_to,
537 })
538}
539
540pub fn parse_marker_line(line: &str, line_number: u32, rule: &MarkerRule) -> Option<Task> {
595 find_marker_outside_markup(line, rule)?;
598 let depth = u8::try_from(indent_width(line) / 2).unwrap_or(u8::MAX);
600 let text = collapse_whitespace(line, |_| true);
602 let span = find_marker_outside_markup(&text, rule);
608
609 Some(Task {
610 line: line_number,
611 depth,
612 kind: TaskKind::Marker,
613 status: TaskStatus::Open,
614 priority: TaskPriority::Normal,
615 marker_start: span.as_ref().map(|s| utf16_offset(&text, s.start)),
616 marker_end: span.as_ref().map(|s| utf16_offset(&text, s.end)),
617 text,
618 tags: Vec::new(),
619 due: None,
620 due_has_time: false,
621 done: None,
622 done_has_time: false,
623 moved_to: None,
624 })
625}
626
627pub fn set_marker(line: &str, status: TaskStatus) -> Option<String> {
646 let (at, _) = match_task_line(line)?;
647 let mut out = String::with_capacity(line.len());
648 out.push_str(&line[..at]);
649 out.push(status.marker_char());
650 out.push_str(&line[at + 1..]);
652 Some(out)
653}
654
655pub fn set_status(line: &str, status: TaskStatus, now: Option<NaiveDateTime>) -> Option<String> {
699 let rewritten = set_marker(line, status)?;
700 let Some(now) = now else {
701 return Some(rewritten);
702 };
703
704 let (content, terminator) = split_line_terminator(&rewritten);
705 let updated = match status {
706 TaskStatus::Done => with_done_stamp(content, now),
707 TaskStatus::Open | TaskStatus::Canceled => without_done_stamp(content),
708 };
709 Some(format!("{updated}{terminator}"))
710}
711
712#[derive(Debug, Clone, PartialEq, Eq)]
714pub struct PatchedSource {
715 pub source: String,
717 pub text: String,
719}
720
721pub fn patch_task_line(
757 source: &str,
758 line_number: u32,
759 expected: &str,
760 status: TaskStatus,
761 now: Option<NaiveDateTime>,
762) -> Result<PatchedSource, TaskPatchError> {
763 let span = line_span(source, line_number)
764 .ok_or(TaskPatchError::LineOutOfRange { line: line_number })?;
765 let (content, terminator) = split_line_terminator(&source[span.clone()]);
766
767 if content != split_line_terminator(expected).0 {
768 return Err(TaskPatchError::Mismatch { line: line_number });
769 }
770 if parse_task_line(content, line_number).is_none() {
773 return Err(TaskPatchError::NotATask { line: line_number });
774 }
775 let text =
776 set_status(content, status, now).ok_or(TaskPatchError::NotATask { line: line_number })?;
777
778 let mut patched = String::with_capacity(source.len() + text.len());
779 patched.push_str(&source[..span.start]);
780 patched.push_str(&text);
781 patched.push_str(terminator);
782 patched.push_str(&source[span.end..]);
783
784 Ok(PatchedSource {
785 source: patched,
786 text,
787 })
788}
789
790pub(crate) fn line_span(source: &str, line_number: u32) -> Option<std::ops::Range<usize>> {
797 let target = usize::try_from(line_number).ok()?.checked_sub(1)?;
798 let mut start = 0;
799 for (index, line) in source.split_inclusive('\n').enumerate() {
800 if index == target {
801 return Some(start..start + line.len());
802 }
803 start += line.len();
804 }
805 None
806}
807
808pub(crate) fn split_line_terminator(line: &str) -> (&str, &str) {
814 let content = line.trim_end_matches(['\r', '\n']);
815 (content, &line[content.len()..])
816}
817
818fn with_done_stamp(content: &str, now: NaiveDateTime) -> String {
820 if done_annotations(content).next().is_some() {
821 return content.to_string();
822 }
823 let trimmed = content.trim_end_matches([' ', '\t']);
826 format!("{trimmed} @done({})", now.format(DONE_STAMP_FORMAT))
827}
828
829fn without_done_stamp(content: &str) -> String {
832 let mut out = String::with_capacity(content.len());
833 let mut copied = 0;
834
835 for range in done_annotations(content) {
836 let mut start = range.start;
837 while start > copied && matches!(content.as_bytes()[start - 1], b' ' | b'\t') {
838 start -= 1;
839 }
840 out.push_str(&content[copied..start]);
841 copied = range.end;
842 }
843 out.push_str(&content[copied..]);
844 out
845}
846
847fn done_annotations(text: &str) -> impl Iterator<Item = std::ops::Range<usize>> + '_ {
853 DATE_ANNOTATION.captures_iter(text).filter_map(|caps| {
854 if group(&caps, "kind") != Some("done") {
855 return None;
856 }
857 group(&caps, "value").and_then(parse_datetime)?;
858 Some(caps.get(0)?.range())
859 })
860}
861
862pub fn scan_source_tasks(source: &str) -> Vec<Task> {
887 scan_source_tasks_with_markers(source, None)
888}
889
890pub fn scan_source_tasks_with_markers(source: &str, markers: Option<&MarkerRule>) -> Vec<Task> {
929 let skip = frontmatter_line_count(source);
930 let mut blocks = BlockScanner::new();
931
932 source
945 .lines()
946 .enumerate()
947 .skip(skip)
948 .filter_map(|(index, line)| {
949 if blocks.classify(line) == LineKind::Code {
952 return None;
953 }
954 let number = u32::try_from(index + 1).unwrap_or(u32::MAX);
955 parse_task_line(line, number)
959 .or_else(|| markers.and_then(|rule| parse_marker_line(line, number, rule)))
960 })
961 .collect()
962}
963
964pub fn strip_annotations(text: &str) -> (String, Annotations) {
990 let mut annotations = Annotations::default();
991
992 let without_dates = strip_date_annotations(text, &mut annotations);
993 let without_move = strip_trailing_move(&without_dates, &mut annotations);
994 let without_tags = strip_tags(&without_move, &mut annotations);
995 let (display, priority) = collapse_taking_priority(&without_tags);
996 annotations.priority = priority;
997
998 (display, annotations)
999}
1000
1001const RUN_BOUNDARY: char = '\0';
1009
1010const RUN_BOUNDARY_REPLACEMENT: char = '\u{fffd}';
1013
1014pub fn strip_annotations_across_runs(runs: &[&str]) -> (Vec<String>, Annotations) {
1046 match runs {
1047 [] => (Vec::new(), Annotations::default()),
1048 [only] => {
1049 let (text, annotations) = strip_annotations(only);
1050 (vec![text], annotations)
1051 }
1052 _ => {
1053 let mut joined = String::with_capacity(runs.iter().map(|r| r.len() + 1).sum());
1054 for (index, run) in runs.iter().enumerate() {
1055 if index > 0 {
1056 joined.push(RUN_BOUNDARY);
1057 }
1058 if run.contains(RUN_BOUNDARY) {
1059 joined.extend(run.chars().map(|c| {
1060 if c == RUN_BOUNDARY {
1061 RUN_BOUNDARY_REPLACEMENT
1062 } else {
1063 c
1064 }
1065 }));
1066 } else {
1067 joined.push_str(run);
1068 }
1069 }
1070
1071 let (stripped, annotations) = strip_annotations(&joined);
1072 let mut parts: Vec<String> = stripped.split(RUN_BOUNDARY).map(str::to_string).collect();
1073
1074 if parts.len() != runs.len() {
1079 debug_assert!(false, "run boundaries were not preserved: {stripped:?}");
1080 parts = std::iter::once(stripped.replace(RUN_BOUNDARY, " "))
1081 .chain(std::iter::repeat_n(String::new(), runs.len() - 1))
1082 .collect();
1083 }
1084 (parts, annotations)
1085 }
1086 }
1087}
1088
1089fn might_be_task(line: &str) -> bool {
1095 let rest = line.trim_start_matches([' ', '\t']).as_bytes();
1096 if !matches!(rest.first(), Some(b'-' | b'*' | b'+' | b'0'..=b'9')) {
1097 return false;
1098 }
1099 rest.windows(3)
1100 .any(|w| w[0] == b'[' && matches!(w[1], b' ' | b'x' | b'X' | b'>' | b'-') && w[2] == b']')
1101}
1102
1103fn group<'h>(caps: &Captures<'h>, name: &str) -> Option<&'h str> {
1105 caps.name(name).map(|m| m.as_str())
1106}
1107
1108fn match_task_line(line: &str) -> Option<(usize, &str)> {
1114 if !might_be_task(line) {
1115 return None;
1116 }
1117 let caps = TASK_LINE.captures(line)?;
1118 let marker_at = caps.name("marker")?.start();
1119 let text = &line[caps.get(0)?.end()..];
1120
1121 let text = text.strip_suffix('\n').unwrap_or(text);
1128 if text.contains('\n') {
1129 return None;
1130 }
1131 Some((marker_at, text))
1132}
1133
1134fn strip_date_annotations(text: &str, annotations: &mut Annotations) -> String {
1137 if !text.contains('@') {
1138 return text.to_string();
1139 }
1140 DATE_ANNOTATION
1141 .replace_all(text, |caps: &Captures| {
1142 let Some((parsed, has_time)) = group(caps, "value").and_then(parse_datetime) else {
1143 return caps[0].to_string();
1145 };
1146 match group(caps, "kind") {
1147 Some("due") if annotations.due.is_none() => {
1148 annotations.due = Some(parsed);
1149 annotations.due_has_time = has_time;
1150 }
1151 Some("done") if annotations.done.is_none() => {
1152 annotations.done = Some(parsed);
1153 annotations.done_has_time = has_time;
1154 }
1155 _ => {}
1158 }
1159 " ".to_string()
1160 })
1161 .into_owned()
1162}
1163
1164fn strip_trailing_move(text: &str, annotations: &mut Annotations) -> String {
1167 let mut rest = text;
1168 while ends_with_digit_ignoring_blanks(rest)
1175 && let Some(caps) = TRAILING_MOVE.captures(rest)
1176 {
1177 let Some(date) =
1178 group(&caps, "date").and_then(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").ok())
1179 else {
1180 break;
1182 };
1183 if group(&caps, "dir") == Some(">") && annotations.moved_to.is_none() {
1184 annotations.moved_to = Some(date);
1185 }
1186 let Some(whole) = caps.get(0) else { break };
1189 rest = &rest[..whole.start()];
1190 }
1191 rest.to_string()
1192}
1193
1194fn ends_with_digit_ignoring_blanks(text: &str) -> bool {
1198 text.as_bytes()
1199 .iter()
1200 .rposition(|byte| !matches!(byte, b' ' | b'\t'))
1201 .is_some_and(|at| text.as_bytes()[at].is_ascii_digit())
1202}
1203
1204fn strip_tags(text: &str, annotations: &mut Annotations) -> String {
1207 if !text.contains('#') {
1208 return text.to_string();
1209 }
1210 TAG.replace_all(text, |caps: &Captures| {
1211 if let Some(tag) = group(caps, "tag")
1212 && !annotations
1213 .tags
1214 .iter()
1215 .any(|seen| seen.eq_ignore_ascii_case(tag))
1216 {
1217 annotations.tags.push(tag.to_string());
1218 }
1219 " ".to_string()
1222 })
1223 .into_owned()
1224}
1225
1226fn collapse_whitespace(text: &str, mut keep: impl FnMut(&str) -> bool) -> String {
1236 let mut display = String::with_capacity(text.len());
1237
1238 for token in text.split_whitespace() {
1239 if !keep(token) {
1240 continue;
1241 }
1242 if !display.is_empty() {
1243 display.push(' ');
1244 }
1245 display.push_str(token);
1246 }
1247 display
1248}
1249
1250fn collapse_taking_priority(text: &str) -> (String, TaskPriority) {
1257 let mut priority = TaskPriority::Normal;
1258 let display = collapse_whitespace(text, |token| match token {
1259 "!!!" => {
1260 priority = priority.max(TaskPriority::Urgent);
1261 false
1262 }
1263 "!!" => {
1264 priority = priority.max(TaskPriority::High);
1265 false
1266 }
1267 _ => true,
1268 });
1269 (display, priority)
1270}
1271
1272fn parse_datetime(value: &str) -> Option<(NaiveDateTime, bool)> {
1277 let caps = DATETIME.captures(value.trim())?;
1278 let date = NaiveDate::from_ymd_opt(
1279 group(&caps, "y")?.parse().ok()?,
1280 group(&caps, "mo")?.parse().ok()?,
1281 group(&caps, "d")?.parse().ok()?,
1282 )?;
1283
1284 let Some(hour_text) = group(&caps, "h") else {
1285 return Some((date.and_time(NaiveTime::MIN), false));
1286 };
1287 let hour: u32 = hour_text.parse().ok()?;
1288 let minute: u32 = group(&caps, "mi")?.parse().ok()?;
1289
1290 let hour24 = match group(&caps, "ap")
1292 .and_then(|ap| ap.as_bytes().first())
1293 .map(|b| b | 0x20)
1294 {
1295 None => hour,
1296 Some(b'a') if (1..=12).contains(&hour) => hour % 12,
1298 Some(_) if (1..=12).contains(&hour) => hour % 12 + 12,
1299 Some(_) => return None,
1301 };
1302 Some((
1303 date.and_time(NaiveTime::from_hms_opt(hour24, minute, 0)?),
1304 true,
1305 ))
1306}
1307
1308fn frontmatter_line_count(source: &str) -> usize {
1316 let mut lines = source.lines();
1317 if lines.next().map(str::trim_end) != Some("---") {
1318 return 0;
1319 }
1320 lines
1321 .position(|line| matches!(line.trim_end(), "---" | "..."))
1322 .map_or(0, |index| index + 2)
1325}
1326
1327#[cfg(test)]
1328mod tests {
1329 use super::*;
1330 use proptest::prelude::*;
1331
1332 fn dt(y: i32, m: u32, d: u32, h: u32, min: u32) -> NaiveDateTime {
1334 NaiveDate::from_ymd_opt(y, m, d)
1335 .and_then(|date| date.and_hms_opt(h, min, 0))
1336 .expect("test datetime is valid")
1337 }
1338
1339 const DEFAULT_MARKERS: &[&str] = &["TK", "TODO", "FIXME", "XXX"];
1342
1343 fn owned(markers: &[&str]) -> Vec<String> {
1346 markers.iter().map(|m| (*m).to_string()).collect()
1347 }
1348
1349 fn rule(markers: &[&str]) -> MarkerRule {
1351 MarkerRule::new(&owned(markers)).expect("a non-empty marker list compiles")
1352 }
1353
1354 fn find(line: &str) -> Option<usize> {
1356 rule(DEFAULT_MARKERS).find_in_line(line)
1357 }
1358
1359 fn parse(line: &str) -> Task {
1360 parse_task_line(line, 1).unwrap_or_else(|| panic!("expected a task from {line:?}"))
1361 }
1362
1363 #[test]
1366 fn open_marker_parses() {
1367 let task = parse("- [ ] open thing");
1368 assert_eq!(task.status, TaskStatus::Open);
1369 assert_eq!(task.text, "open thing");
1370 assert_eq!(task.depth, 0);
1371 assert_eq!(task.line, 1);
1372 }
1373
1374 #[test]
1375 fn lowercase_x_is_done() {
1376 assert_eq!(parse("- [x] finished").status, TaskStatus::Done);
1377 }
1378
1379 #[test]
1380 fn uppercase_x_is_done() {
1381 assert_eq!(parse("- [X] finished").status, TaskStatus::Done);
1382 }
1383
1384 #[test]
1385 fn dash_marker_is_canceled() {
1386 let task = parse("* [-] this task was canceled");
1387 assert_eq!(task.status, TaskStatus::Canceled);
1388 assert_eq!(task.moved_to, None);
1389 }
1390
1391 #[test]
1392 fn angle_marker_is_canceled_with_moved_to() {
1393 let task = parse("* [>] This task was moved to a specific date > 2026-08-04");
1394 assert_eq!(task.status, TaskStatus::Canceled);
1395 assert_eq!(task.text, "This task was moved to a specific date");
1396 assert_eq!(task.moved_to, NaiveDate::from_ymd_opt(2026, 8, 4));
1397 }
1398
1399 #[test]
1400 fn all_bullet_styles_parse() {
1401 for line in [
1402 "- [ ] a",
1403 "* [ ] a",
1404 "+ [ ] a",
1405 "1. [ ] a",
1406 "1) [ ] a",
1407 "42. [ ] a",
1408 ] {
1409 assert_eq!(parse(line).text, "a", "bullet style {line:?}");
1410 }
1411 }
1412
1413 #[test]
1414 fn tab_indent_sets_depth() {
1415 assert_eq!(parse("\t- [ ] subtask").depth, 2);
1417 assert_eq!(parse("\t\t- [ ] deeper").depth, 4);
1418 assert_eq!(parse(" - [ ] two spaces").depth, 1);
1419 assert_eq!(parse(" - [ ] four spaces").depth, 2);
1420 }
1421
1422 #[test]
1423 fn depth_saturates_instead_of_overflowing() {
1424 let line = format!("{}- [ ] very deep", " ".repeat(1000));
1425 assert_eq!(parse(&line).depth, u8::MAX);
1426 }
1427
1428 #[test]
1429 fn empty_task_text_is_still_a_task() {
1430 let task = parse("- [ ]");
1431 assert_eq!(task.text, "");
1432 assert_eq!(task.status, TaskStatus::Open);
1433 assert_eq!(parse("- [x] ").text, "");
1435 }
1436
1437 #[test]
1438 fn tab_after_marker_is_accepted() {
1439 assert_eq!(parse("-\t[ ]\tbuy milk").text, "buy milk");
1440 }
1441
1442 #[test]
1445 fn non_tasks_are_rejected() {
1446 for line in [
1447 "- [] x", "- [ x] x", "-[ ] x", "- [y] x", "- [ ]x", "just some prose", "# A heading", "[ ]", "[ ] do a thing", "> [ ] blockquote", "", " ", ] {
1460 assert!(
1461 parse_task_line(line, 1).is_none(),
1462 "expected {line:?} to be rejected"
1463 );
1464 }
1465 }
1466
1467 #[test]
1468 fn a_single_trailing_newline_is_tolerated() {
1469 assert_eq!(parse("- [ ] buy milk\n").text, "buy milk");
1471 assert_eq!(parse("- [ ] buy milk\r\n").text, "buy milk");
1472 assert_eq!(
1473 set_marker("- [ ] buy milk\n", TaskStatus::Done).as_deref(),
1474 Some("- [x] buy milk\n")
1475 );
1476 }
1477
1478 #[test]
1479 fn multi_line_input_is_refused_rather_than_welded_together() {
1480 for input in ["- [ ] first\n- [ ] second", "- [ ] first\nprose\n"] {
1483 assert!(parse_task_line(input, 1).is_none(), "{input:?}");
1484 assert!(set_marker(input, TaskStatus::Done).is_none(), "{input:?}");
1485 }
1486 }
1487
1488 #[test]
1489 fn might_be_task_never_rejects_a_real_task() {
1490 for line in ["- [ ] a", "\t1) [X] b", " * [-] c", "99. [>] d"] {
1491 assert!(might_be_task(line), "{line:?}");
1492 assert!(parse_task_line(line, 1).is_some(), "{line:?}");
1493 }
1494 }
1495
1496 #[test]
1499 fn due_date_only() {
1500 let task = parse("- [ ] pay rent @due(2026-08-05)");
1501 assert_eq!(task.text, "pay rent");
1502 assert_eq!(task.due, Some(dt(2026, 8, 5, 0, 0)));
1503 assert!(!task.due_has_time);
1504 }
1505
1506 #[test]
1507 fn due_with_24_hour_time() {
1508 let task = parse("- [ ] standup @due(2026-08-05 14:30)");
1509 assert_eq!(task.due, Some(dt(2026, 8, 5, 14, 30)));
1510 assert!(task.due_has_time);
1511 }
1512
1513 #[test]
1514 fn due_with_am_pm_time() {
1515 assert_eq!(
1516 parse("- [ ] a @due(2026-08-05 03:00 PM)").due,
1517 Some(dt(2026, 8, 5, 15, 0))
1518 );
1519 assert_eq!(
1520 parse("- [ ] a @due(2026-08-05 3:00pm)").due,
1521 Some(dt(2026, 8, 5, 15, 0))
1522 );
1523 assert_eq!(
1524 parse("- [ ] a @due(2026-08-05 09:15 am)").due,
1525 Some(dt(2026, 8, 5, 9, 15))
1526 );
1527 assert_eq!(
1529 parse("- [ ] a @due(2026-08-05 12:00 AM)").due,
1530 Some(dt(2026, 8, 5, 0, 0))
1531 );
1532 assert_eq!(
1533 parse("- [ ] a @due(2026-08-05 12:00 PM)").due,
1534 Some(dt(2026, 8, 5, 12, 0))
1535 );
1536 }
1537
1538 #[test]
1539 fn done_annotation() {
1540 let task = parse("- [x] shipped @done(2026-08-04 12:11 PM)");
1541 assert_eq!(task.done, Some(dt(2026, 8, 4, 12, 11)));
1542 assert!(task.done_has_time);
1543 assert_eq!(task.due, None);
1544 }
1545
1546 #[test]
1547 fn invalid_dates_are_left_verbatim() {
1548 for (line, expected) in [
1549 ("- [ ] a @due(next tuesday)", "a @due(next tuesday)"),
1550 ("- [ ] a @due(2026-13-45)", "a @due(2026-13-45)"),
1551 ("- [ ] a @due(2026-02-30)", "a @due(2026-02-30)"),
1552 ("- [ ] a @due()", "a @due()"),
1553 ("- [ ] a @due(2026-08-05 25:00)", "a @due(2026-08-05 25:00)"),
1554 (
1556 "- [ ] a @due(2026-08-05 14:00 PM)",
1557 "a @due(2026-08-05 14:00 PM)",
1558 ),
1559 ("- [x] a @done(garbage)", "a @done(garbage)"),
1560 ] {
1561 let task = parse(line);
1562 assert_eq!(task.text, expected, "for {line:?}");
1563 assert_eq!(task.due, None, "for {line:?}");
1564 assert_eq!(task.done, None, "for {line:?}");
1565 }
1566 }
1567
1568 #[test]
1569 fn first_annotation_of_a_kind_wins_and_all_are_stripped() {
1570 let task = parse("- [ ] a @due(2026-08-05) b @due(2026-09-09) c");
1571 assert_eq!(task.due, Some(dt(2026, 8, 5, 0, 0)));
1572 assert_eq!(task.text, "a b c");
1573 }
1574
1575 #[test]
1576 fn tags_are_extracted_and_stripped() {
1577 let task = parse("- [ ] review #work #urgent-ish #v2_final");
1578 assert_eq!(task.text, "review");
1579 assert_eq!(task.tags, ["work", "urgent-ish", "v2_final"]);
1580 }
1581
1582 #[test]
1583 fn tags_dedupe_case_insensitively_keeping_first_spelling() {
1584 let task = parse("- [ ] a #Work #work #WORK #wOrK b");
1585 assert_eq!(task.tags, ["Work"]);
1586 assert_eq!(task.text, "a b");
1587 }
1588
1589 #[test]
1590 fn url_fragment_is_not_a_tag() {
1591 let task = parse("- [ ] see page.md#anchor for details");
1592 assert!(task.tags.is_empty());
1593 assert_eq!(task.text, "see page.md#anchor for details");
1594 }
1595
1596 #[test]
1597 fn adjacent_hashes_only_yield_the_first_tag() {
1598 let task = parse("- [ ] a #one#two b");
1600 assert_eq!(task.tags, ["one"]);
1601 assert_eq!(task.text, "a #two b");
1602 }
1603
1604 #[test]
1605 fn tag_stops_at_punctuation() {
1606 let task = parse("- [ ] ping #bob, then go");
1607 assert_eq!(task.tags, ["bob"]);
1608 assert_eq!(task.text, "ping , then go");
1609 }
1610
1611 #[test]
1612 fn priorities_are_whitespace_delimited() {
1613 assert_eq!(parse("- [ ] a !! b").priority, TaskPriority::High);
1614 assert_eq!(parse("- [ ] a !!! b").priority, TaskPriority::Urgent);
1615 assert_eq!(parse("- [ ] !! leading").priority, TaskPriority::High);
1616 assert_eq!(parse("- [ ] trailing !!!").priority, TaskPriority::Urgent);
1617 }
1618
1619 #[test]
1620 fn non_delimited_bangs_are_not_priorities() {
1621 for line in ["- [ ] wow!!", "- [ ] a!!b", "- [ ] wow!!!", "- [ ] !!!!"] {
1622 let task = parse(line);
1623 assert_eq!(task.priority, TaskPriority::Normal, "for {line:?}");
1624 assert!(task.text.contains("!!"), "for {line:?}");
1626 }
1627 }
1628
1629 #[test]
1630 fn highest_priority_wins() {
1631 assert_eq!(parse("- [ ] a !! b !!! c").priority, TaskPriority::Urgent);
1632 assert_eq!(parse("- [ ] a !!! b !! c").priority, TaskPriority::Urgent);
1633 assert_eq!(parse("- [ ] a !! b !! c").priority, TaskPriority::High);
1634 }
1635
1636 #[test]
1637 fn priority_markers_are_stripped_from_text() {
1638 assert_eq!(parse("- [ ] a !!! b").text, "a b");
1639 }
1640
1641 #[test]
1642 fn trailing_moved_from_marker_is_discarded() {
1643 let task = parse("- [ ] carried over < 2026-08-01");
1644 assert_eq!(task.text, "carried over");
1645 assert_eq!(task.moved_to, None);
1646 }
1647
1648 #[test]
1649 fn both_trailing_move_markers_on_one_line() {
1650 let task = parse("- [>] shuffled > 2026-08-04 < 2026-08-01");
1651 assert_eq!(task.text, "shuffled");
1652 assert_eq!(task.moved_to, NaiveDate::from_ymd_opt(2026, 8, 4));
1653 }
1654
1655 #[test]
1656 fn mid_line_angle_bracket_is_left_alone() {
1657 for line in [
1658 "- [ ] migrate 1.0 > 2.0 today",
1659 "- [ ] a > b > c",
1660 "- [ ] compare x>y",
1661 ] {
1662 let task = parse(line);
1663 assert_eq!(task.moved_to, None, "for {line:?}");
1664 assert!(task.text.contains('>'), "for {line:?}");
1665 }
1666 }
1667
1668 #[test]
1671 fn trailing_move_prefilter_never_rejects_a_match() {
1672 for text in [
1673 "moved > 2026-08-04",
1674 "moved > 2026-08-04 ",
1675 "moved >2026-08-04\t",
1676 "moved < 2026-08-01",
1677 "> 2026-08-04",
1678 "nothing here",
1679 "ends in a digit 42",
1680 "trailing space ",
1681 "",
1682 " ",
1683 "a > b",
1684 ] {
1685 assert!(
1686 !TRAILING_MOVE.is_match(text) || ends_with_digit_ignoring_blanks(text),
1687 "prefilter rejected a line the regex matches: {text:?}"
1688 );
1689 }
1690 assert!(!ends_with_digit_ignoring_blanks("nothing here"));
1692 assert!(ends_with_digit_ignoring_blanks("moved > 2026-08-04 "));
1693 }
1694
1695 #[test]
1696 fn trailing_move_needs_a_real_date() {
1697 let task = parse("- [ ] nope > 2026-13-45");
1698 assert_eq!(task.moved_to, None);
1699 assert_eq!(task.text, "nope > 2026-13-45");
1700 }
1701
1702 #[test]
1703 fn trailing_means_after_date_annotations_but_before_tags() {
1704 let with_due = parse("- [ ] moved @due(2026-08-05) > 2026-08-04");
1706 assert_eq!(with_due.moved_to, NaiveDate::from_ymd_opt(2026, 8, 4));
1707 assert_eq!(with_due.text, "moved");
1708
1709 let with_tag = parse("- [ ] moved > 2026-08-04 #work");
1711 assert_eq!(with_tag.moved_to, None);
1712 assert_eq!(with_tag.text, "moved > 2026-08-04");
1713 assert_eq!(with_tag.tags, ["work"]);
1714 }
1715
1716 #[test]
1719 fn every_annotation_on_one_line_in_several_orders() {
1720 let expected_due = Some(dt(2026, 8, 5, 15, 0));
1721 let expected_done = Some(dt(2026, 8, 4, 12, 11));
1722 for line in [
1723 "- [x] ship it !!! #work #ops @due(2026-08-05 03:00 PM) @done(2026-08-04 12:11 PM)",
1724 "- [x] @due(2026-08-05 03:00 PM) ship it #work @done(2026-08-04 12:11 PM) !!! #ops",
1725 "- [x] #work ship it @done(2026-08-04 12:11 PM) !!! #ops @due(2026-08-05 15:00)",
1726 "- [x] !!! #work #ops @done(2026-08-04 12:11 PM) @due(2026-08-05 15:00) ship it",
1727 ] {
1728 let task = parse(line);
1729 assert_eq!(task.status, TaskStatus::Done, "for {line:?}");
1730 assert_eq!(task.priority, TaskPriority::Urgent, "for {line:?}");
1731 assert_eq!(task.tags, ["work", "ops"], "for {line:?}");
1732 assert_eq!(task.due, expected_due, "for {line:?}");
1733 assert_eq!(task.done, expected_done, "for {line:?}");
1734 assert!(task.due_has_time && task.done_has_time, "for {line:?}");
1735 assert_eq!(task.text, "ship it", "for {line:?}");
1736 }
1737 }
1738
1739 #[test]
1740 fn whitespace_is_collapsed_in_display_text() {
1741 assert_eq!(parse("- [ ] a b\tc").text, "a b c");
1742 }
1743
1744 #[test]
1746 fn spec_examples_parse_exactly() {
1747 let first = parse("* [ ] this is a task due tomorrow @due(2026-08-05)");
1748 assert_eq!(
1749 first,
1750 Task {
1751 line: 1,
1752 depth: 0,
1753 kind: TaskKind::Task,
1754 status: TaskStatus::Open,
1755 priority: TaskPriority::Normal,
1756 text: "this is a task due tomorrow".to_string(),
1757 marker_start: None,
1758 marker_end: None,
1759 tags: vec![],
1760 due: Some(dt(2026, 8, 5, 0, 0)),
1761 due_has_time: false,
1762 done: None,
1763 done_has_time: false,
1764 moved_to: None,
1765 }
1766 );
1767
1768 let second = parse("* [x] this task was done yesterday @done(2026-08-04 12:11 PM)");
1769 assert_eq!(
1770 second,
1771 Task {
1772 line: 1,
1773 depth: 0,
1774 kind: TaskKind::Task,
1775 status: TaskStatus::Done,
1776 priority: TaskPriority::Normal,
1777 text: "this task was done yesterday".to_string(),
1778 marker_start: None,
1779 marker_end: None,
1780 tags: vec![],
1781 due: None,
1782 due_has_time: false,
1783 done: Some(dt(2026, 8, 4, 12, 11)),
1784 done_has_time: true,
1785 moved_to: None,
1786 }
1787 );
1788
1789 let third = parse("* [ ] this task is urgent !!! #hotlist #work @due(2026-08-04 03:00 PM)");
1790 assert_eq!(
1791 third,
1792 Task {
1793 line: 1,
1794 depth: 0,
1795 kind: TaskKind::Task,
1796 status: TaskStatus::Open,
1797 priority: TaskPriority::Urgent,
1798 text: "this task is urgent".to_string(),
1799 marker_start: None,
1800 marker_end: None,
1801 tags: vec!["hotlist".to_string(), "work".to_string()],
1802 due: Some(dt(2026, 8, 4, 15, 0)),
1803 due_has_time: true,
1804 done: None,
1805 done_has_time: false,
1806 moved_to: None,
1807 }
1808 );
1809 }
1810
1811 #[test]
1813 fn spec_nested_subtasks_are_independent_tasks() {
1814 let source =
1815 "* [ ] parent task\n\t* [ ] broken down subtask 1\n\t* [ ] broken down subtask 2\n";
1816 let tasks = scan_source_tasks(source);
1817 assert_eq!(tasks.len(), 3);
1818 assert_eq!(tasks[0].depth, 0);
1819 assert_eq!(tasks[1].depth, 2);
1820 assert_eq!(tasks[2].depth, 2);
1821 assert_eq!(tasks[1].text, "broken down subtask 1");
1822 }
1823
1824 #[test]
1827 fn set_marker_rewrites_only_the_marker() {
1828 assert_eq!(
1829 set_marker("- [ ] buy milk", TaskStatus::Done).as_deref(),
1830 Some("- [x] buy milk")
1831 );
1832 assert_eq!(
1833 set_marker("- [x] buy milk", TaskStatus::Open).as_deref(),
1834 Some("- [ ] buy milk")
1835 );
1836 assert_eq!(
1837 set_marker("- [ ] buy milk", TaskStatus::Canceled).as_deref(),
1838 Some("- [-] buy milk")
1839 );
1840 }
1841
1842 #[test]
1843 fn set_marker_preserves_exotic_spacing_and_annotations() {
1844 let line =
1845 "\t\t 12) [>] do the thing !!! #work @due(2026-08-05 03:00 PM) > 2026-08-04";
1846 let toggled = set_marker(line, TaskStatus::Done).expect("task line");
1847 assert_eq!(
1848 toggled,
1849 "\t\t 12) [x] do the thing !!! #work @due(2026-08-05 03:00 PM) > 2026-08-04"
1850 );
1851 assert_eq!(toggled.len(), line.len());
1852 }
1853
1854 #[test]
1855 fn set_marker_round_trips() {
1856 let line = " * [ ] a task @due(2026-08-05)";
1857 let done = set_marker(line, TaskStatus::Done).expect("task line");
1858 let back = set_marker(&done, TaskStatus::Open).expect("task line");
1859 assert_eq!(back, line);
1860 }
1861
1862 #[test]
1863 fn set_marker_preserves_a_trailing_carriage_return() {
1864 assert_eq!(
1865 set_marker("- [ ] windows line\r", TaskStatus::Done).as_deref(),
1866 Some("- [x] windows line\r")
1867 );
1868 }
1869
1870 #[test]
1871 fn set_marker_returns_none_for_non_tasks() {
1872 for line in ["# heading", "- not a task", "- [] x", "", "[ ] bare"] {
1873 assert!(set_marker(line, TaskStatus::Done).is_none(), "{line:?}");
1874 }
1875 }
1876
1877 fn stamp() -> NaiveDateTime {
1882 dt(2026, 8, 4, 14, 32)
1883 }
1884
1885 fn stamped(line: &str, status: TaskStatus) -> String {
1886 set_status(line, status, Some(stamp())).unwrap_or_else(|| panic!("task line: {line:?}"))
1887 }
1888
1889 #[test]
1890 fn completing_a_task_appends_a_done_stamp() {
1891 assert_eq!(
1892 stamped("- [ ] write the report !!", TaskStatus::Done),
1893 "- [x] write the report !! @done(2026-08-04 14:32)"
1894 );
1895 }
1896
1897 #[test]
1898 fn a_stamp_round_trips_through_the_parser() {
1899 let task = parse(&stamped("- [ ] ship it", TaskStatus::Done));
1901 assert_eq!(task.status, TaskStatus::Done);
1902 assert_eq!(task.done, Some(stamp()));
1903 assert!(task.done_has_time);
1904 assert_eq!(task.text, "ship it");
1905 }
1906
1907 #[test]
1908 fn stamping_is_idempotent() {
1909 let once = stamped("- [ ] ship it", TaskStatus::Done);
1910 let twice = stamped(&once, TaskStatus::Done);
1911 assert_eq!(twice, once, "a second completion must not stamp again");
1912 assert_eq!(twice.matches("@done(").count(), 1);
1913 }
1914
1915 #[test]
1916 fn an_existing_stamp_is_left_at_the_time_the_author_wrote() {
1917 let line = "- [x] ship it @done(2020-01-02 03:04)";
1919 assert_eq!(stamped(line, TaskStatus::Done), line);
1920 }
1921
1922 #[test]
1923 fn reopening_a_task_removes_the_stamp() {
1924 let done = stamped("- [ ] ship it", TaskStatus::Done);
1925 assert_eq!(stamped(&done, TaskStatus::Open), "- [ ] ship it");
1926 assert_eq!(stamped(&done, TaskStatus::Canceled), "- [-] ship it");
1927 }
1928
1929 #[test]
1930 fn removing_a_stamp_takes_its_leading_whitespace_but_nothing_else() {
1931 assert_eq!(
1932 stamped("- [x] a @done(2026-08-04 14:32) b", TaskStatus::Open),
1933 "- [ ] a b"
1934 );
1935 assert_eq!(
1936 stamped("- [x] a \t@done(2026-08-04 14:32)", TaskStatus::Open),
1937 "- [ ] a"
1938 );
1939 assert_eq!(
1941 stamped(
1942 "\t2) [x] do it !!! #work @due(2026-08-05) @done(2026-08-04 14:32)",
1943 TaskStatus::Open
1944 ),
1945 "\t2) [ ] do it !!! #work @due(2026-08-05)"
1946 );
1947 }
1948
1949 #[test]
1950 fn a_line_that_already_ends_in_annotations_keeps_them_all() {
1951 let line = "- [ ] file taxes !!! #work #irs @due(2026-08-05 09:00)";
1952 let done = stamped(line, TaskStatus::Done);
1953 assert_eq!(
1954 done,
1955 "- [x] file taxes !!! #work #irs @due(2026-08-05 09:00) @done(2026-08-04 14:32)"
1956 );
1957
1958 let task = parse(&done);
1960 assert_eq!(task.text, "file taxes");
1961 assert_eq!(task.priority, TaskPriority::Urgent);
1962 assert_eq!(task.tags, ["work", "irs"]);
1963 assert_eq!(task.due, Some(dt(2026, 8, 5, 9, 0)));
1964 assert_eq!(task.done, Some(stamp()));
1965
1966 assert_eq!(stamped(&done, TaskStatus::Open), line);
1968 }
1969
1970 #[test]
1971 fn a_trailing_move_marker_survives_a_stamp() {
1972 let done = stamped("- [ ] shuffled > 2026-08-04", TaskStatus::Done);
1975 assert_eq!(done, "- [x] shuffled > 2026-08-04 @done(2026-08-04 14:32)");
1976 let task = parse(&done);
1977 assert_eq!(task.text, "shuffled");
1978 assert_eq!(task.moved_to, NaiveDate::from_ymd_opt(2026, 8, 4));
1979 }
1980
1981 #[test]
1982 fn stamping_does_not_pile_up_trailing_whitespace() {
1983 assert_eq!(
1984 stamped("- [ ] ship it \t ", TaskStatus::Done),
1985 "- [x] ship it @done(2026-08-04 14:32)"
1986 );
1987 }
1988
1989 #[test]
1990 fn an_unparseable_done_payload_is_prose_not_a_stamp() {
1991 let done = stamped("- [ ] a @done(sometime)", TaskStatus::Done);
1995 assert_eq!(done, "- [x] a @done(sometime) @done(2026-08-04 14:32)");
1996 assert_eq!(
1997 stamped(&done, TaskStatus::Open),
1998 "- [ ] a @done(sometime)",
1999 "only the recognised stamp is removed"
2000 );
2001 }
2002
2003 #[test]
2004 fn a_line_terminator_is_preserved_through_a_stamp() {
2005 for terminator in ["\n", "\r\n", ""] {
2006 let line = format!("- [ ] ship it{terminator}");
2007 assert_eq!(
2008 stamped(&line, TaskStatus::Done),
2009 format!("- [x] ship it @done(2026-08-04 14:32){terminator}"),
2010 "terminator {terminator:?}"
2011 );
2012 let done = format!("- [x] ship it @done(2026-08-04 14:32){terminator}");
2013 assert_eq!(
2014 stamped(&done, TaskStatus::Open),
2015 format!("- [ ] ship it{terminator}"),
2016 "terminator {terminator:?}"
2017 );
2018 }
2019 }
2020
2021 #[test]
2022 fn without_a_clock_set_status_is_exactly_set_marker() {
2023 for line in [
2024 "- [ ] ship it",
2025 "- [x] ship it @done(2026-08-04 14:32)",
2026 "- [ ] a @done(2020-01-01 00:00) b\r\n",
2027 ] {
2028 for status in [TaskStatus::Open, TaskStatus::Done, TaskStatus::Canceled] {
2029 assert_eq!(
2030 set_status(line, status, None),
2031 set_marker(line, status),
2032 "{line:?} -> {status:?}"
2033 );
2034 }
2035 }
2036 }
2037
2038 #[test]
2039 fn set_status_returns_none_for_non_tasks() {
2040 for line in ["# heading", "- not a task", "", "[ ] bare"] {
2041 assert!(
2042 set_status(line, TaskStatus::Done, Some(stamp())).is_none(),
2043 "{line:?}"
2044 );
2045 }
2046 }
2047
2048 fn patch(
2051 source: &str,
2052 line: u32,
2053 expected: &str,
2054 status: TaskStatus,
2055 ) -> Result<PatchedSource, TaskPatchError> {
2056 patch_task_line(source, line, expected, status, Some(stamp()))
2057 }
2058
2059 #[test]
2060 fn patch_rewrites_only_the_addressed_line() {
2061 let source = "# Notes\n\n- [ ] first\n- [ ] second\n";
2062 let patched = patch(source, 4, "- [ ] second", TaskStatus::Done).expect("patched");
2063 assert_eq!(patched.text, "- [x] second @done(2026-08-04 14:32)");
2064 assert_eq!(
2065 patched.source,
2066 "# Notes\n\n- [ ] first\n- [x] second @done(2026-08-04 14:32)\n"
2067 );
2068 }
2069
2070 #[test]
2071 fn patch_preserves_crlf_terminators() {
2072 let source = "- [ ] first\r\n- [ ] second\r\n";
2073 let patched = patch(source, 1, "- [ ] first", TaskStatus::Done).expect("patched");
2074 assert_eq!(
2075 patched.source,
2076 "- [x] first @done(2026-08-04 14:32)\r\n- [ ] second\r\n"
2077 );
2078 assert_eq!(patched.text, "- [x] first @done(2026-08-04 14:32)");
2080 }
2081
2082 #[test]
2083 fn patch_neither_adds_nor_removes_a_trailing_newline() {
2084 let source = "- [ ] last line has no newline";
2085 let patched = patch(source, 1, source, TaskStatus::Canceled).expect("patched");
2086 assert_eq!(patched.source, "- [-] last line has no newline");
2087 assert!(!patched.source.ends_with('\n'));
2088
2089 let terminated = "- [ ] terminated\n";
2090 let patched =
2091 patch(terminated, 1, "- [ ] terminated", TaskStatus::Canceled).expect("patched");
2092 assert_eq!(patched.source, "- [-] terminated\n");
2093 }
2094
2095 #[test]
2096 fn patch_accepts_expected_with_or_without_its_terminator() {
2097 let source = "- [ ] ship it\r\n";
2098 for expected in ["- [ ] ship it", "- [ ] ship it\n", "- [ ] ship it\r\n"] {
2099 assert!(
2100 patch(source, 1, expected, TaskStatus::Done).is_ok(),
2101 "{expected:?}"
2102 );
2103 }
2104 }
2105
2106 #[test]
2107 fn patch_rejects_a_line_that_changed_underneath_the_client() {
2108 let source = "- [ ] the text changed\n";
2109 assert_eq!(
2110 patch(source, 1, "- [ ] what the client saw", TaskStatus::Done),
2111 Err(TaskPatchError::Mismatch { line: 1 })
2112 );
2113 assert_eq!(
2115 patch(source, 1, "- [ ] the text changed", TaskStatus::Done),
2116 Err(TaskPatchError::Mismatch { line: 1 })
2117 );
2118 }
2119
2120 #[test]
2121 fn patch_rejects_a_line_number_the_file_does_not_have() {
2122 let source = "- [ ] only line\n";
2123 assert_eq!(
2125 patch(source, 2, "", TaskStatus::Done),
2126 Err(TaskPatchError::LineOutOfRange { line: 2 })
2127 );
2128 assert_eq!(
2129 patch(source, 0, "- [ ] only line", TaskStatus::Done),
2130 Err(TaskPatchError::LineOutOfRange { line: 0 })
2131 );
2132 assert_eq!(
2133 patch("", 1, "", TaskStatus::Done),
2134 Err(TaskPatchError::LineOutOfRange { line: 1 })
2135 );
2136 }
2137
2138 #[test]
2139 fn patch_rejects_a_line_that_is_not_a_task() {
2140 let source = "# Notes\n- [ ] a task\n";
2141 assert_eq!(
2142 patch(source, 1, "# Notes", TaskStatus::Done),
2143 Err(TaskPatchError::NotATask { line: 1 })
2144 );
2145 }
2146
2147 #[test]
2148 fn patch_addresses_the_same_lines_the_scanner_reports() {
2149 let source = concat!(
2153 "---\ntitle: T\n---\n\n", "- [ ] first\n", "\n```\n- [ ] fake\n```\n", "- [ ] second\n", );
2158 for task in scan_source_tasks(source) {
2159 let line = source
2160 .lines()
2161 .nth(usize::try_from(task.line).expect("fits") - 1)
2162 .expect("line exists");
2163 let patched = patch(source, task.line, line, TaskStatus::Done).expect("patched");
2164 assert!(patched.source.contains("- [ ] fake"), "code stayed code");
2165 assert_eq!(
2166 patched.source.matches("@done(").count(),
2167 1,
2168 "exactly one line was touched"
2169 );
2170 }
2171 }
2172
2173 #[test]
2174 fn line_span_covers_every_line_including_its_terminator() {
2175 let source = "a\r\nbb\nccc";
2176 assert_eq!(line_span(source, 1).map(|r| &source[r]), Some("a\r\n"));
2177 assert_eq!(line_span(source, 2).map(|r| &source[r]), Some("bb\n"));
2178 assert_eq!(line_span(source, 3).map(|r| &source[r]), Some("ccc"));
2179 assert_eq!(line_span(source, 4), None);
2180 }
2181
2182 #[test]
2185 fn scan_reports_one_based_line_numbers() {
2186 let source = "# Title\n\n- [ ] first\n- [x] second\n";
2187 let tasks = scan_source_tasks(source);
2188 assert_eq!(tasks.len(), 2);
2189 assert_eq!(tasks[0].line, 3);
2190 assert_eq!(tasks[1].line, 4);
2191 }
2192
2193 #[test]
2194 fn scan_skips_backtick_fenced_code() {
2195 let source = "- [ ] real\n\n```\n- [ ] fake\n```\n\n- [x] also real\n";
2196 let tasks = scan_source_tasks(source);
2197 assert_eq!(tasks.iter().map(|t| t.line).collect::<Vec<_>>(), vec![1, 7]);
2198 }
2199
2200 #[test]
2201 fn scan_skips_tilde_fenced_code_with_info_string() {
2202 let source = "~~~markdown\n- [ ] fake\n~~~\n- [ ] real\n";
2203 let tasks = scan_source_tasks(source);
2204 assert_eq!(tasks.len(), 1);
2205 assert_eq!(tasks[0].line, 4);
2206 }
2207
2208 #[test]
2209 fn scan_respects_longer_closing_fence_rule() {
2210 let source = "````\n- [ ] fake\n```\n- [ ] still fake\n````\n- [ ] real\n";
2212 let tasks = scan_source_tasks(source);
2213 assert_eq!(tasks.len(), 1);
2214 assert_eq!(tasks[0].text, "real");
2215 }
2216
2217 #[test]
2218 fn scan_skips_fenced_code_with_an_info_string() {
2219 let source = "```rust\n// - [ ] fake\n- [ ] fake too\n```\n- [ ] real\n";
2220 let tasks = scan_source_tasks(source);
2221 assert_eq!(tasks.len(), 1);
2222 assert_eq!(tasks[0].line, 5);
2223 }
2224
2225 #[test]
2226 fn scan_treats_an_unclosed_fence_as_running_to_end_of_file() {
2227 let source = "```\n- [ ] fake\n- [ ] also fake\n";
2228 assert!(scan_source_tasks(source).is_empty());
2229 }
2230
2231 #[test]
2232 fn scan_skips_indented_code_blocks() {
2233 let source = "Some prose.\n\n - [ ] fake\n\n- [ ] real\n";
2234 let tasks = scan_source_tasks(source);
2235 assert_eq!(tasks.len(), 1);
2236 assert_eq!(tasks[0].line, 5);
2237 }
2238
2239 #[test]
2240 fn scan_skips_tab_indented_code_blocks() {
2241 let source = "Some prose.\n\n\t- [ ] fake\n\n- [ ] real\n";
2242 let tasks = scan_source_tasks(source);
2243 assert_eq!(tasks.len(), 1);
2244 assert_eq!(tasks[0].text, "real");
2245 }
2246
2247 #[test]
2254 fn indented_code_inside_a_list_item_is_a_known_false_positive() {
2255 let source = "- item\n\n - [ ] this really is code\n";
2256 let tasks = scan_source_tasks(source);
2257 assert_eq!(tasks.len(), 1, "known limitation: code inside a list item");
2258 assert_eq!(tasks[0].text, "this really is code");
2259 }
2260
2261 #[test]
2262 fn scan_skips_yaml_frontmatter() {
2263 let source = "---\ntitle: Notes\nchecklist: \"- [ ] fake\"\n---\n\n- [ ] real\n";
2264 let tasks = scan_source_tasks(source);
2265 assert_eq!(tasks.len(), 1);
2266 assert_eq!(tasks[0].line, 6);
2267 assert_eq!(tasks[0].text, "real");
2268 }
2269
2270 #[test]
2271 fn scan_skips_frontmatter_closed_with_dots() {
2272 let source = "---\nfake: \"- [ ] no\"\n...\n- [ ] real\n";
2273 let tasks = scan_source_tasks(source);
2274 assert_eq!(tasks.len(), 1);
2275 assert_eq!(tasks[0].line, 4);
2276 }
2277
2278 #[test]
2279 fn unclosed_frontmatter_opener_is_treated_as_a_thematic_break() {
2280 let source = "---\n\n- [ ] real\n";
2283 let tasks = scan_source_tasks(source);
2284 assert_eq!(tasks.len(), 1);
2285 assert_eq!(tasks[0].line, 3);
2286 }
2287
2288 #[test]
2289 fn a_later_triple_dash_is_not_frontmatter() {
2290 let source = "# Title\n\n---\n\n- [ ] real\n";
2291 let tasks = scan_source_tasks(source);
2292 assert_eq!(tasks.len(), 1);
2293 assert_eq!(tasks[0].line, 5);
2294 }
2295
2296 #[test]
2297 fn scan_line_numbers_survive_multi_line_blocks() {
2298 let source = concat!(
2299 "---\n", "title: T\n", "---\n", "\n", "- [ ] first\n", "\n", "```js\n", "// - [ ] fake\n", "\n", "```\n", "\n", " indented\n", " - [ ] fake\n", "\n", "- [x] second\n", );
2315 let tasks = scan_source_tasks(source);
2316 assert_eq!(
2317 tasks.iter().map(|t| t.line).collect::<Vec<_>>(),
2318 vec![5, 15]
2319 );
2320 assert_eq!(tasks[1].status, TaskStatus::Done);
2321 }
2322
2323 #[test]
2324 fn scan_handles_crlf_line_endings() {
2325 let source = "- [ ] first\r\n```\r\n- [ ] fake\r\n```\r\n- [x] second\r\n";
2326 let tasks = scan_source_tasks(source);
2327 assert_eq!(tasks.len(), 2);
2328 assert_eq!(tasks[0].text, "first");
2329 assert_eq!(tasks[1].text, "second");
2330 assert_eq!(tasks[1].line, 5);
2331 }
2332
2333 #[test]
2334 fn scan_of_empty_source_is_empty() {
2335 assert!(scan_source_tasks("").is_empty());
2336 assert!(scan_source_tasks("\n\n\n").is_empty());
2337 }
2338
2339 #[test]
2340 fn scan_finds_tasks_inside_a_blockquote_fence_correctly() {
2341 let source = "> ```\n> - [ ] fake\n> ```\n- [ ] real\n";
2343 let tasks = scan_source_tasks(source);
2344 assert_eq!(tasks.len(), 1);
2345 assert_eq!(tasks[0].text, "real");
2346 }
2347
2348 fn across(runs: &[&str]) -> (Vec<String>, Annotations) {
2352 strip_annotations_across_runs(runs)
2353 }
2354
2355 #[test]
2356 fn runs_keep_the_whitespace_that_separates_inline_formatting() {
2357 let (runs, ann) = across(&["fix ", "this", " #bug"]);
2360 assert_eq!(runs, ["fix ", "this", ""]);
2361 assert_eq!(ann.tags, ["bug"]);
2362 }
2363
2364 #[test]
2365 fn a_single_run_matches_strip_annotations_exactly() {
2366 let (runs, ann) = across(&["ship it !!! #work @due(2026-08-05)"]);
2367 let (text, expected) = strip_annotations("ship it !!! #work @due(2026-08-05)");
2368 assert_eq!(runs, [text]);
2369 assert_eq!(ann, expected);
2370 }
2371
2372 #[test]
2373 fn an_empty_run_list_yields_nothing() {
2374 let (runs, ann) = across(&[]);
2375 assert!(runs.is_empty());
2376 assert_eq!(ann, Annotations::default());
2377 }
2378
2379 #[test]
2380 fn every_annotation_kind_is_found_across_run_boundaries() {
2381 let (runs, ann) = across(&[
2382 "do ",
2383 "the",
2384 " thing !! #work @due(2026-08-05) > 2026-08-04",
2385 ]);
2386 assert_eq!(runs, ["do ", "the", " thing"]);
2388 assert_eq!(ann.priority, TaskPriority::High);
2389 assert_eq!(ann.tags, ["work"]);
2390 assert_eq!(ann.due, Some(dt(2026, 8, 5, 0, 0)));
2391 assert_eq!(ann.moved_to, NaiveDate::from_ymd_opt(2026, 8, 4));
2392 }
2393
2394 #[test]
2395 fn a_run_boundary_is_not_whitespace_so_it_cannot_invent_a_tag() {
2396 let (runs, ann) = across(&["", "a", "#work"]);
2399 assert!(ann.tags.is_empty());
2400 assert_eq!(runs.concat(), "a#work");
2401 }
2402
2403 #[test]
2404 fn a_literal_boundary_character_cannot_desynchronise_the_split() {
2405 let (runs, ann) = across(&["a\u{0}b ", "c", " #t"]);
2408 assert_eq!(runs.len(), 3, "run count must be preserved");
2409 assert_eq!(runs, ["a\u{fffd}b ", "c", ""]);
2410 assert_eq!(ann.tags, ["t"]);
2411 }
2412
2413 #[test]
2416 fn status_and_priority_serialize_lowercase() {
2417 let task = parse("- [x] a !!! @due(2026-08-05 15:00)");
2418 let json = serde_json::to_value(&task).expect("task serializes");
2419 assert_eq!(json["status"], "done");
2420 assert_eq!(json["priority"], "urgent");
2421 assert_eq!(json["due"], "2026-08-05T15:00:00");
2422 assert_eq!(json["moved_to"], serde_json::Value::Null);
2423 }
2424
2425 #[test]
2426 fn status_and_priority_deserialize_lowercase() {
2427 let status: TaskStatus = serde_json::from_str("\"canceled\"").expect("status");
2428 assert_eq!(status, TaskStatus::Canceled);
2429 let priority: TaskPriority = serde_json::from_str("\"high\"").expect("priority");
2430 assert_eq!(priority, TaskPriority::High);
2431 }
2432
2433 #[test]
2436 fn the_default_markers_match_at_the_start_of_a_block() {
2437 let rule = rule(DEFAULT_MARKERS);
2438 assert_eq!(rule.block_initial_match("TK"), Some(2));
2439 assert_eq!(rule.block_initial_match("TK rewrite this"), Some(2));
2440 assert_eq!(rule.block_initial_match("TODO foo"), Some(4));
2441 assert_eq!(rule.block_initial_match("FIXME(name)"), Some(5));
2442 assert_eq!(rule.block_initial_match("XXX:"), Some(3));
2443
2444 assert_eq!(rule.block_initial_match("TKTK"), None);
2446 assert_eq!(rule.block_initial_match("TODOs"), None);
2447 assert_eq!(rule.block_initial_match("Tk"), None);
2448 assert_eq!(rule.block_initial_match("todo"), None);
2449 assert_eq!(rule.block_initial_match("Tomato"), None);
2450 }
2451
2452 #[test]
2453 fn a_block_initial_match_must_be_at_the_very_start() {
2454 let rule = rule(DEFAULT_MARKERS);
2455 assert_eq!(rule.block_initial_match("see TODO here"), None);
2456 }
2457
2458 #[test]
2459 fn an_empty_marker_list_compiles_to_no_rule() {
2460 assert!(MarkerRule::new(&[]).is_none());
2463 assert!(MarkerRule::new(&["".to_string()]).is_none());
2465 }
2466
2467 #[test]
2468 fn caching_hands_back_the_same_rule_for_the_same_markers() {
2469 let markers = owned(DEFAULT_MARKERS);
2473 let first = MarkerRule::cached(&markers).expect("the default markers compile");
2474 let second = MarkerRule::cached(&markers).expect("the default markers compile");
2475 assert!(Arc::ptr_eq(&first, &second));
2476 }
2477
2478 #[test]
2479 fn caching_keys_on_the_marker_list_not_the_process() {
2480 let custom = MarkerRule::cached(&owned(&["NOTE"])).expect("one marker compiles");
2484 let default = MarkerRule::cached(&owned(DEFAULT_MARKERS)).expect("defaults compile");
2485
2486 assert!(custom.find_in_line("NOTE check this").is_some());
2487 assert!(custom.find_in_line("TODO check this").is_none());
2488 assert!(default.find_in_line("TODO check this").is_some());
2489 }
2490
2491 #[test]
2492 fn caching_remembers_a_marker_list_that_compiles_to_nothing() {
2493 assert!(MarkerRule::cached(&[]).is_none());
2496 assert!(MarkerRule::cached(&[]).is_none());
2497 }
2498
2499 #[test]
2500 fn regex_metacharacters_in_a_marker_are_matched_literally() {
2501 let rule = rule(&["FOO(", "BAR"]);
2504 assert_eq!(rule.block_initial_match("BAR foo"), Some(3));
2505 assert_eq!(rule.block_initial_match("FOO(bar)"), Some(4));
2506 assert_eq!(rule.block_initial_match("Tomato"), None);
2507 }
2508
2509 #[test]
2510 fn a_marker_ending_in_punctuation_does_not_require_a_word_after_it() {
2511 let rule = rule(&["TODO:"]);
2515 let line = "see TODO: here";
2516 assert_eq!(rule.find_in_line(line), line.find("TODO:"));
2517 assert_eq!(rule.block_initial_match("TODO: here"), Some(5));
2518 }
2519
2520 #[test]
2521 fn a_marker_starting_with_punctuation_does_not_require_a_word_before_it() {
2522 let rule = rule(&["@todo"]);
2523 assert_eq!(rule.find_in_line(" @todo "), Some(1));
2525 assert_eq!(rule.find_in_line("x@todo"), Some(1));
2527 }
2528
2529 #[test]
2530 fn the_longest_marker_wins_regardless_of_configuration_order() {
2531 for order in [["TODO", "TODOMAYBE"], ["TODOMAYBE", "TODO"]] {
2534 let rule = rule(&order);
2535 assert_eq!(
2536 rule.block_initial_match("TODOMAYBE later"),
2537 Some(9),
2538 "for {order:?}"
2539 );
2540 }
2541 }
2542
2543 #[test]
2546 fn a_marker_embedded_in_prose_is_found() {
2547 let line = "The market fell 10% (source: TK).";
2549 assert_eq!(find(line), line.find("TK"));
2550 }
2551
2552 #[test]
2553 fn a_marker_inside_an_inline_code_span_is_not_found() {
2554 assert_eq!(find("run `TODO` later"), None);
2555 }
2556
2557 #[test]
2558 fn a_marker_after_an_inline_code_span_is_found() {
2559 let line = "run `x` then TODO";
2560 assert_eq!(find(line), line.find("TODO"));
2561 }
2562
2563 #[test]
2564 fn a_longer_backtick_span_hides_a_shorter_run_inside_it() {
2565 assert_eq!(find("``a ` TODO ` b``"), None);
2569 }
2570
2571 #[test]
2572 fn an_unmatched_backtick_does_not_swallow_the_rest_of_the_line() {
2573 let line = "a ` b TODO";
2574 assert_eq!(find(line), line.find("TODO"));
2575 }
2576
2577 #[test]
2578 fn a_marker_inside_a_wikilink_target_is_not_found() {
2579 assert_eq!(find("see [[TODO list]] here"), None);
2580 }
2581
2582 #[test]
2583 fn a_marker_in_a_wikilink_alias_is_found() {
2584 let line = "[[page|TODO fix]]";
2586 assert_eq!(find(line), line.find("TODO"));
2587 }
2588
2589 #[test]
2590 fn an_unclosed_double_bracket_does_not_hide_a_later_marker() {
2591 let line = "[[page and TODO";
2592 assert_eq!(find(line), line.find("TODO"));
2593 }
2594
2595 #[test]
2596 fn a_marker_in_a_link_destination_is_not_found() {
2597 assert_eq!(find("[link](/TODO/page)"), None);
2598 }
2599
2600 #[test]
2601 fn a_marker_in_a_link_title_is_not_found() {
2602 assert_eq!(find("[link](/page \"TODO\")"), None);
2603 }
2604
2605 #[test]
2606 fn a_marker_in_link_text_is_found() {
2607 let line = "[TODO fix](/page)";
2608 assert_eq!(find(line), line.find("TODO"));
2609 }
2610
2611 #[test]
2612 fn a_marker_in_image_alt_text_is_found_but_not_in_its_destination() {
2613 let line = "";
2616 assert_eq!(find(line), line.find("TODO"));
2617 assert_eq!(find(""), None);
2618 }
2619
2620 #[test]
2621 fn a_link_destination_with_balanced_parentheses_is_skipped_whole() {
2622 let line = "[x](/a(TODO)b) TODO";
2623 assert_eq!(find(line), line.rfind("TODO"));
2624 }
2625
2626 #[test]
2627 fn an_unclosed_link_destination_does_not_hide_a_later_marker() {
2628 let line = "[x](/a and TODO";
2629 assert_eq!(find(line), line.find("TODO"));
2630 }
2631
2632 #[test]
2633 fn a_marker_inside_an_autolink_is_not_found() {
2634 assert_eq!(find("<https://example.com/TODO>"), None);
2635 }
2636
2637 #[test]
2638 fn a_marker_inside_an_inline_html_tag_is_not_found() {
2639 assert_eq!(find("a <!--TODO--> b"), None);
2640 let line = "<kbd>TODO</kbd>";
2642 assert_eq!(find(line), line.find("TODO"));
2643 }
2644
2645 #[test]
2646 fn a_less_than_sign_in_prose_is_not_mistaken_for_a_tag() {
2647 let line = "a < b TODO > c";
2650 assert_eq!(find(line), line.find("TODO"));
2651 }
2652
2653 #[test]
2654 fn a_marker_in_a_bare_url_is_found_the_way_the_renderer_sees_it() {
2655 let line = "see https://example.com/TODO now";
2658 assert_eq!(find(line), line.find("TODO"));
2659 }
2660
2661 #[test]
2662 fn a_reference_link_definition_never_yields_a_marker() {
2663 assert_eq!(find("[TODO]: /some/path"), None);
2664 assert_eq!(find("[label]: /TODO/path \"TODO\""), None);
2665 assert_eq!(find(" [label]: /TODO/path"), None);
2667 }
2668
2669 #[test]
2670 fn a_footnote_definition_is_not_a_reference_definition() {
2671 let line = "[^1]: TODO fix this";
2673 assert_eq!(find(line), line.find("TODO"));
2674 }
2675
2676 #[test]
2677 fn the_first_qualifying_marker_wins_when_an_excluded_one_comes_first() {
2678 let line = "`TODO` then FIXME";
2679 assert_eq!(find(line), line.find("FIXME"));
2680 }
2681
2682 fn marker(line: &str) -> Task {
2686 parse_marker_line(line, 1, &rule(DEFAULT_MARKERS))
2687 .unwrap_or_else(|| panic!("expected a marker from {line:?}"))
2688 }
2689
2690 #[test]
2691 fn a_marker_line_becomes_an_open_normal_priority_pseudo_task() {
2692 let found = marker("The market fell 10% (source: TK).");
2693 assert_eq!(found.kind, TaskKind::Marker);
2694 assert_eq!(found.status, TaskStatus::Open);
2695 assert_eq!(found.priority, TaskPriority::Normal);
2696 assert_eq!(found.line, 1);
2697 assert!(found.tags.is_empty());
2698 assert_eq!(found.due, None);
2699 assert_eq!(found.done, None);
2700 assert_eq!(found.moved_to, None);
2701 assert!(!found.due_has_time && !found.done_has_time);
2702
2703 let rule = rule(DEFAULT_MARKERS);
2706 assert!(parse_marker_line("ordinary prose", 1, &rule).is_none());
2707 assert!(parse_marker_line("call `TODO()` later", 1, &rule).is_none());
2708 }
2709
2710 #[test]
2711 fn marker_text_is_the_whole_line_whitespace_collapsed() {
2712 assert_eq!(
2715 marker(" TK check this\tagain ").text,
2716 "TK check this again"
2717 );
2718 assert_eq!(
2720 marker("The market fell 10% (source: TK).").text,
2721 "The market fell 10% (source: TK)."
2722 );
2723 }
2724
2725 #[test]
2726 fn marker_text_keeps_tags_priority_and_date_annotations_verbatim() {
2727 let found = marker("TODO cross-link #docs !! before @due(2026-08-05)");
2731 assert_eq!(
2732 found.text,
2733 "TODO cross-link #docs !! before @due(2026-08-05)"
2734 );
2735 assert!(found.tags.is_empty());
2736 assert_eq!(found.priority, TaskPriority::Normal);
2737 assert_eq!(found.due, None);
2738 }
2739
2740 fn marker_word(found: &Task) -> String {
2743 let (start, end) = (
2744 found.marker_start.expect("a marker carries a span") as usize,
2745 found.marker_end.expect("a marker carries a span") as usize,
2746 );
2747 let units: Vec<u16> = found.text.encode_utf16().collect();
2748 String::from_utf16(&units[start..end]).expect("the span is on a boundary")
2749 }
2750
2751 #[test]
2752 fn a_marker_span_covers_the_marker_word_in_the_display_text() {
2753 let found = marker("The market fell 10% (source: TK).");
2754 assert_eq!((found.marker_start, found.marker_end), (Some(29), Some(31)));
2755 assert_eq!(marker_word(&found), "TK");
2756 }
2757
2758 #[test]
2759 fn a_marker_span_is_in_utf16_units_so_non_ascii_before_it_does_not_shift_it() {
2760 let found = marker("café costs 5€ — TODO check");
2765 assert_eq!(marker_word(&found), "TODO");
2766 assert_eq!((found.marker_start, found.marker_end), (Some(16), Some(20)));
2767 assert_ne!(
2768 found.marker_start.unwrap() as usize,
2769 found.text.find("TODO").unwrap()
2770 );
2771 }
2772
2773 #[test]
2774 fn a_checkbox_task_carries_no_marker_span() {
2775 let task = parse_task_line("- [ ] TODO looks like a marker but is a task", 1).unwrap();
2778 assert_eq!(task.marker_start, None);
2779 assert_eq!(task.marker_end, None);
2780 }
2781
2782 #[test]
2783 fn a_marker_span_survives_whitespace_collapsing() {
2784 let found = marker("foo TODO bar");
2787 assert_eq!(found.text, "foo TODO bar");
2788 assert_eq!((found.marker_start, found.marker_end), (Some(4), Some(8)));
2789 assert_eq!(marker_word(&found), "TODO");
2790 }
2791
2792 #[test]
2793 fn a_marker_span_points_at_the_occurrence_the_rule_accepted() {
2794 let found = marker("Set `TODO` in config and TK fix it");
2799 assert_eq!(marker_word(&found), "TK");
2800 assert_eq!((found.marker_start, found.marker_end), (Some(25), Some(27)));
2801 }
2802
2803 #[test]
2804 fn marker_depth_comes_from_indentation_like_a_task() {
2805 assert_eq!(marker("TK top level").depth, 0);
2808 assert_eq!(marker(" TK two spaces").depth, 1);
2809 assert_eq!(marker(" TK four spaces").depth, 2);
2810 assert_eq!(marker("\tTK one tab").depth, 2);
2811 assert_eq!(marker("- TK inside a bullet").depth, 0);
2812 }
2813
2814 #[test]
2815 fn a_marker_line_cannot_be_patched() {
2816 let source = "notes\nTODO: write this up\n";
2820 let error = patch_task_line(
2821 source,
2822 2,
2823 "TODO: write this up",
2824 TaskStatus::Done,
2825 Some(dt(2026, 8, 4, 14, 32)),
2826 )
2827 .expect_err("a marker line is not patchable");
2828 assert_eq!(error, TaskPatchError::NotATask { line: 2 });
2829
2830 assert!(set_marker("TODO: write this up", TaskStatus::Done).is_none());
2832 assert!(set_status("TODO: write this up", TaskStatus::Done, None).is_none());
2833 }
2834
2835 fn scan_with_markers(source: &str) -> Vec<Task> {
2839 scan_source_tasks_with_markers(source, Some(&rule(DEFAULT_MARKERS)))
2840 }
2841
2842 #[test]
2843 fn a_checkbox_line_that_also_says_todo_is_one_task_not_two() {
2844 let found = scan_with_markers("- [ ] TODO: ship it\n");
2845 assert_eq!(found.len(), 1, "checkbox wins, and yields one entry");
2846 assert_eq!(found[0].kind, TaskKind::Task);
2847 assert_eq!(found[0].text, "TODO: ship it");
2849 assert_eq!(found[0].status, TaskStatus::Open);
2850 }
2851
2852 #[test]
2853 fn scanning_without_a_marker_rule_matches_the_old_behaviour() {
2854 let source = concat!(
2855 "# TODO list\n",
2856 "\n",
2857 "- [ ] real task\n",
2858 "The market fell 10% (source: TK).\n",
2859 "- [x] FIXME later\n",
2860 );
2861 assert_eq!(
2862 scan_source_tasks(source),
2863 scan_source_tasks_with_markers(source, None)
2864 );
2865 assert!(scan_with_markers(source).len() > scan_source_tasks(source).len());
2868 }
2869
2870 #[test]
2871 fn markers_and_tasks_come_back_in_source_order() {
2872 let source = concat!(
2873 "TK intro needs a source\n", "\n", "- [ ] real task\n", "prose with a FIXME here\n", "- [x] done thing\n", );
2879 let found = scan_with_markers(source);
2880 assert_eq!(
2881 found.iter().map(|t| (t.line, t.kind)).collect::<Vec<_>>(),
2882 vec![
2883 (1, TaskKind::Marker),
2884 (3, TaskKind::Task),
2885 (4, TaskKind::Marker),
2886 (5, TaskKind::Task),
2887 ]
2888 );
2889 }
2890
2891 #[test]
2892 fn markers_are_skipped_inside_fenced_code() {
2893 let source =
2896 "TK real\n\n```rust\n// TODO: fake\nlet x = 1; // FIXME fake\n```\n\nXXX also real\n";
2897 let found = scan_with_markers(source);
2898 assert_eq!(found.iter().map(|t| t.line).collect::<Vec<_>>(), vec![1, 8]);
2899
2900 let indented = "Some prose.\n\n TODO: fake\n\nTK real\n";
2902 assert_eq!(
2903 scan_with_markers(indented)
2904 .iter()
2905 .map(|t| t.line)
2906 .collect::<Vec<_>>(),
2907 vec![5]
2908 );
2909 }
2910
2911 #[test]
2912 fn markers_are_skipped_inside_yaml_frontmatter() {
2913 let source = "---\ntitle: TODO pick a title\nstatus: TK\n---\n\nTK real\n";
2914 let found = scan_with_markers(source);
2915 assert_eq!(found.len(), 1);
2916 assert_eq!(found[0].line, 6);
2917 assert_eq!(found[0].text, "TK real");
2918 }
2919
2920 fn taskish_text() -> impl Strategy<Value = String> {
2925 proptest::collection::vec(
2926 prop::sample::select(vec![
2927 "a", "b", " ", "\t", "#", "@", "!", "(", ")", "[", "]", "-", "*", "+", ">", "<",
2928 "x", "X", "due", "done", "2026", "08", "05", "12", ":", ".", "0", "1", "PM", "am",
2929 ]),
2930 0..40,
2931 )
2932 .prop_map(|parts| parts.concat())
2933 }
2934
2935 fn markerish_line() -> impl Strategy<Value = String> {
2943 prop_oneof![
2944 ".*",
2945 proptest::collection::vec(
2946 prop::sample::select(vec![
2947 "a", " ", "`", "[", "]", "(", ")", "<", ">", "|", "\"", "\\", ":", "!", "TK",
2948 "TODO", "FIXME", "XXX", "todo", "é",
2949 ]),
2950 0..40,
2951 )
2952 .prop_map(|parts| parts.concat()),
2953 ]
2954 }
2955
2956 proptest! {
2957 #[test]
2963 fn a_returned_marker_offset_always_lands_on_a_marker(line in markerish_line()) {
2964 if let Some(at) = rule(DEFAULT_MARKERS).find_in_line(&line) {
2965 prop_assert!(
2966 line.is_char_boundary(at),
2967 "offset {} splits a character in {:?}", at, line
2968 );
2969 prop_assert!(
2970 DEFAULT_MARKERS.iter().any(|m| line[at..].starts_with(m)),
2971 "offset {} in {:?} is not a marker", at, line
2972 );
2973 }
2974 }
2975
2976 #[test]
2981 fn a_marker_wrapped_in_a_code_span_is_never_found(
2982 prefix in "[a-z ]{0,20}",
2983 suffix in "[a-z ]{0,20}",
2984 ) {
2985 let line = format!("{prefix}`x TODO y`{suffix}");
2986 prop_assert_eq!(rule(DEFAULT_MARKERS).find_in_line(&line), None);
2987 }
2988
2989 #[test]
2992 fn finding_a_marker_never_panics_on_arbitrary_input(line in markerish_line()) {
2993 let rule = rule(DEFAULT_MARKERS);
2994 let _ = rule.find_in_line(&line);
2995 let _ = rule.block_initial_match(&line);
2996 let _ = rule.find_iter(&line).count();
2997 }
2998
2999 #[test]
3002 fn scanning_with_markers_never_panics_on_arbitrary_input(s in ".*") {
3003 let rule = rule(DEFAULT_MARKERS);
3004 let _ = scan_source_tasks_with_markers(&s, Some(&rule));
3005 let _ = parse_marker_line(&s, 1, &rule);
3006 }
3007
3008 #[test]
3015 fn every_source_line_yields_at_most_one_entry(source in markerish_line()) {
3016 let with_newlines = source.replace(' ', "\n");
3017 let found = scan_source_tasks_with_markers(
3018 &with_newlines,
3019 Some(&rule(DEFAULT_MARKERS)),
3020 );
3021 for pair in found.windows(2) {
3022 prop_assert!(
3023 pair[0].line < pair[1].line,
3024 "line {} repeated or out of order in {:?}",
3025 pair[1].line,
3026 with_newlines
3027 );
3028 }
3029 }
3030
3031 #[test]
3033 fn parse_task_line_never_panics_on_arbitrary_input(s in ".*") {
3034 let _ = parse_task_line(&s, 1);
3035 let _ = set_marker(&s, TaskStatus::Done);
3036 let _ = scan_source_tasks(&s);
3037 }
3038
3039 #[test]
3040 fn parse_task_line_never_panics_on_taskish_input(s in taskish_text()) {
3041 let _ = parse_task_line(&s, 1);
3042 let _ = set_marker(&s, TaskStatus::Done);
3043 }
3044
3045 #[test]
3052 fn display_text_keeps_no_recognised_annotation(s in taskish_text()) {
3053 let line = format!("- [ ] {s}");
3054 if let Some(task) = parse_task_line(&line, 1) {
3055 for caps in DATE_ANNOTATION.captures_iter(&task.text) {
3056 let value = group(&caps, "value").unwrap_or("");
3057 prop_assert!(
3058 parse_datetime(value).is_none(),
3059 "recognised annotation survived in {:?}",
3060 task.text
3061 );
3062 }
3063 prop_assert!(
3064 !task.text.split_whitespace().any(|t| t == "!!" || t == "!!!"),
3065 "standalone priority marker survived in {:?}",
3066 task.text
3067 );
3068 prop_assert_eq!(task.text.trim(), &task.text, "text is not trimmed");
3069 }
3070 }
3071
3072 #[test]
3074 fn constructed_annotations_are_always_stripped(
3075 body in "[a-z ]{0,24}",
3076 year in 2000i32..2100,
3077 month in 1u32..=12,
3078 day in 1u32..=28,
3079 hour in 0u32..24,
3080 minute in 0u32..60,
3081 urgent in any::<bool>(),
3082 tag in "[A-Za-z][A-Za-z0-9_-]{0,10}",
3083 ) {
3084 let bangs = if urgent { "!!!" } else { "!!" };
3085 let line = format!(
3086 "- [ ] {body} @due({year:04}-{month:02}-{day:02} {hour:02}:{minute:02}) {bangs} #{tag}"
3087 );
3088 let task = parse_task_line(&line, 1).expect("constructed line is a task");
3089
3090 prop_assert!(!task.text.contains("@due("));
3091 prop_assert!(!task.text.contains('#'));
3092 prop_assert!(!task.text.split_whitespace().any(|t| t == bangs));
3093 prop_assert_eq!(task.tags, vec![tag]);
3094 prop_assert_eq!(
3095 task.priority,
3096 if urgent { TaskPriority::Urgent } else { TaskPriority::High }
3097 );
3098 prop_assert_eq!(task.due, Some(dt(year, month, day, hour, minute)));
3099 prop_assert!(task.due_has_time);
3100 }
3101
3102 #[test]
3105 fn set_marker_is_a_one_byte_edit_that_round_trips(
3106 indent in "[ \t]{0,6}",
3107 bullet in prop::sample::select(vec!["-", "*", "+", "1.", "7)"]),
3108 gap in "[ \t]{1,3}",
3109 marker in prop::sample::select(vec![' ', 'x', 'X', '-', '>']),
3110 body in "[a-zA-Z0-9 #@!()-]{0,40}",
3111 status in prop::sample::select(vec![
3112 TaskStatus::Open,
3113 TaskStatus::Done,
3114 TaskStatus::Canceled,
3115 ]),
3116 ) {
3117 let line = format!("{indent}{bullet}{gap}[{marker}]{gap}{body}");
3118 let original = parse_task_line(&line, 1).expect("constructed line is a task");
3119 let rewritten = set_marker(&line, status).expect("constructed line is a task");
3120
3121 prop_assert_eq!(rewritten.len(), line.len(), "marker edit changed the length");
3122 prop_assert_eq!(
3123 rewritten.bytes().zip(line.bytes()).filter(|(a, b)| a != b).count() <= 1,
3124 true,
3125 "more than the marker byte changed"
3126 );
3127
3128 let reparsed = parse_task_line(&rewritten, 1).expect("rewritten line is still a task");
3129 prop_assert_eq!(reparsed.status, status);
3130 prop_assert_eq!(&reparsed.text, &original.text);
3132 prop_assert_eq!(&reparsed.tags, &original.tags);
3133 prop_assert_eq!(reparsed.depth, original.depth);
3134 prop_assert_eq!(reparsed.priority, original.priority);
3135 prop_assert_eq!(reparsed.due, original.due);
3136 }
3137
3138 #[test]
3144 fn stamping_and_unstamping_restores_the_original_line(
3145 indent in "[ \t]{0,4}",
3146 bullet in prop::sample::select(vec!["-", "*", "+", "1.", "7)"]),
3147 body in "[a-zA-Z0-9#@!>< -]{0,40}",
3148 terminator in prop::sample::select(vec!["", "\n", "\r\n"]),
3149 reopen_as in prop::sample::select(vec![TaskStatus::Open, TaskStatus::Canceled]),
3150 ) {
3151 let content = format!("{indent}{bullet} [ ] {body}");
3154 let line = format!("{}{terminator}", content.trim_end());
3155 prop_assume!(parse_task_line(&line, 1).is_some());
3156 prop_assume!(done_annotations(&line).next().is_none());
3157
3158 let now = dt(2026, 8, 4, 14, 32);
3159 let done = set_status(&line, TaskStatus::Done, Some(now)).expect("task line");
3160 prop_assert_eq!(
3161 parse_task_line(&done, 1).and_then(|t| t.done),
3162 Some(now),
3163 "the stamp must be readable back out of {:?}", done
3164 );
3165
3166 let reopened = set_status(&done, reopen_as, Some(now)).expect("task line");
3167 let expected = set_marker(&line, reopen_as).expect("task line");
3168 prop_assert_eq!(reopened, expected);
3169 }
3170
3171 #[test]
3174 fn trailing_move_prefilter_is_sound(s in taskish_text()) {
3175 prop_assert!(
3176 !TRAILING_MOVE.is_match(&s) || ends_with_digit_ignoring_blanks(&s),
3177 "prefilter rejected a match in {s:?}"
3178 );
3179 }
3180
3181 #[test]
3188 fn splicing_runs_preserves_their_count(
3189 runs in proptest::collection::vec(taskish_text(), 0..6)
3190 ) {
3191 let borrowed: Vec<&str> = runs.iter().map(String::as_str).collect();
3192 let (out, _) = strip_annotations_across_runs(&borrowed);
3193 prop_assert_eq!(out.len(), runs.len());
3194 }
3195
3196 #[test]
3200 fn splicing_agrees_with_single_string_stripping_on_plain_text(
3201 words in proptest::collection::vec("[a-z]{1,6}", 1..6),
3202 split_at in 0usize..6,
3203 ) {
3204 let joined = words.join(" ");
3205 let at = split_at.min(words.len());
3206 let (head, tail) = words.split_at(at);
3207 let runs = [
3208 format!("{} ", head.join(" ")),
3209 tail.join(" "),
3210 ];
3211 let borrowed: Vec<&str> = runs.iter().map(String::as_str).collect();
3212 let (out, _) = strip_annotations_across_runs(&borrowed);
3213
3214 let collapsed: String = out.concat().split_whitespace().collect::<Vec<_>>().join(" ");
3215 let (single, _) = strip_annotations(&joined);
3216 prop_assert_eq!(collapsed, single);
3217 }
3218
3219 #[test]
3221 fn scanned_line_numbers_address_the_right_line(source in taskish_text()) {
3222 let with_newlines = source.replace('.', "\n");
3223 let lines: Vec<&str> = with_newlines.lines().collect();
3224 for task in scan_source_tasks(&with_newlines) {
3225 let index = usize::try_from(task.line).unwrap_or(usize::MAX);
3226 let Some(line) = index.checked_sub(1).and_then(|i| lines.get(i)) else {
3227 return Err(TestCaseError::fail("line number out of range"));
3228 };
3229 let reparsed = parse_task_line(line, task.line);
3230 prop_assert_eq!(reparsed.as_ref(), Some(&task));
3231 }
3232 }
3233 }
3234}