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 line,
375 heading: info.heading,
376 content: info.content,
377 task_type: info.task_type,
378 priority: info.priority,
379 created: info.created,
380 timestamp: info.timestamp,
381 timestamp_type: ts_type,
382 timestamp_active: ts_active,
383 timestamp_date: ts_date,
384 timestamp_time: ts_time,
385 timestamp_end_time: ts_end_time,
386 timestamp_repeater: ts_repeater,
387 timestamp_next: None,
390 clocks: clocks_opt,
391 total_clock_time: total_time,
392 properties,
393 })
394}
395
396fn parse_heading(text: &str) -> (Option<TaskType>, Option<Priority>, String) {
412 let (task_type, rest) = if let Some(caps) = HEADING_TODO_RE.captures(text) {
414 let kw = caps.get(1).map(|m| m.as_str()).unwrap_or("");
415 let m = caps
416 .get(0)
417 .expect("Captures::get(0) is Some when captures() succeeds");
418 (TaskType::from_keyword(kw), &text[m.end()..])
419 } else {
420 (None, text)
421 };
422
423 if let Some(caps) = HEADING_PRIORITY_RE.captures(rest) {
425 let value = caps.get(1).map(|m| m.as_str()).unwrap_or("");
426 if let Some(priority) = Priority::parse(value) {
427 let whole = caps
428 .get(0)
429 .expect("Captures::get(0) is Some when captures() succeeds");
430 let after = &rest[whole.end()..];
431 return (task_type, Some(priority), after.trim().to_string());
432 }
433 }
434
435 (task_type, None, rest.trim().to_string())
436}
437
438static HEADING_HASHES_RE: LazyLock<Regex> = LazyLock::new(|| compile_bounded(r"^(#{1,6})[ \t]+"));
446
447#[derive(Debug, Clone, PartialEq)]
453pub struct HeadingToken<T> {
454 pub range: Range<usize>,
456 pub value: T,
458}
459
460#[derive(Debug, Clone, PartialEq)]
475pub struct HeadingLine {
476 pub level: usize,
478 pub status: Option<HeadingToken<TaskType>>,
480 pub priority: Option<HeadingToken<Priority>>,
482 pub title_start: usize,
486}
487
488pub fn parse_heading_line(line: &str) -> Option<HeadingLine> {
503 let hashes = HEADING_HASHES_RE.captures(line)?;
504 let level = hashes
505 .get(1)
506 .expect("group 1 is Some when captures() succeeds")
507 .len();
508 let after_hashes = hashes
509 .get(0)
510 .expect("Captures::get(0) is Some when captures() succeeds")
511 .end();
512
513 let (status, after_status) = match HEADING_TODO_RE.captures(&line[after_hashes..]) {
514 Some(caps) => {
515 let keyword = caps
516 .get(1)
517 .expect("group 1 is Some when captures() succeeds");
518 let whole = caps
519 .get(0)
520 .expect("Captures::get(0) is Some when captures() succeeds");
521 let token = TaskType::from_keyword(keyword.as_str()).map(|value| HeadingToken {
522 range: after_hashes + keyword.start()..after_hashes + keyword.end(),
523 value,
524 });
525 (token, after_hashes + whole.end())
526 }
527 None => (None, after_hashes),
528 };
529
530 let priority = HEADING_PRIORITY_RE
533 .captures(&line[after_status..])
534 .and_then(|caps| {
535 let value = caps
536 .get(1)
537 .expect("group 1 is Some when captures() succeeds");
538 let parsed = Priority::parse(value.as_str())?;
541 Some(HeadingToken {
542 range: after_status + value.start() - "[#".len()
543 ..after_status + value.end() + "]".len(),
544 value: parsed,
545 })
546 });
547
548 let after_tokens = priority
549 .as_ref()
550 .map_or(after_status, |cookie| cookie.range.end);
551 let title_start = after_tokens
552 + line[after_tokens..]
553 .find(|c: char| !c.is_whitespace())
554 .unwrap_or(line.len() - after_tokens);
555
556 Some(HeadingLine {
557 level,
558 status,
559 priority,
560 title_start,
561 })
562}
563
564fn strip_wrapping_backticks(s: &str) -> &str {
576 let bytes = s.as_bytes();
577 let n_leading = bytes.iter().take_while(|&&b| b == b'`').count();
578 if n_leading == 0 {
579 return s;
580 }
581 let n_trailing = bytes.iter().rev().take_while(|&&b| b == b'`').count();
582 if n_trailing != n_leading || bytes.len() < 2 * n_leading + 1 {
586 return s;
587 }
588 s[n_leading..bytes.len() - n_leading].trim()
589}
590
591fn parse_org_properties(
602 literal: &str,
603 props: &mut BTreeMap<String, String>,
604 path: &Path,
605 block_start_line: u32,
606 prop_warning_counter: &mut usize,
607) {
608 for (offset, line) in literal.lines().enumerate() {
609 if line.trim().is_empty() {
610 continue;
611 }
612 let src_line = block_start_line
614 .saturating_add(1)
615 .saturating_add(offset as u32);
616 match line.split_once(':') {
617 Some((key, value)) => {
618 let key = key.trim();
619 if key.is_empty() {
620 warn_invalid_property_line(prop_warning_counter, path, src_line, line);
621 continue;
622 }
623 props.insert(key.to_string(), value.trim().to_string());
624 }
625 None => {
626 warn_invalid_property_line(prop_warning_counter, path, src_line, line);
627 }
628 }
629 }
630}
631
632fn extract_timestamps_from_node<'a>(
634 node: &'a AstNode<'a>,
635 mappings: &[(&str, &str)],
636) -> (Option<String>, Option<String>) {
637 let mut created = None;
638 let mut timestamp = None;
639
640 if let NodeValue::Paragraph = &node.data.borrow().value {
641 for child in node.children() {
642 if let NodeValue::Code(code) = &child.data.borrow().value {
643 let normalized = normalize_weekdays(&code.literal, mappings);
646 if created.is_none() {
647 created = extract_created_normalized(&normalized);
648 }
649 if timestamp.is_none() {
650 timestamp = extract_timestamp_normalized(&normalized);
651 }
652 }
653 }
654 }
655 (created, timestamp)
656}
657
658fn extract_paragraph_text<'a>(node: &'a AstNode<'a>) -> String {
665 let mut text = String::new();
666 collect_text_recursive(node, &mut text, InlineCode::Drop);
667 text.trim().to_string()
668}
669
670fn extract_text<'a>(node: &'a AstNode<'a>) -> String {
673 let mut text = String::new();
674 collect_text_recursive(node, &mut text, InlineCode::Keep);
675 text
676}
677
678#[derive(Clone, Copy, PartialEq, Eq)]
680enum InlineCode {
681 Keep,
684 Drop,
686}
687
688fn collect_text_recursive<'a>(node: &'a AstNode<'a>, out: &mut String, code: InlineCode) {
689 for child in node.children() {
690 let value = child.data.borrow().value.clone();
691 match value {
692 NodeValue::Text(t) => out.push_str(&t),
693 NodeValue::Code(inline) if code == InlineCode::Keep => out.push_str(&inline.literal),
694 NodeValue::Emph | NodeValue::Strong | NodeValue::Link(_) | NodeValue::Strikethrough => {
695 collect_text_recursive(child, out, code)
696 }
697 _ => {}
698 }
699 }
700}
701
702pub fn display_text(markdown: &str) -> String {
719 let arena = Arena::new();
720 let root = parse_document(&arena, markdown, &safe_comrak_options());
721
722 let mut text = String::new();
723 collect_block_text(root, &mut text);
724 text.trim().to_string()
725}
726
727fn collect_block_text<'a>(node: &'a AstNode<'a>, out: &mut String) {
733 for child in node.children() {
734 let value = child.data.borrow().value.clone();
735 match value {
736 NodeValue::Paragraph | NodeValue::Heading(_) => {
737 collect_text_recursive(child, out, InlineCode::Keep)
738 }
739 _ => collect_block_text(child, out),
740 }
741 }
742}
743
744#[cfg(test)]
748mod tests {
749 use super::*;
750 use crate::types::{CancelledSpelling, DEFAULT_MAX_TASKS};
751
752 #[test]
753 fn warn_invalid_timestamp_advances_per_call_counter() {
754 let mut counter = 0_usize;
760 let path = Path::new("t.md");
761 for i in 1..=25 {
762 warn_invalid_timestamp(&mut counter, path, i, "<bad>");
763 }
764 assert_eq!(counter, 25);
765 }
766
767 #[test]
768 fn warn_invalid_property_line_advances_per_call_counter() {
769 let mut counter = 0_usize;
773 let path = Path::new("t.md");
774 for i in 1..=25 {
775 warn_invalid_property_line(&mut counter, path, i, "no-colon-here");
776 }
777 assert_eq!(counter, 25);
778 }
779
780 #[test]
781 fn warn_invalid_timestamp_counters_are_independent() {
782 let mut counter_a = 0_usize;
788 let mut counter_b = 0_usize;
789 let path = Path::new("t.md");
790 for _ in 0..MAX_DIAGNOSTIC_ITEMS {
791 warn_invalid_timestamp(&mut counter_a, path, 1, "<bad>");
792 }
793 warn_invalid_timestamp(&mut counter_b, path, 1, "<bad>");
794 assert_eq!(counter_a, MAX_DIAGNOSTIC_ITEMS);
795 assert_eq!(counter_b, 1);
796 }
797
798 #[test]
799 fn test_parse_heading_with_priority() {
800 let (task_type, priority, heading) = parse_heading("TODO [#A] Important task");
801 assert_eq!(task_type, Some(TaskType::Todo));
802 assert_eq!(priority, Some(Priority::A));
803 assert_eq!(heading, "Important task");
804 }
805
806 #[test]
807 fn test_parse_heading_without_priority() {
808 let (task_type, priority, heading) = parse_heading("DONE Simple task");
809 assert_eq!(task_type, Some(TaskType::Done));
810 assert_eq!(priority, None);
811 assert_eq!(heading, "Simple task");
812 }
813
814 #[test]
815 fn test_parse_heading_no_task() {
816 let (task_type, priority, heading) = parse_heading("Regular heading");
817 assert_eq!(task_type, None);
818 assert_eq!(priority, None);
819 assert_eq!(heading, "Regular heading");
820 }
821
822 #[test]
828 fn parse_heading_priority_without_todo() {
829 let (tt, p, h) = parse_heading("[#A] Заголовок");
831 assert_eq!(tt, None);
832 assert_eq!(p, Some(Priority::A));
833 assert_eq!(h, "Заголовок");
834 }
835
836 #[test]
837 fn parse_heading_todo_with_priority() {
838 let (tt, p, h) = parse_heading("TODO [#A] Заголовок");
840 assert_eq!(tt, Some(TaskType::Todo));
841 assert_eq!(p, Some(Priority::A));
842 assert_eq!(h, "Заголовок");
843 }
844
845 #[test]
846 fn parse_heading_done_with_priority_b() {
847 let (tt, p, h) = parse_heading("DONE [#B] Заголовок");
849 assert_eq!(tt, Some(TaskType::Done));
850 assert_eq!(p, Some(Priority::B));
851 assert_eq!(h, "Заголовок");
852 }
853
854 #[test]
855 fn parse_heading_plain_text_no_markers() {
856 let (tt, p, h) = parse_heading("Заголовок");
858 assert_eq!(tt, None);
859 assert_eq!(p, None);
860 assert_eq!(h, "Заголовок");
861 }
862
863 #[test]
864 fn parse_heading_todo_no_priority() {
865 let (tt, p, h) = parse_heading("TODO Заголовок");
867 assert_eq!(tt, Some(TaskType::Todo));
868 assert_eq!(p, None);
869 assert_eq!(h, "Заголовок");
870 }
871
872 #[test]
873 fn parse_heading_numeric_priority() {
874 let (tt, p, h) = parse_heading("[#1] Заголовок");
876 assert_eq!(tt, None);
877 assert_eq!(p, Some(Priority::Numeric(1)));
878 assert_eq!(h, "Заголовок");
879 }
880
881 #[test]
882 fn parse_heading_extra_whitespace_around_priority() {
883 let (tt, p, h) = parse_heading("[#A] Заголовок");
888 assert_eq!(tt, None);
889 assert_eq!(p, Some(Priority::A));
890 assert_eq!(h, "Заголовок");
891 }
892
893 #[test]
894 fn parse_heading_priority_in_the_middle_org_semantics() {
895 let (tt, p, h) = parse_heading("Без приоритета и [#A] внутри");
902 assert_eq!(tt, None);
903 assert_eq!(p, Some(Priority::A));
904 assert_eq!(h, "внутри");
905 }
906
907 #[test]
908 fn parse_heading_two_digit_numeric_priority() {
909 let (tt, p, h) = parse_heading("[#15] Mid range");
910 assert_eq!(tt, None);
911 assert_eq!(p, Some(Priority::Numeric(15)));
912 assert_eq!(h, "Mid range");
913
914 let (tt, p, h) = parse_heading("[#64] At upper bound");
915 assert_eq!(tt, None);
916 assert_eq!(p, Some(Priority::Numeric(64)));
917 assert_eq!(h, "At upper bound");
918 }
919
920 #[test]
921 fn parse_heading_rejects_numeric_out_of_range() {
922 let (tt, p, h) = parse_heading("[#65] Above range");
925 assert_eq!(tt, None);
926 assert_eq!(p, None);
927 assert_eq!(h, "[#65] Above range");
928 }
929
930 #[test]
931 fn parse_heading_rejects_lowercase_priority() {
932 let (tt, p, h) = parse_heading("[#a] Lowercase");
933 assert_eq!(tt, None);
934 assert_eq!(p, None);
935 assert_eq!(h, "[#a] Lowercase");
936 }
937
938 #[test]
939 fn parse_heading_todo_then_priority_with_intervening_text() {
940 let (tt, p, h) = parse_heading("TODO Купить [#A] фильтр");
943 assert_eq!(tt, Some(TaskType::Todo));
944 assert_eq!(p, Some(Priority::A));
945 assert_eq!(h, "фильтр");
946 }
947
948 #[test]
949 fn parse_heading_priority_without_trailing_space() {
950 let (tt, p, h) = parse_heading("[#A]NoSpace");
952 assert_eq!(tt, None);
953 assert_eq!(p, Some(Priority::A));
954 assert_eq!(h, "NoSpace");
955 }
956
957 #[test]
958 fn parse_heading_cancelled_simple() {
959 let (tt, p, h) = parse_heading("CANCELLED Foo");
960 assert_eq!(tt, Some(TaskType::Cancelled(CancelledSpelling::DoubleL)));
961 assert_eq!(p, None);
962 assert_eq!(h, "Foo");
963 }
964
965 #[test]
966 fn parse_heading_cancelled_with_priority() {
967 let (tt, p, h) = parse_heading("CANCELLED [#A] Foo");
968 assert_eq!(tt, Some(TaskType::Cancelled(CancelledSpelling::DoubleL)));
969 assert_eq!(p, Some(Priority::A));
970 assert_eq!(h, "Foo");
971 }
972
973 #[test]
974 fn parse_heading_cancelled_without_whitespace() {
975 let (tt, p, h) = parse_heading("CANCELLEDFoo");
977 assert_eq!(tt, None);
978 assert_eq!(p, None);
979 assert_eq!(h, "CANCELLEDFoo");
980 }
981
982 #[test]
983 fn parse_heading_cancelled_lowercase_not_recognised() {
984 let (tt, p, h) = parse_heading("cancelled Foo");
986 assert_eq!(tt, None);
987 assert_eq!(p, None);
988 assert_eq!(h, "cancelled Foo");
989 }
990
991 #[test]
992 fn parse_heading_todo_cancelled_first_keyword_wins() {
993 let (tt, p, h) = parse_heading("TODO CANCELLED Foo");
995 assert_eq!(tt, Some(TaskType::Todo));
996 assert_eq!(p, None);
997 assert_eq!(h, "CANCELLED Foo");
998 }
999
1000 #[test]
1001 fn parse_heading_canceled_single_l() {
1002 let (tt, p, h) = parse_heading("CANCELED Foo");
1005 assert_eq!(tt, Some(TaskType::Cancelled(CancelledSpelling::SingleL)));
1006 assert_eq!(p, None);
1007 assert_eq!(h, "Foo");
1008 }
1009
1010 #[test]
1011 fn parse_heading_canceled_with_priority() {
1012 let (tt, p, h) = parse_heading("CANCELED [#A] Foo");
1013 assert_eq!(tt, Some(TaskType::Cancelled(CancelledSpelling::SingleL)));
1014 assert_eq!(p, Some(Priority::A));
1015 assert_eq!(h, "Foo");
1016 }
1017
1018 #[test]
1019 fn parse_heading_canceled_lowercase_not_recognised() {
1020 let (tt, p, h) = parse_heading("canceled Foo");
1022 assert_eq!(tt, None);
1023 assert_eq!(p, None);
1024 assert_eq!(h, "canceled Foo");
1025 }
1026
1027 #[test]
1028 fn parse_heading_canceled_without_whitespace_not_recognised() {
1029 let (tt, p, h) = parse_heading("CANCELEDfoo");
1031 assert_eq!(tt, None);
1032 assert_eq!(p, None);
1033 assert_eq!(h, "CANCELEDfoo");
1034 }
1035
1036 #[test]
1037 fn extract_tasks_marks_scheduled_angle_bracket_as_active() {
1038 let content = "### TODO Pin me\n`SCHEDULED: <2026-05-21 Thu>`\n";
1043 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1044 assert_eq!(tasks.len(), 1);
1045 assert_eq!(tasks[0].timestamp_active, Some(true));
1046 }
1047
1048 #[test]
1049 fn extract_tasks_marks_missing_timestamp_active_as_none() {
1050 let content = "### Project kickoff\n\n`CREATED: [2025-09-01 Mon]`\n";
1054 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1055 assert_eq!(tasks.len(), 1);
1056 assert_eq!(tasks[0].timestamp_active, None);
1057 }
1058
1059 #[test]
1060 fn extract_tasks_basic_todo_with_deadline() {
1061 let content = "\
1062### TODO [#A] Write docs\n\
1063`DEADLINE: <2025-12-10 Wed>`\n";
1064 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1065 assert_eq!(tasks.len(), 1);
1066 let t = &tasks[0];
1067 assert_eq!(t.task_type, Some(TaskType::Todo));
1068 assert_eq!(t.priority, Some(Priority::A));
1069 assert_eq!(t.heading, "Write docs");
1070 assert_eq!(t.timestamp_type, Some("DEADLINE".to_string()));
1071 assert_eq!(t.timestamp_date, Some("2025-12-10".to_string()));
1072 }
1073
1074 #[test]
1075 fn extract_tasks_extracts_emph_text_in_heading() {
1076 let content = "### TODO **Important** task\n`DEADLINE: <2025-12-10 Wed>`\n";
1078 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1079 assert_eq!(tasks.len(), 1);
1080 assert_eq!(tasks[0].heading, "Important task");
1081 }
1082
1083 #[test]
1084 fn extract_tasks_ignores_non_task_headings_without_timestamps() {
1085 let content = "### Just a heading\n\nSome text.\n";
1086 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1087 assert!(tasks.is_empty());
1088 }
1089
1090 #[test]
1091 fn extract_tasks_keeps_created_without_todo() {
1092 let content = "### Project kickoff\n\n`CREATED: [2025-09-01 Mon]`\n";
1094 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1095 assert_eq!(tasks.len(), 1);
1096 assert_eq!(tasks[0].task_type, None);
1097 assert_eq!(tasks[0].created, Some("CREATED: [2025-09-01 Mon]".into()));
1098 }
1099
1100 #[test]
1101 fn extract_tasks_concatenates_multiple_paragraphs() {
1102 let content = "\
1104### TODO Multi-line task\n\
1105First paragraph.\n\
1106\n\
1107Second paragraph.\n\
1108";
1109 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1110 assert_eq!(tasks.len(), 1);
1111 assert!(tasks[0].content.contains("First paragraph"));
1112 assert!(tasks[0].content.contains("Second paragraph"));
1113 }
1114
1115 #[test]
1116 fn extract_tasks_extracts_clock_from_inline_code() {
1117 let content = "\
1118### TODO Track time\n\
1119`CLOCK: [2025-09-01 Mon 10:00]--[2025-09-01 Mon 11:30] => 1:30`\n\
1120";
1121 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1122 assert_eq!(tasks.len(), 1);
1123 let t = &tasks[0];
1124 assert!(t.clocks.is_some());
1125 assert_eq!(t.total_clock_time.as_deref(), Some("1:30"));
1126 }
1127
1128 #[test]
1129 fn extract_tasks_handles_done_priority() {
1130 let content = "### DONE [#B] Wrap up\n";
1131 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1132 assert_eq!(tasks.len(), 1);
1133 assert_eq!(tasks[0].task_type, Some(TaskType::Done));
1134 assert_eq!(tasks[0].priority, Some(Priority::B));
1135 }
1136
1137 #[test]
1138 fn extract_tasks_priority_without_todo_with_scheduled() {
1139 let content = "\
1143### [#A] Поменять резину до 16.05.2026\n\
1144`SCHEDULED: <2026-05-09 Sat>`\n";
1145 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1146 assert_eq!(tasks.len(), 1);
1147 let t = &tasks[0];
1148 assert_eq!(t.task_type, None);
1149 assert_eq!(t.priority, Some(Priority::A));
1150 assert_eq!(t.heading, "Поменять резину до 16.05.2026");
1151 assert_eq!(t.timestamp_type, Some("SCHEDULED".to_string()));
1152 assert_eq!(t.timestamp_date, Some("2026-05-09".to_string()));
1153 }
1154
1155 #[test]
1156 fn extract_tasks_numeric_priority_with_deadline() {
1157 let content = "\
1159### [#1] Numeric priority task\n\
1160`DEADLINE: <2026-05-09 Sat>`\n";
1161 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1162 assert_eq!(tasks.len(), 1);
1163 let t = &tasks[0];
1164 assert_eq!(t.task_type, None);
1165 assert_eq!(t.priority, Some(Priority::Numeric(1)));
1166 assert_eq!(t.heading, "Numeric priority task");
1167 }
1168
1169 #[test]
1170 fn extract_tasks_priority_in_middle_drops_prefix() {
1171 let content = "\
1173### Без приоритета и [#A] внутри\n\
1174`SCHEDULED: <2026-05-09 Sat>`\n";
1175 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1176 assert_eq!(tasks.len(), 1);
1177 let t = &tasks[0];
1178 assert_eq!(t.task_type, None);
1179 assert_eq!(t.priority, Some(Priority::A));
1180 assert_eq!(t.heading, "внутри");
1181 }
1182
1183 #[test]
1184 fn extract_tasks_bug_report_minimal_reproduction() {
1185 let content = "\
1189### [#A] Поменять резину до 16.05.2026\n\
1190`SCHEDULED: <2026-05-09 Sat>`\n\
1191\n\
1192### TODO [#A] Поменять масло\n\
1193`SCHEDULED: <2026-05-09 Sat>`\n\
1194\n\
1195### Купить фильтр\n\
1196`SCHEDULED: <2026-05-09 Sat>`\n";
1197 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1198 assert_eq!(tasks.len(), 3);
1199
1200 assert_eq!(tasks[0].task_type, None);
1201 assert_eq!(tasks[0].priority, Some(Priority::A));
1202 assert_eq!(tasks[0].heading, "Поменять резину до 16.05.2026");
1203
1204 assert_eq!(tasks[1].task_type, Some(TaskType::Todo));
1205 assert_eq!(tasks[1].priority, Some(Priority::A));
1206 assert_eq!(tasks[1].heading, "Поменять масло");
1207
1208 assert_eq!(tasks[2].task_type, None);
1209 assert_eq!(tasks[2].priority, None);
1210 assert_eq!(tasks[2].heading, "Купить фильтр");
1211 }
1212
1213 #[test]
1222 fn extract_tasks_indented_inline_code_deadline() {
1223 let content = "#### Birthday\n `DEADLINE: <2026-05-07 Thu +1y>`\n";
1224 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1225 assert_eq!(tasks.len(), 1, "task should not be dropped");
1226 let t = &tasks[0];
1227 assert_eq!(t.timestamp_type.as_deref(), Some("DEADLINE"));
1228 assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1229 }
1230
1231 #[test]
1232 fn extract_tasks_todo_indented_inline_code_deadline() {
1233 let content = "#### TODO Birthday\n `DEADLINE: <2026-05-07 Thu +1y>`\n";
1234 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1235 assert_eq!(tasks.len(), 1);
1236 let t = &tasks[0];
1237 assert_eq!(t.task_type, Some(TaskType::Todo));
1238 assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1239 }
1240
1241 #[test]
1242 fn extract_tasks_indented_inline_code_blank_lines_between() {
1243 let content = "#### Birthday\n\n \t \n `DEADLINE: <2026-05-07 Thu +1y>`\n";
1246 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1247 assert_eq!(tasks.len(), 1);
1248 let t = &tasks[0];
1249 assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1250 }
1251
1252 #[test]
1253 fn extract_tasks_with_ru_mappings_reproduces_cli_pipeline() {
1254 let content = "#### TODO Birthday\n `DEADLINE: <2026-05-07 Thu +1y>`\n";
1259 let tasks = extract_tasks(
1260 Path::new("t.md"),
1261 content,
1262 crate::locale::RU_WEEKDAY_MAPPINGS,
1263 DEFAULT_MAX_TASKS,
1264 );
1265 assert_eq!(tasks.len(), 1);
1266 let t = &tasks[0];
1267 assert_eq!(t.task_type, Some(TaskType::Todo));
1268 assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1269 }
1270
1271 #[test]
1272 fn extract_tasks_inline_code_scheduled_no_indent() {
1273 let content = "#### Followup\n`SCHEDULED: <2026-05-07 Thu>`\n";
1274 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1275 assert_eq!(tasks.len(), 1);
1276 let t = &tasks[0];
1277 assert_eq!(t.timestamp_type.as_deref(), Some("SCHEDULED"));
1278 assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1279 }
1280
1281 #[test]
1282 fn extract_tasks_indented_inline_code_created() {
1283 let content = "#### Project kickoff\n `CREATED: [2025-09-01 Mon]`\n";
1284 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1285 assert_eq!(tasks.len(), 1);
1286 let t = &tasks[0];
1287 assert_eq!(t.created.as_deref(), Some("CREATED: [2025-09-01 Mon]"));
1288 }
1289
1290 #[test]
1291 fn extract_tasks_parses_single_property() {
1292 let content = "### TODO Ship release\n`SCHEDULED: <2026-06-01 Mon 10:00>`\n```org-properties\nGCAL_EVENT_ID: abc123/primary\n```\n";
1293 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1294 assert_eq!(tasks.len(), 1);
1295 let props = tasks[0].properties.as_ref().expect("properties present");
1296 assert_eq!(
1297 props.get("GCAL_EVENT_ID").map(String::as_str),
1298 Some("abc123/primary")
1299 );
1300 assert!(!tasks[0].content.contains("GCAL_EVENT_ID"));
1302 assert!(!tasks[0].content.contains("org-properties"));
1303 }
1304
1305 #[test]
1306 fn extract_tasks_parses_multiple_properties() {
1307 let content =
1308 "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nA: 1\nB: 2\n```\n";
1309 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1310 let props = tasks[0].properties.as_ref().unwrap();
1311 assert_eq!(props.get("A").map(String::as_str), Some("1"));
1312 assert_eq!(props.get("B").map(String::as_str), Some("2"));
1313 }
1314
1315 #[test]
1316 fn extract_tasks_property_duplicate_keys_last_wins() {
1317 let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nK: first\nK: second\n```\n";
1318 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1319 assert_eq!(
1320 tasks[0]
1321 .properties
1322 .as_ref()
1323 .unwrap()
1324 .get("K")
1325 .map(String::as_str),
1326 Some("second")
1327 );
1328 }
1329
1330 #[test]
1331 fn extract_tasks_property_empty_value_allowed() {
1332 let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nK:\n```\n";
1333 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1334 assert_eq!(
1335 tasks[0]
1336 .properties
1337 .as_ref()
1338 .unwrap()
1339 .get("K")
1340 .map(String::as_str),
1341 Some("")
1342 );
1343 }
1344
1345 #[test]
1346 fn extract_tasks_property_malformed_line_skipped() {
1347 let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nGOOD: x\nno colon here\n```\n";
1348 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1349 let props = tasks[0].properties.as_ref().unwrap();
1350 assert_eq!(props.get("GOOD").map(String::as_str), Some("x"));
1351 assert_eq!(props.len(), 1, "malformed line must be skipped");
1352 }
1353
1354 #[test]
1355 fn extract_tasks_empty_property_block_yields_none() {
1356 let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\n\n```\n";
1357 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1358 assert_eq!(tasks[0].properties, None);
1359 }
1360
1361 #[test]
1362 fn extract_tasks_property_info_with_extra_attrs_not_recognised() {
1363 let content =
1366 "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties extra\nK: v\n```\n";
1367 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1368 assert_eq!(tasks[0].properties, None);
1369 }
1370
1371 #[test]
1372 fn extract_tasks_clock_code_block_unaffected_by_properties() {
1373 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";
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("v")
1385 );
1386 assert_eq!(tasks[0].total_clock_time.as_deref(), Some("1:30"));
1387 }
1388
1389 #[test]
1390 fn extract_tasks_merges_multiple_property_blocks_last_wins() {
1391 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";
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("K").map(String::as_str), Some("two"));
1395 assert_eq!(props.get("L").map(String::as_str), Some("three"));
1396 }
1397}