1use 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
25fn 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
50fn 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
73static HEADING_TODO_RE: LazyLock<Regex> =
82 LazyLock::new(|| compile_bounded(r"^(TODO|DONE|CANCELLED|CANCELED)\s+"));
83
84static HEADING_PRIORITY_RE: LazyLock<Regex> =
99 LazyLock::new(|| compile_bounded(r"\[#([A-Z]|6[0-4]|[1-5][0-9]|[0-9])\] ?"));
100
101pub 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 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#[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
195fn safe_comrak_options() -> Options<'static> {
207 Options::default()
208}
209
210struct 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
223fn 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 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 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 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 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 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 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 Some(Task {
376 file: path.display().to_string(),
377 root: None,
380 line,
381 heading: info.heading,
382 content: info.content,
383 task_type: info.task_type,
384 priority: info.priority,
385 created: info.created,
386 timestamp: info.timestamp,
387 timestamp_type: ts_type,
388 timestamp_active: ts_active,
389 timestamp_date: ts_date,
390 timestamp_time: ts_time,
391 timestamp_end_time: ts_end_time,
392 timestamp_repeater: ts_repeater,
393 timestamp_next: None,
396 timestamp_next_after: None,
397 clocks: clocks_opt,
398 total_clock_time: total_time,
399 properties,
400 })
401}
402
403fn parse_heading(text: &str) -> (Option<TaskType>, Option<Priority>, String) {
430 let (task_type, rest) = if let Some(caps) = HEADING_TODO_RE.captures(text) {
432 let kw = caps.get(1).map(|m| m.as_str()).unwrap_or("");
433 let m = caps
434 .get(0)
435 .expect("Captures::get(0) is Some when captures() succeeds");
436 (TaskType::from_keyword(kw), &text[m.end()..])
437 } else {
438 (None, text)
439 };
440 let title = rest.trim();
441
442 if let Some(caps) = HEADING_PRIORITY_RE.captures(title) {
444 let value = caps.get(1).map(|m| m.as_str()).unwrap_or("");
445 if let Some(priority) = Priority::parse(value) {
446 let whole = caps
447 .get(0)
448 .expect("Captures::get(0) is Some when captures() succeeds");
449 let heading = if whole.start() == 0 {
451 title[whole.end()..].trim()
452 } else {
453 title
454 };
455 return (task_type, Some(priority), heading.to_string());
456 }
457 }
458
459 (task_type, None, title.to_string())
460}
461
462static HEADING_HASHES_RE: LazyLock<Regex> = LazyLock::new(|| compile_bounded(r"^(#{1,6})[ \t]+"));
470
471#[derive(Debug, Clone, PartialEq)]
477pub struct HeadingToken<T> {
478 pub range: Range<usize>,
480 pub value: T,
482}
483
484#[derive(Debug, Clone, PartialEq)]
503pub struct HeadingLine {
504 pub level: usize,
506 pub status: Option<HeadingToken<TaskType>>,
508 pub priority: Option<HeadingToken<Priority>>,
510 pub title_start: usize,
515}
516
517pub fn parse_heading_line(line: &str) -> Option<HeadingLine> {
531 let hashes = HEADING_HASHES_RE.captures(line)?;
532 let level = hashes
533 .get(1)
534 .expect("group 1 is Some when captures() succeeds")
535 .len();
536 let after_hashes = hashes
537 .get(0)
538 .expect("Captures::get(0) is Some when captures() succeeds")
539 .end();
540
541 let (status, after_status) = match HEADING_TODO_RE.captures(&line[after_hashes..]) {
542 Some(caps) => {
543 let keyword = caps
544 .get(1)
545 .expect("group 1 is Some when captures() succeeds");
546 let whole = caps
547 .get(0)
548 .expect("Captures::get(0) is Some when captures() succeeds");
549 let token = TaskType::from_keyword(keyword.as_str()).map(|value| HeadingToken {
550 range: after_hashes + keyword.start()..after_hashes + keyword.end(),
551 value,
552 });
553 (token, after_hashes + whole.end())
554 }
555 None => (None, after_hashes),
556 };
557
558 let priority = HEADING_PRIORITY_RE
561 .captures(&line[after_status..])
562 .and_then(|caps| {
563 let value = caps
564 .get(1)
565 .expect("group 1 is Some when captures() succeeds");
566 let parsed = Priority::parse(value.as_str())?;
569 Some(HeadingToken {
570 range: after_status + value.start() - "[#".len()
571 ..after_status + value.end() + "]".len(),
572 value: parsed,
573 })
574 });
575
576 let after_tokens = priority
581 .as_ref()
582 .filter(|cookie| line[after_status..cookie.range.start].trim().is_empty())
583 .map_or(after_status, |cookie| cookie.range.end);
584 let title_start = after_tokens
585 + line[after_tokens..]
586 .find(|c: char| !c.is_whitespace())
587 .unwrap_or(line.len() - after_tokens);
588
589 Some(HeadingLine {
590 level,
591 status,
592 priority,
593 title_start,
594 })
595}
596
597fn strip_wrapping_backticks(s: &str) -> &str {
609 let bytes = s.as_bytes();
610 let n_leading = bytes.iter().take_while(|&&b| b == b'`').count();
611 if n_leading == 0 {
612 return s;
613 }
614 let n_trailing = bytes.iter().rev().take_while(|&&b| b == b'`').count();
615 if n_trailing != n_leading || bytes.len() < 2 * n_leading + 1 {
619 return s;
620 }
621 s[n_leading..bytes.len() - n_leading].trim()
622}
623
624fn parse_org_properties(
635 literal: &str,
636 props: &mut BTreeMap<String, String>,
637 path: &Path,
638 block_start_line: u32,
639 prop_warning_counter: &mut usize,
640) {
641 for (offset, line) in literal.lines().enumerate() {
642 if line.trim().is_empty() {
643 continue;
644 }
645 let src_line = block_start_line
647 .saturating_add(1)
648 .saturating_add(offset as u32);
649 match line.split_once(':') {
650 Some((key, value)) => {
651 let key = key.trim();
652 if key.is_empty() {
653 warn_invalid_property_line(prop_warning_counter, path, src_line, line);
654 continue;
655 }
656 props.insert(key.to_string(), value.trim().to_string());
657 }
658 None => {
659 warn_invalid_property_line(prop_warning_counter, path, src_line, line);
660 }
661 }
662 }
663}
664
665fn extract_timestamps_from_node<'a>(
667 node: &'a AstNode<'a>,
668 mappings: &[(&str, &str)],
669) -> (Option<String>, Option<String>) {
670 let mut created = None;
671 let mut timestamp = None;
672
673 if let NodeValue::Paragraph = &node.data.borrow().value {
674 for child in node.children() {
675 if let NodeValue::Code(code) = &child.data.borrow().value {
676 let normalized = normalize_weekdays(&code.literal, mappings);
679 if created.is_none() {
680 created = extract_created_normalized(&normalized);
681 }
682 if timestamp.is_none() {
683 timestamp = extract_timestamp_normalized(&normalized);
684 }
685 }
686 }
687 }
688 (created, timestamp)
689}
690
691fn extract_paragraph_text<'a>(node: &'a AstNode<'a>) -> String {
698 let mut text = String::new();
699 collect_text_recursive(node, &mut text, InlineCode::Drop);
700 text.trim().to_string()
701}
702
703fn extract_text<'a>(node: &'a AstNode<'a>) -> String {
706 let mut text = String::new();
707 collect_text_recursive(node, &mut text, InlineCode::Keep);
708 text
709}
710
711#[derive(Clone, Copy, PartialEq, Eq)]
713enum InlineCode {
714 Keep,
717 Drop,
719}
720
721fn collect_text_recursive<'a>(node: &'a AstNode<'a>, out: &mut String, code: InlineCode) {
722 for child in node.children() {
723 let value = child.data.borrow().value.clone();
724 match value {
725 NodeValue::Text(t) => out.push_str(&t),
726 NodeValue::Code(inline) if code == InlineCode::Keep => out.push_str(&inline.literal),
727 NodeValue::Emph | NodeValue::Strong | NodeValue::Link(_) | NodeValue::Strikethrough => {
728 collect_text_recursive(child, out, code)
729 }
730 _ => {}
731 }
732 }
733}
734
735pub fn display_text(markdown: &str) -> String {
752 let arena = Arena::new();
753 let root = parse_document(&arena, markdown, &safe_comrak_options());
754
755 let mut text = String::new();
756 collect_block_text(root, &mut text);
757 text.trim().to_string()
758}
759
760fn collect_block_text<'a>(node: &'a AstNode<'a>, out: &mut String) {
766 for child in node.children() {
767 let value = child.data.borrow().value.clone();
768 match value {
769 NodeValue::Paragraph | NodeValue::Heading(_) => {
770 collect_text_recursive(child, out, InlineCode::Keep)
771 }
772 _ => collect_block_text(child, out),
773 }
774 }
775}
776
777#[cfg(test)]
781mod tests {
782 use super::*;
783 use crate::types::{CancelledSpelling, DEFAULT_MAX_TASKS};
784
785 #[test]
786 fn warn_invalid_timestamp_advances_per_call_counter() {
787 let mut counter = 0_usize;
793 let path = Path::new("t.md");
794 for i in 1..=25 {
795 warn_invalid_timestamp(&mut counter, path, i, "<bad>");
796 }
797 assert_eq!(counter, 25);
798 }
799
800 #[test]
801 fn warn_invalid_property_line_advances_per_call_counter() {
802 let mut counter = 0_usize;
806 let path = Path::new("t.md");
807 for i in 1..=25 {
808 warn_invalid_property_line(&mut counter, path, i, "no-colon-here");
809 }
810 assert_eq!(counter, 25);
811 }
812
813 #[test]
814 fn warn_invalid_timestamp_counters_are_independent() {
815 let mut counter_a = 0_usize;
821 let mut counter_b = 0_usize;
822 let path = Path::new("t.md");
823 for _ in 0..MAX_DIAGNOSTIC_ITEMS {
824 warn_invalid_timestamp(&mut counter_a, path, 1, "<bad>");
825 }
826 warn_invalid_timestamp(&mut counter_b, path, 1, "<bad>");
827 assert_eq!(counter_a, MAX_DIAGNOSTIC_ITEMS);
828 assert_eq!(counter_b, 1);
829 }
830
831 #[test]
832 fn test_parse_heading_with_priority() {
833 let (task_type, priority, heading) = parse_heading("TODO [#A] Important task");
834 assert_eq!(task_type, Some(TaskType::Todo));
835 assert_eq!(priority, Some(Priority::A));
836 assert_eq!(heading, "Important task");
837 }
838
839 #[test]
840 fn test_parse_heading_without_priority() {
841 let (task_type, priority, heading) = parse_heading("DONE Simple task");
842 assert_eq!(task_type, Some(TaskType::Done));
843 assert_eq!(priority, None);
844 assert_eq!(heading, "Simple task");
845 }
846
847 #[test]
848 fn test_parse_heading_no_task() {
849 let (task_type, priority, heading) = parse_heading("Regular heading");
850 assert_eq!(task_type, None);
851 assert_eq!(priority, None);
852 assert_eq!(heading, "Regular heading");
853 }
854
855 #[test]
861 fn parse_heading_priority_without_todo() {
862 let (tt, p, h) = parse_heading("[#A] Заголовок");
864 assert_eq!(tt, None);
865 assert_eq!(p, Some(Priority::A));
866 assert_eq!(h, "Заголовок");
867 }
868
869 #[test]
870 fn parse_heading_todo_with_priority() {
871 let (tt, p, h) = parse_heading("TODO [#A] Заголовок");
873 assert_eq!(tt, Some(TaskType::Todo));
874 assert_eq!(p, Some(Priority::A));
875 assert_eq!(h, "Заголовок");
876 }
877
878 #[test]
879 fn parse_heading_done_with_priority_b() {
880 let (tt, p, h) = parse_heading("DONE [#B] Заголовок");
882 assert_eq!(tt, Some(TaskType::Done));
883 assert_eq!(p, Some(Priority::B));
884 assert_eq!(h, "Заголовок");
885 }
886
887 #[test]
888 fn parse_heading_plain_text_no_markers() {
889 let (tt, p, h) = parse_heading("Заголовок");
891 assert_eq!(tt, None);
892 assert_eq!(p, None);
893 assert_eq!(h, "Заголовок");
894 }
895
896 #[test]
897 fn parse_heading_todo_no_priority() {
898 let (tt, p, h) = parse_heading("TODO Заголовок");
900 assert_eq!(tt, Some(TaskType::Todo));
901 assert_eq!(p, None);
902 assert_eq!(h, "Заголовок");
903 }
904
905 #[test]
906 fn parse_heading_numeric_priority() {
907 let (tt, p, h) = parse_heading("[#1] Заголовок");
909 assert_eq!(tt, None);
910 assert_eq!(p, Some(Priority::Numeric(1)));
911 assert_eq!(h, "Заголовок");
912 }
913
914 #[test]
915 fn parse_heading_extra_whitespace_around_priority() {
916 let (tt, p, h) = parse_heading("[#A] Заголовок");
921 assert_eq!(tt, None);
922 assert_eq!(p, Some(Priority::A));
923 assert_eq!(h, "Заголовок");
924 }
925
926 #[test]
927 fn parse_heading_priority_in_the_middle_org_semantics() {
928 let (tt, p, h) = parse_heading("Без приоритета и [#A] внутри");
933 assert_eq!(tt, None);
934 assert_eq!(p, Some(Priority::A));
935 assert_eq!(h, "Без приоритета и [#A] внутри");
936 }
937
938 #[test]
939 fn parse_heading_trailing_cookie_leaves_a_title_behind() {
940 let (tt, p, h) = parse_heading("TODO Заголовок с cookie в конце [#A]");
944 assert_eq!(tt, Some(TaskType::Todo));
945 assert_eq!(p, Some(Priority::A));
946 assert_eq!(h, "Заголовок с cookie в конце [#A]");
947 }
948
949 #[test]
950 fn parse_heading_two_digit_numeric_priority() {
951 let (tt, p, h) = parse_heading("[#15] Mid range");
952 assert_eq!(tt, None);
953 assert_eq!(p, Some(Priority::Numeric(15)));
954 assert_eq!(h, "Mid range");
955
956 let (tt, p, h) = parse_heading("[#64] At upper bound");
957 assert_eq!(tt, None);
958 assert_eq!(p, Some(Priority::Numeric(64)));
959 assert_eq!(h, "At upper bound");
960 }
961
962 #[test]
963 fn parse_heading_rejects_numeric_out_of_range() {
964 let (tt, p, h) = parse_heading("[#65] Above range");
967 assert_eq!(tt, None);
968 assert_eq!(p, None);
969 assert_eq!(h, "[#65] Above range");
970 }
971
972 #[test]
973 fn parse_heading_rejects_lowercase_priority() {
974 let (tt, p, h) = parse_heading("[#a] Lowercase");
975 assert_eq!(tt, None);
976 assert_eq!(p, None);
977 assert_eq!(h, "[#a] Lowercase");
978 }
979
980 #[test]
981 fn parse_heading_todo_then_priority_with_intervening_text() {
982 let (tt, p, h) = parse_heading("TODO Купить [#A] фильтр");
986 assert_eq!(tt, Some(TaskType::Todo));
987 assert_eq!(p, Some(Priority::A));
988 assert_eq!(h, "Купить [#A] фильтр");
989 }
990
991 #[test]
992 fn parse_heading_priority_without_trailing_space() {
993 let (tt, p, h) = parse_heading("[#A]NoSpace");
995 assert_eq!(tt, None);
996 assert_eq!(p, Some(Priority::A));
997 assert_eq!(h, "NoSpace");
998 }
999
1000 #[test]
1001 fn parse_heading_cancelled_simple() {
1002 let (tt, p, h) = parse_heading("CANCELLED Foo");
1003 assert_eq!(tt, Some(TaskType::Cancelled(CancelledSpelling::DoubleL)));
1004 assert_eq!(p, None);
1005 assert_eq!(h, "Foo");
1006 }
1007
1008 #[test]
1009 fn parse_heading_cancelled_with_priority() {
1010 let (tt, p, h) = parse_heading("CANCELLED [#A] Foo");
1011 assert_eq!(tt, Some(TaskType::Cancelled(CancelledSpelling::DoubleL)));
1012 assert_eq!(p, Some(Priority::A));
1013 assert_eq!(h, "Foo");
1014 }
1015
1016 #[test]
1017 fn parse_heading_cancelled_without_whitespace() {
1018 let (tt, p, h) = parse_heading("CANCELLEDFoo");
1020 assert_eq!(tt, None);
1021 assert_eq!(p, None);
1022 assert_eq!(h, "CANCELLEDFoo");
1023 }
1024
1025 #[test]
1026 fn parse_heading_cancelled_lowercase_not_recognised() {
1027 let (tt, p, h) = parse_heading("cancelled Foo");
1029 assert_eq!(tt, None);
1030 assert_eq!(p, None);
1031 assert_eq!(h, "cancelled Foo");
1032 }
1033
1034 #[test]
1035 fn parse_heading_todo_cancelled_first_keyword_wins() {
1036 let (tt, p, h) = parse_heading("TODO CANCELLED Foo");
1038 assert_eq!(tt, Some(TaskType::Todo));
1039 assert_eq!(p, None);
1040 assert_eq!(h, "CANCELLED Foo");
1041 }
1042
1043 #[test]
1044 fn parse_heading_canceled_single_l() {
1045 let (tt, p, h) = parse_heading("CANCELED Foo");
1048 assert_eq!(tt, Some(TaskType::Cancelled(CancelledSpelling::SingleL)));
1049 assert_eq!(p, None);
1050 assert_eq!(h, "Foo");
1051 }
1052
1053 #[test]
1054 fn parse_heading_canceled_with_priority() {
1055 let (tt, p, h) = parse_heading("CANCELED [#A] Foo");
1056 assert_eq!(tt, Some(TaskType::Cancelled(CancelledSpelling::SingleL)));
1057 assert_eq!(p, Some(Priority::A));
1058 assert_eq!(h, "Foo");
1059 }
1060
1061 #[test]
1062 fn parse_heading_canceled_lowercase_not_recognised() {
1063 let (tt, p, h) = parse_heading("canceled Foo");
1065 assert_eq!(tt, None);
1066 assert_eq!(p, None);
1067 assert_eq!(h, "canceled Foo");
1068 }
1069
1070 #[test]
1071 fn parse_heading_canceled_without_whitespace_not_recognised() {
1072 let (tt, p, h) = parse_heading("CANCELEDfoo");
1074 assert_eq!(tt, None);
1075 assert_eq!(p, None);
1076 assert_eq!(h, "CANCELEDfoo");
1077 }
1078
1079 #[test]
1080 fn extract_tasks_marks_scheduled_angle_bracket_as_active() {
1081 let content = "### TODO Pin me\n`SCHEDULED: <2026-05-21 Thu>`\n";
1086 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1087 assert_eq!(tasks.len(), 1);
1088 assert_eq!(tasks[0].timestamp_active, Some(true));
1089 }
1090
1091 #[test]
1092 fn extract_tasks_marks_missing_timestamp_active_as_none() {
1093 let content = "### Project kickoff\n\n`CREATED: [2025-09-01 Mon]`\n";
1097 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1098 assert_eq!(tasks.len(), 1);
1099 assert_eq!(tasks[0].timestamp_active, None);
1100 }
1101
1102 #[test]
1103 fn extract_tasks_basic_todo_with_deadline() {
1104 let content = "\
1105### TODO [#A] Write docs\n\
1106`DEADLINE: <2025-12-10 Wed>`\n";
1107 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1108 assert_eq!(tasks.len(), 1);
1109 let t = &tasks[0];
1110 assert_eq!(t.task_type, Some(TaskType::Todo));
1111 assert_eq!(t.priority, Some(Priority::A));
1112 assert_eq!(t.heading, "Write docs");
1113 assert_eq!(t.timestamp_type, Some("DEADLINE".to_string()));
1114 assert_eq!(t.timestamp_date, Some("2025-12-10".to_string()));
1115 }
1116
1117 #[test]
1118 fn extract_tasks_extracts_emph_text_in_heading() {
1119 let content = "### TODO **Important** task\n`DEADLINE: <2025-12-10 Wed>`\n";
1121 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1122 assert_eq!(tasks.len(), 1);
1123 assert_eq!(tasks[0].heading, "Important task");
1124 }
1125
1126 #[test]
1127 fn extract_tasks_ignores_non_task_headings_without_timestamps() {
1128 let content = "### Just a heading\n\nSome text.\n";
1129 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1130 assert!(tasks.is_empty());
1131 }
1132
1133 #[test]
1134 fn extract_tasks_keeps_created_without_todo() {
1135 let content = "### Project kickoff\n\n`CREATED: [2025-09-01 Mon]`\n";
1137 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1138 assert_eq!(tasks.len(), 1);
1139 assert_eq!(tasks[0].task_type, None);
1140 assert_eq!(tasks[0].created, Some("CREATED: [2025-09-01 Mon]".into()));
1141 }
1142
1143 #[test]
1144 fn extract_tasks_concatenates_multiple_paragraphs() {
1145 let content = "\
1147### TODO Multi-line task\n\
1148First paragraph.\n\
1149\n\
1150Second paragraph.\n\
1151";
1152 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1153 assert_eq!(tasks.len(), 1);
1154 assert!(tasks[0].content.contains("First paragraph"));
1155 assert!(tasks[0].content.contains("Second paragraph"));
1156 }
1157
1158 #[test]
1159 fn extract_tasks_extracts_clock_from_inline_code() {
1160 let content = "\
1161### TODO Track time\n\
1162`CLOCK: [2025-09-01 Mon 10:00]--[2025-09-01 Mon 11:30] => 1:30`\n\
1163";
1164 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1165 assert_eq!(tasks.len(), 1);
1166 let t = &tasks[0];
1167 assert!(t.clocks.is_some());
1168 assert_eq!(t.total_clock_time.as_deref(), Some("1:30"));
1169 }
1170
1171 #[test]
1172 fn extract_tasks_handles_done_priority() {
1173 let content = "### DONE [#B] Wrap up\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].task_type, Some(TaskType::Done));
1177 assert_eq!(tasks[0].priority, Some(Priority::B));
1178 }
1179
1180 #[test]
1181 fn extract_tasks_priority_without_todo_with_scheduled() {
1182 let content = "\
1186### [#A] Поменять резину до 16.05.2026\n\
1187`SCHEDULED: <2026-05-09 Sat>`\n";
1188 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1189 assert_eq!(tasks.len(), 1);
1190 let t = &tasks[0];
1191 assert_eq!(t.task_type, None);
1192 assert_eq!(t.priority, Some(Priority::A));
1193 assert_eq!(t.heading, "Поменять резину до 16.05.2026");
1194 assert_eq!(t.timestamp_type, Some("SCHEDULED".to_string()));
1195 assert_eq!(t.timestamp_date, Some("2026-05-09".to_string()));
1196 }
1197
1198 #[test]
1199 fn extract_tasks_numeric_priority_with_deadline() {
1200 let content = "\
1202### [#1] Numeric priority task\n\
1203`DEADLINE: <2026-05-09 Sat>`\n";
1204 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1205 assert_eq!(tasks.len(), 1);
1206 let t = &tasks[0];
1207 assert_eq!(t.task_type, None);
1208 assert_eq!(t.priority, Some(Priority::Numeric(1)));
1209 assert_eq!(t.heading, "Numeric priority task");
1210 }
1211
1212 #[test]
1213 fn extract_tasks_priority_in_middle_keeps_the_prefix() {
1214 let content = "\
1217### Без приоритета и [#A] внутри\n\
1218`SCHEDULED: <2026-05-09 Sat>`\n";
1219 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1220 assert_eq!(tasks.len(), 1);
1221 let t = &tasks[0];
1222 assert_eq!(t.task_type, None);
1223 assert_eq!(t.priority, Some(Priority::A));
1224 assert_eq!(t.heading, "Без приоритета и [#A] внутри");
1225 }
1226
1227 #[test]
1228 fn extract_tasks_bug_report_minimal_reproduction() {
1229 let content = "\
1233### [#A] Поменять резину до 16.05.2026\n\
1234`SCHEDULED: <2026-05-09 Sat>`\n\
1235\n\
1236### TODO [#A] Поменять масло\n\
1237`SCHEDULED: <2026-05-09 Sat>`\n\
1238\n\
1239### Купить фильтр\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(), 3);
1243
1244 assert_eq!(tasks[0].task_type, None);
1245 assert_eq!(tasks[0].priority, Some(Priority::A));
1246 assert_eq!(tasks[0].heading, "Поменять резину до 16.05.2026");
1247
1248 assert_eq!(tasks[1].task_type, Some(TaskType::Todo));
1249 assert_eq!(tasks[1].priority, Some(Priority::A));
1250 assert_eq!(tasks[1].heading, "Поменять масло");
1251
1252 assert_eq!(tasks[2].task_type, None);
1253 assert_eq!(tasks[2].priority, None);
1254 assert_eq!(tasks[2].heading, "Купить фильтр");
1255 }
1256
1257 #[test]
1266 fn extract_tasks_indented_inline_code_deadline() {
1267 let content = "#### Birthday\n `DEADLINE: <2026-05-07 Thu +1y>`\n";
1268 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1269 assert_eq!(tasks.len(), 1, "task should not be dropped");
1270 let t = &tasks[0];
1271 assert_eq!(t.timestamp_type.as_deref(), Some("DEADLINE"));
1272 assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1273 }
1274
1275 #[test]
1276 fn extract_tasks_todo_indented_inline_code_deadline() {
1277 let content = "#### TODO Birthday\n `DEADLINE: <2026-05-07 Thu +1y>`\n";
1278 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1279 assert_eq!(tasks.len(), 1);
1280 let t = &tasks[0];
1281 assert_eq!(t.task_type, Some(TaskType::Todo));
1282 assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1283 }
1284
1285 #[test]
1286 fn extract_tasks_indented_inline_code_blank_lines_between() {
1287 let content = "#### Birthday\n\n \t \n `DEADLINE: <2026-05-07 Thu +1y>`\n";
1290 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1291 assert_eq!(tasks.len(), 1);
1292 let t = &tasks[0];
1293 assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1294 }
1295
1296 #[test]
1297 fn extract_tasks_with_ru_mappings_reproduces_cli_pipeline() {
1298 let content = "#### TODO Birthday\n `DEADLINE: <2026-05-07 Thu +1y>`\n";
1303 let tasks = extract_tasks(
1304 Path::new("t.md"),
1305 content,
1306 crate::locale::RU_WEEKDAY_MAPPINGS,
1307 DEFAULT_MAX_TASKS,
1308 );
1309 assert_eq!(tasks.len(), 1);
1310 let t = &tasks[0];
1311 assert_eq!(t.task_type, Some(TaskType::Todo));
1312 assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1313 }
1314
1315 #[test]
1316 fn extract_tasks_inline_code_scheduled_no_indent() {
1317 let content = "#### Followup\n`SCHEDULED: <2026-05-07 Thu>`\n";
1318 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1319 assert_eq!(tasks.len(), 1);
1320 let t = &tasks[0];
1321 assert_eq!(t.timestamp_type.as_deref(), Some("SCHEDULED"));
1322 assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1323 }
1324
1325 #[test]
1326 fn extract_tasks_indented_inline_code_created() {
1327 let content = "#### Project kickoff\n `CREATED: [2025-09-01 Mon]`\n";
1328 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1329 assert_eq!(tasks.len(), 1);
1330 let t = &tasks[0];
1331 assert_eq!(t.created.as_deref(), Some("CREATED: [2025-09-01 Mon]"));
1332 }
1333
1334 #[test]
1335 fn extract_tasks_parses_single_property() {
1336 let content = "### TODO Ship release\n`SCHEDULED: <2026-06-01 Mon 10:00>`\n```org-properties\nGCAL_EVENT_ID: abc123/primary\n```\n";
1337 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1338 assert_eq!(tasks.len(), 1);
1339 let props = tasks[0].properties.as_ref().expect("properties present");
1340 assert_eq!(
1341 props.get("GCAL_EVENT_ID").map(String::as_str),
1342 Some("abc123/primary")
1343 );
1344 assert!(!tasks[0].content.contains("GCAL_EVENT_ID"));
1346 assert!(!tasks[0].content.contains("org-properties"));
1347 }
1348
1349 #[test]
1350 fn extract_tasks_parses_multiple_properties() {
1351 let content =
1352 "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nA: 1\nB: 2\n```\n";
1353 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1354 let props = tasks[0].properties.as_ref().unwrap();
1355 assert_eq!(props.get("A").map(String::as_str), Some("1"));
1356 assert_eq!(props.get("B").map(String::as_str), Some("2"));
1357 }
1358
1359 #[test]
1360 fn extract_tasks_property_duplicate_keys_last_wins() {
1361 let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nK: first\nK: second\n```\n";
1362 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1363 assert_eq!(
1364 tasks[0]
1365 .properties
1366 .as_ref()
1367 .unwrap()
1368 .get("K")
1369 .map(String::as_str),
1370 Some("second")
1371 );
1372 }
1373
1374 #[test]
1375 fn extract_tasks_property_empty_value_allowed() {
1376 let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nK:\n```\n";
1377 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1378 assert_eq!(
1379 tasks[0]
1380 .properties
1381 .as_ref()
1382 .unwrap()
1383 .get("K")
1384 .map(String::as_str),
1385 Some("")
1386 );
1387 }
1388
1389 #[test]
1390 fn extract_tasks_property_malformed_line_skipped() {
1391 let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nGOOD: x\nno colon here\n```\n";
1392 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1393 let props = tasks[0].properties.as_ref().unwrap();
1394 assert_eq!(props.get("GOOD").map(String::as_str), Some("x"));
1395 assert_eq!(props.len(), 1, "malformed line must be skipped");
1396 }
1397
1398 #[test]
1399 fn extract_tasks_empty_property_block_yields_none() {
1400 let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\n\n```\n";
1401 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1402 assert_eq!(tasks[0].properties, None);
1403 }
1404
1405 #[test]
1406 fn extract_tasks_property_info_with_extra_attrs_not_recognised() {
1407 let content =
1410 "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties extra\nK: v\n```\n";
1411 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1412 assert_eq!(tasks[0].properties, None);
1413 }
1414
1415 #[test]
1416 fn extract_tasks_clock_code_block_unaffected_by_properties() {
1417 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";
1420 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1421 assert_eq!(
1422 tasks[0]
1423 .properties
1424 .as_ref()
1425 .unwrap()
1426 .get("K")
1427 .map(String::as_str),
1428 Some("v")
1429 );
1430 assert_eq!(tasks[0].total_clock_time.as_deref(), Some("1:30"));
1431 }
1432
1433 #[test]
1434 fn extract_tasks_merges_multiple_property_blocks_last_wins() {
1435 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";
1436 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1437 let props = tasks[0].properties.as_ref().unwrap();
1438 assert_eq!(props.get("K").map(String::as_str), Some("two"));
1439 assert_eq!(props.get("L").map(String::as_str), Some("three"));
1440 }
1441}