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