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> =
96 LazyLock::new(|| compile_bounded(r"\[#([A-Z]|6[0-4]|[1-5][0-9]|[0-9])\] ?"));
97
98pub fn extract_tasks_with_counter(
115 path: &Path,
116 content: &str,
117 mappings: &[(&str, &str)],
118 max_tasks: usize,
119 ts_warning_counter: &mut usize,
120 prop_warning_counter: &mut usize,
121) -> Vec<Task> {
122 let arena = Arena::new();
123 let root = parse_document(&arena, content, &safe_comrak_options());
124
125 let mut tasks = Vec::new();
126 let mut current_heading: Option<HeadingInfo> = None;
127
128 for node in root.children() {
129 process_node(
130 node,
131 path,
132 &mut tasks,
133 &mut current_heading,
134 mappings,
135 ts_warning_counter,
136 prop_warning_counter,
137 );
138
139 if tasks.len() >= max_tasks {
140 tracing::warn!(
141 file = %path.display(),
142 limit = max_tasks,
143 "reached per-file task limit"
144 );
145 break;
146 }
147 }
148
149 if let Some(info) = current_heading.take() {
151 if let Some(task) = finalize_task(path, info, ts_warning_counter) {
152 tasks.push(task);
153 }
154 }
155
156 tracing::debug!(
157 file = %path.display(),
158 bytes = content.len(),
159 tasks = tasks.len(),
160 "parsed file"
161 );
162
163 tasks
164}
165
166#[cfg_attr(not(test), allow(dead_code))]
174pub fn extract_tasks(
175 path: &Path,
176 content: &str,
177 mappings: &[(&str, &str)],
178 max_tasks: usize,
179) -> Vec<Task> {
180 let mut counter = 0_usize;
181 let mut prop_counter = 0_usize;
182 extract_tasks_with_counter(
183 path,
184 content,
185 mappings,
186 max_tasks,
187 &mut counter,
188 &mut prop_counter,
189 )
190}
191
192fn safe_comrak_options() -> Options<'static> {
204 Options::default()
205}
206
207struct HeadingInfo {
209 heading: String,
210 task_type: Option<TaskType>,
211 priority: Option<Priority>,
212 line: u32,
213 content: String,
214 created: Option<String>,
215 timestamp: Option<String>,
216 clocks: Vec<crate::types::ClockEntry>,
217 properties: BTreeMap<String, String>,
218}
219
220fn process_node<'a>(
222 node: &'a AstNode<'a>,
223 path: &Path,
224 tasks: &mut Vec<Task>,
225 current_heading: &mut Option<HeadingInfo>,
226 mappings: &[(&str, &str)],
227 ts_warning_counter: &mut usize,
228 prop_warning_counter: &mut usize,
229) {
230 let (value_clone, line) = {
234 let data = node.data.borrow();
235 (data.value.clone(), data.sourcepos.start.line as u32)
236 };
237 match value_clone {
238 NodeValue::Heading(_) => {
239 if let Some(info) = current_heading.take() {
241 if let Some(task) = finalize_task(path, info, ts_warning_counter) {
242 tasks.push(task);
243 }
244 }
245
246 let text = extract_text(node);
247 let (task_type, priority, heading) = parse_heading(&text);
248 *current_heading = Some(HeadingInfo {
249 heading,
250 task_type,
251 priority,
252 line,
253 content: String::new(),
254 created: None,
255 timestamp: None,
256 clocks: Vec::new(),
257 properties: BTreeMap::new(),
258 });
259 }
260 NodeValue::Paragraph => {
261 if let Some(ref mut info) = current_heading {
262 let (created, timestamp) = extract_timestamps_from_node(node, mappings);
263 let content = extract_paragraph_text(node);
264
265 for child in node.children() {
266 if let NodeValue::Code(code) = &child.data.borrow().value {
267 info.clocks.extend(extract_clocks(&code.literal));
268 }
269 }
270
271 if created.is_some() {
272 info.created = created;
273 }
274 if timestamp.is_some() {
275 info.timestamp = timestamp;
276 }
277 if !content.is_empty() {
278 if info.content.is_empty() {
279 info.content = content;
280 } else {
281 info.content.push_str("\n\n");
282 info.content.push_str(&content);
283 }
284 }
285 }
286 }
287 NodeValue::CodeBlock(code) => {
288 if let Some(ref mut info) = current_heading {
289 if code.info.trim() == "org-properties" {
296 parse_org_properties(
297 &code.literal,
298 &mut info.properties,
299 path,
300 line,
301 prop_warning_counter,
302 );
303 } else {
304 let raw = code.literal.trim();
305 let literal = strip_wrapping_backticks(raw);
313 let normalized = normalize_weekdays(literal, mappings);
314 let created = extract_created_normalized(&normalized);
315 let timestamp = extract_timestamp_normalized(&normalized);
316
317 info.clocks.extend(extract_clocks(literal));
318
319 if created.is_some() {
320 info.created = created;
321 }
322 if timestamp.is_some() {
323 info.timestamp = timestamp;
324 }
325 }
326 }
327 }
328 _ => {}
329 }
330}
331
332fn finalize_task(path: &Path, info: HeadingInfo, ts_warning_counter: &mut usize) -> Option<Task> {
333 if info.task_type.is_none() && info.created.is_none() && info.timestamp.is_none() {
334 return None;
335 }
336
337 let line = info.line;
338 let (ts_type, ts_date, ts_time, ts_end_time, ts_active, ts_repeater) =
339 if let Some(ref ts) = info.timestamp {
340 let parsed = parse_timestamp_fields_normalized(ts);
345 if parsed.1.is_none() {
346 warn_invalid_timestamp(ts_warning_counter, path, line, ts);
347 }
348 let repeater = extract_repeater_normalized(ts);
354 (parsed.0, parsed.1, parsed.2, parsed.3, parsed.4, repeater)
355 } else {
356 (None, None, None, None, None, None)
357 };
358
359 let (clocks_opt, total_time) = if !info.clocks.is_empty() {
360 let total = calculate_total_minutes(&info.clocks).map(format_duration);
361 (Some(info.clocks), total)
362 } else {
363 (None, None)
364 };
365
366 let properties = if info.properties.is_empty() {
367 None
368 } else {
369 Some(info.properties)
370 };
371
372 Some(Task {
373 file: path.display().to_string(),
374 root: None,
377 line,
378 heading: info.heading,
379 content: info.content,
380 task_type: info.task_type,
381 priority: info.priority,
382 created: info.created,
383 timestamp: info.timestamp,
384 timestamp_type: ts_type,
385 timestamp_active: ts_active,
386 timestamp_date: ts_date,
387 timestamp_time: ts_time,
388 timestamp_end_time: ts_end_time,
389 timestamp_repeater: ts_repeater,
390 timestamp_next: None,
393 clocks: clocks_opt,
394 total_clock_time: total_time,
395 properties,
396 })
397}
398
399fn parse_heading(text: &str) -> (Option<TaskType>, Option<Priority>, String) {
415 let (task_type, rest) = if let Some(caps) = HEADING_TODO_RE.captures(text) {
417 let kw = caps.get(1).map(|m| m.as_str()).unwrap_or("");
418 let m = caps
419 .get(0)
420 .expect("Captures::get(0) is Some when captures() succeeds");
421 (TaskType::from_keyword(kw), &text[m.end()..])
422 } else {
423 (None, text)
424 };
425
426 if let Some(caps) = HEADING_PRIORITY_RE.captures(rest) {
428 let value = caps.get(1).map(|m| m.as_str()).unwrap_or("");
429 if let Some(priority) = Priority::parse(value) {
430 let whole = caps
431 .get(0)
432 .expect("Captures::get(0) is Some when captures() succeeds");
433 let after = &rest[whole.end()..];
434 return (task_type, Some(priority), after.trim().to_string());
435 }
436 }
437
438 (task_type, None, rest.trim().to_string())
439}
440
441static HEADING_HASHES_RE: LazyLock<Regex> = LazyLock::new(|| compile_bounded(r"^(#{1,6})[ \t]+"));
449
450#[derive(Debug, Clone, PartialEq)]
456pub struct HeadingToken<T> {
457 pub range: Range<usize>,
459 pub value: T,
461}
462
463#[derive(Debug, Clone, PartialEq)]
478pub struct HeadingLine {
479 pub level: usize,
481 pub status: Option<HeadingToken<TaskType>>,
483 pub priority: Option<HeadingToken<Priority>>,
485 pub title_start: usize,
489}
490
491pub fn parse_heading_line(line: &str) -> Option<HeadingLine> {
506 let hashes = HEADING_HASHES_RE.captures(line)?;
507 let level = hashes
508 .get(1)
509 .expect("group 1 is Some when captures() succeeds")
510 .len();
511 let after_hashes = hashes
512 .get(0)
513 .expect("Captures::get(0) is Some when captures() succeeds")
514 .end();
515
516 let (status, after_status) = match HEADING_TODO_RE.captures(&line[after_hashes..]) {
517 Some(caps) => {
518 let keyword = caps
519 .get(1)
520 .expect("group 1 is Some when captures() succeeds");
521 let whole = caps
522 .get(0)
523 .expect("Captures::get(0) is Some when captures() succeeds");
524 let token = TaskType::from_keyword(keyword.as_str()).map(|value| HeadingToken {
525 range: after_hashes + keyword.start()..after_hashes + keyword.end(),
526 value,
527 });
528 (token, after_hashes + whole.end())
529 }
530 None => (None, after_hashes),
531 };
532
533 let priority = HEADING_PRIORITY_RE
536 .captures(&line[after_status..])
537 .and_then(|caps| {
538 let value = caps
539 .get(1)
540 .expect("group 1 is Some when captures() succeeds");
541 let parsed = Priority::parse(value.as_str())?;
544 Some(HeadingToken {
545 range: after_status + value.start() - "[#".len()
546 ..after_status + value.end() + "]".len(),
547 value: parsed,
548 })
549 });
550
551 let after_tokens = priority
552 .as_ref()
553 .map_or(after_status, |cookie| cookie.range.end);
554 let title_start = after_tokens
555 + line[after_tokens..]
556 .find(|c: char| !c.is_whitespace())
557 .unwrap_or(line.len() - after_tokens);
558
559 Some(HeadingLine {
560 level,
561 status,
562 priority,
563 title_start,
564 })
565}
566
567fn strip_wrapping_backticks(s: &str) -> &str {
579 let bytes = s.as_bytes();
580 let n_leading = bytes.iter().take_while(|&&b| b == b'`').count();
581 if n_leading == 0 {
582 return s;
583 }
584 let n_trailing = bytes.iter().rev().take_while(|&&b| b == b'`').count();
585 if n_trailing != n_leading || bytes.len() < 2 * n_leading + 1 {
589 return s;
590 }
591 s[n_leading..bytes.len() - n_leading].trim()
592}
593
594fn parse_org_properties(
605 literal: &str,
606 props: &mut BTreeMap<String, String>,
607 path: &Path,
608 block_start_line: u32,
609 prop_warning_counter: &mut usize,
610) {
611 for (offset, line) in literal.lines().enumerate() {
612 if line.trim().is_empty() {
613 continue;
614 }
615 let src_line = block_start_line
617 .saturating_add(1)
618 .saturating_add(offset as u32);
619 match line.split_once(':') {
620 Some((key, value)) => {
621 let key = key.trim();
622 if key.is_empty() {
623 warn_invalid_property_line(prop_warning_counter, path, src_line, line);
624 continue;
625 }
626 props.insert(key.to_string(), value.trim().to_string());
627 }
628 None => {
629 warn_invalid_property_line(prop_warning_counter, path, src_line, line);
630 }
631 }
632 }
633}
634
635fn extract_timestamps_from_node<'a>(
637 node: &'a AstNode<'a>,
638 mappings: &[(&str, &str)],
639) -> (Option<String>, Option<String>) {
640 let mut created = None;
641 let mut timestamp = None;
642
643 if let NodeValue::Paragraph = &node.data.borrow().value {
644 for child in node.children() {
645 if let NodeValue::Code(code) = &child.data.borrow().value {
646 let normalized = normalize_weekdays(&code.literal, mappings);
649 if created.is_none() {
650 created = extract_created_normalized(&normalized);
651 }
652 if timestamp.is_none() {
653 timestamp = extract_timestamp_normalized(&normalized);
654 }
655 }
656 }
657 }
658 (created, timestamp)
659}
660
661fn extract_paragraph_text<'a>(node: &'a AstNode<'a>) -> String {
668 let mut text = String::new();
669 collect_text_recursive(node, &mut text, InlineCode::Drop);
670 text.trim().to_string()
671}
672
673fn extract_text<'a>(node: &'a AstNode<'a>) -> String {
676 let mut text = String::new();
677 collect_text_recursive(node, &mut text, InlineCode::Keep);
678 text
679}
680
681#[derive(Clone, Copy, PartialEq, Eq)]
683enum InlineCode {
684 Keep,
687 Drop,
689}
690
691fn collect_text_recursive<'a>(node: &'a AstNode<'a>, out: &mut String, code: InlineCode) {
692 for child in node.children() {
693 let value = child.data.borrow().value.clone();
694 match value {
695 NodeValue::Text(t) => out.push_str(&t),
696 NodeValue::Code(inline) if code == InlineCode::Keep => out.push_str(&inline.literal),
697 NodeValue::Emph | NodeValue::Strong | NodeValue::Link(_) | NodeValue::Strikethrough => {
698 collect_text_recursive(child, out, code)
699 }
700 _ => {}
701 }
702 }
703}
704
705pub fn display_text(markdown: &str) -> String {
722 let arena = Arena::new();
723 let root = parse_document(&arena, markdown, &safe_comrak_options());
724
725 let mut text = String::new();
726 collect_block_text(root, &mut text);
727 text.trim().to_string()
728}
729
730fn collect_block_text<'a>(node: &'a AstNode<'a>, out: &mut String) {
736 for child in node.children() {
737 let value = child.data.borrow().value.clone();
738 match value {
739 NodeValue::Paragraph | NodeValue::Heading(_) => {
740 collect_text_recursive(child, out, InlineCode::Keep)
741 }
742 _ => collect_block_text(child, out),
743 }
744 }
745}
746
747#[cfg(test)]
751mod tests {
752 use super::*;
753 use crate::types::{CancelledSpelling, DEFAULT_MAX_TASKS};
754
755 #[test]
756 fn warn_invalid_timestamp_advances_per_call_counter() {
757 let mut counter = 0_usize;
763 let path = Path::new("t.md");
764 for i in 1..=25 {
765 warn_invalid_timestamp(&mut counter, path, i, "<bad>");
766 }
767 assert_eq!(counter, 25);
768 }
769
770 #[test]
771 fn warn_invalid_property_line_advances_per_call_counter() {
772 let mut counter = 0_usize;
776 let path = Path::new("t.md");
777 for i in 1..=25 {
778 warn_invalid_property_line(&mut counter, path, i, "no-colon-here");
779 }
780 assert_eq!(counter, 25);
781 }
782
783 #[test]
784 fn warn_invalid_timestamp_counters_are_independent() {
785 let mut counter_a = 0_usize;
791 let mut counter_b = 0_usize;
792 let path = Path::new("t.md");
793 for _ in 0..MAX_DIAGNOSTIC_ITEMS {
794 warn_invalid_timestamp(&mut counter_a, path, 1, "<bad>");
795 }
796 warn_invalid_timestamp(&mut counter_b, path, 1, "<bad>");
797 assert_eq!(counter_a, MAX_DIAGNOSTIC_ITEMS);
798 assert_eq!(counter_b, 1);
799 }
800
801 #[test]
802 fn test_parse_heading_with_priority() {
803 let (task_type, priority, heading) = parse_heading("TODO [#A] Important task");
804 assert_eq!(task_type, Some(TaskType::Todo));
805 assert_eq!(priority, Some(Priority::A));
806 assert_eq!(heading, "Important task");
807 }
808
809 #[test]
810 fn test_parse_heading_without_priority() {
811 let (task_type, priority, heading) = parse_heading("DONE Simple task");
812 assert_eq!(task_type, Some(TaskType::Done));
813 assert_eq!(priority, None);
814 assert_eq!(heading, "Simple task");
815 }
816
817 #[test]
818 fn test_parse_heading_no_task() {
819 let (task_type, priority, heading) = parse_heading("Regular heading");
820 assert_eq!(task_type, None);
821 assert_eq!(priority, None);
822 assert_eq!(heading, "Regular heading");
823 }
824
825 #[test]
831 fn parse_heading_priority_without_todo() {
832 let (tt, p, h) = parse_heading("[#A] Заголовок");
834 assert_eq!(tt, None);
835 assert_eq!(p, Some(Priority::A));
836 assert_eq!(h, "Заголовок");
837 }
838
839 #[test]
840 fn parse_heading_todo_with_priority() {
841 let (tt, p, h) = parse_heading("TODO [#A] Заголовок");
843 assert_eq!(tt, Some(TaskType::Todo));
844 assert_eq!(p, Some(Priority::A));
845 assert_eq!(h, "Заголовок");
846 }
847
848 #[test]
849 fn parse_heading_done_with_priority_b() {
850 let (tt, p, h) = parse_heading("DONE [#B] Заголовок");
852 assert_eq!(tt, Some(TaskType::Done));
853 assert_eq!(p, Some(Priority::B));
854 assert_eq!(h, "Заголовок");
855 }
856
857 #[test]
858 fn parse_heading_plain_text_no_markers() {
859 let (tt, p, h) = parse_heading("Заголовок");
861 assert_eq!(tt, None);
862 assert_eq!(p, None);
863 assert_eq!(h, "Заголовок");
864 }
865
866 #[test]
867 fn parse_heading_todo_no_priority() {
868 let (tt, p, h) = parse_heading("TODO Заголовок");
870 assert_eq!(tt, Some(TaskType::Todo));
871 assert_eq!(p, None);
872 assert_eq!(h, "Заголовок");
873 }
874
875 #[test]
876 fn parse_heading_numeric_priority() {
877 let (tt, p, h) = parse_heading("[#1] Заголовок");
879 assert_eq!(tt, None);
880 assert_eq!(p, Some(Priority::Numeric(1)));
881 assert_eq!(h, "Заголовок");
882 }
883
884 #[test]
885 fn parse_heading_extra_whitespace_around_priority() {
886 let (tt, p, h) = parse_heading("[#A] Заголовок");
891 assert_eq!(tt, None);
892 assert_eq!(p, Some(Priority::A));
893 assert_eq!(h, "Заголовок");
894 }
895
896 #[test]
897 fn parse_heading_priority_in_the_middle_org_semantics() {
898 let (tt, p, h) = parse_heading("Без приоритета и [#A] внутри");
905 assert_eq!(tt, None);
906 assert_eq!(p, Some(Priority::A));
907 assert_eq!(h, "внутри");
908 }
909
910 #[test]
911 fn parse_heading_two_digit_numeric_priority() {
912 let (tt, p, h) = parse_heading("[#15] Mid range");
913 assert_eq!(tt, None);
914 assert_eq!(p, Some(Priority::Numeric(15)));
915 assert_eq!(h, "Mid range");
916
917 let (tt, p, h) = parse_heading("[#64] At upper bound");
918 assert_eq!(tt, None);
919 assert_eq!(p, Some(Priority::Numeric(64)));
920 assert_eq!(h, "At upper bound");
921 }
922
923 #[test]
924 fn parse_heading_rejects_numeric_out_of_range() {
925 let (tt, p, h) = parse_heading("[#65] Above range");
928 assert_eq!(tt, None);
929 assert_eq!(p, None);
930 assert_eq!(h, "[#65] Above range");
931 }
932
933 #[test]
934 fn parse_heading_rejects_lowercase_priority() {
935 let (tt, p, h) = parse_heading("[#a] Lowercase");
936 assert_eq!(tt, None);
937 assert_eq!(p, None);
938 assert_eq!(h, "[#a] Lowercase");
939 }
940
941 #[test]
942 fn parse_heading_todo_then_priority_with_intervening_text() {
943 let (tt, p, h) = parse_heading("TODO Купить [#A] фильтр");
946 assert_eq!(tt, Some(TaskType::Todo));
947 assert_eq!(p, Some(Priority::A));
948 assert_eq!(h, "фильтр");
949 }
950
951 #[test]
952 fn parse_heading_priority_without_trailing_space() {
953 let (tt, p, h) = parse_heading("[#A]NoSpace");
955 assert_eq!(tt, None);
956 assert_eq!(p, Some(Priority::A));
957 assert_eq!(h, "NoSpace");
958 }
959
960 #[test]
961 fn parse_heading_cancelled_simple() {
962 let (tt, p, h) = parse_heading("CANCELLED Foo");
963 assert_eq!(tt, Some(TaskType::Cancelled(CancelledSpelling::DoubleL)));
964 assert_eq!(p, None);
965 assert_eq!(h, "Foo");
966 }
967
968 #[test]
969 fn parse_heading_cancelled_with_priority() {
970 let (tt, p, h) = parse_heading("CANCELLED [#A] Foo");
971 assert_eq!(tt, Some(TaskType::Cancelled(CancelledSpelling::DoubleL)));
972 assert_eq!(p, Some(Priority::A));
973 assert_eq!(h, "Foo");
974 }
975
976 #[test]
977 fn parse_heading_cancelled_without_whitespace() {
978 let (tt, p, h) = parse_heading("CANCELLEDFoo");
980 assert_eq!(tt, None);
981 assert_eq!(p, None);
982 assert_eq!(h, "CANCELLEDFoo");
983 }
984
985 #[test]
986 fn parse_heading_cancelled_lowercase_not_recognised() {
987 let (tt, p, h) = parse_heading("cancelled Foo");
989 assert_eq!(tt, None);
990 assert_eq!(p, None);
991 assert_eq!(h, "cancelled Foo");
992 }
993
994 #[test]
995 fn parse_heading_todo_cancelled_first_keyword_wins() {
996 let (tt, p, h) = parse_heading("TODO CANCELLED Foo");
998 assert_eq!(tt, Some(TaskType::Todo));
999 assert_eq!(p, None);
1000 assert_eq!(h, "CANCELLED Foo");
1001 }
1002
1003 #[test]
1004 fn parse_heading_canceled_single_l() {
1005 let (tt, p, h) = parse_heading("CANCELED Foo");
1008 assert_eq!(tt, Some(TaskType::Cancelled(CancelledSpelling::SingleL)));
1009 assert_eq!(p, None);
1010 assert_eq!(h, "Foo");
1011 }
1012
1013 #[test]
1014 fn parse_heading_canceled_with_priority() {
1015 let (tt, p, h) = parse_heading("CANCELED [#A] Foo");
1016 assert_eq!(tt, Some(TaskType::Cancelled(CancelledSpelling::SingleL)));
1017 assert_eq!(p, Some(Priority::A));
1018 assert_eq!(h, "Foo");
1019 }
1020
1021 #[test]
1022 fn parse_heading_canceled_lowercase_not_recognised() {
1023 let (tt, p, h) = parse_heading("canceled Foo");
1025 assert_eq!(tt, None);
1026 assert_eq!(p, None);
1027 assert_eq!(h, "canceled Foo");
1028 }
1029
1030 #[test]
1031 fn parse_heading_canceled_without_whitespace_not_recognised() {
1032 let (tt, p, h) = parse_heading("CANCELEDfoo");
1034 assert_eq!(tt, None);
1035 assert_eq!(p, None);
1036 assert_eq!(h, "CANCELEDfoo");
1037 }
1038
1039 #[test]
1040 fn extract_tasks_marks_scheduled_angle_bracket_as_active() {
1041 let content = "### TODO Pin me\n`SCHEDULED: <2026-05-21 Thu>`\n";
1046 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1047 assert_eq!(tasks.len(), 1);
1048 assert_eq!(tasks[0].timestamp_active, Some(true));
1049 }
1050
1051 #[test]
1052 fn extract_tasks_marks_missing_timestamp_active_as_none() {
1053 let content = "### Project kickoff\n\n`CREATED: [2025-09-01 Mon]`\n";
1057 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1058 assert_eq!(tasks.len(), 1);
1059 assert_eq!(tasks[0].timestamp_active, None);
1060 }
1061
1062 #[test]
1063 fn extract_tasks_basic_todo_with_deadline() {
1064 let content = "\
1065### TODO [#A] Write docs\n\
1066`DEADLINE: <2025-12-10 Wed>`\n";
1067 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1068 assert_eq!(tasks.len(), 1);
1069 let t = &tasks[0];
1070 assert_eq!(t.task_type, Some(TaskType::Todo));
1071 assert_eq!(t.priority, Some(Priority::A));
1072 assert_eq!(t.heading, "Write docs");
1073 assert_eq!(t.timestamp_type, Some("DEADLINE".to_string()));
1074 assert_eq!(t.timestamp_date, Some("2025-12-10".to_string()));
1075 }
1076
1077 #[test]
1078 fn extract_tasks_extracts_emph_text_in_heading() {
1079 let content = "### TODO **Important** task\n`DEADLINE: <2025-12-10 Wed>`\n";
1081 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1082 assert_eq!(tasks.len(), 1);
1083 assert_eq!(tasks[0].heading, "Important task");
1084 }
1085
1086 #[test]
1087 fn extract_tasks_ignores_non_task_headings_without_timestamps() {
1088 let content = "### Just a heading\n\nSome text.\n";
1089 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1090 assert!(tasks.is_empty());
1091 }
1092
1093 #[test]
1094 fn extract_tasks_keeps_created_without_todo() {
1095 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].task_type, None);
1100 assert_eq!(tasks[0].created, Some("CREATED: [2025-09-01 Mon]".into()));
1101 }
1102
1103 #[test]
1104 fn extract_tasks_concatenates_multiple_paragraphs() {
1105 let content = "\
1107### TODO Multi-line task\n\
1108First paragraph.\n\
1109\n\
1110Second paragraph.\n\
1111";
1112 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1113 assert_eq!(tasks.len(), 1);
1114 assert!(tasks[0].content.contains("First paragraph"));
1115 assert!(tasks[0].content.contains("Second paragraph"));
1116 }
1117
1118 #[test]
1119 fn extract_tasks_extracts_clock_from_inline_code() {
1120 let content = "\
1121### TODO Track time\n\
1122`CLOCK: [2025-09-01 Mon 10:00]--[2025-09-01 Mon 11:30] => 1:30`\n\
1123";
1124 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1125 assert_eq!(tasks.len(), 1);
1126 let t = &tasks[0];
1127 assert!(t.clocks.is_some());
1128 assert_eq!(t.total_clock_time.as_deref(), Some("1:30"));
1129 }
1130
1131 #[test]
1132 fn extract_tasks_handles_done_priority() {
1133 let content = "### DONE [#B] Wrap up\n";
1134 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1135 assert_eq!(tasks.len(), 1);
1136 assert_eq!(tasks[0].task_type, Some(TaskType::Done));
1137 assert_eq!(tasks[0].priority, Some(Priority::B));
1138 }
1139
1140 #[test]
1141 fn extract_tasks_priority_without_todo_with_scheduled() {
1142 let content = "\
1146### [#A] Поменять резину до 16.05.2026\n\
1147`SCHEDULED: <2026-05-09 Sat>`\n";
1148 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1149 assert_eq!(tasks.len(), 1);
1150 let t = &tasks[0];
1151 assert_eq!(t.task_type, None);
1152 assert_eq!(t.priority, Some(Priority::A));
1153 assert_eq!(t.heading, "Поменять резину до 16.05.2026");
1154 assert_eq!(t.timestamp_type, Some("SCHEDULED".to_string()));
1155 assert_eq!(t.timestamp_date, Some("2026-05-09".to_string()));
1156 }
1157
1158 #[test]
1159 fn extract_tasks_numeric_priority_with_deadline() {
1160 let content = "\
1162### [#1] Numeric priority task\n\
1163`DEADLINE: <2026-05-09 Sat>`\n";
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_eq!(t.task_type, None);
1168 assert_eq!(t.priority, Some(Priority::Numeric(1)));
1169 assert_eq!(t.heading, "Numeric priority task");
1170 }
1171
1172 #[test]
1173 fn extract_tasks_priority_in_middle_drops_prefix() {
1174 let content = "\
1176### Без приоритета и [#A] внутри\n\
1177`SCHEDULED: <2026-05-09 Sat>`\n";
1178 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1179 assert_eq!(tasks.len(), 1);
1180 let t = &tasks[0];
1181 assert_eq!(t.task_type, None);
1182 assert_eq!(t.priority, Some(Priority::A));
1183 assert_eq!(t.heading, "внутри");
1184 }
1185
1186 #[test]
1187 fn extract_tasks_bug_report_minimal_reproduction() {
1188 let content = "\
1192### [#A] Поменять резину до 16.05.2026\n\
1193`SCHEDULED: <2026-05-09 Sat>`\n\
1194\n\
1195### TODO [#A] Поменять масло\n\
1196`SCHEDULED: <2026-05-09 Sat>`\n\
1197\n\
1198### Купить фильтр\n\
1199`SCHEDULED: <2026-05-09 Sat>`\n";
1200 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1201 assert_eq!(tasks.len(), 3);
1202
1203 assert_eq!(tasks[0].task_type, None);
1204 assert_eq!(tasks[0].priority, Some(Priority::A));
1205 assert_eq!(tasks[0].heading, "Поменять резину до 16.05.2026");
1206
1207 assert_eq!(tasks[1].task_type, Some(TaskType::Todo));
1208 assert_eq!(tasks[1].priority, Some(Priority::A));
1209 assert_eq!(tasks[1].heading, "Поменять масло");
1210
1211 assert_eq!(tasks[2].task_type, None);
1212 assert_eq!(tasks[2].priority, None);
1213 assert_eq!(tasks[2].heading, "Купить фильтр");
1214 }
1215
1216 #[test]
1225 fn extract_tasks_indented_inline_code_deadline() {
1226 let content = "#### Birthday\n `DEADLINE: <2026-05-07 Thu +1y>`\n";
1227 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1228 assert_eq!(tasks.len(), 1, "task should not be dropped");
1229 let t = &tasks[0];
1230 assert_eq!(t.timestamp_type.as_deref(), Some("DEADLINE"));
1231 assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1232 }
1233
1234 #[test]
1235 fn extract_tasks_todo_indented_inline_code_deadline() {
1236 let content = "#### TODO Birthday\n `DEADLINE: <2026-05-07 Thu +1y>`\n";
1237 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1238 assert_eq!(tasks.len(), 1);
1239 let t = &tasks[0];
1240 assert_eq!(t.task_type, Some(TaskType::Todo));
1241 assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1242 }
1243
1244 #[test]
1245 fn extract_tasks_indented_inline_code_blank_lines_between() {
1246 let content = "#### Birthday\n\n \t \n `DEADLINE: <2026-05-07 Thu +1y>`\n";
1249 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1250 assert_eq!(tasks.len(), 1);
1251 let t = &tasks[0];
1252 assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1253 }
1254
1255 #[test]
1256 fn extract_tasks_with_ru_mappings_reproduces_cli_pipeline() {
1257 let content = "#### TODO Birthday\n `DEADLINE: <2026-05-07 Thu +1y>`\n";
1262 let tasks = extract_tasks(
1263 Path::new("t.md"),
1264 content,
1265 crate::locale::RU_WEEKDAY_MAPPINGS,
1266 DEFAULT_MAX_TASKS,
1267 );
1268 assert_eq!(tasks.len(), 1);
1269 let t = &tasks[0];
1270 assert_eq!(t.task_type, Some(TaskType::Todo));
1271 assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1272 }
1273
1274 #[test]
1275 fn extract_tasks_inline_code_scheduled_no_indent() {
1276 let content = "#### Followup\n`SCHEDULED: <2026-05-07 Thu>`\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.timestamp_type.as_deref(), Some("SCHEDULED"));
1281 assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1282 }
1283
1284 #[test]
1285 fn extract_tasks_indented_inline_code_created() {
1286 let content = "#### Project kickoff\n `CREATED: [2025-09-01 Mon]`\n";
1287 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1288 assert_eq!(tasks.len(), 1);
1289 let t = &tasks[0];
1290 assert_eq!(t.created.as_deref(), Some("CREATED: [2025-09-01 Mon]"));
1291 }
1292
1293 #[test]
1294 fn extract_tasks_parses_single_property() {
1295 let content = "### TODO Ship release\n`SCHEDULED: <2026-06-01 Mon 10:00>`\n```org-properties\nGCAL_EVENT_ID: abc123/primary\n```\n";
1296 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1297 assert_eq!(tasks.len(), 1);
1298 let props = tasks[0].properties.as_ref().expect("properties present");
1299 assert_eq!(
1300 props.get("GCAL_EVENT_ID").map(String::as_str),
1301 Some("abc123/primary")
1302 );
1303 assert!(!tasks[0].content.contains("GCAL_EVENT_ID"));
1305 assert!(!tasks[0].content.contains("org-properties"));
1306 }
1307
1308 #[test]
1309 fn extract_tasks_parses_multiple_properties() {
1310 let content =
1311 "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nA: 1\nB: 2\n```\n";
1312 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1313 let props = tasks[0].properties.as_ref().unwrap();
1314 assert_eq!(props.get("A").map(String::as_str), Some("1"));
1315 assert_eq!(props.get("B").map(String::as_str), Some("2"));
1316 }
1317
1318 #[test]
1319 fn extract_tasks_property_duplicate_keys_last_wins() {
1320 let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nK: first\nK: second\n```\n";
1321 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1322 assert_eq!(
1323 tasks[0]
1324 .properties
1325 .as_ref()
1326 .unwrap()
1327 .get("K")
1328 .map(String::as_str),
1329 Some("second")
1330 );
1331 }
1332
1333 #[test]
1334 fn extract_tasks_property_empty_value_allowed() {
1335 let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nK:\n```\n";
1336 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1337 assert_eq!(
1338 tasks[0]
1339 .properties
1340 .as_ref()
1341 .unwrap()
1342 .get("K")
1343 .map(String::as_str),
1344 Some("")
1345 );
1346 }
1347
1348 #[test]
1349 fn extract_tasks_property_malformed_line_skipped() {
1350 let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nGOOD: x\nno colon here\n```\n";
1351 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1352 let props = tasks[0].properties.as_ref().unwrap();
1353 assert_eq!(props.get("GOOD").map(String::as_str), Some("x"));
1354 assert_eq!(props.len(), 1, "malformed line must be skipped");
1355 }
1356
1357 #[test]
1358 fn extract_tasks_empty_property_block_yields_none() {
1359 let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\n\n```\n";
1360 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1361 assert_eq!(tasks[0].properties, None);
1362 }
1363
1364 #[test]
1365 fn extract_tasks_property_info_with_extra_attrs_not_recognised() {
1366 let content =
1369 "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties extra\nK: v\n```\n";
1370 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1371 assert_eq!(tasks[0].properties, None);
1372 }
1373
1374 #[test]
1375 fn extract_tasks_clock_code_block_unaffected_by_properties() {
1376 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";
1379 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1380 assert_eq!(
1381 tasks[0]
1382 .properties
1383 .as_ref()
1384 .unwrap()
1385 .get("K")
1386 .map(String::as_str),
1387 Some("v")
1388 );
1389 assert_eq!(tasks[0].total_clock_time.as_deref(), Some("1:30"));
1390 }
1391
1392 #[test]
1393 fn extract_tasks_merges_multiple_property_blocks_last_wins() {
1394 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";
1395 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1396 let props = tasks[0].properties.as_ref().unwrap();
1397 assert_eq!(props.get("K").map(String::as_str), Some("two"));
1398 assert_eq!(props.get("L").map(String::as_str), Some("three"));
1399 }
1400}