1use 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct Task {
69 pub status: char,
71 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 pub metadata: HashMap<String, String>,
88}
89
90pub 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#[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}