Skip to main content

markdown_org_extract/
parser.rs

1//! Markdown to tasks: the extraction step itself.
2//!
3//! A document is parsed with comrak, and every heading carrying an org-mode
4//! keyword (`TODO`, `DONE`, …) becomes a [`Task`], together with the timestamp,
5//! priority and clock entries found in its body.
6//!
7//! [`Task`]: crate::types::Task
8
9use comrak::nodes::{AstNode, NodeValue};
10use comrak::{parse_document, Arena, Options};
11use regex::Regex;
12use std::collections::BTreeMap;
13use std::ops::Range;
14use std::path::Path;
15use std::sync::LazyLock;
16
17use crate::clock::{calculate_total_minutes, extract_clocks, format_duration};
18use crate::regex_limits::compile_bounded;
19use crate::timestamp::{
20    extract_created_normalized, extract_repeater_normalized, extract_timestamp_normalized,
21    normalize_weekdays, parse_timestamp_fields_normalized,
22};
23use crate::types::{Priority, Task, TaskType, MAX_DIAGNOSTIC_ITEMS};
24
25// Per-call cap on invalid-timestamp warnings reuses `MAX_DIAGNOSTIC_ITEMS` so
26// both diagnostic surfaces (failed-path list and parse-warning stream) stay
27// aligned: "20 entries is already noisy". The counter is owned by the caller
28// -- typically `ProcessingStats::ts_warnings_emitted` for a CLI run -- so
29// long-running library use cases and parallel scans do not pollute each
30// other's budget. The previous process-global `AtomicUsize` was replaced as
31// part of the 0.5.0 review (M1).
32fn warn_invalid_timestamp(counter: &mut usize, path: &Path, line: u32, ts: &str) {
33    let n = *counter;
34    *counter = counter.saturating_add(1);
35    if n < MAX_DIAGNOSTIC_ITEMS {
36        tracing::warn!(
37            file = %path.display(),
38            line,
39            timestamp = ts.trim(),
40            "cannot parse timestamp"
41        );
42    } else if n == MAX_DIAGNOSTIC_ITEMS {
43        tracing::warn!(
44            limit = MAX_DIAGNOSTIC_ITEMS,
45            "more invalid timestamps suppressed (showed first {MAX_DIAGNOSTIC_ITEMS})"
46        );
47    }
48}
49
50// Mirror of `warn_invalid_timestamp` for malformed `org-properties` lines
51// (a line that has no `:`). The counter is owned by the caller -- typically
52// `ProcessingStats::prop_warnings_emitted` for a CLI run -- so the
53// per-`MAX_DIAGNOSTIC_ITEMS` cap spans the whole scan and parallel/library
54// uses do not pollute each other's budget. See ADR-0020.
55fn warn_invalid_property_line(counter: &mut usize, path: &Path, line: u32, raw: &str) {
56    let n = *counter;
57    *counter = counter.saturating_add(1);
58    if n < MAX_DIAGNOSTIC_ITEMS {
59        tracing::warn!(
60            file = %path.display(),
61            line,
62            content = raw.trim(),
63            "org-properties line has no ':'; skipping"
64        );
65    } else if n == MAX_DIAGNOSTIC_ITEMS {
66        tracing::warn!(
67            limit = MAX_DIAGNOSTIC_ITEMS,
68            "more malformed org-properties lines suppressed (showed first {MAX_DIAGNOSTIC_ITEMS})"
69        );
70    }
71}
72
73// Mirror of `warn_invalid_property_line` for an exception key whose value
74// cannot be used as written (ADR-0031): a date in `EXDATE` that does not
75// read, a `RECURRENCE_ID` that names no occurrence, half a pair. It shares
76// the `org-properties` counter -- typically
77// `ProcessingStats::prop_warnings_emitted` -- because the mistake is in a
78// property and not in the entry's timestamp: a file full of unreadable
79// `EXDATE` values must not spend the budget that warns about the timestamps
80// of every file after it, and a run summary must not file property mistakes
81// under timestamps. `key` and `problem` tell the classes apart inside the
82// shared channel.
83fn warn_unusable_exception(
84    counter: &mut usize,
85    path: &Path,
86    line: u32,
87    key: &str,
88    value: &str,
89    problem: &str,
90) {
91    let n = *counter;
92    *counter = counter.saturating_add(1);
93    if n < MAX_DIAGNOSTIC_ITEMS {
94        tracing::warn!(
95            file = %path.display(),
96            line,
97            key,
98            value = value.trim(),
99            problem,
100            "exception property cannot be used as written"
101        );
102    } else if n == MAX_DIAGNOSTIC_ITEMS {
103        tracing::warn!(
104            limit = MAX_DIAGNOSTIC_ITEMS,
105            "more unusable exception properties suppressed (showed first {MAX_DIAGNOSTIC_ITEMS})"
106        );
107    }
108}
109
110/// Optional TODO/DONE/CANCELLED/CANCELED keyword anchored to the start of a
111/// heading.
112///
113/// Matches `TODO`, `DONE`, `CANCELLED` (double-L) or `CANCELED` (single-L,
114/// the upstream Emacs Org-mode spelling) followed by at least one whitespace
115/// character. The double-L `CANCELLED` is listed before the single-L
116/// `CANCELED` so the alternation prefers the longer spelling. Used as the
117/// first step of heading parsing — see `parse_heading`.
118static HEADING_TODO_RE: LazyLock<Regex> =
119    LazyLock::new(|| compile_bounded(r"^(TODO|DONE|CANCELLED|CANCELED)\s+"));
120
121/// Priority cookie `[#X]` with an optional trailing space, matching anywhere
122/// in the heading text.
123///
124/// Mirrors emacs org-mode's `org-priority-regexp` semantics: the priority
125/// cookie may appear at any position in the (remaining) heading title. The
126/// value is either an uppercase ASCII letter or a one- or two-digit integer;
127/// the integer range is validated by `Priority::parse` (only `0..=64` is
128/// accepted).
129///
130/// Where the cookie sits decides only whether it is taken out of the title —
131/// see `parse_heading`. It never decides whether the priority is read.
132///
133/// Two-digit alternatives are listed before single-digit `[0-9]` so the
134/// matcher prefers the longest valid run (e.g. matches `15`, not just `1`).
135static HEADING_PRIORITY_RE: LazyLock<Regex> =
136    LazyLock::new(|| compile_bounded(r"\[#([A-Z]|6[0-4]|[1-5][0-9]|[0-9])\] ?"));
137
138/// Extract tasks from markdown content with a caller-owned warning counter.
139///
140/// Production callers (see `main.rs::scan_files`) pass
141/// `&mut ProcessingStats::ts_warnings_emitted` so the per-`MAX_DIAGNOSTIC_ITEMS`
142/// cap on invalid-timestamp warnings spans every file in the run. Library
143/// callers can pass their own counter to scope the budget per scan.
144///
145/// # Arguments
146/// * `path` - Path to the markdown file. Stored verbatim in `Task.file` for output.
147/// * `content` - File content (UTF-8).
148/// * `mappings` - Weekday name mappings for localization.
149/// * `max_tasks` - Per-file cap. Parsing stops as soon as this many tasks accumulate.
150/// * `ts_warning_counter` - Mutable counter used to gate invalid-timestamp warnings.
151///
152/// # Returns
153/// Vector of extracted tasks, capped at `max_tasks`.
154pub fn extract_tasks_with_counter(
155    path: &Path,
156    content: &str,
157    mappings: &[(&str, &str)],
158    max_tasks: usize,
159    ts_warning_counter: &mut usize,
160    prop_warning_counter: &mut usize,
161) -> Vec<Task> {
162    let arena = Arena::new();
163    let root = parse_document(&arena, content, &safe_comrak_options());
164
165    let mut tasks = Vec::new();
166    let mut current_heading: Option<HeadingInfo> = None;
167
168    for node in root.children() {
169        process_node(
170            node,
171            path,
172            &mut tasks,
173            &mut current_heading,
174            mappings,
175            ts_warning_counter,
176            prop_warning_counter,
177        );
178
179        if tasks.len() >= max_tasks {
180            tracing::warn!(
181                file = %path.display(),
182                limit = max_tasks,
183                "reached per-file task limit"
184            );
185            break;
186        }
187    }
188
189    // Flush remaining heading
190    if let Some(info) = current_heading.take() {
191        if let Some(task) = finalize_task(path, info, ts_warning_counter, prop_warning_counter) {
192            tasks.push(task);
193        }
194    }
195
196    tracing::debug!(
197        file = %path.display(),
198        bytes = content.len(),
199        tasks = tasks.len(),
200        "parsed file"
201    );
202
203    tasks
204}
205
206/// Extract tasks from markdown content with a per-call warning budget.
207///
208/// Convenience wrapper around [`extract_tasks_with_counter`] that owns the
209/// counter for the duration of one call. Used by the unit-test suite and
210/// available to library callers that scope the invalid-timestamp warning
211/// cap per file. The production CLI (`main.rs::scan_files`) uses
212/// `extract_tasks_with_counter` directly so the cap spans the whole run.
213#[cfg_attr(not(test), allow(dead_code))]
214pub fn extract_tasks(
215    path: &Path,
216    content: &str,
217    mappings: &[(&str, &str)],
218    max_tasks: usize,
219) -> Vec<Task> {
220    let mut counter = 0_usize;
221    let mut prop_counter = 0_usize;
222    extract_tasks_with_counter(
223        path,
224        content,
225        mappings,
226        max_tasks,
227        &mut counter,
228        &mut prop_counter,
229    )
230}
231
232/// Comrak parsing options.
233///
234/// **Security note**: this is `Options::default()` deliberately. Defaults:
235/// - `render.unsafe_ = false` — raw HTML in markdown is escaped, not passed through.
236/// - `extension.tagfilter = false` (filter not applied, since unsafe HTML is already escaped).
237/// - No extensions that interpret embedded HTML or scripts are enabled.
238///
239/// **Do not enable `render.unsafe_` or `extension.tagfilter` here without a
240/// security review** — the HTML output goes through `html_escape`, but enabling
241/// raw HTML would let untrusted markdown inject arbitrary tags into the rendered
242/// page bypassing that escape.
243fn safe_comrak_options() -> Options<'static> {
244    Options::default()
245}
246
247/// Information extracted from a heading
248struct HeadingInfo {
249    heading: String,
250    task_type: Option<TaskType>,
251    priority: Option<Priority>,
252    line: u32,
253    content: String,
254    created: Option<String>,
255    timestamp: Option<String>,
256    clocks: Vec<crate::types::ClockEntry>,
257    properties: BTreeMap<String, String>,
258}
259
260/// Process a single markdown node
261fn process_node<'a>(
262    node: &'a AstNode<'a>,
263    path: &Path,
264    tasks: &mut Vec<Task>,
265    current_heading: &mut Option<HeadingInfo>,
266    mappings: &[(&str, &str)],
267    ts_warning_counter: &mut usize,
268    prop_warning_counter: &mut usize,
269) {
270    // Snapshot the borrow once — clone the value (cheap for Heading/Paragraph) and
271    // read the sourcepos line in the same scope; drop before any code that
272    // recurses into children (which take their own borrows).
273    let (value_clone, line) = {
274        let data = node.data.borrow();
275        (data.value.clone(), data.sourcepos.start.line as u32)
276    };
277    match value_clone {
278        NodeValue::Heading(_) => {
279            // Finalize previous heading first
280            if let Some(info) = current_heading.take() {
281                if let Some(task) =
282                    finalize_task(path, info, ts_warning_counter, prop_warning_counter)
283                {
284                    tasks.push(task);
285                }
286            }
287
288            let text = extract_text(node);
289            let (task_type, priority, heading) = parse_heading(&text);
290            *current_heading = Some(HeadingInfo {
291                heading,
292                task_type,
293                priority,
294                line,
295                content: String::new(),
296                created: None,
297                timestamp: None,
298                clocks: Vec::new(),
299                properties: BTreeMap::new(),
300            });
301        }
302        NodeValue::Paragraph => {
303            if let Some(ref mut info) = current_heading {
304                let (created, timestamp) = extract_timestamps_from_node(node, mappings);
305                let content = extract_paragraph_text(node);
306
307                for child in node.children() {
308                    if let NodeValue::Code(code) = &child.data.borrow().value {
309                        info.clocks.extend(extract_clocks(&code.literal));
310                    }
311                }
312
313                if created.is_some() {
314                    info.created = created;
315                }
316                if timestamp.is_some() {
317                    info.timestamp = timestamp;
318                }
319                if !content.is_empty() {
320                    if info.content.is_empty() {
321                        info.content = content;
322                    } else {
323                        info.content.push_str("\n\n");
324                        info.content.push_str(&content);
325                    }
326                }
327            }
328        }
329        NodeValue::CodeBlock(code) => {
330            if let Some(ref mut info) = current_heading {
331                // Performance: check the property-block info string first and
332                // return early on a match, so an org-properties block skips the
333                // backtick-strip / weekday-normalise / clock-extract work below.
334                // For every other code block the only added cost is this one
335                // `&str` comparison. The grep pre-filter (main.rs) is NOT widened
336                // for `org-properties`, so the set of scanned files is unchanged.
337                if code.info.trim() == "org-properties" {
338                    parse_org_properties(
339                        &code.literal,
340                        &mut info.properties,
341                        path,
342                        line,
343                        prop_warning_counter,
344                    );
345                } else {
346                    let raw = code.literal.trim();
347                    // An indented code block (4-space indent) reaches us with
348                    // the planning line still wrapped in inline-code backticks
349                    // (`    \`DEADLINE: <...>\``). Comrak strips the indent but
350                    // leaves the wrapping backticks in `code.literal`, which
351                    // would otherwise prevent the DEADLINE/SCHEDULED/CREATED
352                    // regex from anchoring on the keyword. Drop a matched
353                    // backtick pair before regex matching.
354                    let literal = strip_wrapping_backticks(raw);
355                    let normalized = normalize_weekdays(literal, mappings);
356                    let created = extract_created_normalized(&normalized);
357                    let timestamp = extract_timestamp_normalized(&normalized);
358
359                    info.clocks.extend(extract_clocks(literal));
360
361                    if created.is_some() {
362                        info.created = created;
363                    }
364                    if timestamp.is_some() {
365                        info.timestamp = timestamp;
366                    }
367                }
368            }
369        }
370        _ => {}
371    }
372}
373
374fn finalize_task(
375    path: &Path,
376    info: HeadingInfo,
377    ts_warning_counter: &mut usize,
378    prop_warning_counter: &mut usize,
379) -> Option<Task> {
380    if info.task_type.is_none() && info.created.is_none() && info.timestamp.is_none() {
381        return None;
382    }
383
384    let line = info.line;
385    let (ts_type, ts_date, ts_time, ts_end_time, ts_active, ts_repeater) =
386        if let Some(ref ts) = info.timestamp {
387            // `info.timestamp` is assembled from `extract_timestamp_normalized`
388            // regex captures over an already-`normalize_weekdays`d string in
389            // both `process_node` branches, so a second normalisation here
390            // would be redundant work on every task.
391            let parsed = parse_timestamp_fields_normalized(ts);
392            if parsed.1.is_none() {
393                warn_invalid_timestamp(ts_warning_counter, path, line, ts);
394            }
395            // The repeater is extracted via a second, fuller pass
396            // (`parse_org_timestamp`) rather than the light regex path above:
397            // the repeater grammar (prefix/value/unit, `wd`) lives in that
398            // parser and is not worth duplicating as another regex helper.
399            // The extra parse is timestamp-string-local and runs once per task.
400            let repeater = extract_repeater_normalized(ts);
401            (parsed.0, parsed.1, parsed.2, parsed.3, parsed.4, repeater)
402        } else {
403            (None, None, None, None, None, None)
404        };
405
406    let (clocks_opt, total_time) = if !info.clocks.is_empty() {
407        let total = calculate_total_minutes(&info.clocks).map(format_duration);
408        (Some(info.clocks), total)
409    } else {
410        (None, None)
411    };
412
413    let properties = if info.properties.is_empty() {
414        None
415    } else {
416        Some(info.properties)
417    };
418
419    // The exception keys of ADR-0031, lifted out of the property map into
420    // fields of their own: every consumer that answers "does this series
421    // occur on this day" needs them parsed, and parsing them once here keeps
422    // the string handling out of the agenda.
423    let exceptions = exception_fields(path, line, properties.as_ref(), prop_warning_counter);
424
425    Some(Task {
426        file: path.display().to_string(),
427        // Filled in by the scan when it walked several roots: the parser is
428        // given one file and has no notion of which collection it belongs to.
429        root: None,
430        line,
431        heading: info.heading,
432        content: info.content,
433        task_type: info.task_type,
434        priority: info.priority,
435        created: info.created,
436        timestamp: info.timestamp,
437        timestamp_type: ts_type,
438        timestamp_active: ts_active,
439        timestamp_date: ts_date,
440        timestamp_time: ts_time,
441        timestamp_end_time: ts_end_time,
442        timestamp_repeater: ts_repeater,
443        // Populated later by `annotate_next_occurrences` (needs the agenda
444        // reference date, which the parser does not have).
445        timestamp_next: None,
446        timestamp_next_after: None,
447        clocks: clocks_opt,
448        total_clock_time: total_time,
449        properties,
450        excluded_dates: exceptions.excluded_dates,
451        recurrence_id: exceptions.recurrence_id,
452        series_id: exceptions.series_id,
453    })
454}
455
456/// Read the ADR-0031 exception keys out of a task's properties.
457///
458/// Every way an exception can fail to work is reported, because each of them
459/// leaves an entry that reads like an exception and behaves like none: a date
460/// that cannot be read, a key that yields no date at all, a `RECURRENCE_ID`
461/// whose time is lost, and half a pair. The one silence ADR-0031 does allow —
462/// a replacement in a file the scan never reached — cannot be seen from here
463/// and is answered where the run's task list is whole (see
464/// [`crate::exceptions::OccurrenceExceptions::unknown_series`]).
465///
466/// Reported through the capped `org-properties` channel: see
467/// `warn_unusable_exception` for why that one rather than the timestamp one.
468/// What the exception keys of one entry read as. Named rather than a tuple:
469/// `recurrence_id` and `series_id` are both `Option<String>`, and swapping
470/// them at a call site would compile.
471#[derive(Debug, Default, PartialEq, Eq)]
472struct ExceptionFields {
473    /// Dates the entry cancels, from `EXDATE`.
474    excluded_dates: Option<Vec<String>>,
475    /// The occurrence the entry stands in for, from `RECURRENCE_ID`.
476    recurrence_id: Option<String>,
477    /// The series that occurrence belongs to, from `SERIES_ID`.
478    series_id: Option<String>,
479}
480
481fn exception_fields(
482    path: &Path,
483    line: u32,
484    properties: Option<&BTreeMap<String, String>>,
485    prop_warning_counter: &mut usize,
486) -> ExceptionFields {
487    use crate::exceptions::{
488        parse_excluded_dates, parse_recurrence_id, EXDATE_KEY, RECURRENCE_ID_KEY, SERIES_ID_KEY,
489    };
490
491    let Some(props) = properties else {
492        return ExceptionFields::default();
493    };
494
495    let excluded = props.get(EXDATE_KEY).map(|raw| {
496        let mut rejected = 0_usize;
497        let dates = parse_excluded_dates(raw, |field| {
498            rejected += 1;
499            warn_unusable_exception(
500                prop_warning_counter,
501                path,
502                line,
503                EXDATE_KEY,
504                field,
505                "not a date in YYYY-MM-DD form",
506            );
507        });
508        // Said once for a value that held nothing to reject either — an empty
509        // key, or one written of separators. A value whose fields were all
510        // rejected has been reported field by field already.
511        if dates.is_empty() && rejected == 0 {
512            warn_unusable_exception(
513                prop_warning_counter,
514                path,
515                line,
516                EXDATE_KEY,
517                raw,
518                "no date to cancel an occurrence on",
519            );
520        }
521        dates
522    });
523    let excluded = excluded.filter(|dates| !dates.is_empty());
524
525    let recurrence = props.get(RECURRENCE_ID_KEY).and_then(|raw| {
526        let parsed = parse_recurrence_id(raw, |dropped| {
527            warn_unusable_exception(
528                prop_warning_counter,
529                path,
530                line,
531                RECURRENCE_ID_KEY,
532                dropped,
533                "not a time in HH:MM form, so the date alone is kept",
534            );
535        });
536        if parsed.is_none() {
537            warn_unusable_exception(
538                prop_warning_counter,
539                path,
540                line,
541                RECURRENCE_ID_KEY,
542                raw,
543                "not a date, optionally followed by a time",
544            );
545        }
546        parsed
547    });
548
549    let series = props
550        .get(SERIES_ID_KEY)
551        .map(|raw| raw.trim().to_string())
552        .filter(|id| !id.is_empty());
553
554    warn_about_half_a_pair(
555        path,
556        line,
557        props,
558        series.as_deref(),
559        recurrence.as_deref(),
560        prop_warning_counter,
561    );
562
563    ExceptionFields {
564        excluded_dates: excluded,
565        recurrence_id: recurrence,
566        series_id: series,
567    }
568}
569
570/// Report an entry carrying one half of `SERIES_ID` / `RECURRENCE_ID`.
571///
572/// The pair names one occurrence of one series, and half of it replaces
573/// nothing: the day keeps both the series occurrence and the entry that meant
574/// to stand in for it. What is compared is the keys as written against the
575/// values that survived, so a key present but empty, or one whose value did
576/// not read, counts as the half that is missing.
577fn warn_about_half_a_pair(
578    path: &Path,
579    line: u32,
580    props: &BTreeMap<String, String>,
581    series: Option<&str>,
582    recurrence: Option<&str>,
583    prop_warning_counter: &mut usize,
584) {
585    use crate::exceptions::{RECURRENCE_ID_KEY, SERIES_ID_KEY};
586
587    let incomplete = match (
588        props.contains_key(SERIES_ID_KEY),
589        props.contains_key(RECURRENCE_ID_KEY),
590    ) {
591        (true, true) => series.is_none() || recurrence.is_none(),
592        (true, false) | (false, true) => true,
593        (false, false) => false,
594    };
595    if !incomplete {
596        return;
597    }
598
599    let (key, value) = match (series, recurrence) {
600        (Some(id), None) => (SERIES_ID_KEY, id),
601        (None, Some(occurrence)) => (RECURRENCE_ID_KEY, occurrence),
602        // Both keys written and neither usable. Each value has been reported
603        // on its own already; what is left to say is that the pair is not
604        // there.
605        _ => (SERIES_ID_KEY, ""),
606    };
607    warn_unusable_exception(
608        prop_warning_counter,
609        path,
610        line,
611        key,
612        value,
613        "an exception needs both SERIES_ID and RECURRENCE_ID, and only one of them is usable here",
614    );
615}
616
617/// Parse heading text to extract task type, priority, and title.
618///
619/// Follows the emacs org-mode parser
620/// (`org-element--headline-parse-title` / `org-priority-regexp`):
621///
622/// 1. Strip an optional `TODO` / `DONE` keyword anchored at the start.
623/// 2. Search the remaining text for the first `[#X]` cookie at any position,
624///    where `X` is `A-Z` or an integer `0..=64`. If found, that becomes the
625///    priority, wherever it sits — emacs reads it the same way, through the
626///    `.*?` prefix of `org-priority-regexp`.
627/// 3. The cookie is taken **out of** the title only when it is in its canonical
628///    place: at the start of what is left, i.e. directly after the keyword, or
629///    opening the heading when there is no keyword. A cookie written anywhere
630///    else stays in the title, together with the text before it.
631/// 4. Whatever remains is trimmed and returned as the heading.
632///
633/// Step 3 is where this parser parts company with `org-element`, whose
634/// `:raw-value` drops everything up to the cookie (`goto-char (match-end 0)`).
635/// That value is not what a reader sees: `org-agenda` builds its line from
636/// `org-get-heading`, which keeps the title whole — `* TODO Buy [#A] filter`
637/// is shown as written and still sorts as an `A`. Dropping the prefix here
638/// left a trailing cookie with an empty heading and a blank agenda row. See
639/// [ADR-0002](../docs/adr/0002-supported-org-mode-subset.md).
640///
641/// A heading without TODO/DONE and without a priority cookie is returned
642/// verbatim (trimmed).
643fn parse_heading(text: &str) -> (Option<TaskType>, Option<Priority>, String) {
644    // Step 1: optional TODO/DONE prefix.
645    let (task_type, rest) = if let Some(caps) = HEADING_TODO_RE.captures(text) {
646        let kw = caps.get(1).map(|m| m.as_str()).unwrap_or("");
647        let m = caps
648            .get(0)
649            .expect("Captures::get(0) is Some when captures() succeeds");
650        (TaskType::from_keyword(kw), &text[m.end()..])
651    } else {
652        (None, text)
653    };
654    let title = rest.trim();
655
656    // Step 2: optional priority cookie anywhere in the remainder.
657    if let Some(caps) = HEADING_PRIORITY_RE.captures(title) {
658        let value = caps.get(1).map(|m| m.as_str()).unwrap_or("");
659        if let Some(priority) = Priority::parse(value) {
660            let whole = caps
661                .get(0)
662                .expect("Captures::get(0) is Some when captures() succeeds");
663            // Step 3: only a cookie opening the title is consumed by it.
664            let heading = if whole.start() == 0 {
665                title[whole.end()..].trim()
666            } else {
667                title
668            };
669            return (task_type, Some(priority), heading.to_string());
670        }
671    }
672
673    (task_type, None, title.to_string())
674}
675
676/// Leading `#` run of an ATX heading and the gap after it.
677///
678/// Capped at six hashes, which is where markdown stops treating the run as a
679/// heading, and the gap is mandatory for the same reason: `#no gap` is a
680/// paragraph. Used by `parse_heading_line`, which works on the raw file line —
681/// unlike `parse_heading`, which is handed the text comrak already stripped
682/// the hashes from.
683static HEADING_HASHES_RE: LazyLock<Regex> = LazyLock::new(|| compile_bounded(r"^(#{1,6})[ \t]+"));
684
685/// A token found on a heading line, with the byte range it occupies.
686///
687/// The range covers the token as written, framing included: a priority cookie
688/// reports `[#A]`, not `A`. Callers replacing one token slice the line around
689/// this range and keep everything else byte-for-byte.
690#[derive(Debug, Clone, PartialEq)]
691pub struct HeadingToken<T> {
692    /// Byte range within the line the token was parsed from.
693    pub range: Range<usize>,
694    /// What the token parsed to.
695    pub value: T,
696}
697
698/// A heading line as it sits in a file, located token by token.
699///
700/// This is the read half of an editing operation and the reason it lives here
701/// rather than in the editor: the keyword and cookie grammars are the ones the
702/// extractor itself applies, and a second copy of them would drift. Writing —
703/// assembling a line back from parts — is deliberately not part of this crate.
704///
705/// Unlike [`Task`], which carries the heading the agenda displays, nothing is
706/// summarised here. A heading may hold text between the keyword and the cookie
707/// (`# TODO leftover [#B] Title`): the agenda keeps that text and shows the
708/// cookie inside it, and [`title_start`] points at the text rather than past
709/// the cookie, so an editor rewriting the title neither swallows the cookie
710/// nor moves it. Replacing the cookie itself is what [`priority`] is for — its
711/// range addresses the cookie where the user wrote it.
712///
713/// [`title_start`]: HeadingLine::title_start
714/// [`priority`]: HeadingLine::priority
715/// [`Task`]: crate::types::Task
716#[derive(Debug, Clone, PartialEq)]
717pub struct HeadingLine {
718    /// Heading level, i.e. the number of leading `#` characters (1 to 6).
719    pub level: usize,
720    /// The `TODO` / `DONE` / `CANCELLED` / `CANCELED` keyword, when present.
721    pub status: Option<HeadingToken<TaskType>>,
722    /// The `[#A]` priority cookie, when present and within the accepted range.
723    pub priority: Option<HeadingToken<Priority>>,
724    /// Byte offset the title starts at, past the tokens above and the
725    /// whitespace after them. Also where a caller inserts a token the heading
726    /// does not carry yet. A cookie away from its canonical place counts as
727    /// part of the title, so the offset stops before it.
728    pub title_start: usize,
729}
730
731/// Locate the parts of a heading line, or return `None` when `line` is not a
732/// heading.
733///
734/// Applies the same keyword and priority grammars as the extraction path, so a
735/// heading the extractor reads one way cannot be rewritten another way. See
736/// [`HeadingLine`] for what the result addresses.
737///
738/// ```
739/// # use markdown_org_extract::parse_heading_line;
740/// let line = "## TODO [#A] Write the report";
741/// let heading = parse_heading_line(line).expect("a heading");
742/// assert_eq!(&line[heading.priority.expect("a cookie").range], "[#A]");
743/// ```
744pub fn parse_heading_line(line: &str) -> Option<HeadingLine> {
745    let hashes = HEADING_HASHES_RE.captures(line)?;
746    let level = hashes
747        .get(1)
748        .expect("group 1 is Some when captures() succeeds")
749        .len();
750    let after_hashes = hashes
751        .get(0)
752        .expect("Captures::get(0) is Some when captures() succeeds")
753        .end();
754
755    let (status, after_status) = match HEADING_TODO_RE.captures(&line[after_hashes..]) {
756        Some(caps) => {
757            let keyword = caps
758                .get(1)
759                .expect("group 1 is Some when captures() succeeds");
760            let whole = caps
761                .get(0)
762                .expect("Captures::get(0) is Some when captures() succeeds");
763            let token = TaskType::from_keyword(keyword.as_str()).map(|value| HeadingToken {
764                range: after_hashes + keyword.start()..after_hashes + keyword.end(),
765                value,
766            });
767            (token, after_hashes + whole.end())
768        }
769        None => (None, after_hashes),
770    };
771
772    // The cookie is searched for in the remainder, at any position, the way
773    // `parse_heading` does it — org-mode allows text before it.
774    let priority = HEADING_PRIORITY_RE
775        .captures(&line[after_status..])
776        .and_then(|caps| {
777            let value = caps
778                .get(1)
779                .expect("group 1 is Some when captures() succeeds");
780            // The cookie is `[#` + value + `]`; the regex match may also cover
781            // a trailing space, which is not part of the token.
782            let parsed = Priority::parse(value.as_str())?;
783            Some(HeadingToken {
784                range: after_status + value.start() - "[#".len()
785                    ..after_status + value.end() + "]".len(),
786                value: parsed,
787            })
788        });
789
790    // A cookie opening the remainder is a token of its own and the title
791    // starts past it; one written further along belongs to the title, which
792    // therefore starts where the keyword left off. Same rule as `parse_heading`
793    // applies to the heading it hands the agenda.
794    let after_tokens = priority
795        .as_ref()
796        .filter(|cookie| line[after_status..cookie.range.start].trim().is_empty())
797        .map_or(after_status, |cookie| cookie.range.end);
798    let title_start = after_tokens
799        + line[after_tokens..]
800            .find(|c: char| !c.is_whitespace())
801            .unwrap_or(line.len() - after_tokens);
802
803    Some(HeadingLine {
804        level,
805        status,
806        priority,
807        title_start,
808    })
809}
810
811/// Strip a matched pair of inline-code backtick fences from the trimmed
812/// content of an indented code block.
813///
814/// Markdown's indented code blocks preserve the literal source minus the
815/// leading 4-space indent, so a line like `    \`DEADLINE: <...>\`` arrives
816/// here with the wrapping backticks intact. Those wrappers are not part of
817/// the planning-line keyword grammar — they're inline-code framing that the
818/// user added to keep the line visually attached to the heading in their
819/// editor — so we peel one balanced run of backticks before regex matching.
820///
821/// Returns `s` unchanged when the wrapping is asymmetric or absent.
822fn strip_wrapping_backticks(s: &str) -> &str {
823    let bytes = s.as_bytes();
824    let n_leading = bytes.iter().take_while(|&&b| b == b'`').count();
825    if n_leading == 0 {
826        return s;
827    }
828    let n_trailing = bytes.iter().rev().take_while(|&&b| b == b'`').count();
829    // Require equal-length fences with at least one non-fence byte between
830    // them; otherwise the input is just a run of backticks and stripping
831    // would over-consume.
832    if n_trailing != n_leading || bytes.len() < 2 * n_leading + 1 {
833        return s;
834    }
835    s[n_leading..bytes.len() - n_leading].trim()
836}
837
838/// Parse the literal of an `org-properties` fenced code block into `props`,
839/// merging into any existing entries with last-wins on duplicate keys.
840///
841/// Each non-blank line is split on its first `:`: the key is the text
842/// before it (trimmed, case preserved), the value is the remainder
843/// (trimmed). An empty key or a line with no `:` is skipped and reported
844/// via `warn_invalid_property_line`, gated by the caller-owned counter so
845/// the `MAX_DIAGNOSTIC_ITEMS` budget spans the whole run. `block_start_line`
846/// is the source line of the opening fence; the per-line offset is added so
847/// warnings point near the offending line. See ADR-0020.
848fn parse_org_properties(
849    literal: &str,
850    props: &mut BTreeMap<String, String>,
851    path: &Path,
852    block_start_line: u32,
853    prop_warning_counter: &mut usize,
854) {
855    for (offset, line) in literal.lines().enumerate() {
856        if line.trim().is_empty() {
857            continue;
858        }
859        // Source line of this content line: opening fence + 1 + offset.
860        let src_line = block_start_line
861            .saturating_add(1)
862            .saturating_add(offset as u32);
863        match line.split_once(':') {
864            Some((key, value)) => {
865                let key = key.trim();
866                if key.is_empty() {
867                    warn_invalid_property_line(prop_warning_counter, path, src_line, line);
868                    continue;
869                }
870                props.insert(key.to_string(), value.trim().to_string());
871            }
872            None => {
873                warn_invalid_property_line(prop_warning_counter, path, src_line, line);
874            }
875        }
876    }
877}
878
879/// Extract timestamps (CREATED and others) from paragraph node
880fn extract_timestamps_from_node<'a>(
881    node: &'a AstNode<'a>,
882    mappings: &[(&str, &str)],
883) -> (Option<String>, Option<String>) {
884    let mut created = None;
885    let mut timestamp = None;
886
887    if let NodeValue::Paragraph = &node.data.borrow().value {
888        for child in node.children() {
889            if let NodeValue::Code(code) = &child.data.borrow().value {
890                // Normalize the literal once per inline-code node; both extractors
891                // would otherwise scan the same string in lockstep.
892                let normalized = normalize_weekdays(&code.literal, mappings);
893                if created.is_none() {
894                    created = extract_created_normalized(&normalized);
895                }
896                if timestamp.is_none() {
897                    timestamp = extract_timestamp_normalized(&normalized);
898                }
899            }
900        }
901    }
902    (created, timestamp)
903}
904
905/// Extract plain text from paragraph, including text inside Emph/Strong/Link nodes
906///
907/// Inline code is left out here, unlike in a heading: in a body paragraph it
908/// carries the planning lines and the property markers, which are read by
909/// their own extractors and would otherwise appear twice — once as data and
910/// once as prose.
911fn extract_paragraph_text<'a>(node: &'a AstNode<'a>) -> String {
912    let mut text = String::new();
913    collect_text_recursive(node, &mut text, InlineCode::Drop);
914    text.trim().to_string()
915}
916
917/// Extract all text from a heading node, including text inside Emph/Strong
918/// and the literal of an inline code span.
919fn extract_text<'a>(node: &'a AstNode<'a>) -> String {
920    let mut text = String::new();
921    collect_text_recursive(node, &mut text, InlineCode::Keep);
922    text
923}
924
925/// What [`collect_text_recursive`] does with an inline code span.
926#[derive(Clone, Copy, PartialEq, Eq)]
927enum InlineCode {
928    /// Write its literal out, so a heading keeps every word it was written
929    /// with.
930    Keep,
931    /// Skip it.
932    Drop,
933}
934
935fn collect_text_recursive<'a>(node: &'a AstNode<'a>, out: &mut String, code: InlineCode) {
936    for child in node.children() {
937        let value = child.data.borrow().value.clone();
938        match value {
939            NodeValue::Text(t) => out.push_str(&t),
940            NodeValue::Code(inline) if code == InlineCode::Keep => out.push_str(&inline.literal),
941            NodeValue::Emph | NodeValue::Strong | NodeValue::Link(_) | NodeValue::Strikethrough => {
942                collect_text_recursive(child, out, code)
943            }
944            _ => {}
945        }
946    }
947}
948
949/// The text of a markdown fragment as the agenda shows it, with the inline
950/// markup taken off.
951///
952/// This is how a heading reaches [`Task::heading`](crate::Task::heading):
953/// emphasis, strong text, links and strikethrough contribute their text, and
954/// an inline code span contributes its literal. An editor holding the raw
955/// line can therefore compare what the file says against what it was handed
956/// — `parse_heading_line` says where the title starts, and this says what it
957/// looks like once extracted.
958///
959/// ```
960/// # use markdown_org_extract::{display_text, parse_heading_line};
961/// let line = "# TODO **Отчёт** за июль";
962/// let heading = parse_heading_line(line).expect("a heading");
963/// assert_eq!(display_text(&line[heading.title_start..]), "Отчёт за июль");
964/// ```
965pub fn display_text(markdown: &str) -> String {
966    let arena = Arena::new();
967    let root = parse_document(&arena, markdown, &safe_comrak_options());
968
969    let mut text = String::new();
970    collect_block_text(root, &mut text);
971    text.trim().to_string()
972}
973
974/// Walk down to the inline content of every block and collect its text.
975///
976/// A fragment handed to [`display_text`] is parsed as a document, so its
977/// inline nodes sit under a paragraph (or a heading, when the caller passes a
978/// whole line); the leaves are the same either way.
979fn collect_block_text<'a>(node: &'a AstNode<'a>, out: &mut String) {
980    for child in node.children() {
981        let value = child.data.borrow().value.clone();
982        match value {
983            NodeValue::Paragraph | NodeValue::Heading(_) => {
984                collect_text_recursive(child, out, InlineCode::Keep)
985            }
986            _ => collect_block_text(child, out),
987        }
988    }
989}
990
991// Need clone for NodeValue match — comrak nodes are RefCell-borrowed
992// Re-import for clone derive if not present.
993
994#[cfg(test)]
995mod tests {
996    use super::*;
997    use crate::types::{CancelledSpelling, DEFAULT_MAX_TASKS};
998
999    #[test]
1000    fn warn_invalid_timestamp_advances_per_call_counter() {
1001        // The 0.5.0 review (M1) replaced a process-global
1002        // `TS_WARNINGS_EMITTED: AtomicUsize` with a counter owned by the
1003        // caller (typically `ProcessingStats::ts_warnings_emitted`).
1004        // This test pins the per-call advance: each call bumps the
1005        // counter by exactly one.
1006        let mut counter = 0_usize;
1007        let path = Path::new("t.md");
1008        for i in 1..=25 {
1009            warn_invalid_timestamp(&mut counter, path, i, "<bad>");
1010        }
1011        assert_eq!(counter, 25);
1012    }
1013
1014    #[test]
1015    fn warn_invalid_property_line_advances_per_call_counter() {
1016        // Same per-call advance contract as warn_invalid_timestamp: each
1017        // call bumps the caller-owned counter by exactly one, so the
1018        // MAX_DIAGNOSTIC_ITEMS cap spans the whole run (ADR-0020).
1019        let mut counter = 0_usize;
1020        let path = Path::new("t.md");
1021        for i in 1..=25 {
1022            warn_invalid_property_line(&mut counter, path, i, "no-colon-here");
1023        }
1024        assert_eq!(counter, 25);
1025    }
1026
1027    #[test]
1028    fn warn_invalid_timestamp_counters_are_independent() {
1029        // Independent counters do not pollute each other: e.g. a library
1030        // consumer running two separate scans, or unit tests in the same
1031        // binary, each see a fresh budget. With the previous global
1032        // static this assertion would not hold across runs in one
1033        // process.
1034        let mut counter_a = 0_usize;
1035        let mut counter_b = 0_usize;
1036        let path = Path::new("t.md");
1037        for _ in 0..MAX_DIAGNOSTIC_ITEMS {
1038            warn_invalid_timestamp(&mut counter_a, path, 1, "<bad>");
1039        }
1040        warn_invalid_timestamp(&mut counter_b, path, 1, "<bad>");
1041        assert_eq!(counter_a, MAX_DIAGNOSTIC_ITEMS);
1042        assert_eq!(counter_b, 1);
1043    }
1044
1045    #[test]
1046    fn test_parse_heading_with_priority() {
1047        let (task_type, priority, heading) = parse_heading("TODO [#A] Important task");
1048        assert_eq!(task_type, Some(TaskType::Todo));
1049        assert_eq!(priority, Some(Priority::A));
1050        assert_eq!(heading, "Important task");
1051    }
1052
1053    #[test]
1054    fn test_parse_heading_without_priority() {
1055        let (task_type, priority, heading) = parse_heading("DONE Simple task");
1056        assert_eq!(task_type, Some(TaskType::Done));
1057        assert_eq!(priority, None);
1058        assert_eq!(heading, "Simple task");
1059    }
1060
1061    #[test]
1062    fn test_parse_heading_no_task() {
1063        let (task_type, priority, heading) = parse_heading("Regular heading");
1064        assert_eq!(task_type, None);
1065        assert_eq!(priority, None);
1066        assert_eq!(heading, "Regular heading");
1067    }
1068
1069    // The next batch mirrors the test matrix from the bug report
1070    // "markdown-org-extract: приоритет `[#X]` без TODO/DONE не распознаётся".
1071    // We follow emacs org-mode semantics (`org-priority-regexp`) wherever the
1072    // bug report diverged from it — concretely case 8 below.
1073
1074    #[test]
1075    fn parse_heading_priority_without_todo() {
1076        // Case 1: `### [#A] Заголовок`.
1077        let (tt, p, h) = parse_heading("[#A] Заголовок");
1078        assert_eq!(tt, None);
1079        assert_eq!(p, Some(Priority::A));
1080        assert_eq!(h, "Заголовок");
1081    }
1082
1083    #[test]
1084    fn parse_heading_todo_with_priority() {
1085        // Case 2: `### TODO [#A] Заголовок`.
1086        let (tt, p, h) = parse_heading("TODO [#A] Заголовок");
1087        assert_eq!(tt, Some(TaskType::Todo));
1088        assert_eq!(p, Some(Priority::A));
1089        assert_eq!(h, "Заголовок");
1090    }
1091
1092    #[test]
1093    fn parse_heading_done_with_priority_b() {
1094        // Case 3: `### DONE [#B] Заголовок`.
1095        let (tt, p, h) = parse_heading("DONE [#B] Заголовок");
1096        assert_eq!(tt, Some(TaskType::Done));
1097        assert_eq!(p, Some(Priority::B));
1098        assert_eq!(h, "Заголовок");
1099    }
1100
1101    #[test]
1102    fn parse_heading_plain_text_no_markers() {
1103        // Case 4: `### Заголовок`.
1104        let (tt, p, h) = parse_heading("Заголовок");
1105        assert_eq!(tt, None);
1106        assert_eq!(p, None);
1107        assert_eq!(h, "Заголовок");
1108    }
1109
1110    #[test]
1111    fn parse_heading_todo_no_priority() {
1112        // Case 5: `### TODO Заголовок`.
1113        let (tt, p, h) = parse_heading("TODO Заголовок");
1114        assert_eq!(tt, Some(TaskType::Todo));
1115        assert_eq!(p, None);
1116        assert_eq!(h, "Заголовок");
1117    }
1118
1119    #[test]
1120    fn parse_heading_numeric_priority() {
1121        // Case 6: `### [#1] Заголовок`.
1122        let (tt, p, h) = parse_heading("[#1] Заголовок");
1123        assert_eq!(tt, None);
1124        assert_eq!(p, Some(Priority::Numeric(1)));
1125        assert_eq!(h, "Заголовок");
1126    }
1127
1128    #[test]
1129    fn parse_heading_extra_whitespace_around_priority() {
1130        // Case 7: `###     [#A]     Заголовок` — comrak normalises the leading
1131        // whitespace after the `###` marker, so the heading text reaching us
1132        // starts at `[#A]`. Trailing extra spaces around the heading are
1133        // trimmed.
1134        let (tt, p, h) = parse_heading("[#A]     Заголовок");
1135        assert_eq!(tt, None);
1136        assert_eq!(p, Some(Priority::A));
1137        assert_eq!(h, "Заголовок");
1138    }
1139
1140    #[test]
1141    fn parse_heading_priority_in_the_middle_org_semantics() {
1142        // Case 8: `### Без приоритета и [#A] внутри`.
1143        // The cookie counts wherever it sits — `org-get-priority` finds it
1144        // through the `.*?` prefix of `org-priority-regexp` — but the title is
1145        // left as written: that is the line emacs puts in the agenda.
1146        let (tt, p, h) = parse_heading("Без приоритета и [#A] внутри");
1147        assert_eq!(tt, None);
1148        assert_eq!(p, Some(Priority::A));
1149        assert_eq!(h, "Без приоритета и [#A] внутри");
1150    }
1151
1152    #[test]
1153    fn parse_heading_trailing_cookie_leaves_a_title_behind() {
1154        // The case the two clients answered differently: a cookie written last
1155        // used to take the whole title with it and leave the agenda showing an
1156        // empty row.
1157        let (tt, p, h) = parse_heading("TODO Заголовок с cookie в конце [#A]");
1158        assert_eq!(tt, Some(TaskType::Todo));
1159        assert_eq!(p, Some(Priority::A));
1160        assert_eq!(h, "Заголовок с cookie в конце [#A]");
1161    }
1162
1163    #[test]
1164    fn parse_heading_two_digit_numeric_priority() {
1165        let (tt, p, h) = parse_heading("[#15] Mid range");
1166        assert_eq!(tt, None);
1167        assert_eq!(p, Some(Priority::Numeric(15)));
1168        assert_eq!(h, "Mid range");
1169
1170        let (tt, p, h) = parse_heading("[#64] At upper bound");
1171        assert_eq!(tt, None);
1172        assert_eq!(p, Some(Priority::Numeric(64)));
1173        assert_eq!(h, "At upper bound");
1174    }
1175
1176    #[test]
1177    fn parse_heading_rejects_numeric_out_of_range() {
1178        // `[#65]` and higher are not a valid org-mode priority. The cookie
1179        // stays inside the heading text verbatim.
1180        let (tt, p, h) = parse_heading("[#65] Above range");
1181        assert_eq!(tt, None);
1182        assert_eq!(p, None);
1183        assert_eq!(h, "[#65] Above range");
1184    }
1185
1186    #[test]
1187    fn parse_heading_rejects_lowercase_priority() {
1188        let (tt, p, h) = parse_heading("[#a] Lowercase");
1189        assert_eq!(tt, None);
1190        assert_eq!(p, None);
1191        assert_eq!(h, "[#a] Lowercase");
1192    }
1193
1194    #[test]
1195    fn parse_heading_todo_then_priority_with_intervening_text() {
1196        // The cookie is out of its canonical place, so it stays in the title
1197        // and only the priority is taken from it. Emacs shows the same line in
1198        // the agenda, cookie included.
1199        let (tt, p, h) = parse_heading("TODO Купить [#A] фильтр");
1200        assert_eq!(tt, Some(TaskType::Todo));
1201        assert_eq!(p, Some(Priority::A));
1202        assert_eq!(h, "Купить [#A] фильтр");
1203    }
1204
1205    #[test]
1206    fn parse_heading_priority_without_trailing_space() {
1207        // `\] ?` makes the post-cookie space optional.
1208        let (tt, p, h) = parse_heading("[#A]NoSpace");
1209        assert_eq!(tt, None);
1210        assert_eq!(p, Some(Priority::A));
1211        assert_eq!(h, "NoSpace");
1212    }
1213
1214    #[test]
1215    fn parse_heading_cancelled_simple() {
1216        let (tt, p, h) = parse_heading("CANCELLED Foo");
1217        assert_eq!(tt, Some(TaskType::Cancelled(CancelledSpelling::DoubleL)));
1218        assert_eq!(p, None);
1219        assert_eq!(h, "Foo");
1220    }
1221
1222    #[test]
1223    fn parse_heading_cancelled_with_priority() {
1224        let (tt, p, h) = parse_heading("CANCELLED [#A] Foo");
1225        assert_eq!(tt, Some(TaskType::Cancelled(CancelledSpelling::DoubleL)));
1226        assert_eq!(p, Some(Priority::A));
1227        assert_eq!(h, "Foo");
1228    }
1229
1230    #[test]
1231    fn parse_heading_cancelled_without_whitespace() {
1232        // No whitespace after the keyword: not recognised, stays in title.
1233        let (tt, p, h) = parse_heading("CANCELLEDFoo");
1234        assert_eq!(tt, None);
1235        assert_eq!(p, None);
1236        assert_eq!(h, "CANCELLEDFoo");
1237    }
1238
1239    #[test]
1240    fn parse_heading_cancelled_lowercase_not_recognised() {
1241        // Case-sensitive, like TODO/DONE.
1242        let (tt, p, h) = parse_heading("cancelled Foo");
1243        assert_eq!(tt, None);
1244        assert_eq!(p, None);
1245        assert_eq!(h, "cancelled Foo");
1246    }
1247
1248    #[test]
1249    fn parse_heading_todo_cancelled_first_keyword_wins() {
1250        // First keyword wins; the rest goes into the title (existing rule).
1251        let (tt, p, h) = parse_heading("TODO CANCELLED Foo");
1252        assert_eq!(tt, Some(TaskType::Todo));
1253        assert_eq!(p, None);
1254        assert_eq!(h, "CANCELLED Foo");
1255    }
1256
1257    #[test]
1258    fn parse_heading_canceled_single_l() {
1259        // Upstream Emacs Org-mode spells the keyword with a single L. See
1260        // ADR-0021; recognised alongside the double-L `CANCELLED`.
1261        let (tt, p, h) = parse_heading("CANCELED Foo");
1262        assert_eq!(tt, Some(TaskType::Cancelled(CancelledSpelling::SingleL)));
1263        assert_eq!(p, None);
1264        assert_eq!(h, "Foo");
1265    }
1266
1267    #[test]
1268    fn parse_heading_canceled_with_priority() {
1269        let (tt, p, h) = parse_heading("CANCELED [#A] Foo");
1270        assert_eq!(tt, Some(TaskType::Cancelled(CancelledSpelling::SingleL)));
1271        assert_eq!(p, Some(Priority::A));
1272        assert_eq!(h, "Foo");
1273    }
1274
1275    #[test]
1276    fn parse_heading_canceled_lowercase_not_recognised() {
1277        // Case-sensitive, like TODO/DONE/CANCELLED.
1278        let (tt, p, h) = parse_heading("canceled Foo");
1279        assert_eq!(tt, None);
1280        assert_eq!(p, None);
1281        assert_eq!(h, "canceled Foo");
1282    }
1283
1284    #[test]
1285    fn parse_heading_canceled_without_whitespace_not_recognised() {
1286        // No whitespace after the keyword: not recognised, stays in title.
1287        let (tt, p, h) = parse_heading("CANCELEDfoo");
1288        assert_eq!(tt, None);
1289        assert_eq!(p, None);
1290        assert_eq!(h, "CANCELEDfoo");
1291    }
1292
1293    #[test]
1294    fn extract_tasks_marks_scheduled_angle_bracket_as_active() {
1295        // End-to-end: a SCHEDULED line with `<...>` must surface
1296        // `timestamp_active = Some(true)` in the resulting Task, so
1297        // downstream consumers can branch on bracket form without
1298        // re-parsing the timestamp string. See ADR-0014.
1299        let content = "### TODO Pin me\n`SCHEDULED: <2026-05-21 Thu>`\n";
1300        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1301        assert_eq!(tasks.len(), 1);
1302        assert_eq!(tasks[0].timestamp_active, Some(true));
1303    }
1304
1305    #[test]
1306    fn extract_tasks_marks_missing_timestamp_active_as_none() {
1307        // Heading without a timestamp must keep `timestamp_active = None`,
1308        // matching the rule that absent optional fields skip JSON
1309        // serialisation (ADR-0015).
1310        let content = "### Project kickoff\n\n`CREATED: [2025-09-01 Mon]`\n";
1311        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1312        assert_eq!(tasks.len(), 1);
1313        assert_eq!(tasks[0].timestamp_active, None);
1314    }
1315
1316    #[test]
1317    fn extract_tasks_basic_todo_with_deadline() {
1318        let content = "\
1319### TODO [#A] Write docs\n\
1320`DEADLINE: <2025-12-10 Wed>`\n";
1321        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1322        assert_eq!(tasks.len(), 1);
1323        let t = &tasks[0];
1324        assert_eq!(t.task_type, Some(TaskType::Todo));
1325        assert_eq!(t.priority, Some(Priority::A));
1326        assert_eq!(t.heading, "Write docs");
1327        assert_eq!(t.timestamp_type, Some("DEADLINE".to_string()));
1328        assert_eq!(t.timestamp_date, Some("2025-12-10".to_string()));
1329    }
1330
1331    #[test]
1332    fn extract_tasks_extracts_emph_text_in_heading() {
1333        // Regression: previously emphasised text inside heading was dropped.
1334        let content = "### TODO **Important** task\n`DEADLINE: <2025-12-10 Wed>`\n";
1335        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1336        assert_eq!(tasks.len(), 1);
1337        assert_eq!(tasks[0].heading, "Important task");
1338    }
1339
1340    #[test]
1341    fn extract_tasks_ignores_non_task_headings_without_timestamps() {
1342        let content = "### Just a heading\n\nSome text.\n";
1343        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1344        assert!(tasks.is_empty());
1345    }
1346
1347    #[test]
1348    fn extract_tasks_keeps_created_without_todo() {
1349        // Heading without TODO/DONE keyword but with a CREATED line is still a task.
1350        let content = "### Project kickoff\n\n`CREATED: [2025-09-01 Mon]`\n";
1351        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1352        assert_eq!(tasks.len(), 1);
1353        assert_eq!(tasks[0].task_type, None);
1354        assert_eq!(tasks[0].created, Some("CREATED: [2025-09-01 Mon]".into()));
1355    }
1356
1357    #[test]
1358    fn extract_tasks_concatenates_multiple_paragraphs() {
1359        // Regression: previously only the first paragraph was kept as content.
1360        let content = "\
1361### TODO Multi-line task\n\
1362First paragraph.\n\
1363\n\
1364Second paragraph.\n\
1365";
1366        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1367        assert_eq!(tasks.len(), 1);
1368        assert!(tasks[0].content.contains("First paragraph"));
1369        assert!(tasks[0].content.contains("Second paragraph"));
1370    }
1371
1372    #[test]
1373    fn extract_tasks_extracts_clock_from_inline_code() {
1374        let content = "\
1375### TODO Track time\n\
1376`CLOCK: [2025-09-01 Mon 10:00]--[2025-09-01 Mon 11:30] => 1:30`\n\
1377";
1378        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1379        assert_eq!(tasks.len(), 1);
1380        let t = &tasks[0];
1381        assert!(t.clocks.is_some());
1382        assert_eq!(t.total_clock_time.as_deref(), Some("1:30"));
1383    }
1384
1385    #[test]
1386    fn extract_tasks_handles_done_priority() {
1387        let content = "### DONE [#B] Wrap up\n";
1388        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1389        assert_eq!(tasks.len(), 1);
1390        assert_eq!(tasks[0].task_type, Some(TaskType::Done));
1391        assert_eq!(tasks[0].priority, Some(Priority::B));
1392    }
1393
1394    #[test]
1395    fn extract_tasks_priority_without_todo_with_scheduled() {
1396        // Bug report case: priority cookie before SCHEDULED heading, no TODO.
1397        // After the fix the heading must surface as a task with priority=A and
1398        // task_type=None, since the SCHEDULED line is what makes it agenda-eligible.
1399        let content = "\
1400### [#A] Поменять резину до 16.05.2026\n\
1401`SCHEDULED: <2026-05-09 Sat>`\n";
1402        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1403        assert_eq!(tasks.len(), 1);
1404        let t = &tasks[0];
1405        assert_eq!(t.task_type, None);
1406        assert_eq!(t.priority, Some(Priority::A));
1407        assert_eq!(t.heading, "Поменять резину до 16.05.2026");
1408        assert_eq!(t.timestamp_type, Some("SCHEDULED".to_string()));
1409        assert_eq!(t.timestamp_date, Some("2026-05-09".to_string()));
1410    }
1411
1412    #[test]
1413    fn extract_tasks_numeric_priority_with_deadline() {
1414        // Numeric priority `[#1]` without TODO, with a DEADLINE line.
1415        let content = "\
1416### [#1] Numeric priority task\n\
1417`DEADLINE: <2026-05-09 Sat>`\n";
1418        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1419        assert_eq!(tasks.len(), 1);
1420        let t = &tasks[0];
1421        assert_eq!(t.task_type, None);
1422        assert_eq!(t.priority, Some(Priority::Numeric(1)));
1423        assert_eq!(t.heading, "Numeric priority task");
1424    }
1425
1426    #[test]
1427    fn extract_tasks_priority_in_middle_keeps_the_prefix() {
1428        // The cookie is read wherever it sits, and the heading reaches the
1429        // agenda as the file has it — nothing before the cookie is dropped.
1430        let content = "\
1431### Без приоритета и [#A] внутри\n\
1432`SCHEDULED: <2026-05-09 Sat>`\n";
1433        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1434        assert_eq!(tasks.len(), 1);
1435        let t = &tasks[0];
1436        assert_eq!(t.task_type, None);
1437        assert_eq!(t.priority, Some(Priority::A));
1438        assert_eq!(t.heading, "Без приоритета и [#A] внутри");
1439    }
1440
1441    #[test]
1442    fn extract_tasks_bug_report_minimal_reproduction() {
1443        // Three-heading reproduction from the bug report: priority without
1444        // TODO, TODO + priority, plain heading. SCHEDULED is wrapped in
1445        // backticks so the existing inline-code parser picks it up.
1446        let content = "\
1447### [#A] Поменять резину до 16.05.2026\n\
1448`SCHEDULED: <2026-05-09 Sat>`\n\
1449\n\
1450### TODO [#A] Поменять масло\n\
1451`SCHEDULED: <2026-05-09 Sat>`\n\
1452\n\
1453### Купить фильтр\n\
1454`SCHEDULED: <2026-05-09 Sat>`\n";
1455        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1456        assert_eq!(tasks.len(), 3);
1457
1458        assert_eq!(tasks[0].task_type, None);
1459        assert_eq!(tasks[0].priority, Some(Priority::A));
1460        assert_eq!(tasks[0].heading, "Поменять резину до 16.05.2026");
1461
1462        assert_eq!(tasks[1].task_type, Some(TaskType::Todo));
1463        assert_eq!(tasks[1].priority, Some(Priority::A));
1464        assert_eq!(tasks[1].heading, "Поменять масло");
1465
1466        assert_eq!(tasks[2].task_type, None);
1467        assert_eq!(tasks[2].priority, None);
1468        assert_eq!(tasks[2].heading, "Купить фильтр");
1469    }
1470
1471    // Regression suite for the "indented planning line" cases. A heading
1472    // followed by a 4-space-indented DEADLINE/SCHEDULED/CREATED line is
1473    // parsed by comrak as an indented code block; the timestamp must still
1474    // be recovered, whether or not the planning line is wrapped in inline
1475    // backticks. Matches what `emacs` org-agenda surfaces.
1476    // The literals below use real newlines (no `\\\n` Rust string
1477    // continuation): the continuation form would silently swallow the
1478    // four leading spaces and reduce the case to "no indent at all".
1479    #[test]
1480    fn extract_tasks_indented_inline_code_deadline() {
1481        let content = "#### Birthday\n    `DEADLINE: <2026-05-07 Thu +1y>`\n";
1482        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1483        assert_eq!(tasks.len(), 1, "task should not be dropped");
1484        let t = &tasks[0];
1485        assert_eq!(t.timestamp_type.as_deref(), Some("DEADLINE"));
1486        assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1487    }
1488
1489    #[test]
1490    fn extract_tasks_todo_indented_inline_code_deadline() {
1491        let content = "#### TODO Birthday\n    `DEADLINE: <2026-05-07 Thu +1y>`\n";
1492        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1493        assert_eq!(tasks.len(), 1);
1494        let t = &tasks[0];
1495        assert_eq!(t.task_type, Some(TaskType::Todo));
1496        assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1497    }
1498
1499    #[test]
1500    fn extract_tasks_indented_inline_code_blank_lines_between() {
1501        // Whitespace between heading and planning line (blank lines, tabs,
1502        // mixed indentation) must not block timestamp recovery.
1503        let content = "#### Birthday\n\n  \t  \n    `DEADLINE: <2026-05-07 Thu +1y>`\n";
1504        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1505        assert_eq!(tasks.len(), 1);
1506        let t = &tasks[0];
1507        assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1508    }
1509
1510    #[test]
1511    fn extract_tasks_with_ru_mappings_reproduces_cli_pipeline() {
1512        // Reproduce what `main.rs` feeds to `extract_tasks` when the default
1513        // `--locale ru,en` is in effect. Pulling the table from `cli` keeps
1514        // this test in sync with whatever `get_weekday_mappings("ru")` would
1515        // produce in production.
1516        let content = "#### TODO Birthday\n    `DEADLINE: <2026-05-07 Thu +1y>`\n";
1517        let tasks = extract_tasks(
1518            Path::new("t.md"),
1519            content,
1520            crate::locale::RU_WEEKDAY_MAPPINGS,
1521            DEFAULT_MAX_TASKS,
1522        );
1523        assert_eq!(tasks.len(), 1);
1524        let t = &tasks[0];
1525        assert_eq!(t.task_type, Some(TaskType::Todo));
1526        assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1527    }
1528
1529    #[test]
1530    fn extract_tasks_inline_code_scheduled_no_indent() {
1531        let content = "#### Followup\n`SCHEDULED: <2026-05-07 Thu>`\n";
1532        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1533        assert_eq!(tasks.len(), 1);
1534        let t = &tasks[0];
1535        assert_eq!(t.timestamp_type.as_deref(), Some("SCHEDULED"));
1536        assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1537    }
1538
1539    #[test]
1540    fn extract_tasks_indented_inline_code_created() {
1541        let content = "#### Project kickoff\n    `CREATED: [2025-09-01 Mon]`\n";
1542        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1543        assert_eq!(tasks.len(), 1);
1544        let t = &tasks[0];
1545        assert_eq!(t.created.as_deref(), Some("CREATED: [2025-09-01 Mon]"));
1546    }
1547
1548    #[test]
1549    fn extract_tasks_parses_single_property() {
1550        let content = "### TODO Ship release\n`SCHEDULED: <2026-06-01 Mon 10:00>`\n```org-properties\nGCAL_EVENT_ID: abc123/primary\n```\n";
1551        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1552        assert_eq!(tasks.len(), 1);
1553        let props = tasks[0].properties.as_ref().expect("properties present");
1554        assert_eq!(
1555            props.get("GCAL_EVENT_ID").map(String::as_str),
1556            Some("abc123/primary")
1557        );
1558        // The block must not leak into the task body content.
1559        assert!(!tasks[0].content.contains("GCAL_EVENT_ID"));
1560        assert!(!tasks[0].content.contains("org-properties"));
1561    }
1562
1563    #[test]
1564    fn extract_tasks_parses_multiple_properties() {
1565        let content =
1566            "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nA: 1\nB: 2\n```\n";
1567        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1568        let props = tasks[0].properties.as_ref().unwrap();
1569        assert_eq!(props.get("A").map(String::as_str), Some("1"));
1570        assert_eq!(props.get("B").map(String::as_str), Some("2"));
1571    }
1572
1573    #[test]
1574    fn extract_tasks_property_duplicate_keys_last_wins() {
1575        let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nK: first\nK: second\n```\n";
1576        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1577        assert_eq!(
1578            tasks[0]
1579                .properties
1580                .as_ref()
1581                .unwrap()
1582                .get("K")
1583                .map(String::as_str),
1584            Some("second")
1585        );
1586    }
1587
1588    #[test]
1589    fn extract_tasks_property_empty_value_allowed() {
1590        let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nK:\n```\n";
1591        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1592        assert_eq!(
1593            tasks[0]
1594                .properties
1595                .as_ref()
1596                .unwrap()
1597                .get("K")
1598                .map(String::as_str),
1599            Some("")
1600        );
1601    }
1602
1603    /// Parse one file and report what each diagnostic budget spent on it:
1604    /// the timestamp one, and the `org-properties` one that the exception
1605    /// keys share.
1606    fn counted(content: &str) -> (Vec<Task>, usize, usize) {
1607        let mut timestamps = 0_usize;
1608        let mut properties = 0_usize;
1609        let tasks = extract_tasks_with_counter(
1610            Path::new("t.md"),
1611            content,
1612            &[],
1613            DEFAULT_MAX_TASKS,
1614            &mut timestamps,
1615            &mut properties,
1616        );
1617        (tasks, timestamps, properties)
1618    }
1619
1620    /// A task with the given `org-properties` block under it.
1621    fn with_properties(properties: &str) -> String {
1622        format!(
1623            "### TODO T\n`SCHEDULED: <2026-08-13 Thu +1w>`\n```org-properties\n{properties}\n```\n"
1624        )
1625    }
1626
1627    #[test]
1628    fn a_date_in_an_exdate_that_cannot_be_read_is_not_a_broken_timestamp() {
1629        // A property value is not an entry's timestamp, and telling the two
1630        // apart is what lets a run say which of them is wrong. Sharing the
1631        // timestamp budget also let a file full of unreadable `EXDATE` values
1632        // silence the warnings about real timestamps in every file after it.
1633        let (tasks, timestamps, properties) =
1634            counted(&with_properties("EXDATE: 2026-08-20, next-thursday"));
1635
1636        assert_eq!(
1637            tasks[0].excluded_dates.as_deref(),
1638            Some(["2026-08-20".to_string()].as_slice()),
1639            "the date that reads is kept"
1640        );
1641        assert_eq!(timestamps, 0, "nothing here is a timestamp");
1642        assert_eq!(properties, 1, "the field that does not read is reported");
1643    }
1644
1645    #[test]
1646    fn an_exdate_that_holds_no_date_at_all_is_reported() {
1647        // The key was written on purpose and cancels nothing. Without a word
1648        // about it, the entry looks like one carrying an exception and behaves
1649        // like one that does not.
1650        let (tasks, _, properties) = counted(&with_properties("EXDATE:"));
1651
1652        assert_eq!(tasks[0].excluded_dates, None);
1653        assert_eq!(properties, 1, "a key with nothing usable in it is reported");
1654    }
1655
1656    #[test]
1657    fn an_exdate_of_separators_alone_is_reported() {
1658        let (tasks, _, properties) = counted(&with_properties("EXDATE: , ,"));
1659
1660        assert_eq!(tasks[0].excluded_dates, None);
1661        assert_eq!(properties, 1);
1662    }
1663
1664    #[test]
1665    fn half_of_an_exception_pair_is_reported() {
1666        // `SERIES_ID` alone replaces nothing: the day keeps both the series
1667        // occurrence and the entry that meant to stand in for it.
1668        let (tasks, _, properties) = counted(&with_properties("SERIES_ID: series-1"));
1669
1670        assert_eq!(tasks[0].series_id.as_deref(), Some("series-1"));
1671        assert_eq!(tasks[0].recurrence_id, None);
1672        assert_eq!(properties, 1, "the missing half is reported");
1673    }
1674
1675    #[test]
1676    fn the_other_half_of_an_exception_pair_is_reported_too() {
1677        let (tasks, _, properties) = counted(&with_properties("RECURRENCE_ID: 2026-08-20 15:00"));
1678
1679        assert_eq!(tasks[0].recurrence_id.as_deref(), Some("2026-08-20 15:00"));
1680        assert_eq!(tasks[0].series_id, None);
1681        assert_eq!(properties, 1, "the missing half is reported");
1682    }
1683
1684    #[test]
1685    fn an_empty_series_id_leaves_the_pair_incomplete_and_is_reported() {
1686        let (tasks, _, properties) =
1687            counted(&with_properties("SERIES_ID:\nRECURRENCE_ID: 2026-08-20"));
1688
1689        assert_eq!(tasks[0].series_id, None, "an empty id names no series");
1690        assert_eq!(properties, 1);
1691    }
1692
1693    #[test]
1694    fn a_time_in_a_recurrence_id_that_cannot_be_read_is_reported() {
1695        // The date is what an occurrence is matched on, so it is kept; the
1696        // time the file wrote is dropped, and the export built from this field
1697        // will not carry it. That is worth a word.
1698        let (tasks, _, properties) = counted(&with_properties(
1699            "SERIES_ID: series-1\nRECURRENCE_ID: 2026-08-20 15-00",
1700        ));
1701
1702        assert_eq!(tasks[0].recurrence_id.as_deref(), Some("2026-08-20"));
1703        assert_eq!(properties, 1, "the dropped time is reported");
1704    }
1705
1706    #[test]
1707    fn a_recurrence_id_written_with_seconds_keeps_its_time() {
1708        // The form a calendar exports. The seconds are dropped -- occurrences
1709        // are named to the minute here -- and the time survives.
1710        let (tasks, _, properties) = counted(&with_properties(
1711            "SERIES_ID: series-1\nRECURRENCE_ID: 2026-08-20 15:00:00",
1712        ));
1713
1714        assert_eq!(tasks[0].recurrence_id.as_deref(), Some("2026-08-20 15:00"));
1715        assert_eq!(properties, 0, "nothing was lost, so nothing is reported");
1716    }
1717
1718    #[test]
1719    fn a_recurrence_id_that_is_not_a_date_is_reported_as_the_broken_pair_it_leaves() {
1720        // Two things went wrong and each is worth its own line: the value does
1721        // not read, and what is left is half a pair, which replaces nothing.
1722        let (tasks, timestamps, properties) = counted(&with_properties(
1723            "SERIES_ID: series-1\nRECURRENCE_ID: whenever",
1724        ));
1725
1726        assert_eq!(tasks[0].recurrence_id, None);
1727        assert_eq!(timestamps, 0, "a property value is not a timestamp");
1728        assert_eq!(properties, 2, "the unreadable value, then the broken pair");
1729    }
1730
1731    #[test]
1732    fn extract_tasks_property_malformed_line_skipped() {
1733        let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nGOOD: x\nno colon here\n```\n";
1734        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1735        let props = tasks[0].properties.as_ref().unwrap();
1736        assert_eq!(props.get("GOOD").map(String::as_str), Some("x"));
1737        assert_eq!(props.len(), 1, "malformed line must be skipped");
1738    }
1739
1740    #[test]
1741    fn extract_tasks_empty_property_block_yields_none() {
1742        let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\n\n```\n";
1743        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1744        assert_eq!(tasks[0].properties, None);
1745    }
1746
1747    #[test]
1748    fn extract_tasks_property_info_with_extra_attrs_not_recognised() {
1749        // Info string must be exactly "org-properties"; extra attributes
1750        // mean it is a plain code block, not a property block.
1751        let content =
1752            "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties extra\nK: v\n```\n";
1753        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1754        assert_eq!(tasks[0].properties, None);
1755    }
1756
1757    #[test]
1758    fn extract_tasks_clock_code_block_unaffected_by_properties() {
1759        // A CLOCK-bearing code block on the same task is still parsed for
1760        // clocks; the org-properties block is parsed for properties.
1761        let content = "### TODO T\n```org-properties\nK: v\n```\n`CLOCK: [2025-09-01 Mon 10:00]--[2025-09-01 Mon 11:30] => 1:30`\n";
1762        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1763        assert_eq!(
1764            tasks[0]
1765                .properties
1766                .as_ref()
1767                .unwrap()
1768                .get("K")
1769                .map(String::as_str),
1770            Some("v")
1771        );
1772        assert_eq!(tasks[0].total_clock_time.as_deref(), Some("1:30"));
1773    }
1774
1775    #[test]
1776    fn extract_tasks_merges_multiple_property_blocks_last_wins() {
1777        let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nK: one\n```\n```org-properties\nK: two\nL: three\n```\n";
1778        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1779        let props = tasks[0].properties.as_ref().unwrap();
1780        assert_eq!(props.get("K").map(String::as_str), Some("two"));
1781        assert_eq!(props.get("L").map(String::as_str), Some("three"));
1782    }
1783
1784    // Two answers the cases above do not reach: an `EXDATE` where nothing at
1785    // all reads, and the per-field count of what was reported. Both are about
1786    // the field the parser leaves behind rather than the line it logs.
1787
1788    #[test]
1789    fn an_exdate_of_nothing_readable_leaves_no_field_at_all() {
1790        // Not `Some([])`. The field is skipped on serialisation when it is
1791        // None, so an empty list would reach a client as a series announcing
1792        // exceptions and naming none -- a state every client would then have
1793        // to answer for. The value is reported field by field regardless.
1794        let (tasks, _, properties) = counted(&with_properties("EXDATE: next-thursday, sometime"));
1795
1796        assert_eq!(tasks[0].excluded_dates, None);
1797        assert_eq!(properties, 2, "each field that does not read is reported");
1798    }
1799
1800    #[test]
1801    fn the_budget_for_unusable_exceptions_spans_the_file() {
1802        // ADR-0020's cap is per run, not per task: two entries whose `EXDATE`
1803        // does not read advance one counter. Pinned because the counter is
1804        // threaded through `finalize_task` by hand, and a fresh one per task
1805        // would look the same in every single-task test above.
1806        let content = format!(
1807            "{}{}",
1808            with_properties("EXDATE: never"),
1809            with_properties("EXDATE: sometime, whenever")
1810        );
1811        let (tasks, timestamps, properties) = counted(&content);
1812
1813        assert_eq!(tasks.len(), 2);
1814        assert_eq!(timestamps, 0);
1815        assert_eq!(
1816            properties, 3,
1817            "one field in the first entry, two in the second"
1818        );
1819    }
1820
1821    #[test]
1822    fn a_task_without_properties_asks_nothing_of_the_exception_keys() {
1823        let mut counter = 0_usize;
1824        let fields = exception_fields(Path::new("t.md"), 1, None, &mut counter);
1825
1826        assert_eq!(fields, ExceptionFields::default());
1827        assert_eq!(counter, 0);
1828    }
1829}