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