Skip to main content

turbovault_core/
task_parser.rs

1//! Winnow parsers for Obsidian task metadata.
2//!
3//! Supports the Obsidian Tasks emoji format and Dataview inline field format.
4//! Metadata is parsed only when it appears in the trailing metadata section of
5//! the task, matching the Tasks plugin behavior described in its task-format
6//! docs.
7//!
8//! This module intentionally exposes two entry points:
9//!
10//! - [`parse_task_content`] parses the text after the checkbox marker. This is
11//!   the path used by the pulldown-cmark parser, because pulldown-cmark already
12//!   recognizes Markdown task list markers and reports the task body separately.
13//! - [`parse_task_line`] parses a raw Markdown task line, including its checkbox.
14//!   It is useful for standalone line parsing, parser tests, and future
15//!   task-focused APIs that do not need to run the full Markdown parser first.
16//!
17//! Keeping both entry points in one module ensures every caller shares the same
18//! metadata parser instead of reimplementing task metadata extraction.
19
20use std::collections::HashMap;
21
22use winnow::{
23    ModalResult, Parser,
24    ascii::space0,
25    combinator::alt,
26    error::{ContextError, ErrMode},
27    token::{literal, one_of, take_while},
28};
29
30const DUE_EMOJI: &str = "📅";
31const SCHEDULED_EMOJI: &str = "⏳";
32const START_EMOJI: &str = "🛫";
33const DONE_EMOJI: &str = "✅";
34const CANCELLED_EMOJI: &str = "❌";
35const CREATED_EMOJI: &str = "➕";
36const RECURRENCE_EMOJI: &str = "🔁";
37const ON_COMPLETION_EMOJI: &str = "🏁";
38const ID_EMOJI: &str = "🆔";
39const DEPENDS_ON_EMOJI: &str = "⛔";
40const PRIORITY_HIGHEST_EMOJI: &str = "🔺";
41const PRIORITY_HIGH_EMOJI: &str = "⏫";
42const PRIORITY_MEDIUM_EMOJI: &str = "🔼";
43const PRIORITY_LOW_EMOJI: &str = "🔽";
44const PRIORITY_LOWEST_EMOJI: &str = "⏬";
45
46/// Parsed content and trailing metadata for an Obsidian task.
47#[derive(Debug, Clone, Default, PartialEq, Eq)]
48pub struct ParsedTaskMetadata {
49    pub description: String,
50    pub due: Option<String>,
51    pub scheduled: Option<String>,
52    pub start: Option<String>,
53    pub done: Option<String>,
54    pub cancelled: Option<String>,
55    pub created: Option<String>,
56    pub priority: Option<char>,
57    pub recurrence: Option<String>,
58    pub on_completion: Option<String>,
59    pub id: Option<String>,
60    pub depends_on: Vec<String>,
61    pub tags: Vec<String>,
62    pub block_ref: Option<String>,
63    pub metadata: HashMap<String, String>,
64}
65
66/// A parsed Obsidian task line.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct Task {
69    /// Status character inside the checkbox.
70    pub status: char,
71    /// Human-readable description with trailing metadata removed.
72    pub description: String,
73    pub due: Option<String>,
74    pub scheduled: Option<String>,
75    pub start: Option<String>,
76    pub done: Option<String>,
77    pub cancelled: Option<String>,
78    pub created: Option<String>,
79    pub priority: Option<char>,
80    pub recurrence: Option<String>,
81    pub on_completion: Option<String>,
82    pub id: Option<String>,
83    pub depends_on: Vec<String>,
84    pub tags: Vec<String>,
85    pub block_ref: Option<String>,
86    /// Dataview inline fields, including custom fields.
87    pub metadata: HashMap<String, String>,
88}
89
90/// Parse a complete Obsidian task line, including the checkbox marker.
91///
92/// This is not used by the pulldown-cmark integration path, which receives task
93/// content after Markdown has already identified the checkbox. It exists as a
94/// small standalone API for callers that have a raw task line and want the same
95/// metadata parsing behavior as the full document parser.
96///
97/// # Errors
98///
99/// Returns `Err` when the line does not start with a supported task checkbox.
100pub fn parse_task_line(input: &str) -> Result<Task, String> {
101    let mut input = input.trim();
102    let status = parse_checkbox(&mut input).map_err(|e| format!("expected checkbox: {e}"))?;
103    let parsed = parse_task_content(input);
104
105    Ok(Task {
106        status,
107        description: parsed.description,
108        due: parsed.due,
109        scheduled: parsed.scheduled,
110        start: parsed.start,
111        done: parsed.done,
112        cancelled: parsed.cancelled,
113        created: parsed.created,
114        priority: parsed.priority,
115        recurrence: parsed.recurrence,
116        on_completion: parsed.on_completion,
117        id: parsed.id,
118        depends_on: parsed.depends_on,
119        tags: parsed.tags,
120        block_ref: parsed.block_ref,
121        metadata: parsed.metadata,
122    })
123}
124
125/// Parse task text after the checkbox marker.
126///
127/// The returned description is the original content before the trailing metadata
128/// section. Metadata-like text in the middle of the description is preserved.
129#[must_use]
130pub fn parse_task_content(content: &str) -> ParsedTaskMetadata {
131    let meta_start = find_metadata_start(content);
132    let mut parsed = parse_metadata_section(content[meta_start..].trim_start());
133    parsed.description = content[..meta_start].trim_end().to_string();
134    parsed
135}
136
137fn parse_checkbox(input: &mut &str) -> ModalResult<char> {
138    let _ = space0.parse_next(input)?;
139    let _ = one_of(['-', '*', '+']).parse_next(input)?;
140    let _ = space0.parse_next(input)?;
141    let _ = literal("[").parse_next(input)?;
142    let status = one_of([' ', 'x', 'X', '-', '>', '<', '/']).parse_next(input)?;
143    let _ = literal("]").parse_next(input)?;
144    let _ = space0.parse_next(input)?;
145    Ok(status)
146}
147
148fn find_metadata_start(content: &str) -> usize {
149    let mut in_word = false;
150    for (i, c) in content.char_indices() {
151        if c.is_whitespace() {
152            in_word = false;
153        } else if !in_word {
154            in_word = true;
155            let rest = &content[i..];
156            if starts_metadata_token(rest)
157                && !previous_token_is_priority(&content[..i])
158                && is_pure_metadata(rest)
159            {
160                return i;
161            }
162        }
163    }
164    content.len()
165}
166
167fn previous_token_is_priority(prefix: &str) -> bool {
168    prefix
169        .split_whitespace()
170        .next_back()
171        .is_some_and(is_priority_emoji)
172}
173
174fn is_pure_metadata(s: &str) -> bool {
175    let mut input = s;
176    let mut seen_priority = false;
177    let mut seen_item = false;
178    loop {
179        if input.is_empty() {
180            return seen_item;
181        }
182
183        if consume_metadata_separator(&mut input).is_err() {
184            return false;
185        }
186
187        match parse_one_metadata_item(&mut input) {
188            Ok(item) => {
189                seen_item = true;
190                if item.priority.is_some() {
191                    if seen_priority {
192                        return false;
193                    }
194                    seen_priority = true;
195                }
196            }
197            Err(_) => return false,
198        }
199    }
200}
201
202fn parse_metadata_section(meta_str: &str) -> ParsedTaskMetadata {
203    let mut result = ParsedTaskMetadata::default();
204    let mut input = meta_str;
205
206    loop {
207        if input.is_empty() {
208            break;
209        }
210
211        if consume_metadata_separator(&mut input).is_err() {
212            break;
213        }
214
215        match parse_one_metadata_item(&mut input) {
216            Ok(item) => merge_metadata(&mut result, item),
217            Err(_) => break,
218        }
219    }
220
221    result
222}
223
224fn consume_metadata_separator(input: &mut &str) -> ModalResult<()> {
225    let _ = space0.parse_next(input)?;
226
227    if input.starts_with(',') {
228        let _ = literal(",").parse_next(input)?;
229        let _ = space0.parse_next(input)?;
230    }
231
232    Ok(())
233}
234
235fn parse_one_metadata_item(input: &mut &str) -> ModalResult<ParsedTaskMetadata> {
236    alt((
237        parse_emoji_date_field,
238        parse_recurrence_field,
239        parse_on_completion_field,
240        parse_id_field,
241        parse_depends_on_field,
242        parse_dataview_field,
243        parse_priority_emoji,
244        parse_tag_field,
245        parse_block_ref_field,
246    ))
247    .parse_next(input)
248}
249
250fn parse_emoji_date_field(input: &mut &str) -> ModalResult<ParsedTaskMetadata> {
251    let key = alt((
252        literal(DUE_EMOJI).value("due"),
253        literal(SCHEDULED_EMOJI).value("scheduled"),
254        literal(START_EMOJI).value("start"),
255        literal(DONE_EMOJI).value("done"),
256        literal(CANCELLED_EMOJI).value("cancelled"),
257        literal(CREATED_EMOJI).value("created"),
258    ))
259    .parse_next(input)?;
260    let _ = space0.parse_next(input)?;
261    let value: &str = take_while(1.., |c: char| !c.is_whitespace()).parse_next(input)?;
262
263    let mut meta = ParsedTaskMetadata::default();
264    set_standard_field(&mut meta, key, value.trim());
265    Ok(meta)
266}
267
268fn parse_recurrence_field(input: &mut &str) -> ModalResult<ParsedTaskMetadata> {
269    let _ = literal(RECURRENCE_EMOJI).parse_next(input)?;
270    let _ = space0.parse_next(input)?;
271
272    let mut words = Vec::new();
273    loop {
274        let trimmed = input.trim_start();
275        if trimmed.is_empty() || starts_metadata_token(trimmed) {
276            break;
277        }
278        let word: &str = take_while(1.., |c: char| !c.is_whitespace()).parse_next(input)?;
279        words.push(word);
280        let _ = space0.parse_next(input)?;
281    }
282
283    if words.is_empty() {
284        return Err(ErrMode::Backtrack(ContextError::new()));
285    }
286
287    Ok(ParsedTaskMetadata {
288        recurrence: Some(words.join(" ")),
289        ..Default::default()
290    })
291}
292
293fn parse_on_completion_field(input: &mut &str) -> ModalResult<ParsedTaskMetadata> {
294    let _ = literal(ON_COMPLETION_EMOJI).parse_next(input)?;
295    let _ = space0.parse_next(input)?;
296    let value: &str = take_while(1.., |c: char| !c.is_whitespace()).parse_next(input)?;
297
298    Ok(ParsedTaskMetadata {
299        on_completion: Some(value.trim().to_ascii_lowercase()),
300        ..Default::default()
301    })
302}
303
304fn parse_id_field(input: &mut &str) -> ModalResult<ParsedTaskMetadata> {
305    let _ = literal(ID_EMOJI).parse_next(input)?;
306    let _ = space0.parse_next(input)?;
307    let id = parse_task_id(input)?;
308
309    Ok(ParsedTaskMetadata {
310        id: Some(id.to_string()),
311        ..Default::default()
312    })
313}
314
315fn parse_depends_on_field(input: &mut &str) -> ModalResult<ParsedTaskMetadata> {
316    let _ = literal(DEPENDS_ON_EMOJI).parse_next(input)?;
317    let _ = space0.parse_next(input)?;
318    let value: &str = take_while(1.., |c: char| !c.is_whitespace()).parse_next(input)?;
319
320    Ok(ParsedTaskMetadata {
321        depends_on: split_dependency_ids(value),
322        ..Default::default()
323    })
324}
325
326fn parse_task_id<'i>(input: &mut &'i str) -> ModalResult<&'i str> {
327    take_while(1.., |c: char| c.is_alphanumeric() || c == '-' || c == '_').parse_next(input)
328}
329
330fn parse_dataview_field(input: &mut &str) -> ModalResult<ParsedTaskMetadata> {
331    let closing = parse_dataview_open(input)?;
332    let key: &str = take_while(1.., is_dataview_key_char).parse_next(input)?;
333    let _ = literal("::").parse_next(input)?;
334    let _ = space0.parse_next(input)?;
335    let value = parse_balanced_inline_value(input, closing)?;
336    let _ = one_of([closing]).parse_next(input)?;
337
338    let key = key.trim().to_ascii_lowercase();
339    let value = value.trim().to_string();
340    let mut meta = ParsedTaskMetadata::default();
341    set_standard_field(&mut meta, &key, &value);
342    meta.metadata.insert(key, value);
343    Ok(meta)
344}
345
346fn parse_dataview_open(input: &mut &str) -> ModalResult<char> {
347    let opening = one_of(['[', '(']).parse_next(input)?;
348    Ok(match opening {
349        '[' => ']',
350        '(' => ')',
351        _ => unreachable!(),
352    })
353}
354
355fn is_dataview_key_char(c: char) -> bool {
356    c.is_alphanumeric() || c == '_' || c == '-'
357}
358
359fn parse_balanced_inline_value<'i>(input: &mut &'i str, closing: char) -> ModalResult<&'i str> {
360    let start = *input;
361    let mut square_depth = 0usize;
362    let mut paren_depth = 0usize;
363    let mut consumed = 0usize;
364    let mut found_close = false;
365
366    for c in start.chars() {
367        match c {
368            '[' => {
369                square_depth += 1;
370                consumed += c.len_utf8();
371            }
372            ']' if closing == ']' && square_depth == 0 && paren_depth == 0 => {
373                found_close = true;
374                break;
375            }
376            ']' => {
377                square_depth = square_depth.saturating_sub(1);
378                consumed += c.len_utf8();
379            }
380            '(' => {
381                paren_depth += 1;
382                consumed += c.len_utf8();
383            }
384            ')' if closing == ')' && square_depth == 0 && paren_depth == 0 => {
385                found_close = true;
386                break;
387            }
388            ')' => {
389                paren_depth = paren_depth.saturating_sub(1);
390                consumed += c.len_utf8();
391            }
392            _ => consumed += c.len_utf8(),
393        }
394    }
395
396    if !found_close {
397        return Err(ErrMode::Backtrack(ContextError::new()));
398    }
399
400    let value = &start[..consumed];
401    *input = &start[consumed..];
402    Ok(value)
403}
404
405fn parse_priority_emoji(input: &mut &str) -> ModalResult<ParsedTaskMetadata> {
406    let priority = alt((
407        literal(PRIORITY_HIGH_EMOJI).value('⏫'),
408        literal(PRIORITY_HIGHEST_EMOJI).value('🔺'),
409        literal(PRIORITY_MEDIUM_EMOJI).value('🔼'),
410        literal(PRIORITY_LOW_EMOJI).value('🔽'),
411        literal(PRIORITY_LOWEST_EMOJI).value('⏬'),
412    ))
413    .parse_next(input)?;
414
415    Ok(ParsedTaskMetadata {
416        priority: Some(priority),
417        ..Default::default()
418    })
419}
420
421fn parse_tag_field(input: &mut &str) -> ModalResult<ParsedTaskMetadata> {
422    let _ = literal("#").parse_next(input)?;
423    let name: &str = take_while(1.., |c: char| {
424        c.is_alphanumeric() || c == '-' || c == '_' || c == '/'
425    })
426    .parse_next(input)?;
427
428    if !name.chars().any(|c| c.is_alphabetic()) {
429        return Err(ErrMode::Backtrack(ContextError::new()));
430    }
431
432    let mut meta = ParsedTaskMetadata::default();
433    meta.tags.push(name.to_string());
434    Ok(meta)
435}
436
437fn parse_block_ref_field(input: &mut &str) -> ModalResult<ParsedTaskMetadata> {
438    let _ = literal("^").parse_next(input)?;
439    let id: &str =
440        take_while(1.., |c: char| c.is_alphanumeric() || c == '-' || c == '_').parse_next(input)?;
441
442    Ok(ParsedTaskMetadata {
443        block_ref: Some(id.to_string()),
444        ..Default::default()
445    })
446}
447
448fn starts_metadata_token(s: &str) -> bool {
449    s.starts_with(DUE_EMOJI)
450        || s.starts_with(SCHEDULED_EMOJI)
451        || s.starts_with(START_EMOJI)
452        || s.starts_with(DONE_EMOJI)
453        || s.starts_with(CANCELLED_EMOJI)
454        || s.starts_with(CREATED_EMOJI)
455        || s.starts_with(RECURRENCE_EMOJI)
456        || s.starts_with(ON_COMPLETION_EMOJI)
457        || s.starts_with(ID_EMOJI)
458        || s.starts_with(DEPENDS_ON_EMOJI)
459        || is_priority_metadata_start(s)
460        || s.starts_with('#')
461        || s.starts_with('^')
462        || ((s.starts_with('[') || s.starts_with('(')) && s.contains("::"))
463}
464
465fn is_priority_metadata_start(s: &str) -> bool {
466    s.starts_with(PRIORITY_HIGH_EMOJI)
467        || s.starts_with(PRIORITY_HIGHEST_EMOJI)
468        || s.starts_with(PRIORITY_MEDIUM_EMOJI)
469        || s.starts_with(PRIORITY_LOW_EMOJI)
470        || s.starts_with(PRIORITY_LOWEST_EMOJI)
471}
472
473fn is_priority_emoji(s: &str) -> bool {
474    matches!(
475        s,
476        PRIORITY_HIGH_EMOJI
477            | PRIORITY_HIGHEST_EMOJI
478            | PRIORITY_MEDIUM_EMOJI
479            | PRIORITY_LOW_EMOJI
480            | PRIORITY_LOWEST_EMOJI
481    )
482}
483
484fn set_standard_field(meta: &mut ParsedTaskMetadata, key: &str, value: &str) {
485    match key {
486        "due" => meta.due = Some(value.to_string()),
487        "scheduled" => meta.scheduled = Some(value.to_string()),
488        "start" => meta.start = Some(value.to_string()),
489        "done" | "completion" => meta.done = Some(value.to_string()),
490        "cancelled" | "canceled" => meta.cancelled = Some(value.to_string()),
491        "created" => meta.created = Some(value.to_string()),
492        "recurrence" | "repeat" => meta.recurrence = Some(value.to_string()),
493        "oncompletion" => meta.on_completion = Some(value.to_ascii_lowercase()),
494        "id" => meta.id = Some(value.to_string()),
495        "dependson" => meta.depends_on = split_dependency_ids(value),
496        "priority" => meta.priority = priority_from_dataview_value(value),
497        _ => {}
498    }
499}
500
501fn split_dependency_ids(value: &str) -> Vec<String> {
502    value
503        .split(',')
504        .map(str::trim)
505        .filter(|id| !id.is_empty())
506        .map(ToString::to_string)
507        .collect()
508}
509
510fn priority_from_dataview_value(value: &str) -> Option<char> {
511    match value.trim().to_ascii_lowercase().as_str() {
512        "highest" => Some('🔺'),
513        "high" => Some('⏫'),
514        "medium" => Some('🔼'),
515        "low" => Some('🔽'),
516        "lowest" => Some('⏬'),
517        "normal" | "none" => None,
518        _ => None,
519    }
520}
521
522fn merge_metadata(dst: &mut ParsedTaskMetadata, src: ParsedTaskMetadata) {
523    if src.due.is_some() {
524        dst.due = src.due;
525    }
526    if src.scheduled.is_some() {
527        dst.scheduled = src.scheduled;
528    }
529    if src.start.is_some() {
530        dst.start = src.start;
531    }
532    if src.done.is_some() {
533        dst.done = src.done;
534    }
535    if src.cancelled.is_some() {
536        dst.cancelled = src.cancelled;
537    }
538    if src.created.is_some() {
539        dst.created = src.created;
540    }
541    if src.priority.is_some() {
542        dst.priority = src.priority;
543    }
544    if src.recurrence.is_some() {
545        dst.recurrence = src.recurrence;
546    }
547    if src.on_completion.is_some() {
548        dst.on_completion = src.on_completion;
549    }
550    if src.id.is_some() {
551        dst.id = src.id;
552    }
553    if !src.depends_on.is_empty() {
554        dst.depends_on = src.depends_on;
555    }
556    if src.block_ref.is_some() {
557        dst.block_ref = src.block_ref;
558    }
559    dst.tags.extend(src.tags);
560    dst.metadata.extend(src.metadata);
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    #[test]
568    fn parses_basic_emoji_metadata() {
569        let task = parse_task_line("- [ ] Buy milk 📅 2025-05-15").unwrap();
570        assert_eq!(task.description, "Buy milk");
571        assert_eq!(task.due.as_deref(), Some("2025-05-15"));
572    }
573
574    #[test]
575    fn parses_dataview_metadata() {
576        let task = parse_task_line("- [ ] Finish report [due:: 2025-06-01]").unwrap();
577        assert_eq!(task.description, "Finish report");
578        assert_eq!(task.due.as_deref(), Some("2025-06-01"));
579        assert_eq!(
580            task.metadata.get("due").map(String::as_str),
581            Some("2025-06-01")
582        );
583    }
584
585    #[test]
586    fn parses_mixed_emoji_and_dataview() {
587        let task = parse_task_line(
588            "- [x] Do the thing 📅 2025-05-01 [scheduled:: 2025-05-03] 🔁 every weekday",
589        )
590        .unwrap();
591        assert_eq!(task.description, "Do the thing");
592        assert_eq!(task.due.as_deref(), Some("2025-05-01"));
593        assert_eq!(task.scheduled.as_deref(), Some("2025-05-03"));
594        assert_eq!(task.recurrence.as_deref(), Some("every weekday"));
595        assert_eq!(task.status, 'x');
596    }
597
598    #[test]
599    fn parses_comma_separated_dataview_fields() {
600        let task = parse_task_line(
601            "- [ ] This is a task [priority:: high], [start:: 2023-04-24], [due:: 2023-05-01]",
602        )
603        .unwrap();
604
605        assert_eq!(task.description, "This is a task");
606        assert_eq!(task.priority, Some('⏫'));
607        assert_eq!(task.start.as_deref(), Some("2023-04-24"));
608        assert_eq!(task.due.as_deref(), Some("2023-05-01"));
609    }
610
611    #[test]
612    fn parses_dataview_completion_repeat_and_priority() {
613        let task = parse_task_line(
614            "- [ ] Ship it [completion:: 2025-07-01] [repeat:: every month] [priority:: high]",
615        )
616        .unwrap();
617        assert_eq!(task.description, "Ship it");
618        assert_eq!(task.done.as_deref(), Some("2025-07-01"));
619        assert_eq!(task.recurrence.as_deref(), Some("every month"));
620        assert_eq!(task.priority, Some('⏫'));
621    }
622
623    #[test]
624    fn parses_dataview_on_completion_and_dependencies() {
625        let task = parse_task_line(
626            "- [ ] Blocked work [repeat:: every day], [onCompletion:: delete], [id:: dcf64c], [dependsOn:: abc123,def456]",
627        )
628        .unwrap();
629
630        assert_eq!(task.description, "Blocked work");
631        assert_eq!(task.recurrence.as_deref(), Some("every day"));
632        assert_eq!(task.on_completion.as_deref(), Some("delete"));
633        assert_eq!(task.id.as_deref(), Some("dcf64c"));
634        assert_eq!(
635            task.depends_on,
636            vec!["abc123".to_string(), "def456".to_string()]
637        );
638    }
639
640    #[test]
641    fn parses_emoji_on_completion_and_dependencies() {
642        let task =
643            parse_task_line("- [ ] Blocked work 🏁 keep 🆔 dcf64c ⛔ abc123,def456").unwrap();
644
645        assert_eq!(task.description, "Blocked work");
646        assert_eq!(task.on_completion.as_deref(), Some("keep"));
647        assert_eq!(task.id.as_deref(), Some("dcf64c"));
648        assert_eq!(
649            task.depends_on,
650            vec!["abc123".to_string(), "def456".to_string()]
651        );
652    }
653
654    #[test]
655    fn parses_tags_and_block_ref_after_metadata() {
656        let task = parse_task_line("- [ ] Task description 📅 2025-05-10 #urgent #work ^task-123")
657            .unwrap();
658        assert_eq!(task.description, "Task description");
659        assert_eq!(task.due.as_deref(), Some("2025-05-10"));
660        assert_eq!(task.tags, vec!["urgent".to_string(), "work".to_string()]);
661        assert_eq!(task.block_ref.as_deref(), Some("task-123"));
662    }
663
664    #[test]
665    fn ignores_false_positive_in_description() {
666        let task =
667            parse_task_line("- [ ] Review the [due date] section and 📅 2025-04-30").unwrap();
668        assert_eq!(task.description, "Review the [due date] section and");
669        assert_eq!(task.due.as_deref(), Some("2025-04-30"));
670    }
671
672    #[test]
673    fn duplicate_fields_rightmost_wins() {
674        let task = parse_task_line("- [ ] Task 📅 2025-05-01 📅 2025-06-15").unwrap();
675        assert_eq!(task.due.as_deref(), Some("2025-06-15"));
676    }
677
678    #[test]
679    fn parses_priority() {
680        let task = parse_task_line("- [ ] High priority task ⏫").unwrap();
681        assert_eq!(task.priority, Some('⏫'));
682        assert_eq!(task.description, "High priority task");
683    }
684
685    #[test]
686    fn keeps_middle_metadata_in_description() {
687        let task = parse_task_line(
688            "- [ ] 📅 2025-05-01 This date in middle should be ignored ⏳ 2025-05-10",
689        )
690        .unwrap();
691        assert_eq!(
692            task.description,
693            "📅 2025-05-01 This date in middle should be ignored"
694        );
695        assert_eq!(task.scheduled.as_deref(), Some("2025-05-10"));
696        assert!(task.due.is_none());
697    }
698
699    #[test]
700    fn parses_dataview_value_with_wikilink() {
701        let task = parse_task_line(
702            "- [ ] Review PR #123 [project:: [[Team Work]]] 📅 2025-05-20 🔼 #review ^pr-123",
703        )
704        .unwrap();
705        assert_eq!(task.description, "Review PR #123");
706        assert_eq!(task.due.as_deref(), Some("2025-05-20"));
707        assert_eq!(task.priority, Some('🔼'));
708        assert_eq!(task.tags, vec!["review".to_string()]);
709        assert_eq!(task.block_ref.as_deref(), Some("pr-123"));
710        assert_eq!(
711            task.metadata.get("project").map(String::as_str),
712            Some("[[Team Work]]")
713        );
714    }
715
716    #[test]
717    fn supports_parenthesized_dataview_fields() {
718        let task = parse_task_line("- [ ] Call Alex (due:: 2025-06-01)").unwrap();
719        assert_eq!(task.description, "Call Alex");
720        assert_eq!(task.due.as_deref(), Some("2025-06-01"));
721    }
722
723    #[test]
724    fn keeps_bare_recurrence_marker_in_description() {
725        let task = parse_task_line("- [ ] Decide recurrence 🔁").unwrap();
726        assert_eq!(task.description, "Decide recurrence 🔁");
727        assert!(task.recurrence.is_none());
728    }
729}