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/// Optional TODO/DONE/CANCELLED/CANCELED keyword anchored to the start of a
74/// heading.
75///
76/// Matches `TODO`, `DONE`, `CANCELLED` (double-L) or `CANCELED` (single-L,
77/// the upstream Emacs Org-mode spelling) followed by at least one whitespace
78/// character. The double-L `CANCELLED` is listed before the single-L
79/// `CANCELED` so the alternation prefers the longer spelling. Used as the
80/// first step of heading parsing — see `parse_heading`.
81static HEADING_TODO_RE: LazyLock<Regex> =
82    LazyLock::new(|| compile_bounded(r"^(TODO|DONE|CANCELLED|CANCELED)\s+"));
83
84/// Priority cookie `[#X]` with an optional trailing space, matching anywhere
85/// in the heading text.
86///
87/// Mirrors emacs org-mode's `org-priority-regexp` semantics: the priority
88/// cookie may appear at any position in the (remaining) heading title, and the
89/// title content before it is dropped. The value is either an uppercase ASCII
90/// letter or a one- or two-digit integer; the integer range is validated by
91/// `Priority::parse` (only `0..=64` is accepted).
92///
93/// Two-digit alternatives are listed before single-digit `[0-9]` so the
94/// matcher prefers the longest valid run (e.g. matches `15`, not just `1`).
95static HEADING_PRIORITY_RE: LazyLock<Regex> =
96    LazyLock::new(|| compile_bounded(r"\[#([A-Z]|6[0-4]|[1-5][0-9]|[0-9])\] ?"));
97
98/// Extract tasks from markdown content with a caller-owned warning counter.
99///
100/// Production callers (see `main.rs::scan_files`) pass
101/// `&mut ProcessingStats::ts_warnings_emitted` so the per-`MAX_DIAGNOSTIC_ITEMS`
102/// cap on invalid-timestamp warnings spans every file in the run. Library
103/// callers can pass their own counter to scope the budget per scan.
104///
105/// # Arguments
106/// * `path` - Path to the markdown file. Stored verbatim in `Task.file` for output.
107/// * `content` - File content (UTF-8).
108/// * `mappings` - Weekday name mappings for localization.
109/// * `max_tasks` - Per-file cap. Parsing stops as soon as this many tasks accumulate.
110/// * `ts_warning_counter` - Mutable counter used to gate invalid-timestamp warnings.
111///
112/// # Returns
113/// Vector of extracted tasks, capped at `max_tasks`.
114pub fn extract_tasks_with_counter(
115    path: &Path,
116    content: &str,
117    mappings: &[(&str, &str)],
118    max_tasks: usize,
119    ts_warning_counter: &mut usize,
120    prop_warning_counter: &mut usize,
121) -> Vec<Task> {
122    let arena = Arena::new();
123    let root = parse_document(&arena, content, &safe_comrak_options());
124
125    let mut tasks = Vec::new();
126    let mut current_heading: Option<HeadingInfo> = None;
127
128    for node in root.children() {
129        process_node(
130            node,
131            path,
132            &mut tasks,
133            &mut current_heading,
134            mappings,
135            ts_warning_counter,
136            prop_warning_counter,
137        );
138
139        if tasks.len() >= max_tasks {
140            tracing::warn!(
141                file = %path.display(),
142                limit = max_tasks,
143                "reached per-file task limit"
144            );
145            break;
146        }
147    }
148
149    // Flush remaining heading
150    if let Some(info) = current_heading.take() {
151        if let Some(task) = finalize_task(path, info, ts_warning_counter) {
152            tasks.push(task);
153        }
154    }
155
156    tracing::debug!(
157        file = %path.display(),
158        bytes = content.len(),
159        tasks = tasks.len(),
160        "parsed file"
161    );
162
163    tasks
164}
165
166/// Extract tasks from markdown content with a per-call warning budget.
167///
168/// Convenience wrapper around [`extract_tasks_with_counter`] that owns the
169/// counter for the duration of one call. Used by the unit-test suite and
170/// available to library callers that scope the invalid-timestamp warning
171/// cap per file. The production CLI (`main.rs::scan_files`) uses
172/// `extract_tasks_with_counter` directly so the cap spans the whole run.
173#[cfg_attr(not(test), allow(dead_code))]
174pub fn extract_tasks(
175    path: &Path,
176    content: &str,
177    mappings: &[(&str, &str)],
178    max_tasks: usize,
179) -> Vec<Task> {
180    let mut counter = 0_usize;
181    let mut prop_counter = 0_usize;
182    extract_tasks_with_counter(
183        path,
184        content,
185        mappings,
186        max_tasks,
187        &mut counter,
188        &mut prop_counter,
189    )
190}
191
192/// Comrak parsing options.
193///
194/// **Security note**: this is `Options::default()` deliberately. Defaults:
195/// - `render.unsafe_ = false` — raw HTML in markdown is escaped, not passed through.
196/// - `extension.tagfilter = false` (filter not applied, since unsafe HTML is already escaped).
197/// - No extensions that interpret embedded HTML or scripts are enabled.
198///
199/// **Do not enable `render.unsafe_` or `extension.tagfilter` here without a
200/// security review** — the HTML output goes through `html_escape`, but enabling
201/// raw HTML would let untrusted markdown inject arbitrary tags into the rendered
202/// page bypassing that escape.
203fn safe_comrak_options() -> Options<'static> {
204    Options::default()
205}
206
207/// Information extracted from a heading
208struct HeadingInfo {
209    heading: String,
210    task_type: Option<TaskType>,
211    priority: Option<Priority>,
212    line: u32,
213    content: String,
214    created: Option<String>,
215    timestamp: Option<String>,
216    clocks: Vec<crate::types::ClockEntry>,
217    properties: BTreeMap<String, String>,
218}
219
220/// Process a single markdown node
221fn process_node<'a>(
222    node: &'a AstNode<'a>,
223    path: &Path,
224    tasks: &mut Vec<Task>,
225    current_heading: &mut Option<HeadingInfo>,
226    mappings: &[(&str, &str)],
227    ts_warning_counter: &mut usize,
228    prop_warning_counter: &mut usize,
229) {
230    // Snapshot the borrow once — clone the value (cheap for Heading/Paragraph) and
231    // read the sourcepos line in the same scope; drop before any code that
232    // recurses into children (which take their own borrows).
233    let (value_clone, line) = {
234        let data = node.data.borrow();
235        (data.value.clone(), data.sourcepos.start.line as u32)
236    };
237    match value_clone {
238        NodeValue::Heading(_) => {
239            // Finalize previous heading first
240            if let Some(info) = current_heading.take() {
241                if let Some(task) = finalize_task(path, info, ts_warning_counter) {
242                    tasks.push(task);
243                }
244            }
245
246            let text = extract_text(node);
247            let (task_type, priority, heading) = parse_heading(&text);
248            *current_heading = Some(HeadingInfo {
249                heading,
250                task_type,
251                priority,
252                line,
253                content: String::new(),
254                created: None,
255                timestamp: None,
256                clocks: Vec::new(),
257                properties: BTreeMap::new(),
258            });
259        }
260        NodeValue::Paragraph => {
261            if let Some(ref mut info) = current_heading {
262                let (created, timestamp) = extract_timestamps_from_node(node, mappings);
263                let content = extract_paragraph_text(node);
264
265                for child in node.children() {
266                    if let NodeValue::Code(code) = &child.data.borrow().value {
267                        info.clocks.extend(extract_clocks(&code.literal));
268                    }
269                }
270
271                if created.is_some() {
272                    info.created = created;
273                }
274                if timestamp.is_some() {
275                    info.timestamp = timestamp;
276                }
277                if !content.is_empty() {
278                    if info.content.is_empty() {
279                        info.content = content;
280                    } else {
281                        info.content.push_str("\n\n");
282                        info.content.push_str(&content);
283                    }
284                }
285            }
286        }
287        NodeValue::CodeBlock(code) => {
288            if let Some(ref mut info) = current_heading {
289                // Performance: check the property-block info string first and
290                // return early on a match, so an org-properties block skips the
291                // backtick-strip / weekday-normalise / clock-extract work below.
292                // For every other code block the only added cost is this one
293                // `&str` comparison. The grep pre-filter (main.rs) is NOT widened
294                // for `org-properties`, so the set of scanned files is unchanged.
295                if code.info.trim() == "org-properties" {
296                    parse_org_properties(
297                        &code.literal,
298                        &mut info.properties,
299                        path,
300                        line,
301                        prop_warning_counter,
302                    );
303                } else {
304                    let raw = code.literal.trim();
305                    // An indented code block (4-space indent) reaches us with
306                    // the planning line still wrapped in inline-code backticks
307                    // (`    \`DEADLINE: <...>\``). Comrak strips the indent but
308                    // leaves the wrapping backticks in `code.literal`, which
309                    // would otherwise prevent the DEADLINE/SCHEDULED/CREATED
310                    // regex from anchoring on the keyword. Drop a matched
311                    // backtick pair before regex matching.
312                    let literal = strip_wrapping_backticks(raw);
313                    let normalized = normalize_weekdays(literal, mappings);
314                    let created = extract_created_normalized(&normalized);
315                    let timestamp = extract_timestamp_normalized(&normalized);
316
317                    info.clocks.extend(extract_clocks(literal));
318
319                    if created.is_some() {
320                        info.created = created;
321                    }
322                    if timestamp.is_some() {
323                        info.timestamp = timestamp;
324                    }
325                }
326            }
327        }
328        _ => {}
329    }
330}
331
332fn finalize_task(path: &Path, info: HeadingInfo, ts_warning_counter: &mut usize) -> Option<Task> {
333    if info.task_type.is_none() && info.created.is_none() && info.timestamp.is_none() {
334        return None;
335    }
336
337    let line = info.line;
338    let (ts_type, ts_date, ts_time, ts_end_time, ts_active, ts_repeater) =
339        if let Some(ref ts) = info.timestamp {
340            // `info.timestamp` is assembled from `extract_timestamp_normalized`
341            // regex captures over an already-`normalize_weekdays`d string in
342            // both `process_node` branches, so a second normalisation here
343            // would be redundant work on every task.
344            let parsed = parse_timestamp_fields_normalized(ts);
345            if parsed.1.is_none() {
346                warn_invalid_timestamp(ts_warning_counter, path, line, ts);
347            }
348            // The repeater is extracted via a second, fuller pass
349            // (`parse_org_timestamp`) rather than the light regex path above:
350            // the repeater grammar (prefix/value/unit, `wd`) lives in that
351            // parser and is not worth duplicating as another regex helper.
352            // The extra parse is timestamp-string-local and runs once per task.
353            let repeater = extract_repeater_normalized(ts);
354            (parsed.0, parsed.1, parsed.2, parsed.3, parsed.4, repeater)
355        } else {
356            (None, None, None, None, None, None)
357        };
358
359    let (clocks_opt, total_time) = if !info.clocks.is_empty() {
360        let total = calculate_total_minutes(&info.clocks).map(format_duration);
361        (Some(info.clocks), total)
362    } else {
363        (None, None)
364    };
365
366    let properties = if info.properties.is_empty() {
367        None
368    } else {
369        Some(info.properties)
370    };
371
372    Some(Task {
373        file: path.display().to_string(),
374        // Filled in by the scan when it walked several roots: the parser is
375        // given one file and has no notion of which collection it belongs to.
376        root: None,
377        line,
378        heading: info.heading,
379        content: info.content,
380        task_type: info.task_type,
381        priority: info.priority,
382        created: info.created,
383        timestamp: info.timestamp,
384        timestamp_type: ts_type,
385        timestamp_active: ts_active,
386        timestamp_date: ts_date,
387        timestamp_time: ts_time,
388        timestamp_end_time: ts_end_time,
389        timestamp_repeater: ts_repeater,
390        // Populated later by `annotate_next_occurrences` (needs the agenda
391        // reference date, which the parser does not have).
392        timestamp_next: None,
393        clocks: clocks_opt,
394        total_clock_time: total_time,
395        properties,
396    })
397}
398
399/// Parse heading text to extract task type, priority, and title.
400///
401/// Follows the emacs org-mode parser
402/// (`org-element--headline-parse-title` / `org-priority-regexp`):
403///
404/// 1. Strip an optional `TODO` / `DONE` keyword anchored at the start.
405/// 2. Search the remaining text for the first `[#X]` cookie at any position,
406///    where `X` is `A-Z` or an integer `0..=64`. If found, that becomes the
407///    priority; the title is everything after `[#X]` + optional space.
408///    Text before the cookie (between TODO and `[#X]`) is **discarded** —
409///    this matches emacs's `goto-char (match-end 0)` behaviour.
410/// 3. Whatever remains is trimmed and returned as the heading.
411///
412/// A heading without TODO/DONE and without a priority cookie is returned
413/// verbatim (trimmed).
414fn parse_heading(text: &str) -> (Option<TaskType>, Option<Priority>, String) {
415    // Step 1: optional TODO/DONE prefix.
416    let (task_type, rest) = if let Some(caps) = HEADING_TODO_RE.captures(text) {
417        let kw = caps.get(1).map(|m| m.as_str()).unwrap_or("");
418        let m = caps
419            .get(0)
420            .expect("Captures::get(0) is Some when captures() succeeds");
421        (TaskType::from_keyword(kw), &text[m.end()..])
422    } else {
423        (None, text)
424    };
425
426    // Step 2: optional priority cookie anywhere in the remainder.
427    if let Some(caps) = HEADING_PRIORITY_RE.captures(rest) {
428        let value = caps.get(1).map(|m| m.as_str()).unwrap_or("");
429        if let Some(priority) = Priority::parse(value) {
430            let whole = caps
431                .get(0)
432                .expect("Captures::get(0) is Some when captures() succeeds");
433            let after = &rest[whole.end()..];
434            return (task_type, Some(priority), after.trim().to_string());
435        }
436    }
437
438    (task_type, None, rest.trim().to_string())
439}
440
441/// Leading `#` run of an ATX heading and the gap after it.
442///
443/// Capped at six hashes, which is where markdown stops treating the run as a
444/// heading, and the gap is mandatory for the same reason: `#no gap` is a
445/// paragraph. Used by `parse_heading_line`, which works on the raw file line —
446/// unlike `parse_heading`, which is handed the text comrak already stripped
447/// the hashes from.
448static HEADING_HASHES_RE: LazyLock<Regex> = LazyLock::new(|| compile_bounded(r"^(#{1,6})[ \t]+"));
449
450/// A token found on a heading line, with the byte range it occupies.
451///
452/// The range covers the token as written, framing included: a priority cookie
453/// reports `[#A]`, not `A`. Callers replacing one token slice the line around
454/// this range and keep everything else byte-for-byte.
455#[derive(Debug, Clone, PartialEq)]
456pub struct HeadingToken<T> {
457    /// Byte range within the line the token was parsed from.
458    pub range: Range<usize>,
459    /// What the token parsed to.
460    pub value: T,
461}
462
463/// A heading line as it sits in a file, located token by token.
464///
465/// This is the read half of an editing operation and the reason it lives here
466/// rather than in the editor: the keyword and cookie grammars are the ones the
467/// extractor itself applies, and a second copy of them would drift. Writing —
468/// assembling a line back from parts — is deliberately not part of this crate.
469///
470/// Unlike [`Task`], which carries the heading the agenda displays, nothing is
471/// dropped here. A heading may hold text between the keyword and the cookie
472/// (`# TODO leftover [#B] Title`); the agenda discards it, following emacs
473/// `org-element--headline-parse-title`, but an editor that did the same would
474/// delete what the user typed.
475///
476/// [`Task`]: crate::types::Task
477#[derive(Debug, Clone, PartialEq)]
478pub struct HeadingLine {
479    /// Heading level, i.e. the number of leading `#` characters (1 to 6).
480    pub level: usize,
481    /// The `TODO` / `DONE` / `CANCELLED` / `CANCELED` keyword, when present.
482    pub status: Option<HeadingToken<TaskType>>,
483    /// The `[#A]` priority cookie, when present and within the accepted range.
484    pub priority: Option<HeadingToken<Priority>>,
485    /// Byte offset the title starts at, past the tokens above and the
486    /// whitespace after them. Also where a caller inserts a token the heading
487    /// does not carry yet.
488    pub title_start: usize,
489}
490
491/// Locate the parts of a heading line, or return `None` when `line` is not a
492/// heading.
493///
494/// Applies the same keyword and priority grammars as the extraction path, so a
495/// heading the extractor reads one way cannot be rewritten another way. See
496/// [`HeadingLine`] for what the result addresses and why it keeps text the
497/// agenda drops.
498///
499/// ```
500/// # use markdown_org_extract::parse_heading_line;
501/// let line = "## TODO [#A] Write the report";
502/// let heading = parse_heading_line(line).expect("a heading");
503/// assert_eq!(&line[heading.priority.expect("a cookie").range], "[#A]");
504/// ```
505pub fn parse_heading_line(line: &str) -> Option<HeadingLine> {
506    let hashes = HEADING_HASHES_RE.captures(line)?;
507    let level = hashes
508        .get(1)
509        .expect("group 1 is Some when captures() succeeds")
510        .len();
511    let after_hashes = hashes
512        .get(0)
513        .expect("Captures::get(0) is Some when captures() succeeds")
514        .end();
515
516    let (status, after_status) = match HEADING_TODO_RE.captures(&line[after_hashes..]) {
517        Some(caps) => {
518            let keyword = caps
519                .get(1)
520                .expect("group 1 is Some when captures() succeeds");
521            let whole = caps
522                .get(0)
523                .expect("Captures::get(0) is Some when captures() succeeds");
524            let token = TaskType::from_keyword(keyword.as_str()).map(|value| HeadingToken {
525                range: after_hashes + keyword.start()..after_hashes + keyword.end(),
526                value,
527            });
528            (token, after_hashes + whole.end())
529        }
530        None => (None, after_hashes),
531    };
532
533    // The cookie is searched for in the remainder, at any position, the way
534    // `parse_heading` does it — org-mode allows text before it.
535    let priority = HEADING_PRIORITY_RE
536        .captures(&line[after_status..])
537        .and_then(|caps| {
538            let value = caps
539                .get(1)
540                .expect("group 1 is Some when captures() succeeds");
541            // The cookie is `[#` + value + `]`; the regex match may also cover
542            // a trailing space, which is not part of the token.
543            let parsed = Priority::parse(value.as_str())?;
544            Some(HeadingToken {
545                range: after_status + value.start() - "[#".len()
546                    ..after_status + value.end() + "]".len(),
547                value: parsed,
548            })
549        });
550
551    let after_tokens = priority
552        .as_ref()
553        .map_or(after_status, |cookie| cookie.range.end);
554    let title_start = after_tokens
555        + line[after_tokens..]
556            .find(|c: char| !c.is_whitespace())
557            .unwrap_or(line.len() - after_tokens);
558
559    Some(HeadingLine {
560        level,
561        status,
562        priority,
563        title_start,
564    })
565}
566
567/// Strip a matched pair of inline-code backtick fences from the trimmed
568/// content of an indented code block.
569///
570/// Markdown's indented code blocks preserve the literal source minus the
571/// leading 4-space indent, so a line like `    \`DEADLINE: <...>\`` arrives
572/// here with the wrapping backticks intact. Those wrappers are not part of
573/// the planning-line keyword grammar — they're inline-code framing that the
574/// user added to keep the line visually attached to the heading in their
575/// editor — so we peel one balanced run of backticks before regex matching.
576///
577/// Returns `s` unchanged when the wrapping is asymmetric or absent.
578fn strip_wrapping_backticks(s: &str) -> &str {
579    let bytes = s.as_bytes();
580    let n_leading = bytes.iter().take_while(|&&b| b == b'`').count();
581    if n_leading == 0 {
582        return s;
583    }
584    let n_trailing = bytes.iter().rev().take_while(|&&b| b == b'`').count();
585    // Require equal-length fences with at least one non-fence byte between
586    // them; otherwise the input is just a run of backticks and stripping
587    // would over-consume.
588    if n_trailing != n_leading || bytes.len() < 2 * n_leading + 1 {
589        return s;
590    }
591    s[n_leading..bytes.len() - n_leading].trim()
592}
593
594/// Parse the literal of an `org-properties` fenced code block into `props`,
595/// merging into any existing entries with last-wins on duplicate keys.
596///
597/// Each non-blank line is split on its first `:`: the key is the text
598/// before it (trimmed, case preserved), the value is the remainder
599/// (trimmed). An empty key or a line with no `:` is skipped and reported
600/// via `warn_invalid_property_line`, gated by the caller-owned counter so
601/// the `MAX_DIAGNOSTIC_ITEMS` budget spans the whole run. `block_start_line`
602/// is the source line of the opening fence; the per-line offset is added so
603/// warnings point near the offending line. See ADR-0020.
604fn parse_org_properties(
605    literal: &str,
606    props: &mut BTreeMap<String, String>,
607    path: &Path,
608    block_start_line: u32,
609    prop_warning_counter: &mut usize,
610) {
611    for (offset, line) in literal.lines().enumerate() {
612        if line.trim().is_empty() {
613            continue;
614        }
615        // Source line of this content line: opening fence + 1 + offset.
616        let src_line = block_start_line
617            .saturating_add(1)
618            .saturating_add(offset as u32);
619        match line.split_once(':') {
620            Some((key, value)) => {
621                let key = key.trim();
622                if key.is_empty() {
623                    warn_invalid_property_line(prop_warning_counter, path, src_line, line);
624                    continue;
625                }
626                props.insert(key.to_string(), value.trim().to_string());
627            }
628            None => {
629                warn_invalid_property_line(prop_warning_counter, path, src_line, line);
630            }
631        }
632    }
633}
634
635/// Extract timestamps (CREATED and others) from paragraph node
636fn extract_timestamps_from_node<'a>(
637    node: &'a AstNode<'a>,
638    mappings: &[(&str, &str)],
639) -> (Option<String>, Option<String>) {
640    let mut created = None;
641    let mut timestamp = None;
642
643    if let NodeValue::Paragraph = &node.data.borrow().value {
644        for child in node.children() {
645            if let NodeValue::Code(code) = &child.data.borrow().value {
646                // Normalize the literal once per inline-code node; both extractors
647                // would otherwise scan the same string in lockstep.
648                let normalized = normalize_weekdays(&code.literal, mappings);
649                if created.is_none() {
650                    created = extract_created_normalized(&normalized);
651                }
652                if timestamp.is_none() {
653                    timestamp = extract_timestamp_normalized(&normalized);
654                }
655            }
656        }
657    }
658    (created, timestamp)
659}
660
661/// Extract plain text from paragraph, including text inside Emph/Strong/Link nodes
662///
663/// Inline code is left out here, unlike in a heading: in a body paragraph it
664/// carries the planning lines and the property markers, which are read by
665/// their own extractors and would otherwise appear twice — once as data and
666/// once as prose.
667fn extract_paragraph_text<'a>(node: &'a AstNode<'a>) -> String {
668    let mut text = String::new();
669    collect_text_recursive(node, &mut text, InlineCode::Drop);
670    text.trim().to_string()
671}
672
673/// Extract all text from a heading node, including text inside Emph/Strong
674/// and the literal of an inline code span.
675fn extract_text<'a>(node: &'a AstNode<'a>) -> String {
676    let mut text = String::new();
677    collect_text_recursive(node, &mut text, InlineCode::Keep);
678    text
679}
680
681/// What [`collect_text_recursive`] does with an inline code span.
682#[derive(Clone, Copy, PartialEq, Eq)]
683enum InlineCode {
684    /// Write its literal out, so a heading keeps every word it was written
685    /// with.
686    Keep,
687    /// Skip it.
688    Drop,
689}
690
691fn collect_text_recursive<'a>(node: &'a AstNode<'a>, out: &mut String, code: InlineCode) {
692    for child in node.children() {
693        let value = child.data.borrow().value.clone();
694        match value {
695            NodeValue::Text(t) => out.push_str(&t),
696            NodeValue::Code(inline) if code == InlineCode::Keep => out.push_str(&inline.literal),
697            NodeValue::Emph | NodeValue::Strong | NodeValue::Link(_) | NodeValue::Strikethrough => {
698                collect_text_recursive(child, out, code)
699            }
700            _ => {}
701        }
702    }
703}
704
705/// The text of a markdown fragment as the agenda shows it, with the inline
706/// markup taken off.
707///
708/// This is how a heading reaches [`Task::heading`](crate::Task::heading):
709/// emphasis, strong text, links and strikethrough contribute their text, and
710/// an inline code span contributes its literal. An editor holding the raw
711/// line can therefore compare what the file says against what it was handed
712/// — `parse_heading_line` says where the title starts, and this says what it
713/// looks like once extracted.
714///
715/// ```
716/// # use markdown_org_extract::{display_text, parse_heading_line};
717/// let line = "# TODO **Отчёт** за июль";
718/// let heading = parse_heading_line(line).expect("a heading");
719/// assert_eq!(display_text(&line[heading.title_start..]), "Отчёт за июль");
720/// ```
721pub fn display_text(markdown: &str) -> String {
722    let arena = Arena::new();
723    let root = parse_document(&arena, markdown, &safe_comrak_options());
724
725    let mut text = String::new();
726    collect_block_text(root, &mut text);
727    text.trim().to_string()
728}
729
730/// Walk down to the inline content of every block and collect its text.
731///
732/// A fragment handed to [`display_text`] is parsed as a document, so its
733/// inline nodes sit under a paragraph (or a heading, when the caller passes a
734/// whole line); the leaves are the same either way.
735fn collect_block_text<'a>(node: &'a AstNode<'a>, out: &mut String) {
736    for child in node.children() {
737        let value = child.data.borrow().value.clone();
738        match value {
739            NodeValue::Paragraph | NodeValue::Heading(_) => {
740                collect_text_recursive(child, out, InlineCode::Keep)
741            }
742            _ => collect_block_text(child, out),
743        }
744    }
745}
746
747// Need clone for NodeValue match — comrak nodes are RefCell-borrowed
748// Re-import for clone derive if not present.
749
750#[cfg(test)]
751mod tests {
752    use super::*;
753    use crate::types::{CancelledSpelling, DEFAULT_MAX_TASKS};
754
755    #[test]
756    fn warn_invalid_timestamp_advances_per_call_counter() {
757        // The 0.5.0 review (M1) replaced a process-global
758        // `TS_WARNINGS_EMITTED: AtomicUsize` with a counter owned by the
759        // caller (typically `ProcessingStats::ts_warnings_emitted`).
760        // This test pins the per-call advance: each call bumps the
761        // counter by exactly one.
762        let mut counter = 0_usize;
763        let path = Path::new("t.md");
764        for i in 1..=25 {
765            warn_invalid_timestamp(&mut counter, path, i, "<bad>");
766        }
767        assert_eq!(counter, 25);
768    }
769
770    #[test]
771    fn warn_invalid_property_line_advances_per_call_counter() {
772        // Same per-call advance contract as warn_invalid_timestamp: each
773        // call bumps the caller-owned counter by exactly one, so the
774        // MAX_DIAGNOSTIC_ITEMS cap spans the whole run (ADR-0020).
775        let mut counter = 0_usize;
776        let path = Path::new("t.md");
777        for i in 1..=25 {
778            warn_invalid_property_line(&mut counter, path, i, "no-colon-here");
779        }
780        assert_eq!(counter, 25);
781    }
782
783    #[test]
784    fn warn_invalid_timestamp_counters_are_independent() {
785        // Independent counters do not pollute each other: e.g. a library
786        // consumer running two separate scans, or unit tests in the same
787        // binary, each see a fresh budget. With the previous global
788        // static this assertion would not hold across runs in one
789        // process.
790        let mut counter_a = 0_usize;
791        let mut counter_b = 0_usize;
792        let path = Path::new("t.md");
793        for _ in 0..MAX_DIAGNOSTIC_ITEMS {
794            warn_invalid_timestamp(&mut counter_a, path, 1, "<bad>");
795        }
796        warn_invalid_timestamp(&mut counter_b, path, 1, "<bad>");
797        assert_eq!(counter_a, MAX_DIAGNOSTIC_ITEMS);
798        assert_eq!(counter_b, 1);
799    }
800
801    #[test]
802    fn test_parse_heading_with_priority() {
803        let (task_type, priority, heading) = parse_heading("TODO [#A] Important task");
804        assert_eq!(task_type, Some(TaskType::Todo));
805        assert_eq!(priority, Some(Priority::A));
806        assert_eq!(heading, "Important task");
807    }
808
809    #[test]
810    fn test_parse_heading_without_priority() {
811        let (task_type, priority, heading) = parse_heading("DONE Simple task");
812        assert_eq!(task_type, Some(TaskType::Done));
813        assert_eq!(priority, None);
814        assert_eq!(heading, "Simple task");
815    }
816
817    #[test]
818    fn test_parse_heading_no_task() {
819        let (task_type, priority, heading) = parse_heading("Regular heading");
820        assert_eq!(task_type, None);
821        assert_eq!(priority, None);
822        assert_eq!(heading, "Regular heading");
823    }
824
825    // The next batch mirrors the test matrix from the bug report
826    // "markdown-org-extract: приоритет `[#X]` без TODO/DONE не распознаётся".
827    // We follow emacs org-mode semantics (`org-priority-regexp`) wherever the
828    // bug report diverged from it — concretely case 8 below.
829
830    #[test]
831    fn parse_heading_priority_without_todo() {
832        // Case 1: `### [#A] Заголовок`.
833        let (tt, p, h) = parse_heading("[#A] Заголовок");
834        assert_eq!(tt, None);
835        assert_eq!(p, Some(Priority::A));
836        assert_eq!(h, "Заголовок");
837    }
838
839    #[test]
840    fn parse_heading_todo_with_priority() {
841        // Case 2: `### TODO [#A] Заголовок`.
842        let (tt, p, h) = parse_heading("TODO [#A] Заголовок");
843        assert_eq!(tt, Some(TaskType::Todo));
844        assert_eq!(p, Some(Priority::A));
845        assert_eq!(h, "Заголовок");
846    }
847
848    #[test]
849    fn parse_heading_done_with_priority_b() {
850        // Case 3: `### DONE [#B] Заголовок`.
851        let (tt, p, h) = parse_heading("DONE [#B] Заголовок");
852        assert_eq!(tt, Some(TaskType::Done));
853        assert_eq!(p, Some(Priority::B));
854        assert_eq!(h, "Заголовок");
855    }
856
857    #[test]
858    fn parse_heading_plain_text_no_markers() {
859        // Case 4: `### Заголовок`.
860        let (tt, p, h) = parse_heading("Заголовок");
861        assert_eq!(tt, None);
862        assert_eq!(p, None);
863        assert_eq!(h, "Заголовок");
864    }
865
866    #[test]
867    fn parse_heading_todo_no_priority() {
868        // Case 5: `### TODO Заголовок`.
869        let (tt, p, h) = parse_heading("TODO Заголовок");
870        assert_eq!(tt, Some(TaskType::Todo));
871        assert_eq!(p, None);
872        assert_eq!(h, "Заголовок");
873    }
874
875    #[test]
876    fn parse_heading_numeric_priority() {
877        // Case 6: `### [#1] Заголовок`.
878        let (tt, p, h) = parse_heading("[#1] Заголовок");
879        assert_eq!(tt, None);
880        assert_eq!(p, Some(Priority::Numeric(1)));
881        assert_eq!(h, "Заголовок");
882    }
883
884    #[test]
885    fn parse_heading_extra_whitespace_around_priority() {
886        // Case 7: `###     [#A]     Заголовок` — comrak normalises the leading
887        // whitespace after the `###` marker, so the heading text reaching us
888        // starts at `[#A]`. Trailing extra spaces around the heading are
889        // trimmed.
890        let (tt, p, h) = parse_heading("[#A]     Заголовок");
891        assert_eq!(tt, None);
892        assert_eq!(p, Some(Priority::A));
893        assert_eq!(h, "Заголовок");
894    }
895
896    #[test]
897    fn parse_heading_priority_in_the_middle_org_semantics() {
898        // Case 8: `### Без приоритета и [#A] внутри`.
899        // The bug report's table proposed keeping `[#A]` as part of the
900        // heading, but emacs org-mode parses any `[#X]` cookie inside the
901        // title as the priority via the `.*?` prefix in `org-priority-regexp`
902        // and drops the text that precedes it. By project decision we follow
903        // the reference parser here.
904        let (tt, p, h) = parse_heading("Без приоритета и [#A] внутри");
905        assert_eq!(tt, None);
906        assert_eq!(p, Some(Priority::A));
907        assert_eq!(h, "внутри");
908    }
909
910    #[test]
911    fn parse_heading_two_digit_numeric_priority() {
912        let (tt, p, h) = parse_heading("[#15] Mid range");
913        assert_eq!(tt, None);
914        assert_eq!(p, Some(Priority::Numeric(15)));
915        assert_eq!(h, "Mid range");
916
917        let (tt, p, h) = parse_heading("[#64] At upper bound");
918        assert_eq!(tt, None);
919        assert_eq!(p, Some(Priority::Numeric(64)));
920        assert_eq!(h, "At upper bound");
921    }
922
923    #[test]
924    fn parse_heading_rejects_numeric_out_of_range() {
925        // `[#65]` and higher are not a valid org-mode priority. The cookie
926        // stays inside the heading text verbatim.
927        let (tt, p, h) = parse_heading("[#65] Above range");
928        assert_eq!(tt, None);
929        assert_eq!(p, None);
930        assert_eq!(h, "[#65] Above range");
931    }
932
933    #[test]
934    fn parse_heading_rejects_lowercase_priority() {
935        let (tt, p, h) = parse_heading("[#a] Lowercase");
936        assert_eq!(tt, None);
937        assert_eq!(p, None);
938        assert_eq!(h, "[#a] Lowercase");
939    }
940
941    #[test]
942    fn parse_heading_todo_then_priority_with_intervening_text() {
943        // Direct consequence of the emacs `.*?` semantics: the text between
944        // TODO and `[#X]` is discarded.
945        let (tt, p, h) = parse_heading("TODO Купить [#A] фильтр");
946        assert_eq!(tt, Some(TaskType::Todo));
947        assert_eq!(p, Some(Priority::A));
948        assert_eq!(h, "фильтр");
949    }
950
951    #[test]
952    fn parse_heading_priority_without_trailing_space() {
953        // `\] ?` makes the post-cookie space optional.
954        let (tt, p, h) = parse_heading("[#A]NoSpace");
955        assert_eq!(tt, None);
956        assert_eq!(p, Some(Priority::A));
957        assert_eq!(h, "NoSpace");
958    }
959
960    #[test]
961    fn parse_heading_cancelled_simple() {
962        let (tt, p, h) = parse_heading("CANCELLED Foo");
963        assert_eq!(tt, Some(TaskType::Cancelled(CancelledSpelling::DoubleL)));
964        assert_eq!(p, None);
965        assert_eq!(h, "Foo");
966    }
967
968    #[test]
969    fn parse_heading_cancelled_with_priority() {
970        let (tt, p, h) = parse_heading("CANCELLED [#A] Foo");
971        assert_eq!(tt, Some(TaskType::Cancelled(CancelledSpelling::DoubleL)));
972        assert_eq!(p, Some(Priority::A));
973        assert_eq!(h, "Foo");
974    }
975
976    #[test]
977    fn parse_heading_cancelled_without_whitespace() {
978        // No whitespace after the keyword: not recognised, stays in title.
979        let (tt, p, h) = parse_heading("CANCELLEDFoo");
980        assert_eq!(tt, None);
981        assert_eq!(p, None);
982        assert_eq!(h, "CANCELLEDFoo");
983    }
984
985    #[test]
986    fn parse_heading_cancelled_lowercase_not_recognised() {
987        // Case-sensitive, like TODO/DONE.
988        let (tt, p, h) = parse_heading("cancelled Foo");
989        assert_eq!(tt, None);
990        assert_eq!(p, None);
991        assert_eq!(h, "cancelled Foo");
992    }
993
994    #[test]
995    fn parse_heading_todo_cancelled_first_keyword_wins() {
996        // First keyword wins; the rest goes into the title (existing rule).
997        let (tt, p, h) = parse_heading("TODO CANCELLED Foo");
998        assert_eq!(tt, Some(TaskType::Todo));
999        assert_eq!(p, None);
1000        assert_eq!(h, "CANCELLED Foo");
1001    }
1002
1003    #[test]
1004    fn parse_heading_canceled_single_l() {
1005        // Upstream Emacs Org-mode spells the keyword with a single L. See
1006        // ADR-0021; recognised alongside the double-L `CANCELLED`.
1007        let (tt, p, h) = parse_heading("CANCELED Foo");
1008        assert_eq!(tt, Some(TaskType::Cancelled(CancelledSpelling::SingleL)));
1009        assert_eq!(p, None);
1010        assert_eq!(h, "Foo");
1011    }
1012
1013    #[test]
1014    fn parse_heading_canceled_with_priority() {
1015        let (tt, p, h) = parse_heading("CANCELED [#A] Foo");
1016        assert_eq!(tt, Some(TaskType::Cancelled(CancelledSpelling::SingleL)));
1017        assert_eq!(p, Some(Priority::A));
1018        assert_eq!(h, "Foo");
1019    }
1020
1021    #[test]
1022    fn parse_heading_canceled_lowercase_not_recognised() {
1023        // Case-sensitive, like TODO/DONE/CANCELLED.
1024        let (tt, p, h) = parse_heading("canceled Foo");
1025        assert_eq!(tt, None);
1026        assert_eq!(p, None);
1027        assert_eq!(h, "canceled Foo");
1028    }
1029
1030    #[test]
1031    fn parse_heading_canceled_without_whitespace_not_recognised() {
1032        // No whitespace after the keyword: not recognised, stays in title.
1033        let (tt, p, h) = parse_heading("CANCELEDfoo");
1034        assert_eq!(tt, None);
1035        assert_eq!(p, None);
1036        assert_eq!(h, "CANCELEDfoo");
1037    }
1038
1039    #[test]
1040    fn extract_tasks_marks_scheduled_angle_bracket_as_active() {
1041        // End-to-end: a SCHEDULED line with `<...>` must surface
1042        // `timestamp_active = Some(true)` in the resulting Task, so
1043        // downstream consumers can branch on bracket form without
1044        // re-parsing the timestamp string. See ADR-0014.
1045        let content = "### TODO Pin me\n`SCHEDULED: <2026-05-21 Thu>`\n";
1046        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1047        assert_eq!(tasks.len(), 1);
1048        assert_eq!(tasks[0].timestamp_active, Some(true));
1049    }
1050
1051    #[test]
1052    fn extract_tasks_marks_missing_timestamp_active_as_none() {
1053        // Heading without a timestamp must keep `timestamp_active = None`,
1054        // matching the rule that absent optional fields skip JSON
1055        // serialisation (ADR-0015).
1056        let content = "### Project kickoff\n\n`CREATED: [2025-09-01 Mon]`\n";
1057        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1058        assert_eq!(tasks.len(), 1);
1059        assert_eq!(tasks[0].timestamp_active, None);
1060    }
1061
1062    #[test]
1063    fn extract_tasks_basic_todo_with_deadline() {
1064        let content = "\
1065### TODO [#A] Write docs\n\
1066`DEADLINE: <2025-12-10 Wed>`\n";
1067        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1068        assert_eq!(tasks.len(), 1);
1069        let t = &tasks[0];
1070        assert_eq!(t.task_type, Some(TaskType::Todo));
1071        assert_eq!(t.priority, Some(Priority::A));
1072        assert_eq!(t.heading, "Write docs");
1073        assert_eq!(t.timestamp_type, Some("DEADLINE".to_string()));
1074        assert_eq!(t.timestamp_date, Some("2025-12-10".to_string()));
1075    }
1076
1077    #[test]
1078    fn extract_tasks_extracts_emph_text_in_heading() {
1079        // Regression: previously emphasised text inside heading was dropped.
1080        let content = "### TODO **Important** task\n`DEADLINE: <2025-12-10 Wed>`\n";
1081        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1082        assert_eq!(tasks.len(), 1);
1083        assert_eq!(tasks[0].heading, "Important task");
1084    }
1085
1086    #[test]
1087    fn extract_tasks_ignores_non_task_headings_without_timestamps() {
1088        let content = "### Just a heading\n\nSome text.\n";
1089        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1090        assert!(tasks.is_empty());
1091    }
1092
1093    #[test]
1094    fn extract_tasks_keeps_created_without_todo() {
1095        // Heading without TODO/DONE keyword but with a CREATED line is still a task.
1096        let content = "### Project kickoff\n\n`CREATED: [2025-09-01 Mon]`\n";
1097        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1098        assert_eq!(tasks.len(), 1);
1099        assert_eq!(tasks[0].task_type, None);
1100        assert_eq!(tasks[0].created, Some("CREATED: [2025-09-01 Mon]".into()));
1101    }
1102
1103    #[test]
1104    fn extract_tasks_concatenates_multiple_paragraphs() {
1105        // Regression: previously only the first paragraph was kept as content.
1106        let content = "\
1107### TODO Multi-line task\n\
1108First paragraph.\n\
1109\n\
1110Second paragraph.\n\
1111";
1112        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1113        assert_eq!(tasks.len(), 1);
1114        assert!(tasks[0].content.contains("First paragraph"));
1115        assert!(tasks[0].content.contains("Second paragraph"));
1116    }
1117
1118    #[test]
1119    fn extract_tasks_extracts_clock_from_inline_code() {
1120        let content = "\
1121### TODO Track time\n\
1122`CLOCK: [2025-09-01 Mon 10:00]--[2025-09-01 Mon 11:30] => 1:30`\n\
1123";
1124        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1125        assert_eq!(tasks.len(), 1);
1126        let t = &tasks[0];
1127        assert!(t.clocks.is_some());
1128        assert_eq!(t.total_clock_time.as_deref(), Some("1:30"));
1129    }
1130
1131    #[test]
1132    fn extract_tasks_handles_done_priority() {
1133        let content = "### DONE [#B] Wrap up\n";
1134        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1135        assert_eq!(tasks.len(), 1);
1136        assert_eq!(tasks[0].task_type, Some(TaskType::Done));
1137        assert_eq!(tasks[0].priority, Some(Priority::B));
1138    }
1139
1140    #[test]
1141    fn extract_tasks_priority_without_todo_with_scheduled() {
1142        // Bug report case: priority cookie before SCHEDULED heading, no TODO.
1143        // After the fix the heading must surface as a task with priority=A and
1144        // task_type=None, since the SCHEDULED line is what makes it agenda-eligible.
1145        let content = "\
1146### [#A] Поменять резину до 16.05.2026\n\
1147`SCHEDULED: <2026-05-09 Sat>`\n";
1148        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1149        assert_eq!(tasks.len(), 1);
1150        let t = &tasks[0];
1151        assert_eq!(t.task_type, None);
1152        assert_eq!(t.priority, Some(Priority::A));
1153        assert_eq!(t.heading, "Поменять резину до 16.05.2026");
1154        assert_eq!(t.timestamp_type, Some("SCHEDULED".to_string()));
1155        assert_eq!(t.timestamp_date, Some("2026-05-09".to_string()));
1156    }
1157
1158    #[test]
1159    fn extract_tasks_numeric_priority_with_deadline() {
1160        // Numeric priority `[#1]` without TODO, with a DEADLINE line.
1161        let content = "\
1162### [#1] Numeric priority task\n\
1163`DEADLINE: <2026-05-09 Sat>`\n";
1164        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1165        assert_eq!(tasks.len(), 1);
1166        let t = &tasks[0];
1167        assert_eq!(t.task_type, None);
1168        assert_eq!(t.priority, Some(Priority::Numeric(1)));
1169        assert_eq!(t.heading, "Numeric priority task");
1170    }
1171
1172    #[test]
1173    fn extract_tasks_priority_in_middle_drops_prefix() {
1174        // Org-mode `.*?` semantics: text before `[#A]` is dropped.
1175        let content = "\
1176### Без приоритета и [#A] внутри\n\
1177`SCHEDULED: <2026-05-09 Sat>`\n";
1178        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1179        assert_eq!(tasks.len(), 1);
1180        let t = &tasks[0];
1181        assert_eq!(t.task_type, None);
1182        assert_eq!(t.priority, Some(Priority::A));
1183        assert_eq!(t.heading, "внутри");
1184    }
1185
1186    #[test]
1187    fn extract_tasks_bug_report_minimal_reproduction() {
1188        // Three-heading reproduction from the bug report: priority without
1189        // TODO, TODO + priority, plain heading. SCHEDULED is wrapped in
1190        // backticks so the existing inline-code parser picks it up.
1191        let content = "\
1192### [#A] Поменять резину до 16.05.2026\n\
1193`SCHEDULED: <2026-05-09 Sat>`\n\
1194\n\
1195### TODO [#A] Поменять масло\n\
1196`SCHEDULED: <2026-05-09 Sat>`\n\
1197\n\
1198### Купить фильтр\n\
1199`SCHEDULED: <2026-05-09 Sat>`\n";
1200        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1201        assert_eq!(tasks.len(), 3);
1202
1203        assert_eq!(tasks[0].task_type, None);
1204        assert_eq!(tasks[0].priority, Some(Priority::A));
1205        assert_eq!(tasks[0].heading, "Поменять резину до 16.05.2026");
1206
1207        assert_eq!(tasks[1].task_type, Some(TaskType::Todo));
1208        assert_eq!(tasks[1].priority, Some(Priority::A));
1209        assert_eq!(tasks[1].heading, "Поменять масло");
1210
1211        assert_eq!(tasks[2].task_type, None);
1212        assert_eq!(tasks[2].priority, None);
1213        assert_eq!(tasks[2].heading, "Купить фильтр");
1214    }
1215
1216    // Regression suite for the "indented planning line" cases. A heading
1217    // followed by a 4-space-indented DEADLINE/SCHEDULED/CREATED line is
1218    // parsed by comrak as an indented code block; the timestamp must still
1219    // be recovered, whether or not the planning line is wrapped in inline
1220    // backticks. Matches what `emacs` org-agenda surfaces.
1221    // The literals below use real newlines (no `\\\n` Rust string
1222    // continuation): the continuation form would silently swallow the
1223    // four leading spaces and reduce the case to "no indent at all".
1224    #[test]
1225    fn extract_tasks_indented_inline_code_deadline() {
1226        let content = "#### Birthday\n    `DEADLINE: <2026-05-07 Thu +1y>`\n";
1227        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1228        assert_eq!(tasks.len(), 1, "task should not be dropped");
1229        let t = &tasks[0];
1230        assert_eq!(t.timestamp_type.as_deref(), Some("DEADLINE"));
1231        assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1232    }
1233
1234    #[test]
1235    fn extract_tasks_todo_indented_inline_code_deadline() {
1236        let content = "#### TODO Birthday\n    `DEADLINE: <2026-05-07 Thu +1y>`\n";
1237        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1238        assert_eq!(tasks.len(), 1);
1239        let t = &tasks[0];
1240        assert_eq!(t.task_type, Some(TaskType::Todo));
1241        assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1242    }
1243
1244    #[test]
1245    fn extract_tasks_indented_inline_code_blank_lines_between() {
1246        // Whitespace between heading and planning line (blank lines, tabs,
1247        // mixed indentation) must not block timestamp recovery.
1248        let content = "#### Birthday\n\n  \t  \n    `DEADLINE: <2026-05-07 Thu +1y>`\n";
1249        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1250        assert_eq!(tasks.len(), 1);
1251        let t = &tasks[0];
1252        assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1253    }
1254
1255    #[test]
1256    fn extract_tasks_with_ru_mappings_reproduces_cli_pipeline() {
1257        // Reproduce what `main.rs` feeds to `extract_tasks` when the default
1258        // `--locale ru,en` is in effect. Pulling the table from `cli` keeps
1259        // this test in sync with whatever `get_weekday_mappings("ru")` would
1260        // produce in production.
1261        let content = "#### TODO Birthday\n    `DEADLINE: <2026-05-07 Thu +1y>`\n";
1262        let tasks = extract_tasks(
1263            Path::new("t.md"),
1264            content,
1265            crate::locale::RU_WEEKDAY_MAPPINGS,
1266            DEFAULT_MAX_TASKS,
1267        );
1268        assert_eq!(tasks.len(), 1);
1269        let t = &tasks[0];
1270        assert_eq!(t.task_type, Some(TaskType::Todo));
1271        assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1272    }
1273
1274    #[test]
1275    fn extract_tasks_inline_code_scheduled_no_indent() {
1276        let content = "#### Followup\n`SCHEDULED: <2026-05-07 Thu>`\n";
1277        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1278        assert_eq!(tasks.len(), 1);
1279        let t = &tasks[0];
1280        assert_eq!(t.timestamp_type.as_deref(), Some("SCHEDULED"));
1281        assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1282    }
1283
1284    #[test]
1285    fn extract_tasks_indented_inline_code_created() {
1286        let content = "#### Project kickoff\n    `CREATED: [2025-09-01 Mon]`\n";
1287        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1288        assert_eq!(tasks.len(), 1);
1289        let t = &tasks[0];
1290        assert_eq!(t.created.as_deref(), Some("CREATED: [2025-09-01 Mon]"));
1291    }
1292
1293    #[test]
1294    fn extract_tasks_parses_single_property() {
1295        let content = "### TODO Ship release\n`SCHEDULED: <2026-06-01 Mon 10:00>`\n```org-properties\nGCAL_EVENT_ID: abc123/primary\n```\n";
1296        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1297        assert_eq!(tasks.len(), 1);
1298        let props = tasks[0].properties.as_ref().expect("properties present");
1299        assert_eq!(
1300            props.get("GCAL_EVENT_ID").map(String::as_str),
1301            Some("abc123/primary")
1302        );
1303        // The block must not leak into the task body content.
1304        assert!(!tasks[0].content.contains("GCAL_EVENT_ID"));
1305        assert!(!tasks[0].content.contains("org-properties"));
1306    }
1307
1308    #[test]
1309    fn extract_tasks_parses_multiple_properties() {
1310        let content =
1311            "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nA: 1\nB: 2\n```\n";
1312        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1313        let props = tasks[0].properties.as_ref().unwrap();
1314        assert_eq!(props.get("A").map(String::as_str), Some("1"));
1315        assert_eq!(props.get("B").map(String::as_str), Some("2"));
1316    }
1317
1318    #[test]
1319    fn extract_tasks_property_duplicate_keys_last_wins() {
1320        let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nK: first\nK: second\n```\n";
1321        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1322        assert_eq!(
1323            tasks[0]
1324                .properties
1325                .as_ref()
1326                .unwrap()
1327                .get("K")
1328                .map(String::as_str),
1329            Some("second")
1330        );
1331    }
1332
1333    #[test]
1334    fn extract_tasks_property_empty_value_allowed() {
1335        let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nK:\n```\n";
1336        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1337        assert_eq!(
1338            tasks[0]
1339                .properties
1340                .as_ref()
1341                .unwrap()
1342                .get("K")
1343                .map(String::as_str),
1344            Some("")
1345        );
1346    }
1347
1348    #[test]
1349    fn extract_tasks_property_malformed_line_skipped() {
1350        let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nGOOD: x\nno colon here\n```\n";
1351        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1352        let props = tasks[0].properties.as_ref().unwrap();
1353        assert_eq!(props.get("GOOD").map(String::as_str), Some("x"));
1354        assert_eq!(props.len(), 1, "malformed line must be skipped");
1355    }
1356
1357    #[test]
1358    fn extract_tasks_empty_property_block_yields_none() {
1359        let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\n\n```\n";
1360        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1361        assert_eq!(tasks[0].properties, None);
1362    }
1363
1364    #[test]
1365    fn extract_tasks_property_info_with_extra_attrs_not_recognised() {
1366        // Info string must be exactly "org-properties"; extra attributes
1367        // mean it is a plain code block, not a property block.
1368        let content =
1369            "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties extra\nK: v\n```\n";
1370        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1371        assert_eq!(tasks[0].properties, None);
1372    }
1373
1374    #[test]
1375    fn extract_tasks_clock_code_block_unaffected_by_properties() {
1376        // A CLOCK-bearing code block on the same task is still parsed for
1377        // clocks; the org-properties block is parsed for properties.
1378        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";
1379        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1380        assert_eq!(
1381            tasks[0]
1382                .properties
1383                .as_ref()
1384                .unwrap()
1385                .get("K")
1386                .map(String::as_str),
1387            Some("v")
1388        );
1389        assert_eq!(tasks[0].total_clock_time.as_deref(), Some("1:30"));
1390    }
1391
1392    #[test]
1393    fn extract_tasks_merges_multiple_property_blocks_last_wins() {
1394        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";
1395        let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1396        let props = tasks[0].properties.as_ref().unwrap();
1397        assert_eq!(props.get("K").map(String::as_str), Some("two"));
1398        assert_eq!(props.get("L").map(String::as_str), Some("three"));
1399    }
1400}