Skip to main content

mbr_core/
tasks.rs

1//! Task Parsing Module
2//!
3//! Pure, allocation-light parsing of markdown task lines and of whole markdown
4//! sources. This module knows nothing about the filesystem, the index, or HTTP —
5//! it turns text into [`Task`] values and back again.
6//!
7//! # Grammar
8//!
9//! A task line is a list item whose content starts with a checkbox:
10//!
11//! ```text
12//! ^[ \t]*(?:[-*+]|\d+[.)])[ \t]+\[[ xX>-]\](?:[ \t]+text)?$
13//! ```
14//!
15//! | Marker  | Status                            |
16//! |---------|-----------------------------------|
17//! | ` `     | [`TaskStatus::Open`]              |
18//! | `x` `X` | [`TaskStatus::Done`]              |
19//! | `-`     | [`TaskStatus::Canceled`]          |
20//! | `>`     | [`TaskStatus::Canceled`] (moved)  |
21//!
22//! # Annotations
23//!
24//! Everything below is parsed out of the task text and stripped from
25//! [`Task::text`], which is left whitespace-collapsed and trimmed.
26//!
27//! | Syntax                    | Meaning       | Rule |
28//! |---------------------------|---------------|------|
29//! | `@due(<dt>)`              | Due date      | `<dt>` = `YYYY-MM-DD`, optional ` HH:MM` (24h) or ` HH:MM AM/PM` |
30//! | `@done(<dt>)`             | Completion    | same datetime grammar |
31//! | `#tag`                    | Tag           | `[A-Za-z0-9_-]+`, after start-of-text or whitespace |
32//! | `!!` / `!!!`              | High / Urgent | whitespace-delimited on both sides |
33//! | `> YYYY-MM-DD` (trailing) | Moved-to date | recorded in [`Task::moved_to`], stripped |
34//! | `< YYYY-MM-DD` (trailing) | Moved-from    | stripped and discarded |
35//!
36//! Dates are naive and local: no timezone conversion, no UTC round-trip. A
37//! `@due(2026-08-05)` with no time is start-of-day.
38//!
39//! An annotation whose payload does not parse as a date is *not* an annotation.
40//! `@due(next tuesday)` stays in the display text verbatim rather than being
41//! silently swallowed — a user who mistypes a date should see their typo, not a
42//! task that quietly lost its deadline.
43//!
44//! # Incomplete markers
45//!
46//! A line containing a configured marker word ([`MarkerRule`], from
47//! `incomplete_markers`) is a second, read-only kind of entry:
48//! [`parse_marker_line`] turns it into a [`TaskKind::Marker`] whose text is the
49//! whole line and whose annotations are deliberately not parsed. Nothing here
50//! can write one back — [`patch_task_line`] and [`set_marker`] both go through
51//! the checkbox grammar — so "read-only" is a property of the code, not a
52//! convention.
53//!
54//! # Examples
55//!
56//! ```
57//! use mbr_core::tasks::{TaskPriority, TaskStatus, parse_task_line};
58//!
59//! let task = parse_task_line("- [ ] ship it !!! #work @due(2026-08-05)", 12).unwrap();
60//! assert_eq!(task.line, 12);
61//! assert_eq!(task.status, TaskStatus::Open);
62//! assert_eq!(task.priority, TaskPriority::Urgent);
63//! assert_eq!(task.text, "ship it");
64//! assert_eq!(task.tags, ["work"]);
65//! assert!(task.due.is_some() && !task.due_has_time);
66//! ```
67
68use 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
81/// Format of the `@done(...)` timestamp [`set_status`] writes.
82///
83/// The 24-hour form on purpose: it is the shortest spelling
84/// [`parse_datetime`] accepts, so a line mbr stamps parses straight back to the
85/// instant it wrote.
86const DONE_STAMP_FORMAT: &str = "%Y-%m-%d %H:%M";
87
88/// The task-line *prefix*: `- [ ] `, `1. [x]\t`, `* [-]`, `+ [>] `.
89///
90/// The grammar this implements is
91/// `^[ \t]*(?:[-*+]|\d+[.)])[ \t]+\[[ xX>-]\](?:[ \t]+text)?$`, but the pattern
92/// deliberately stops at the checkbox instead of capturing the indent and the
93/// trailing text. Those two are recovered by slicing in [`match_task_line`],
94/// which is exactly equivalent and about **ten times faster**: a trailing `.*`
95/// capture forces the regex crate onto its slow capture-tracking engine and makes
96/// it walk the whole line, which showed up as ~710ns per task line against ~70ns
97/// here. On a repository with hundreds of thousands of tasks that is the
98/// difference between a noticeable index build and an invisible one.
99///
100/// `(?:[ \t]+|$)` preserves the `$`-anchored original: whitespace must follow the
101/// checkbox unless the line ends there, so `- [ ]x` is still not a task.
102static 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
107/// `@due(...)` / `@done(...)`. The payload is captured loosely and validated by
108/// [`parse_datetime`], so an unparseable payload can be left in place verbatim.
109static 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
114/// `YYYY-MM-DD`, optionally ` HH:MM` (24-hour) or ` HH:MM AM/PM`. The AM/PM
115/// suffix is case-insensitive and its leading space is optional.
116static 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
123/// `#tag`, anchored to start-of-text or whitespace so that the fragment in
124/// `see page.md#anchor` is not mistaken for a tag.
125static 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
130/// A trailing `> YYYY-MM-DD` (moved to) or `< YYYY-MM-DD` (moved from). Anchored
131/// to the end of the text and preceded by whitespace, so a `>` used in prose
132/// mid-line is never eaten.
133static 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/// Completion state of a task.
139///
140/// `[>]` (moved elsewhere) collapses to `Canceled`; the destination date is kept
141/// separately in [`Task::moved_to`].
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
143#[serde(rename_all = "lowercase")]
144pub enum TaskStatus {
145    /// `[ ]` — not yet done.
146    Open,
147    /// `[x]` or `[X]` — completed.
148    Done,
149    /// `[-]` or `[>]` — will not be done here.
150    Canceled,
151}
152
153impl TaskStatus {
154    /// The marker byte [`set_marker`] writes for this status.
155    ///
156    /// `Canceled` always writes `-`: `>` carries a destination date this function
157    /// has no way to supply, so a toggle to canceled deliberately downgrades to
158    /// the plain form.
159    const fn marker_char(self) -> char {
160        match self {
161            Self::Open => ' ',
162            Self::Done => 'x',
163            Self::Canceled => '-',
164        }
165    }
166}
167
168/// Task priority. There is no "low": `Normal` is the default and `!!` / `!!!`
169/// escalate from there.
170///
171/// `Ord` is derived and meaningful — variants are declared lowest-first so that
172/// "highest priority wins" is a `max`.
173#[derive(
174    Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
175)]
176#[serde(rename_all = "lowercase")]
177pub enum TaskPriority {
178    /// No priority marker.
179    #[default]
180    Normal,
181    /// `!!`
182    High,
183    /// `!!!`
184    Urgent,
185}
186
187/// What kind of source line a [`Task`] came from.
188///
189/// A checkbox is writable and carries the full annotation grammar; an incomplete
190/// marker (`TK`, `TODO`, …) is a read-only pointer at a line somebody left
191/// unfinished. Both are surfaced by the task browser, so the two have to travel
192/// in one type — but almost every behaviour that differs between them keys off
193/// this field, which is why it is not an `Option`-shaped afterthought.
194#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
195#[serde(rename_all = "lowercase")]
196pub enum TaskKind {
197    /// A `- [ ]` checkbox line.
198    #[default]
199    Task,
200    /// A line containing an incomplete marker; see [`MarkerRule`].
201    Marker,
202}
203
204/// One parsed task line.
205#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
206pub struct Task {
207    /// 1-based source line number.
208    pub line: u32,
209    /// Display indent level; see [`parse_task_line`] for how it is derived.
210    pub depth: u8,
211    /// Whether this came from a checkbox or from an incomplete marker.
212    pub kind: TaskKind,
213    /// Completion state.
214    pub status: TaskStatus,
215    /// Priority, `Normal` unless `!!` or `!!!` appeared.
216    pub priority: TaskPriority,
217    /// Display text: annotations stripped, whitespace collapsed, trimmed.
218    pub text: String,
219    /// Where the marker word sits inside [`Task::text`], as **UTF-16 code unit**
220    /// indices — which is what a JavaScript string index is, so the panel can
221    /// `slice()` with them directly. `None` for a checkbox task.
222    ///
223    /// Sent rather than re-derived in the browser because the grammar is
224    /// markup-aware and the boundary rules are per-alternative; a second
225    /// implementation in TypeScript would drift from this one.
226    pub marker_start: Option<u32>,
227    /// End of the marker word; see [`Task::marker_start`].
228    pub marker_end: Option<u32>,
229    /// Tags without the leading `#`, original case, de-duplicated.
230    pub tags: Vec<String>,
231    /// `@due(...)`, start-of-day when no time was given.
232    pub due: Option<NaiveDateTime>,
233    /// Whether `due` carried an explicit time.
234    pub due_has_time: bool,
235    /// `@done(...)`, start-of-day when no time was given.
236    pub done: Option<NaiveDateTime>,
237    /// Whether `done` carried an explicit time.
238    pub done_has_time: bool,
239    /// Trailing `> YYYY-MM-DD`: where this task was moved to.
240    pub moved_to: Option<NaiveDate>,
241}
242
243/// Everything [`strip_annotations`] pulls out of a task's text.
244#[derive(Debug, Clone, Default, PartialEq, Eq)]
245pub struct Annotations {
246    /// Highest priority marker found.
247    pub priority: TaskPriority,
248    /// Tags without the leading `#`, original case, de-duplicated.
249    pub tags: Vec<String>,
250    /// `@due(...)`.
251    pub due: Option<NaiveDateTime>,
252    /// Whether `due` carried an explicit time.
253    pub due_has_time: bool,
254    /// `@done(...)`.
255    pub done: Option<NaiveDateTime>,
256    /// Whether `done` carried an explicit time.
257    pub done_has_time: bool,
258    /// Trailing `> YYYY-MM-DD`.
259    pub moved_to: Option<NaiveDate>,
260}
261
262/// The compiled `incomplete_markers` rule: one shared answer to "is this an
263/// incomplete marker?", used by the renderer that highlights them and by the
264/// scanner that indexes them.
265///
266/// It is a newtype rather than a bare `Regex` so that `regex` stays out of this
267/// crate's public API, and so the two consumers cannot drift apart into two
268/// notions of what a marker is.
269///
270/// # Word boundaries
271///
272/// Boundaries are decided **per alternative**, not once around the group. A
273/// single `\b` on the whole alternation demands a word character on the far
274/// side of every marker, whatever the marker ends with — so a marker configured
275/// as `TODO:` would never match `TODO: foo`, because the byte after the colon is
276/// a space. Symmetrically, a leading `\b` would stop `@todo` from matching at the
277/// start of a line, and would demand a word character *before* the `@`. So a
278/// `\b` is prefixed only when the marker's first character is word-ish and
279/// suffixed only when its last one is, where word-ish means
280/// `char::is_alphanumeric() || c == '_'` — the definition the regex crate's own
281/// Unicode `\b` uses.
282///
283/// # Longest match
284///
285/// The regex crate's alternation is leftmost-*first*, not leftmost-longest: in
286/// `TODO|TODOMAYBE` the shorter branch would win. Alternatives are therefore
287/// sorted longest-first at construction, which makes the result independent of
288/// the order the markers appear in the configuration file.
289///
290/// # No prefilter
291///
292/// There is deliberately no hand-rolled pre-check of the kind [`might_be_task`]
293/// performs. The two situations are not the same. [`might_be_task`] exists to
294/// keep [`TASK_LINE`] off the regex crate's *capture-tracking* engine, which is
295/// the slow one — roughly 710ns against 70ns, as documented on [`TASK_LINE`]. A
296/// [`MarkerRule`] search captures nothing, so it runs on the meta engine, which
297/// already extracts the literal alternation and drives a SIMD prefilter over it.
298/// A second prefilter in front of that would duplicate work the regex crate does
299/// better, and would have to be kept in agreement with the boundary rules above.
300#[derive(Debug, Clone)]
301pub struct MarkerRule {
302    pattern: Regex,
303}
304
305impl MarkerRule {
306    /// Compiles a rule from the configured markers, or returns `None` when there
307    /// is nothing to match.
308    ///
309    /// Empty entries are dropped, so `incomplete_markers = [""]` is the same off
310    /// switch as `incomplete_markers = []` — and `None` is the signal to skip the
311    /// pass entirely rather than to run a pattern that matches everywhere.
312    ///
313    /// Markers are `regex::escape`d, so a marker containing metacharacters
314    /// (`FIXME(`) is matched literally instead of breaking compilation.
315    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        // Stable, so markers of equal length keep their configured order.
325        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        // Escaping makes the pattern valid by construction; `ok()` only absorbs
333        // the pathological case of a marker list large enough to blow the
334        // regex crate's compiled-size limit.
335        Regex::new(&format!("(?:{alternation})"))
336            .ok()
337            .map(|pattern| Self { pattern })
338    }
339
340    /// [`MarkerRule::new`], memoised on the marker list that produced it.
341    ///
342    /// **Use this on any path that runs per file or per request.** Compiling the
343    /// pattern costs ~80µs — three to four times what rendering a small page
344    /// costs in total — because the regex crate builds an NFA, a lazy DFA and a
345    /// literal prefilter. The render pipeline needs a rule for every page it
346    /// renders, and `mark_incomplete` is on by default in server and GUI mode,
347    /// so compiling per render put that 80µs on the default path for every
348    /// request. (Callers that build a rule once and keep it — [`TaskIndex`] does
349    /// — should keep calling [`MarkerRule::new`] and own the result.)
350    ///
351    /// Keyed by the marker list rather than held in a plain `OnceLock` because a
352    /// single process legitimately renders with more than one configuration: the
353    /// test suite does it constantly, and nothing stops a GUI from opening two
354    /// repositories whose `.mbr/config.toml` disagree. A `None` result is cached
355    /// too, so a marker list that cannot compile is not retried per page.
356    ///
357    /// Entries are never evicted. The key space is the set of distinct
358    /// `incomplete_markers` values a process ever sees — one in production, a
359    /// handful under test — so there is nothing to bound.
360    ///
361    /// [`TaskIndex`]: crate::task_index::TaskIndex
362    pub fn cached(markers: &[String]) -> Option<Arc<Self>> {
363        /// Marker list -> the rule it compiles to, or `None` when it compiles to
364        /// nothing. The `Option` is part of the value so a miss is cached too.
365        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        // A concurrent miss just compiles twice and one insert wins; the rules
374        // are identical, so there is nothing to serialise on.
375        let rule = Self::new(markers).map(Arc::new);
376        compiled
377            .get_or_insert_with(markers.into(), || rule.clone())
378            .clone()
379    }
380
381    /// Byte length of the marker `text` begins with, or `None` when it begins
382    /// with something else.
383    ///
384    /// This is the renderer's block-initial test, and the direct replacement for
385    /// the old `^(?:M1|M2)\b` pattern.
386    pub fn block_initial_match(&self, text: &str) -> Option<usize> {
387        // Matching is leftmost-first, so if anything matches at 0 this is it.
388        // The prefix `\b` cannot spoil a match at offset 0: it is only ever
389        // added when the marker starts with a word character, which is exactly
390        // when start-of-haystack is a boundary.
391        self.pattern
392            .find(text)
393            .filter(|m| m.start() == 0)
394            .map(|m| m.end())
395    }
396
397    /// Every marker in `text`, as byte ranges, ascending and non-overlapping.
398    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    /// Byte offset of the first marker in a raw source line that a reader would
406    /// actually see, or `None` when the line has none.
407    ///
408    /// Unlike [`MarkerRule::find_iter`] this understands markdown: a marker
409    /// inside a code span, a wikilink target, a link destination or title, an
410    /// autolink or a reference definition does not count. See [`MarkupCursor`].
411    pub fn find_in_line(&self, line: &str) -> Option<usize> {
412        find_marker_outside_markup(line, self).map(|span| span.start)
413    }
414}
415
416/// One alternation branch: the escaped marker, with a `\b` on each side that the
417/// marker's own spelling asks for. See [`MarkerRule`] for why the boundaries are
418/// conditional.
419fn 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(&regex::escape(marker));
427    if marker.chars().next_back().is_some_and(word_ish) {
428        out.push_str(r"\b");
429    }
430    out
431}
432
433/// First marker in `line` that falls outside markup, as a byte range.
434///
435/// The **range**, not just the start, because the panel highlights the marker
436/// word inside the card the way the rendered page highlights it in the
437/// document, and it cannot recover the end itself: the alternation is
438/// longest-first over a configurable list, so which marker matched is only
439/// known here.
440///
441/// **The order of the two passes is the performance design, not an accident.**
442/// The regex runs first over the whole line and [`MarkupCursor`] is consulted
443/// only about the offsets it returned, because well under one line in a hundred
444/// contains a marker word at all — so on the overwhelming majority of lines the
445/// markup scanner never runs. Segmenting the line first and matching within the
446/// segments would invert that: every line in the repository would be parsed, and
447/// the segments would have to be materialised somewhere.
448///
449/// Only a match's **start** is tested. A marker straddling the edge of a code
450/// span is not a thing an author can write, and testing the whole range would
451/// mean asking the forward-only cursor two questions per match.
452fn find_marker_outside_markup(line: &str, rule: &MarkerRule) -> Option<std::ops::Range<usize>> {
453    // A reference definition is a whole-line judgement rather than a span, so it
454    // is settled before the cursor is built.
455    if is_reference_definition(line) {
456        return None;
457    }
458    let mut cursor = MarkupCursor::new(line);
459    // `find_iter` yields ascending, non-overlapping ranges, which is exactly the
460    // non-decreasing query order the cursor requires.
461    rule.find_iter(line).find(|m| !cursor.excludes(m.start))
462}
463
464/// Byte offset within `s` re-expressed as a count of **UTF-16 code units**.
465///
466/// The panel slices [`Task::text`] with these, and a JavaScript string index
467/// *is* a UTF-16 code unit index: a Rust byte offset would land mid-character
468/// the moment the line contains anything outside ASCII, so `"café … TODO"`
469/// would highlight the wrong span (or, past the end, none at all). Saturating
470/// into `u32` costs nothing real — a source line long enough to overflow it is
471/// not a line anybody is reading.
472fn utf16_offset(s: &str, byte_offset: usize) -> u32 {
473    u32::try_from(s[..byte_offset].encode_utf16().count()).unwrap_or(u32::MAX)
474}
475
476/// Parses a single line as a task, returning `None` if it is not one.
477///
478/// `line_number` is stored verbatim in [`Task::line`]; callers are responsible
479/// for it being 1-based.
480///
481/// # Depth
482///
483/// Indentation is measured in columns with a tab counting as four, then halved:
484/// `depth = columns / 2`. Markdown nesting is two-or-four spaces depending on the
485/// author (and a tab depending on the editor), and there is no way to recover the
486/// author's intent from a single line. All this value has to be is *monotonic*
487/// for display indentation, so the cheap halving is enough — it maps both the
488/// two-space and the four-space convention onto increasing levels, and it never
489/// panics because it saturates at [`u8::MAX`].
490///
491/// # Examples
492///
493/// ```
494/// use mbr_core::tasks::{TaskStatus, parse_task_line};
495///
496/// assert!(parse_task_line("not a task", 1).is_none());
497/// assert!(parse_task_line("- [y] unknown marker", 1).is_none());
498///
499/// let task = parse_task_line("  * [x] done thing @done(2026-08-04 12:11 PM)", 3).unwrap();
500/// assert_eq!(task.status, TaskStatus::Done);
501/// assert_eq!(task.depth, 1);
502/// assert_eq!(task.text, "done thing");
503/// assert!(task.done_has_time);
504/// ```
505pub 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        // `-` and `>`; the regex admits nothing else.
514        _ => TaskStatus::Canceled,
515    };
516    // `indent_width` stops at the first non-space/tab byte, which is the bullet,
517    // so measuring the whole line measures exactly the indent.
518    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        // A checkbox task has no marker word to point at.
529        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
540/// Parses a single line as an incomplete *marker* — a `TK`, `TODO`, `FIXME`
541/// that a reader would actually see — returning `None` when it has none.
542///
543/// The result is a read-only pseudo-task: `status` is [`TaskStatus::Open`],
544/// `priority` is [`TaskPriority::Normal`], and `tags`/`due`/`done`/`moved_to`
545/// are all empty. Nothing here is inferred from the line, because there is no
546/// checkbox to write back to — [`patch_task_line`] refuses a marker line, and
547/// [`set_marker`] cannot match one — so an inferred field would be a value the
548/// user can never correct.
549///
550/// # Text
551///
552/// [`Task::text`] is the **whole line**, whitespace-collapsed and otherwise
553/// verbatim: the marker word, any `#tag`, `!!` or `@due(...)` all survive. A
554/// marker is a pointer at a line somebody left unfinished, and the annotation
555/// grammar is the checkbox grammar — parsing `#docs` out of
556/// `TODO: cross-link #docs` would strip text the reader wrote as prose and file
557/// the entry under a tag they never applied to a task.
558///
559/// [`Task::marker_start`] and [`Task::marker_end`] say where in that text the
560/// marker word itself sits, so the panel can give it the same wash the rendered
561/// page does without re-implementing the grammar.
562///
563/// # Matching
564///
565/// Whether a line carries a marker is [`MarkerRule::find_in_line`]'s judgement,
566/// which is markup-aware: a marker inside a code span, a wikilink target, a
567/// link destination or an autolink does not count. That is the same call the
568/// renderer's highlighting makes, so a line that shows a highlight is a line
569/// the panel lists, and only that.
570///
571/// # Depth
572///
573/// Indentation is measured exactly as [`parse_task_line`] measures it, so a
574/// marker nested inside a list indents alongside the tasks around it.
575///
576/// # Examples
577///
578/// ```
579/// use mbr_core::tasks::{MarkerRule, TaskKind, TaskStatus, parse_marker_line};
580///
581/// let rule = MarkerRule::new(&["TODO".to_string()]).expect("one marker compiles");
582///
583/// let marker = parse_marker_line("  the source is  TODO #docs", 7, &rule).unwrap();
584/// assert_eq!(marker.line, 7);
585/// assert_eq!(marker.kind, TaskKind::Marker);
586/// assert_eq!(marker.status, TaskStatus::Open);
587/// assert_eq!(marker.text, "the source is TODO #docs");
588/// assert_eq!((marker.marker_start, marker.marker_end), (Some(14), Some(18)));
589/// assert!(marker.tags.is_empty());
590///
591/// // A marker inside a code span is markup, not a marker.
592/// assert!(parse_marker_line("call `TODO()` later", 1, &rule).is_none());
593/// ```
594pub fn parse_marker_line(line: &str, line_number: u32, rule: &MarkerRule) -> Option<Task> {
595    // Gate on the RAW line, so what counts as a marker stays decided by the
596    // bytes the author actually wrote.
597    find_marker_outside_markup(line, rule)?;
598    // Same measurement as `parse_task_line`, deliberately: see its `# Depth`.
599    let depth = u8::try_from(indent_width(line) / 2).unwrap_or(u8::MAX);
600    // Every token survives: markers get no annotation parsing at all.
601    let text = collapse_whitespace(line, |_| true);
602    // Located a second time, because the raw-line offset does not index `text`
603    // once runs of whitespace have been collapsed. Collapsing whitespace cannot
604    // change markup structure, so this finds the same occurrence. If it somehow
605    // does not, the panel just renders the line without a highlight — a missing
606    // wash is invisible, a wrong one is a lie.
607    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
627/// Rewrites the marker of an existing task line, preserving every other byte.
628///
629/// Indentation, bullet style, internal spacing and annotations all survive
630/// untouched, which is what makes this safe to use for an in-place line patch of
631/// a file the user also edits by hand. Returns `None` when `line` is not a task.
632///
633/// # Examples
634///
635/// ```
636/// use mbr_core::tasks::{TaskStatus, set_marker};
637///
638/// let line = "\t2)  [ ]   buy milk   @due(2026-08-05)";
639/// assert_eq!(
640///     set_marker(line, TaskStatus::Done).unwrap(),
641///     "\t2)  [x]   buy milk   @due(2026-08-05)"
642/// );
643/// assert!(set_marker("# not a task", TaskStatus::Done).is_none());
644/// ```
645pub 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    // Every marker is one ASCII byte, so this split is always on a char boundary.
651    out.push_str(&line[at + 1..]);
652    Some(out)
653}
654
655/// Rewrites the marker of a task line *and* maintains its `@done(...)` stamp.
656///
657/// `now` is the clock, passed in rather than read, so this stays a pure
658/// function of its inputs. `None` disables stamping entirely (the
659/// `tasks_stamp_done = false` case), making this exactly [`set_marker`]: an
660/// existing `@done(...)` is then left as the author wrote it, because a user who
661/// opted out has not asked mbr to curate their annotations.
662///
663/// With a clock:
664///
665/// - to [`TaskStatus::Done`], ` @done(<now>)` is appended — unless the line
666///   already carries a recognised one, so toggling done twice does not stamp
667///   twice;
668/// - away from `Done`, every recognised `@done(...)` is removed together with
669///   the whitespace in front of it.
670///
671/// A `@done(...)` whose payload is not a date is not an annotation (see
672/// [`strip_annotations`]) and is therefore neither recognised nor removed.
673///
674/// Everything else survives byte for byte, including any line terminator — a
675/// CRLF line stays CRLF.
676///
677/// # Examples
678///
679/// ```
680/// use chrono::NaiveDate;
681/// use mbr_core::tasks::{TaskStatus, set_status};
682///
683/// let now = NaiveDate::from_ymd_opt(2026, 8, 4)
684///     .and_then(|d| d.and_hms_opt(14, 32, 0))
685///     .unwrap();
686///
687/// let done = set_status("- [ ] write the report !!", TaskStatus::Done, Some(now)).unwrap();
688/// assert_eq!(done, "- [x] write the report !! @done(2026-08-04 14:32)");
689///
690/// // Stamping is idempotent...
691/// assert_eq!(set_status(&done, TaskStatus::Done, Some(now)).unwrap(), done);
692/// // ...and reversible.
693/// assert_eq!(
694///     set_status(&done, TaskStatus::Open, Some(now)).unwrap(),
695///     "- [ ] write the report !!"
696/// );
697/// ```
698pub 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/// A markdown source with exactly one line rewritten by [`patch_task_line`].
713#[derive(Debug, Clone, PartialEq, Eq)]
714pub struct PatchedSource {
715    /// The whole file, ready to be written back.
716    pub source: String,
717    /// The new text of the patched line, without its terminator.
718    pub text: String,
719}
720
721/// Rewrites one task line of a markdown source, checking first that the line is
722/// still what the caller last saw.
723///
724/// This is the whole body of `POST /.mbr/task`, minus the I/O: the caller reads
725/// the file, hands the text here, and writes back [`PatchedSource::source`].
726/// Keeping it pure is what makes the awkward parts — line addressing, the
727/// terminator, the `expected` comparison — testable without a filesystem.
728///
729/// `line_number` is 1-based. `expected` is compared to the line on disk
730/// byte-for-byte *modulo the line terminator*, so a client that captured the
731/// line with or without its `\n` (or `\r\n`) is treated the same.
732///
733/// Only the one line is touched: no other byte of `source` moves, the patched
734/// line keeps its own terminator, and a file that did not end in a newline still
735/// does not.
736///
737/// # Examples
738///
739/// ```
740/// use chrono::NaiveDate;
741/// use mbr_core::tasks::{TaskStatus, patch_task_line};
742///
743/// let now = NaiveDate::from_ymd_opt(2026, 8, 4)
744///     .and_then(|d| d.and_hms_opt(14, 32, 0))
745///     .unwrap();
746/// let source = "# Notes\r\n- [ ] ship it\r\n- [ ] later\r\n";
747///
748/// let patched =
749///     patch_task_line(source, 2, "- [ ] ship it", TaskStatus::Done, Some(now)).unwrap();
750/// assert_eq!(patched.text, "- [x] ship it @done(2026-08-04 14:32)");
751/// assert_eq!(
752///     patched.source,
753///     "# Notes\r\n- [x] ship it @done(2026-08-04 14:32)\r\n- [ ] later\r\n"
754/// );
755/// ```
756pub 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    // `set_status` would refuse too, but checking here means a non-task line is
771    // reported as one rather than as a generic failure to rewrite.
772    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
790/// Byte range of the 1-based `line_number`th line of `source`, terminator
791/// included, or `None` when the source has no such line.
792///
793/// A trailing newline terminates the last line rather than starting an empty
794/// one, so `"a\n"` has exactly one line — which is what every editor, and
795/// [`str::lines`], also says.
796pub(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
808/// Splits a line into its content and its terminator (`""`, `"\n"`, `"\r\n"`,
809/// or a lone `"\r"`).
810///
811/// The terminator is carried through verbatim rather than normalized: a CRLF
812/// file that mbr patches one line of must stay a CRLF file.
813pub(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
818/// Appends `@done(now)`, unless a recognised one is already there.
819fn with_done_stamp(content: &str, now: NaiveDateTime) -> String {
820    if done_annotations(content).next().is_some() {
821        return content.to_string();
822    }
823    // Trailing blanks would otherwise strand a widening gap in front of the
824    // stamp every time a task is re-completed.
825    let trimmed = content.trim_end_matches([' ', '\t']);
826    format!("{trimmed} @done({})", now.format(DONE_STAMP_FORMAT))
827}
828
829/// Removes every recognised `@done(...)`, taking the whitespace in front of it
830/// so that un-completing a task does not leave a trailing space behind.
831fn 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
847/// Byte ranges of every `@done(...)` in `text` whose payload really is a
848/// datetime.
849///
850/// Shared by the add and remove paths so they cannot disagree about what a
851/// stamp is — the idempotence of [`set_status`] rests on exactly that.
852fn 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
862/// Scans a whole markdown source for task lines.
863///
864/// Checkboxes only; [`scan_source_tasks_with_markers`] is the same pass with
865/// incomplete markers turned on.
866///
867/// Line numbers are 1-based and count every physical line, including the ones
868/// skipped below. Lines are *not* treated as tasks when they fall inside:
869///
870/// - YAML frontmatter at the top of the file;
871/// - a fenced code block (backtick or tilde, any fence length, info string or
872///   not, including inside a blockquote);
873/// - an indented code block.
874///
875/// # Examples
876///
877/// ```
878/// use mbr_core::tasks::scan_source_tasks;
879///
880/// let source = "---\ntitle: Notes\n---\n\n- [ ] real task\n\n```\n- [ ] sample\n```\n";
881/// let tasks = scan_source_tasks(source);
882/// assert_eq!(tasks.len(), 1);
883/// assert_eq!(tasks[0].line, 5);
884/// assert_eq!(tasks[0].text, "real task");
885/// ```
886pub fn scan_source_tasks(source: &str) -> Vec<Task> {
887    scan_source_tasks_with_markers(source, None)
888}
889
890/// [`scan_source_tasks`], additionally recording incomplete markers.
891///
892/// `None` scans checkboxes only and is exactly [`scan_source_tasks`]; `Some`
893/// also records a [`TaskKind::Marker`] entry for every line carrying a marker
894/// the rule recognises. The two skip rules are shared, so a `TODO` inside
895/// frontmatter or a fenced code block is no more a marker than a `- [ ]` there
896/// is a task.
897///
898/// # One entry per source line
899///
900/// The composition below **is** the precedence rule, structurally rather than
901/// by inspection:
902///
903/// ```text
904/// parse_task_line(line, n).or_else(|| markers.and_then(|r| parse_marker_line(line, n, r)))
905/// ```
906///
907/// `- [ ] TODO: ship it` is a task and never also a marker, because
908/// [`parse_task_line`] answering first short-circuits the `or_else`. And since
909/// the whole thing sits inside a `filter_map`, a line can yield at most one
910/// entry — which is what makes "one entry per source line" hold by
911/// construction, and with it the uniqueness of the `#mbr-marker-{line}` anchor
912/// the panel deep-links to.
913///
914/// # Examples
915///
916/// ```
917/// use mbr_core::tasks::{MarkerRule, TaskKind, scan_source_tasks_with_markers};
918///
919/// let rule = MarkerRule::new(&["TODO".to_string()]).expect("one marker compiles");
920/// let source = "- [ ] TODO: ship it\n\nthe rest is TODO\n";
921/// let found = scan_source_tasks_with_markers(source, Some(&rule));
922///
923/// assert_eq!(found.len(), 2, "the checkbox line is one entry, not two");
924/// assert_eq!(found[0].kind, TaskKind::Task);
925/// assert_eq!(found[1].kind, TaskKind::Marker);
926/// assert_eq!(found[1].line, 3);
927/// ```
928pub 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    // NOTE (indented code): `BlockScanner` only opens an indented code block
933    // outside a list, deciding "inside a list" from the last column-0 line. That
934    // is deliberate and load-bearing here: a tab-indented `- [ ] subtask` is four
935    // columns deep, so treating four columns as code unconditionally would lose
936    // every nested subtask — the exact shape TASKS_SPEC.md asks us to support.
937    // The price is one narrow false positive: a genuine indented code block
938    // written *inside* a list item (item indent plus four more columns) has its
939    // `- [ ]` lines read as tasks, because recognising it needs the full
940    // list-item context this raw-text pass deliberately does not track. We take
941    // that trade because a spurious task from a code sample is visible and
942    // harmless, while a silently missing subtask is neither. Covered by
943    // `indented_code_inside_a_list_item_is_a_known_false_positive`.
944    source
945        .lines()
946        .enumerate()
947        .skip(skip)
948        .filter_map(|(index, line)| {
949            // Every non-frontmatter line must be classified, even ones that
950            // cannot be tasks, or the fence state machine loses track.
951            if blocks.classify(line) == LineKind::Code {
952                return None;
953            }
954            let number = u32::try_from(index + 1).unwrap_or(u32::MAX);
955            // See `# One entry per source line`: the `or_else` is the checkbox
956            // precedence rule, and the enclosing `filter_map` is the
957            // one-entry-per-line rule.
958            parse_task_line(line, number)
959                .or_else(|| markers.and_then(|rule| parse_marker_line(line, number, rule)))
960        })
961        .collect()
962}
963
964/// Pulls every annotation out of a task's text.
965///
966/// Returns the display text — annotations removed, whitespace collapsed to
967/// single spaces, trimmed — alongside what was found. Collapsing is
968/// unconditional, so `a    b` becomes `a b` even in a task with no annotations
969/// at all; the alternative is text whose spacing depends on whether an
970/// annotation happened to be present.
971///
972/// Stripping runs in a fixed order: date annotations, then the trailing
973/// move marker, then tags, then priority. That order is what "trailing" means for
974/// `> YYYY-MM-DD` — at the very end once `@due(...)`/`@done(...)` are gone, but
975/// still ahead of any trailing tag. `do the thing > 2026-08-04 #work` therefore
976/// keeps its `>` in the display text, because the `#work` is what is trailing.
977///
978/// # Examples
979///
980/// ```
981/// use mbr_core::tasks::strip_annotations;
982///
983/// let (text, ann) = strip_annotations("email #work #WORK bob @due(2026-08-05 09:30)");
984/// assert_eq!(text, "email bob");
985/// // De-duplication is case-insensitive and keeps the first spelling.
986/// assert_eq!(ann.tags, ["work"]);
987/// assert!(ann.due_has_time);
988/// ```
989pub 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
1001/// Separator [`strip_annotations_across_runs`] splices between inline text runs.
1002///
1003/// U+0000 is the natural choice: CommonMark tells a parser to replace it with
1004/// U+FFFD, so it should never reach a renderer, and it is not whitespace, so it
1005/// cannot merge two runs into one token. pulldown-cmark 0.13 does *not* in fact
1006/// perform that replacement, so [`strip_annotations_across_runs`] does it
1007/// itself rather than assume — see [`RUN_BOUNDARY_REPLACEMENT`].
1008const RUN_BOUNDARY: char = '\0';
1009
1010/// What a literal [`RUN_BOUNDARY`] in the source is rewritten to, which is what
1011/// CommonMark says should have happened to it upstream.
1012const RUN_BOUNDARY_REPLACEMENT: char = '\u{fffd}';
1013
1014/// [`strip_annotations`] for a task whose text is split across inline
1015/// formatting boundaries.
1016///
1017/// A rendered task line does not arrive as one string: `fix **this** #bug`
1018/// reaches the renderer as the runs `["fix ", "this", " #bug"]` with
1019/// `<strong>` events in between. Stripping each run on its own is wrong twice
1020/// over — the per-run trim would delete the space before `<strong>`, and
1021/// `$`-anchored rules like the trailing `> YYYY-MM-DD` would fire at the end of
1022/// every run rather than at the end of the text.
1023///
1024/// So the runs are spliced into one string with [`RUN_BOUNDARY`] between them,
1025/// stripped by the single grammar in [`strip_annotations`], and split apart
1026/// again. The boundary is not whitespace, so it never merges two runs into one
1027/// token, and none of the stripping passes can consume or emit one; the
1028/// returned vector therefore has exactly one entry per input run. A literal
1029/// boundary character already in the text is rewritten first, so a document
1030/// containing one cannot desynchronise the split.
1031///
1032/// Splicing also makes the *renderer* agree with [`scan_source_tasks`] about
1033/// what is adjacent to what: `**a**#work` has no whitespace before the `#` in
1034/// the source, and the boundary preserves that, so neither reads it as a tag.
1035///
1036/// # Examples
1037///
1038/// ```
1039/// use mbr_core::tasks::strip_annotations_across_runs;
1040///
1041/// let (runs, ann) = strip_annotations_across_runs(&["fix ", "this", " #bug"]);
1042/// assert_eq!(runs, ["fix ", "this", ""]);
1043/// assert_eq!(ann.tags, ["bug"]);
1044/// ```
1045pub 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            // Unreachable given the invariant documented above, but a
1075            // misalignment here would silently drop text from the page, so
1076            // recover by keeping every character in the first run rather than
1077            // trusting the split.
1078            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
1089/// Cheap conservative pre-filter: rejects lines that cannot possibly match
1090/// [`TASK_LINE`] without paying for the regex.
1091///
1092/// Most lines in a markdown file are prose, and this kills them on the first
1093/// non-whitespace byte. It must never reject a real task line.
1094fn 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
1103/// Borrows a named capture group as a `&str`, without the panic of `caps[name]`.
1104fn group<'h>(caps: &Captures<'h>, name: &str) -> Option<&'h str> {
1105    caps.name(name).map(|m| m.as_str())
1106}
1107
1108/// Matches the task-line prefix, returning the byte offset of the marker
1109/// character and the un-parsed text that follows the checkbox.
1110///
1111/// Shared by [`parse_task_line`] and [`set_marker`] so the two can never disagree
1112/// about what counts as a task — the round-trip property test depends on that.
1113fn 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    // Both functions are documented to take a *single* line. The original
1122    // `$`-anchored pattern enforced that for free; now that the pattern stops at
1123    // the checkbox, say it explicitly. One trailing newline is fine (callers
1124    // reasonably pass a line with its terminator), but an interior one means the
1125    // caller handed us several lines, and silently welding them into one task's
1126    // text would be worse than refusing.
1127    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
1134/// Replaces every *valid* `@due(...)` / `@done(...)` with a space, recording the
1135/// first of each kind. Invalid payloads are left exactly as written.
1136fn 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                // Not a date, so not an annotation: keep the user's text.
1144                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                // A repeat of a kind we already have: still an annotation, so it
1156                // is still stripped; first one wins.
1157                _ => {}
1158            }
1159            " ".to_string()
1160        })
1161        .into_owned()
1162}
1163
1164/// Strips trailing `> YYYY-MM-DD` / `< YYYY-MM-DD` markers, recording the `>`
1165/// destination. Loops so that `... > 2026-08-04 < 2026-08-01` yields both.
1166fn strip_trailing_move(text: &str, annotations: &mut Annotations) -> String {
1167    let mut rest = text;
1168    // The pattern ends `\d{4}-\d{2}-\d{2}[ \t]*$`, so it can only match when the
1169    // last non-blank byte is a digit. Testing that first keeps the regex crate's
1170    // capture-tracking engine — the slow one, by roughly a factor of ten, as
1171    // documented on [`TASK_LINE`] — off the overwhelming majority of task lines,
1172    // which carry no move marker at all. This is on the render path for every
1173    // task in a document and on the read path for every task in a repository.
1174    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            // Digit-shaped but not a real date (`2026-13-45`): leave it alone.
1181            break;
1182        };
1183        if group(&caps, "dir") == Some(">") && annotations.moved_to.is_none() {
1184            annotations.moved_to = Some(date);
1185        }
1186        // A `<` source marker is recorded nowhere on purpose — it says where the
1187        // task came from, which nothing in this feature surfaces.
1188        let Some(whole) = caps.get(0) else { break };
1189        rest = &rest[..whole.start()];
1190    }
1191    rest.to_string()
1192}
1193
1194/// Whether the last byte of `text`, ignoring trailing spaces and tabs, is an
1195/// ASCII digit. A necessary condition for [`TRAILING_MOVE`] to match, and far
1196/// cheaper to test.
1197fn 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
1204/// Removes `#tag` occurrences, collecting them de-duplicated case-insensitively
1205/// with the first spelling kept.
1206fn 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        // The leading whitespace the pattern consumed is restored as a single
1220        // space; the collapse pass tidies up whatever that leaves behind.
1221        " ".to_string()
1222    })
1223    .into_owned()
1224}
1225
1226/// Collapses runs of whitespace to single spaces, keeping only the tokens
1227/// `keep` accepts.
1228///
1229/// This is the one definition of what [`Task::text`] is made of, and it is
1230/// shared on purpose: a checkbox drops its `!!` here while a marker keeps every
1231/// token, but "whitespace-collapsed and trimmed" has to mean the same thing for
1232/// both kinds or the panel would render two subtly different sorts of text.
1233///
1234/// `keep` is `FnMut` because the priority pass consumes the tokens it rejects.
1235fn 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
1250/// Collapses whitespace to single spaces and lifts out `!!` / `!!!` priority
1251/// markers in the same pass.
1252///
1253/// Splitting on whitespace *is* the "whitespace-delimited on both sides" rule:
1254/// `wow!!` and `a!!b` are single tokens that are not equal to `!!`, so they never
1255/// register as priorities.
1256fn 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
1272/// Parses `YYYY-MM-DD`, `YYYY-MM-DD HH:MM` or `YYYY-MM-DD HH:MM AM/PM`.
1273///
1274/// Returns the datetime and whether an explicit time was present. Times are
1275/// naive and local; a bare date is start-of-day.
1276fn 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    // `| 0x20` lowercases the ASCII letter; the regex guarantees one is there.
1291    let hour24 = match group(&caps, "ap")
1292        .and_then(|ap| ap.as_bytes().first())
1293        .map(|b| b | 0x20)
1294    {
1295        None => hour,
1296        // 12-hour clock: 12 AM is 00:00 and 12 PM is 12:00, hence the `% 12`.
1297        Some(b'a') if (1..=12).contains(&hour) => hour % 12,
1298        Some(_) if (1..=12).contains(&hour) => hour % 12 + 12,
1299        // `14:00 PM` is not a time anybody meant.
1300        Some(_) => return None,
1301    };
1302    Some((
1303        date.and_time(NaiveTime::from_hms_opt(hour24, minute, 0)?),
1304        true,
1305    ))
1306}
1307
1308/// Number of leading lines occupied by YAML frontmatter, or `0` when there is
1309/// none.
1310///
1311/// Requires an opening `---` on line 1 at column 0 and a closing `---` or `...`.
1312/// An *unclosed* opener is not treated as frontmatter: a lone `---` is a thematic
1313/// break far more often than it is the start of a metadata block someone forgot
1314/// to finish, and swallowing the rest of the file would hide every task in it.
1315fn 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        // `position` is 0-based and relative to line 2, so +2 covers the opening
1323        // delimiter and the closing one.
1324        .map_or(0, |index| index + 2)
1325}
1326
1327#[cfg(test)]
1328mod tests {
1329    use super::*;
1330    use proptest::prelude::*;
1331
1332    /// Builds the `NaiveDateTime` a test expects, without `unwrap` noise.
1333    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    /// The shipped `incomplete_markers` default, which is what almost every
1340    /// marker test wants.
1341    const DEFAULT_MARKERS: &[&str] = &["TK", "TODO", "FIXME", "XXX"];
1342
1343    /// An `&str` marker list as the `Vec<String>` the config carries, so tests
1344    /// are not littered with `to_string()`.
1345    fn owned(markers: &[&str]) -> Vec<String> {
1346        markers.iter().map(|m| (*m).to_string()).collect()
1347    }
1348
1349    /// Compiles a [`MarkerRule`] from an `&str` list.
1350    fn rule(markers: &[&str]) -> MarkerRule {
1351        MarkerRule::new(&owned(markers)).expect("a non-empty marker list compiles")
1352    }
1353
1354    /// The default rule's markup-aware search, which most Stage-2 tests drive.
1355    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    // ---- markers and bullets -------------------------------------------------
1364
1365    #[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        // A tab is four columns, and depth is columns / 2.
1416        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        // Trailing whitespace after the marker is equally fine.
1434        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    // ---- non-tasks -----------------------------------------------------------
1443
1444    #[test]
1445    fn non_tasks_are_rejected() {
1446        for line in [
1447            "- [] x",           // no marker character
1448            "- [ x] x",         // two characters in the box
1449            "-[ ] x",           // no space after the bullet
1450            "- [y] x",          // unknown marker
1451            "- [ ]x",           // no space after the box
1452            "just some prose",  // prose
1453            "# A heading",      // heading
1454            "[ ]",              // bare checkbox, no list item
1455            "[ ] do a thing",   // still no list item
1456            "> [ ] blockquote", // blockquote, not a list item
1457            "",                 // empty line
1458            "  ",               // whitespace only
1459        ] {
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        // Callers reading a line from a file reasonably keep its terminator.
1470        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        // Both entry points must agree, or `set_marker` could patch a line that
1481        // `parse_task_line` never accepted.
1482        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    // ---- annotations in isolation -------------------------------------------
1497
1498    #[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        // Midnight and noon are the two the 12-hour clock always gets wrong.
1528        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            // 14 o'clock cannot also be PM.
1555            (
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        // The second `#` is not preceded by whitespace, so it is not a tag.
1599        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            // ...and they stay in the text.
1625            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    /// The cheap pre-filter in front of [`TRAILING_MOVE`] must never reject a
1669    /// line the regex would have matched.
1670    #[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        // It really does reject: otherwise it would be buying nothing.
1691        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        // Documented ordering: `@due(..)` is removed first, so the `>` is trailing.
1705        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        // ...but a trailing tag means the `>` is no longer at the very end.
1710        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    // ---- combinations --------------------------------------------------------
1717
1718    #[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    /// The three literal examples from `TASKS_SPEC.md` lines 20-23.
1745    #[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    /// The nested-subtask shape from `TASKS_SPEC.md` lines 41-45.
1812    #[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    // ---- set_marker ----------------------------------------------------------
1825
1826    #[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    // ---- set_status (the @done stamp) ----------------------------------------
1878
1879    /// The clock every stamping test is handed, so the expected strings can be
1880    /// written out literally.
1881    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        // The stamp is only worth writing if the reader agrees it is one.
1900        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        // Re-completing an already-done task must not silently re-date it.
1918        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        // A stamp is the only thing that goes; the rest of the line is intact.
1940        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        // And every one of them still parses out of the stamped line.
1959        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        // ...and reopening puts the line back exactly as it was.
1967        assert_eq!(stamped(&done, TaskStatus::Open), line);
1968    }
1969
1970    #[test]
1971    fn a_trailing_move_marker_survives_a_stamp() {
1972        // `> YYYY-MM-DD` is "trailing" only after date annotations are stripped,
1973        // which is exactly why appending the stamp after it is safe.
1974        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        // `strip_annotations` leaves it verbatim, so neither may this: the user
1992        // gets one real stamp added next to their typo, and reopening the task
1993        // leaves the typo alone.
1994        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    // ---- patch_task_line -----------------------------------------------------
2049
2050    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        // The response text is the line, not the line plus its terminator.
2079        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        // Whitespace is part of the line: a difference there is still a change.
2114        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        // A trailing newline terminates line 1; it does not start a line 2.
2124        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        // The endpoint's line numbers come from the index, which comes from
2150        // `scan_source_tasks`; if the two ever disagreed, a toggle would patch
2151        // the wrong line.
2152        let source = concat!(
2153            "---\ntitle: T\n---\n\n",   // 1-4
2154            "- [ ] first\n",            // 5
2155            "\n```\n- [ ] fake\n```\n", // 6-9
2156            "- [ ] second\n",           // 10
2157        );
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    // ---- scan_source_tasks ---------------------------------------------------
2183
2184    #[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        // A three-backtick run does not close a four-backtick fence.
2211        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    /// The one place the raw-text scan knowingly gets indented code wrong.
2248    ///
2249    /// Recognising an indented code block *inside* a list item needs the list's
2250    /// content-column, which this pass does not track — and pretending every
2251    /// four-column line is code would delete the nested-subtask feature. See the
2252    /// NOTE in [`scan_source_tasks`].
2253    #[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        // Otherwise a document that opens with a horizontal rule would lose every
2281        // task in it.
2282        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",            // 1
2300            "title: T\n",       // 2
2301            "---\n",            // 3
2302            "\n",               // 4
2303            "- [ ] first\n",    // 5
2304            "\n",               // 6
2305            "```js\n",          // 7
2306            "// - [ ] fake\n",  // 8
2307            "\n",               // 9
2308            "```\n",            // 10
2309            "\n",               // 11
2310            "    indented\n",   // 12
2311            "    - [ ] fake\n", // 13
2312            "\n",               // 14
2313            "- [x] second\n",   // 15
2314        );
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        // A fence written inside a blockquote still hides its contents.
2342        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    // ---- strip_annotations_across_runs ---------------------------------------
2349
2350    /// Convenience wrapper so the tests read like the renderer's call site.
2351    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        // `fix **this** #bug`: stripping each run alone would trim the trailing
2358        // space off "fix " and weld the words together.
2359        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        // Only the annotations go; the words around them stay where they were.
2387        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        // `**a**#work` has no space before the `#` in the source, and
2397        // `strip_annotations` would not call it a tag either.
2398        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        // pulldown-cmark 0.13 does not perform CommonMark's U+0000 → U+FFFD
2406        // replacement, so a NUL really can reach the renderer.
2407        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    // ---- serde ---------------------------------------------------------------
2414
2415    #[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    // ---- MarkerRule ----------------------------------------------------------
2434
2435    #[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        // Word boundaries rule out TKTK / TODOs / Tk / lowercase.
2445        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        // The documented `incomplete_markers = []` off switch: `None` tells the
2461        // caller to skip the pass, rather than handing back a rule.
2462        assert!(MarkerRule::new(&[]).is_none());
2463        // An entry that is only an empty string is the same off switch.
2464        assert!(MarkerRule::new(&["".to_string()]).is_none());
2465    }
2466
2467    #[test]
2468    fn caching_hands_back_the_same_rule_for_the_same_markers() {
2469        // The point of `cached` is that the render pipeline stops paying ~80µs
2470        // of regex compilation per page, so what matters is that the second call
2471        // returns the *same allocation*, not merely an equal one.
2472        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        // A plain `OnceLock` would be wrong here: the test suite alone renders
2481        // with several marker sets, and nothing stops a GUI from opening two
2482        // repositories whose `.mbr/config.toml` disagree.
2483        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        // `None` is cached as a value, so the off switch does not re-attempt
2494        // compilation on every page.
2495        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        // Without `regex::escape` the unbalanced `(` would fail to compile and
2502        // take its sibling markers down with it.
2503        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        // The bug the per-alternative boundaries fix: one `\b` on the whole
2512        // group demanded a word character after the colon, so this never
2513        // matched at all.
2514        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        // A leading `\b` would demand a word character in front of the `@`...
2524        assert_eq!(rule.find_in_line(" @todo "), Some(1));
2525        // ...and would equally reject the case where one is present.
2526        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        // Alternation is leftmost-first, so without the longest-first sort the
2532        // answer would depend on how the user happened to order their config.
2533        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    // ---- MarkupCursor exclusions ---------------------------------------------
2544
2545    #[test]
2546    fn a_marker_embedded_in_prose_is_found() {
2547        // The motivating case: not block-initial, and invisible to the old rule.
2548        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        // Only a run of exactly two backticks closes this span, so the single
2566        // ones are content — which is why the scan needs `code_span_end` rather
2567        // than a pattern.
2568        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        // pulldown-cmark emits the alias as `Event::Text`, so a reader sees it.
2585        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        // No rule of its own: the `](` case fires on the `]` that ends the alt
2614        // text, so the alt stays included and the destination is excluded.
2615        let line = "![TODO](/img/shot.png)";
2616        assert_eq!(find(line), line.find("TODO"));
2617        assert_eq!(find("![alt](/img/TODO.png)"), 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        // The delimiters are markup; what sits between two of them is not.
2641        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        // Whitespace inside the brackets is what tells these apart, and it is
2648        // also what pulldown-cmark uses to decide this is text.
2649        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        // A bare URL is `Event::Text`, so the page highlights it; agreeing with
2656        // the renderer beats being clever about what the author meant.
2657        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        // Up to three columns of indent is still a definition.
2666        assert_eq!(find("   [label]: /TODO/path"), None);
2667    }
2668
2669    #[test]
2670    fn a_footnote_definition_is_not_a_reference_definition() {
2671        // `[^1]:` is a footnote, and its body really is `Event::Text`.
2672        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    // ---- parse_marker_line ---------------------------------------------------
2683
2684    /// Parses a marker line with the default rule, panicking if it is not one.
2685    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        // A line with no marker is not one, and neither is a marker hidden in
2704        // markup — `find_in_line` is the whole test.
2705        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        // The marker word itself stays in the text: it is the reason the entry
2713        // exists, and stripping it would leave a card the reader cannot place.
2714        assert_eq!(
2715            marker("  TK   check   this\tagain  ").text,
2716            "TK check this again"
2717        );
2718        // Prose around a mid-line marker is kept on both sides.
2719        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        // A marker gets no annotation parsing at all: the checkbox grammar is
2728        // the checkbox's, and applying it here would strip prose the author
2729        // wrote and file the entry under a tag they never applied to a task.
2730        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    /// `text[start..end]` for a marker, sliced the way the panel slices it —
2741    /// by UTF-16 code unit, not by byte.
2742    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        // The test that fails if these are ever byte offsets: `é` is two bytes
2761        // and `€`/`—` are three, so the byte offset of `TODO` here is nine past
2762        // its UTF-16 index — a JavaScript `slice()` with the former would cut
2763        // the wrong nine characters.
2764        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        // Not "a span covering nothing": there is no marker word, and a client
2776        // that highlights on presence must see absence.
2777        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        // The raw-line offset of `TODO` is 7; the collapsed text puts it at 4.
2785        // Locating twice is what keeps the span an index into `text`.
2786        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        // The first `TODO` is inside a code span, so it is not the marker — and
2795        // a client re-deriving the position with a naive `indexOf` would wash
2796        // the wrong word. This is the case that makes sending the span the
2797        // point of the exercise.
2798        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        // Same measurement `parse_task_line` makes, so a marker inside a list
2806        // indents alongside the tasks around it.
2807        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        // The read-only guarantee, pinned at the API rather than left to the
2817        // frontend: `patch_task_line` goes through `parse_task_line`, which
2818        // never produces a marker, so the endpoint answers 400.
2819        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        // ...and the lower-level rewrite refuses it too, so there is no way in.
2831        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    // ---- scan_source_tasks_with_markers --------------------------------------
2836
2837    /// Scans with the default marker rule.
2838    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        // Parsed as a task, annotations and all.
2848        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        // ...and the rule really would have found something, so the equality
2866        // above is not vacuous.
2867        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", // 1
2874            "\n",                        // 2
2875            "- [ ] real task\n",         // 3
2876            "prose with a FIXME here\n", // 4
2877            "- [x] done thing\n",        // 5
2878        );
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        // The fence state machine is shared with the checkbox scan, so a code
2894        // sample full of `// TODO:` stays invisible.
2895        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        // An indented code block is skipped too.
2901        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    // ---- property tests ------------------------------------------------------
2921
2922    /// A small alphabet that can actually spell annotations, so the invariants
2923    /// below are exercised rather than passing vacuously on random Unicode.
2924    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    /// Lines for the marker properties: arbitrary text, plus text drawn from an
2936    /// alphabet that can actually spell markup and markers.
2937    ///
2938    /// The union is deliberate. `.*` gets the multi-byte characters that stress
2939    /// the byte-wise scan's char-boundary handling, but almost never happens to
2940    /// spell `](` or a marker word, so on its own it would exercise
2941    /// [`MarkupCursor`] barely at all.
2942    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        /// Every offset the scanner reports lands on a character boundary and on
2958        /// a configured marker.
2959        ///
2960        /// The offset is handed straight to a slice by the renderer, so a stale
2961        /// or mid-character one is a panic in production.
2962        #[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        /// A marker inside a code span is never reported, whatever surrounds it.
2977        ///
2978        /// The alphabet contains neither a backtick nor a marker, so the span in
2979        /// the middle is the only candidate the generated line can contain.
2980        #[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        /// The scanner is fed whatever is on disk, so it must not panic on any
2990        /// of it — including bytes that are not markdown at all.
2991        #[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        /// The marker scan is fed whatever is on disk too, and it runs over
3000        /// every line of every file rather than only over candidate ones.
3001        #[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        /// A source line yields at most one entry, so line numbers come back
3009        /// strictly increasing.
3010        ///
3011        /// This is the invariant the `#mbr-marker-{line}` anchor rests on: two
3012        /// entries for one line would be two cards pointing at one id, and one
3013        /// of them could never be reached.
3014        #[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        /// The parser is fed whatever is on disk, so it must not panic on any of it.
3032        #[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        /// No *recognised* annotation survives into the display text.
3046        ///
3047        /// The invariant has to be phrased in terms of recognition rather than
3048        /// the literal `@due(`, because an unparseable payload is deliberately
3049        /// left verbatim — see [`strip_annotations`]. So the check is: whatever
3050        /// `@due(...)`-shaped text remains must not parse as a datetime.
3051        #[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        /// Annotations built from valid components are always recognised and removed.
3073        #[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        /// `set_marker` is a single-byte edit, and the result parses back to the
3103        /// status that was asked for.
3104        #[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            // Only the marker moved; everything derived from the text is untouched.
3131            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        /// Completing a task and then reopening it restores the line exactly,
3139        /// as long as it carried no stamp to begin with.
3140        ///
3141        /// This is the round trip the UI performs every time somebody mis-clicks
3142        /// a checkbox, so anything it corrupts, it corrupts in the user's file.
3143        #[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            // Trailing blanks are deliberately not preserved (the stamp would
3152            // strand them), so keep them out of the generated line.
3153            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        /// The pre-filter guarding [`TRAILING_MOVE`] is only ever allowed to
3172        /// reject text the regex would also have rejected.
3173        #[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        /// Splicing runs together never loses or gains one, whatever the text.
3182        ///
3183        /// This is the invariant the renderer's inline-formatting support rests
3184        /// on: one output run per input run, so each `Event::Text` can be
3185        /// rewritten in place without disturbing the `<strong>`/`<em>`/`<a>`
3186        /// events between them.
3187        #[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        /// Whatever the run split, the concatenated display text is the same as
3197        /// stripping the equivalent single string — so a task's text does not
3198        /// depend on where the author happened to put a `**`.
3199        #[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        /// Every task the scanner returns points at a line that really is that task.
3220        #[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}