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 let (excluded_dates, recurrence_id, series_id) =
380 exception_fields(path, line, properties.as_ref(), ts_warning_counter);
381
382 Some(Task {
383 file: path.display().to_string(),
384 root: None,
387 line,
388 heading: info.heading,
389 content: info.content,
390 task_type: info.task_type,
391 priority: info.priority,
392 created: info.created,
393 timestamp: info.timestamp,
394 timestamp_type: ts_type,
395 timestamp_active: ts_active,
396 timestamp_date: ts_date,
397 timestamp_time: ts_time,
398 timestamp_end_time: ts_end_time,
399 timestamp_repeater: ts_repeater,
400 timestamp_next: None,
403 timestamp_next_after: None,
404 clocks: clocks_opt,
405 total_clock_time: total_time,
406 properties,
407 excluded_dates,
408 recurrence_id,
409 series_id,
410 })
411}
412
413fn exception_fields(
420 path: &Path,
421 line: u32,
422 properties: Option<&BTreeMap<String, String>>,
423 ts_warning_counter: &mut usize,
424) -> (Option<Vec<String>>, Option<String>, Option<String>) {
425 use crate::exceptions::{
426 parse_excluded_dates, parse_recurrence_id, EXDATE_KEY, RECURRENCE_ID_KEY, SERIES_ID_KEY,
427 };
428
429 let Some(props) = properties else {
430 return (None, None, None);
431 };
432
433 let excluded = props.get(EXDATE_KEY).map(|raw| {
434 parse_excluded_dates(raw, |field| {
435 warn_invalid_timestamp(ts_warning_counter, path, line, field);
436 })
437 });
438 let excluded = excluded.filter(|dates| !dates.is_empty());
439
440 let recurrence = props.get(RECURRENCE_ID_KEY).and_then(|raw| {
441 let parsed = parse_recurrence_id(raw);
442 if parsed.is_none() {
443 warn_invalid_timestamp(ts_warning_counter, path, line, raw);
444 }
445 parsed
446 });
447
448 let series = props
449 .get(SERIES_ID_KEY)
450 .map(|raw| raw.trim().to_string())
451 .filter(|id| !id.is_empty());
452
453 (excluded, recurrence, series)
454}
455
456fn parse_heading(text: &str) -> (Option<TaskType>, Option<Priority>, String) {
483 let (task_type, rest) = if let Some(caps) = HEADING_TODO_RE.captures(text) {
485 let kw = caps.get(1).map(|m| m.as_str()).unwrap_or("");
486 let m = caps
487 .get(0)
488 .expect("Captures::get(0) is Some when captures() succeeds");
489 (TaskType::from_keyword(kw), &text[m.end()..])
490 } else {
491 (None, text)
492 };
493 let title = rest.trim();
494
495 if let Some(caps) = HEADING_PRIORITY_RE.captures(title) {
497 let value = caps.get(1).map(|m| m.as_str()).unwrap_or("");
498 if let Some(priority) = Priority::parse(value) {
499 let whole = caps
500 .get(0)
501 .expect("Captures::get(0) is Some when captures() succeeds");
502 let heading = if whole.start() == 0 {
504 title[whole.end()..].trim()
505 } else {
506 title
507 };
508 return (task_type, Some(priority), heading.to_string());
509 }
510 }
511
512 (task_type, None, title.to_string())
513}
514
515static HEADING_HASHES_RE: LazyLock<Regex> = LazyLock::new(|| compile_bounded(r"^(#{1,6})[ \t]+"));
523
524#[derive(Debug, Clone, PartialEq)]
530pub struct HeadingToken<T> {
531 pub range: Range<usize>,
533 pub value: T,
535}
536
537#[derive(Debug, Clone, PartialEq)]
556pub struct HeadingLine {
557 pub level: usize,
559 pub status: Option<HeadingToken<TaskType>>,
561 pub priority: Option<HeadingToken<Priority>>,
563 pub title_start: usize,
568}
569
570pub fn parse_heading_line(line: &str) -> Option<HeadingLine> {
584 let hashes = HEADING_HASHES_RE.captures(line)?;
585 let level = hashes
586 .get(1)
587 .expect("group 1 is Some when captures() succeeds")
588 .len();
589 let after_hashes = hashes
590 .get(0)
591 .expect("Captures::get(0) is Some when captures() succeeds")
592 .end();
593
594 let (status, after_status) = match HEADING_TODO_RE.captures(&line[after_hashes..]) {
595 Some(caps) => {
596 let keyword = caps
597 .get(1)
598 .expect("group 1 is Some when captures() succeeds");
599 let whole = caps
600 .get(0)
601 .expect("Captures::get(0) is Some when captures() succeeds");
602 let token = TaskType::from_keyword(keyword.as_str()).map(|value| HeadingToken {
603 range: after_hashes + keyword.start()..after_hashes + keyword.end(),
604 value,
605 });
606 (token, after_hashes + whole.end())
607 }
608 None => (None, after_hashes),
609 };
610
611 let priority = HEADING_PRIORITY_RE
614 .captures(&line[after_status..])
615 .and_then(|caps| {
616 let value = caps
617 .get(1)
618 .expect("group 1 is Some when captures() succeeds");
619 let parsed = Priority::parse(value.as_str())?;
622 Some(HeadingToken {
623 range: after_status + value.start() - "[#".len()
624 ..after_status + value.end() + "]".len(),
625 value: parsed,
626 })
627 });
628
629 let after_tokens = priority
634 .as_ref()
635 .filter(|cookie| line[after_status..cookie.range.start].trim().is_empty())
636 .map_or(after_status, |cookie| cookie.range.end);
637 let title_start = after_tokens
638 + line[after_tokens..]
639 .find(|c: char| !c.is_whitespace())
640 .unwrap_or(line.len() - after_tokens);
641
642 Some(HeadingLine {
643 level,
644 status,
645 priority,
646 title_start,
647 })
648}
649
650fn strip_wrapping_backticks(s: &str) -> &str {
662 let bytes = s.as_bytes();
663 let n_leading = bytes.iter().take_while(|&&b| b == b'`').count();
664 if n_leading == 0 {
665 return s;
666 }
667 let n_trailing = bytes.iter().rev().take_while(|&&b| b == b'`').count();
668 if n_trailing != n_leading || bytes.len() < 2 * n_leading + 1 {
672 return s;
673 }
674 s[n_leading..bytes.len() - n_leading].trim()
675}
676
677fn parse_org_properties(
688 literal: &str,
689 props: &mut BTreeMap<String, String>,
690 path: &Path,
691 block_start_line: u32,
692 prop_warning_counter: &mut usize,
693) {
694 for (offset, line) in literal.lines().enumerate() {
695 if line.trim().is_empty() {
696 continue;
697 }
698 let src_line = block_start_line
700 .saturating_add(1)
701 .saturating_add(offset as u32);
702 match line.split_once(':') {
703 Some((key, value)) => {
704 let key = key.trim();
705 if key.is_empty() {
706 warn_invalid_property_line(prop_warning_counter, path, src_line, line);
707 continue;
708 }
709 props.insert(key.to_string(), value.trim().to_string());
710 }
711 None => {
712 warn_invalid_property_line(prop_warning_counter, path, src_line, line);
713 }
714 }
715 }
716}
717
718fn extract_timestamps_from_node<'a>(
720 node: &'a AstNode<'a>,
721 mappings: &[(&str, &str)],
722) -> (Option<String>, Option<String>) {
723 let mut created = None;
724 let mut timestamp = None;
725
726 if let NodeValue::Paragraph = &node.data.borrow().value {
727 for child in node.children() {
728 if let NodeValue::Code(code) = &child.data.borrow().value {
729 let normalized = normalize_weekdays(&code.literal, mappings);
732 if created.is_none() {
733 created = extract_created_normalized(&normalized);
734 }
735 if timestamp.is_none() {
736 timestamp = extract_timestamp_normalized(&normalized);
737 }
738 }
739 }
740 }
741 (created, timestamp)
742}
743
744fn extract_paragraph_text<'a>(node: &'a AstNode<'a>) -> String {
751 let mut text = String::new();
752 collect_text_recursive(node, &mut text, InlineCode::Drop);
753 text.trim().to_string()
754}
755
756fn extract_text<'a>(node: &'a AstNode<'a>) -> String {
759 let mut text = String::new();
760 collect_text_recursive(node, &mut text, InlineCode::Keep);
761 text
762}
763
764#[derive(Clone, Copy, PartialEq, Eq)]
766enum InlineCode {
767 Keep,
770 Drop,
772}
773
774fn collect_text_recursive<'a>(node: &'a AstNode<'a>, out: &mut String, code: InlineCode) {
775 for child in node.children() {
776 let value = child.data.borrow().value.clone();
777 match value {
778 NodeValue::Text(t) => out.push_str(&t),
779 NodeValue::Code(inline) if code == InlineCode::Keep => out.push_str(&inline.literal),
780 NodeValue::Emph | NodeValue::Strong | NodeValue::Link(_) | NodeValue::Strikethrough => {
781 collect_text_recursive(child, out, code)
782 }
783 _ => {}
784 }
785 }
786}
787
788pub fn display_text(markdown: &str) -> String {
805 let arena = Arena::new();
806 let root = parse_document(&arena, markdown, &safe_comrak_options());
807
808 let mut text = String::new();
809 collect_block_text(root, &mut text);
810 text.trim().to_string()
811}
812
813fn collect_block_text<'a>(node: &'a AstNode<'a>, out: &mut String) {
819 for child in node.children() {
820 let value = child.data.borrow().value.clone();
821 match value {
822 NodeValue::Paragraph | NodeValue::Heading(_) => {
823 collect_text_recursive(child, out, InlineCode::Keep)
824 }
825 _ => collect_block_text(child, out),
826 }
827 }
828}
829
830#[cfg(test)]
834mod tests {
835 use super::*;
836 use crate::types::{CancelledSpelling, DEFAULT_MAX_TASKS};
837
838 #[test]
839 fn warn_invalid_timestamp_advances_per_call_counter() {
840 let mut counter = 0_usize;
846 let path = Path::new("t.md");
847 for i in 1..=25 {
848 warn_invalid_timestamp(&mut counter, path, i, "<bad>");
849 }
850 assert_eq!(counter, 25);
851 }
852
853 #[test]
854 fn warn_invalid_property_line_advances_per_call_counter() {
855 let mut counter = 0_usize;
859 let path = Path::new("t.md");
860 for i in 1..=25 {
861 warn_invalid_property_line(&mut counter, path, i, "no-colon-here");
862 }
863 assert_eq!(counter, 25);
864 }
865
866 #[test]
867 fn warn_invalid_timestamp_counters_are_independent() {
868 let mut counter_a = 0_usize;
874 let mut counter_b = 0_usize;
875 let path = Path::new("t.md");
876 for _ in 0..MAX_DIAGNOSTIC_ITEMS {
877 warn_invalid_timestamp(&mut counter_a, path, 1, "<bad>");
878 }
879 warn_invalid_timestamp(&mut counter_b, path, 1, "<bad>");
880 assert_eq!(counter_a, MAX_DIAGNOSTIC_ITEMS);
881 assert_eq!(counter_b, 1);
882 }
883
884 #[test]
885 fn test_parse_heading_with_priority() {
886 let (task_type, priority, heading) = parse_heading("TODO [#A] Important task");
887 assert_eq!(task_type, Some(TaskType::Todo));
888 assert_eq!(priority, Some(Priority::A));
889 assert_eq!(heading, "Important task");
890 }
891
892 #[test]
893 fn test_parse_heading_without_priority() {
894 let (task_type, priority, heading) = parse_heading("DONE Simple task");
895 assert_eq!(task_type, Some(TaskType::Done));
896 assert_eq!(priority, None);
897 assert_eq!(heading, "Simple task");
898 }
899
900 #[test]
901 fn test_parse_heading_no_task() {
902 let (task_type, priority, heading) = parse_heading("Regular heading");
903 assert_eq!(task_type, None);
904 assert_eq!(priority, None);
905 assert_eq!(heading, "Regular heading");
906 }
907
908 #[test]
914 fn parse_heading_priority_without_todo() {
915 let (tt, p, h) = parse_heading("[#A] Заголовок");
917 assert_eq!(tt, None);
918 assert_eq!(p, Some(Priority::A));
919 assert_eq!(h, "Заголовок");
920 }
921
922 #[test]
923 fn parse_heading_todo_with_priority() {
924 let (tt, p, h) = parse_heading("TODO [#A] Заголовок");
926 assert_eq!(tt, Some(TaskType::Todo));
927 assert_eq!(p, Some(Priority::A));
928 assert_eq!(h, "Заголовок");
929 }
930
931 #[test]
932 fn parse_heading_done_with_priority_b() {
933 let (tt, p, h) = parse_heading("DONE [#B] Заголовок");
935 assert_eq!(tt, Some(TaskType::Done));
936 assert_eq!(p, Some(Priority::B));
937 assert_eq!(h, "Заголовок");
938 }
939
940 #[test]
941 fn parse_heading_plain_text_no_markers() {
942 let (tt, p, h) = parse_heading("Заголовок");
944 assert_eq!(tt, None);
945 assert_eq!(p, None);
946 assert_eq!(h, "Заголовок");
947 }
948
949 #[test]
950 fn parse_heading_todo_no_priority() {
951 let (tt, p, h) = parse_heading("TODO Заголовок");
953 assert_eq!(tt, Some(TaskType::Todo));
954 assert_eq!(p, None);
955 assert_eq!(h, "Заголовок");
956 }
957
958 #[test]
959 fn parse_heading_numeric_priority() {
960 let (tt, p, h) = parse_heading("[#1] Заголовок");
962 assert_eq!(tt, None);
963 assert_eq!(p, Some(Priority::Numeric(1)));
964 assert_eq!(h, "Заголовок");
965 }
966
967 #[test]
968 fn parse_heading_extra_whitespace_around_priority() {
969 let (tt, p, h) = parse_heading("[#A] Заголовок");
974 assert_eq!(tt, None);
975 assert_eq!(p, Some(Priority::A));
976 assert_eq!(h, "Заголовок");
977 }
978
979 #[test]
980 fn parse_heading_priority_in_the_middle_org_semantics() {
981 let (tt, p, h) = parse_heading("Без приоритета и [#A] внутри");
986 assert_eq!(tt, None);
987 assert_eq!(p, Some(Priority::A));
988 assert_eq!(h, "Без приоритета и [#A] внутри");
989 }
990
991 #[test]
992 fn parse_heading_trailing_cookie_leaves_a_title_behind() {
993 let (tt, p, h) = parse_heading("TODO Заголовок с cookie в конце [#A]");
997 assert_eq!(tt, Some(TaskType::Todo));
998 assert_eq!(p, Some(Priority::A));
999 assert_eq!(h, "Заголовок с cookie в конце [#A]");
1000 }
1001
1002 #[test]
1003 fn parse_heading_two_digit_numeric_priority() {
1004 let (tt, p, h) = parse_heading("[#15] Mid range");
1005 assert_eq!(tt, None);
1006 assert_eq!(p, Some(Priority::Numeric(15)));
1007 assert_eq!(h, "Mid range");
1008
1009 let (tt, p, h) = parse_heading("[#64] At upper bound");
1010 assert_eq!(tt, None);
1011 assert_eq!(p, Some(Priority::Numeric(64)));
1012 assert_eq!(h, "At upper bound");
1013 }
1014
1015 #[test]
1016 fn parse_heading_rejects_numeric_out_of_range() {
1017 let (tt, p, h) = parse_heading("[#65] Above range");
1020 assert_eq!(tt, None);
1021 assert_eq!(p, None);
1022 assert_eq!(h, "[#65] Above range");
1023 }
1024
1025 #[test]
1026 fn parse_heading_rejects_lowercase_priority() {
1027 let (tt, p, h) = parse_heading("[#a] Lowercase");
1028 assert_eq!(tt, None);
1029 assert_eq!(p, None);
1030 assert_eq!(h, "[#a] Lowercase");
1031 }
1032
1033 #[test]
1034 fn parse_heading_todo_then_priority_with_intervening_text() {
1035 let (tt, p, h) = parse_heading("TODO Купить [#A] фильтр");
1039 assert_eq!(tt, Some(TaskType::Todo));
1040 assert_eq!(p, Some(Priority::A));
1041 assert_eq!(h, "Купить [#A] фильтр");
1042 }
1043
1044 #[test]
1045 fn parse_heading_priority_without_trailing_space() {
1046 let (tt, p, h) = parse_heading("[#A]NoSpace");
1048 assert_eq!(tt, None);
1049 assert_eq!(p, Some(Priority::A));
1050 assert_eq!(h, "NoSpace");
1051 }
1052
1053 #[test]
1054 fn parse_heading_cancelled_simple() {
1055 let (tt, p, h) = parse_heading("CANCELLED Foo");
1056 assert_eq!(tt, Some(TaskType::Cancelled(CancelledSpelling::DoubleL)));
1057 assert_eq!(p, None);
1058 assert_eq!(h, "Foo");
1059 }
1060
1061 #[test]
1062 fn parse_heading_cancelled_with_priority() {
1063 let (tt, p, h) = parse_heading("CANCELLED [#A] Foo");
1064 assert_eq!(tt, Some(TaskType::Cancelled(CancelledSpelling::DoubleL)));
1065 assert_eq!(p, Some(Priority::A));
1066 assert_eq!(h, "Foo");
1067 }
1068
1069 #[test]
1070 fn parse_heading_cancelled_without_whitespace() {
1071 let (tt, p, h) = parse_heading("CANCELLEDFoo");
1073 assert_eq!(tt, None);
1074 assert_eq!(p, None);
1075 assert_eq!(h, "CANCELLEDFoo");
1076 }
1077
1078 #[test]
1079 fn parse_heading_cancelled_lowercase_not_recognised() {
1080 let (tt, p, h) = parse_heading("cancelled Foo");
1082 assert_eq!(tt, None);
1083 assert_eq!(p, None);
1084 assert_eq!(h, "cancelled Foo");
1085 }
1086
1087 #[test]
1088 fn parse_heading_todo_cancelled_first_keyword_wins() {
1089 let (tt, p, h) = parse_heading("TODO CANCELLED Foo");
1091 assert_eq!(tt, Some(TaskType::Todo));
1092 assert_eq!(p, None);
1093 assert_eq!(h, "CANCELLED Foo");
1094 }
1095
1096 #[test]
1097 fn parse_heading_canceled_single_l() {
1098 let (tt, p, h) = parse_heading("CANCELED Foo");
1101 assert_eq!(tt, Some(TaskType::Cancelled(CancelledSpelling::SingleL)));
1102 assert_eq!(p, None);
1103 assert_eq!(h, "Foo");
1104 }
1105
1106 #[test]
1107 fn parse_heading_canceled_with_priority() {
1108 let (tt, p, h) = parse_heading("CANCELED [#A] Foo");
1109 assert_eq!(tt, Some(TaskType::Cancelled(CancelledSpelling::SingleL)));
1110 assert_eq!(p, Some(Priority::A));
1111 assert_eq!(h, "Foo");
1112 }
1113
1114 #[test]
1115 fn parse_heading_canceled_lowercase_not_recognised() {
1116 let (tt, p, h) = parse_heading("canceled Foo");
1118 assert_eq!(tt, None);
1119 assert_eq!(p, None);
1120 assert_eq!(h, "canceled Foo");
1121 }
1122
1123 #[test]
1124 fn parse_heading_canceled_without_whitespace_not_recognised() {
1125 let (tt, p, h) = parse_heading("CANCELEDfoo");
1127 assert_eq!(tt, None);
1128 assert_eq!(p, None);
1129 assert_eq!(h, "CANCELEDfoo");
1130 }
1131
1132 #[test]
1133 fn extract_tasks_marks_scheduled_angle_bracket_as_active() {
1134 let content = "### TODO Pin me\n`SCHEDULED: <2026-05-21 Thu>`\n";
1139 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1140 assert_eq!(tasks.len(), 1);
1141 assert_eq!(tasks[0].timestamp_active, Some(true));
1142 }
1143
1144 #[test]
1145 fn extract_tasks_marks_missing_timestamp_active_as_none() {
1146 let content = "### Project kickoff\n\n`CREATED: [2025-09-01 Mon]`\n";
1150 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1151 assert_eq!(tasks.len(), 1);
1152 assert_eq!(tasks[0].timestamp_active, None);
1153 }
1154
1155 #[test]
1156 fn extract_tasks_basic_todo_with_deadline() {
1157 let content = "\
1158### TODO [#A] Write docs\n\
1159`DEADLINE: <2025-12-10 Wed>`\n";
1160 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1161 assert_eq!(tasks.len(), 1);
1162 let t = &tasks[0];
1163 assert_eq!(t.task_type, Some(TaskType::Todo));
1164 assert_eq!(t.priority, Some(Priority::A));
1165 assert_eq!(t.heading, "Write docs");
1166 assert_eq!(t.timestamp_type, Some("DEADLINE".to_string()));
1167 assert_eq!(t.timestamp_date, Some("2025-12-10".to_string()));
1168 }
1169
1170 #[test]
1171 fn extract_tasks_extracts_emph_text_in_heading() {
1172 let content = "### TODO **Important** task\n`DEADLINE: <2025-12-10 Wed>`\n";
1174 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1175 assert_eq!(tasks.len(), 1);
1176 assert_eq!(tasks[0].heading, "Important task");
1177 }
1178
1179 #[test]
1180 fn extract_tasks_ignores_non_task_headings_without_timestamps() {
1181 let content = "### Just a heading\n\nSome text.\n";
1182 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1183 assert!(tasks.is_empty());
1184 }
1185
1186 #[test]
1187 fn extract_tasks_keeps_created_without_todo() {
1188 let content = "### Project kickoff\n\n`CREATED: [2025-09-01 Mon]`\n";
1190 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1191 assert_eq!(tasks.len(), 1);
1192 assert_eq!(tasks[0].task_type, None);
1193 assert_eq!(tasks[0].created, Some("CREATED: [2025-09-01 Mon]".into()));
1194 }
1195
1196 #[test]
1197 fn extract_tasks_concatenates_multiple_paragraphs() {
1198 let content = "\
1200### TODO Multi-line task\n\
1201First paragraph.\n\
1202\n\
1203Second paragraph.\n\
1204";
1205 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1206 assert_eq!(tasks.len(), 1);
1207 assert!(tasks[0].content.contains("First paragraph"));
1208 assert!(tasks[0].content.contains("Second paragraph"));
1209 }
1210
1211 #[test]
1212 fn extract_tasks_extracts_clock_from_inline_code() {
1213 let content = "\
1214### TODO Track time\n\
1215`CLOCK: [2025-09-01 Mon 10:00]--[2025-09-01 Mon 11:30] => 1:30`\n\
1216";
1217 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1218 assert_eq!(tasks.len(), 1);
1219 let t = &tasks[0];
1220 assert!(t.clocks.is_some());
1221 assert_eq!(t.total_clock_time.as_deref(), Some("1:30"));
1222 }
1223
1224 #[test]
1225 fn extract_tasks_handles_done_priority() {
1226 let content = "### DONE [#B] Wrap up\n";
1227 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1228 assert_eq!(tasks.len(), 1);
1229 assert_eq!(tasks[0].task_type, Some(TaskType::Done));
1230 assert_eq!(tasks[0].priority, Some(Priority::B));
1231 }
1232
1233 #[test]
1234 fn extract_tasks_priority_without_todo_with_scheduled() {
1235 let content = "\
1239### [#A] Поменять резину до 16.05.2026\n\
1240`SCHEDULED: <2026-05-09 Sat>`\n";
1241 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1242 assert_eq!(tasks.len(), 1);
1243 let t = &tasks[0];
1244 assert_eq!(t.task_type, None);
1245 assert_eq!(t.priority, Some(Priority::A));
1246 assert_eq!(t.heading, "Поменять резину до 16.05.2026");
1247 assert_eq!(t.timestamp_type, Some("SCHEDULED".to_string()));
1248 assert_eq!(t.timestamp_date, Some("2026-05-09".to_string()));
1249 }
1250
1251 #[test]
1252 fn extract_tasks_numeric_priority_with_deadline() {
1253 let content = "\
1255### [#1] Numeric priority task\n\
1256`DEADLINE: <2026-05-09 Sat>`\n";
1257 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1258 assert_eq!(tasks.len(), 1);
1259 let t = &tasks[0];
1260 assert_eq!(t.task_type, None);
1261 assert_eq!(t.priority, Some(Priority::Numeric(1)));
1262 assert_eq!(t.heading, "Numeric priority task");
1263 }
1264
1265 #[test]
1266 fn extract_tasks_priority_in_middle_keeps_the_prefix() {
1267 let content = "\
1270### Без приоритета и [#A] внутри\n\
1271`SCHEDULED: <2026-05-09 Sat>`\n";
1272 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1273 assert_eq!(tasks.len(), 1);
1274 let t = &tasks[0];
1275 assert_eq!(t.task_type, None);
1276 assert_eq!(t.priority, Some(Priority::A));
1277 assert_eq!(t.heading, "Без приоритета и [#A] внутри");
1278 }
1279
1280 #[test]
1281 fn extract_tasks_bug_report_minimal_reproduction() {
1282 let content = "\
1286### [#A] Поменять резину до 16.05.2026\n\
1287`SCHEDULED: <2026-05-09 Sat>`\n\
1288\n\
1289### TODO [#A] Поменять масло\n\
1290`SCHEDULED: <2026-05-09 Sat>`\n\
1291\n\
1292### Купить фильтр\n\
1293`SCHEDULED: <2026-05-09 Sat>`\n";
1294 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1295 assert_eq!(tasks.len(), 3);
1296
1297 assert_eq!(tasks[0].task_type, None);
1298 assert_eq!(tasks[0].priority, Some(Priority::A));
1299 assert_eq!(tasks[0].heading, "Поменять резину до 16.05.2026");
1300
1301 assert_eq!(tasks[1].task_type, Some(TaskType::Todo));
1302 assert_eq!(tasks[1].priority, Some(Priority::A));
1303 assert_eq!(tasks[1].heading, "Поменять масло");
1304
1305 assert_eq!(tasks[2].task_type, None);
1306 assert_eq!(tasks[2].priority, None);
1307 assert_eq!(tasks[2].heading, "Купить фильтр");
1308 }
1309
1310 #[test]
1319 fn extract_tasks_indented_inline_code_deadline() {
1320 let content = "#### Birthday\n `DEADLINE: <2026-05-07 Thu +1y>`\n";
1321 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1322 assert_eq!(tasks.len(), 1, "task should not be dropped");
1323 let t = &tasks[0];
1324 assert_eq!(t.timestamp_type.as_deref(), Some("DEADLINE"));
1325 assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1326 }
1327
1328 #[test]
1329 fn extract_tasks_todo_indented_inline_code_deadline() {
1330 let content = "#### TODO Birthday\n `DEADLINE: <2026-05-07 Thu +1y>`\n";
1331 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1332 assert_eq!(tasks.len(), 1);
1333 let t = &tasks[0];
1334 assert_eq!(t.task_type, Some(TaskType::Todo));
1335 assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1336 }
1337
1338 #[test]
1339 fn extract_tasks_indented_inline_code_blank_lines_between() {
1340 let content = "#### Birthday\n\n \t \n `DEADLINE: <2026-05-07 Thu +1y>`\n";
1343 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1344 assert_eq!(tasks.len(), 1);
1345 let t = &tasks[0];
1346 assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1347 }
1348
1349 #[test]
1350 fn extract_tasks_with_ru_mappings_reproduces_cli_pipeline() {
1351 let content = "#### TODO Birthday\n `DEADLINE: <2026-05-07 Thu +1y>`\n";
1356 let tasks = extract_tasks(
1357 Path::new("t.md"),
1358 content,
1359 crate::locale::RU_WEEKDAY_MAPPINGS,
1360 DEFAULT_MAX_TASKS,
1361 );
1362 assert_eq!(tasks.len(), 1);
1363 let t = &tasks[0];
1364 assert_eq!(t.task_type, Some(TaskType::Todo));
1365 assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1366 }
1367
1368 #[test]
1369 fn extract_tasks_inline_code_scheduled_no_indent() {
1370 let content = "#### Followup\n`SCHEDULED: <2026-05-07 Thu>`\n";
1371 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1372 assert_eq!(tasks.len(), 1);
1373 let t = &tasks[0];
1374 assert_eq!(t.timestamp_type.as_deref(), Some("SCHEDULED"));
1375 assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1376 }
1377
1378 #[test]
1379 fn extract_tasks_indented_inline_code_created() {
1380 let content = "#### Project kickoff\n `CREATED: [2025-09-01 Mon]`\n";
1381 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1382 assert_eq!(tasks.len(), 1);
1383 let t = &tasks[0];
1384 assert_eq!(t.created.as_deref(), Some("CREATED: [2025-09-01 Mon]"));
1385 }
1386
1387 #[test]
1388 fn extract_tasks_parses_single_property() {
1389 let content = "### TODO Ship release\n`SCHEDULED: <2026-06-01 Mon 10:00>`\n```org-properties\nGCAL_EVENT_ID: abc123/primary\n```\n";
1390 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1391 assert_eq!(tasks.len(), 1);
1392 let props = tasks[0].properties.as_ref().expect("properties present");
1393 assert_eq!(
1394 props.get("GCAL_EVENT_ID").map(String::as_str),
1395 Some("abc123/primary")
1396 );
1397 assert!(!tasks[0].content.contains("GCAL_EVENT_ID"));
1399 assert!(!tasks[0].content.contains("org-properties"));
1400 }
1401
1402 #[test]
1403 fn extract_tasks_parses_multiple_properties() {
1404 let content =
1405 "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nA: 1\nB: 2\n```\n";
1406 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1407 let props = tasks[0].properties.as_ref().unwrap();
1408 assert_eq!(props.get("A").map(String::as_str), Some("1"));
1409 assert_eq!(props.get("B").map(String::as_str), Some("2"));
1410 }
1411
1412 #[test]
1413 fn extract_tasks_property_duplicate_keys_last_wins() {
1414 let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nK: first\nK: second\n```\n";
1415 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1416 assert_eq!(
1417 tasks[0]
1418 .properties
1419 .as_ref()
1420 .unwrap()
1421 .get("K")
1422 .map(String::as_str),
1423 Some("second")
1424 );
1425 }
1426
1427 #[test]
1428 fn extract_tasks_property_empty_value_allowed() {
1429 let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nK:\n```\n";
1430 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1431 assert_eq!(
1432 tasks[0]
1433 .properties
1434 .as_ref()
1435 .unwrap()
1436 .get("K")
1437 .map(String::as_str),
1438 Some("")
1439 );
1440 }
1441
1442 #[test]
1443 fn extract_tasks_property_malformed_line_skipped() {
1444 let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nGOOD: x\nno colon here\n```\n";
1445 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1446 let props = tasks[0].properties.as_ref().unwrap();
1447 assert_eq!(props.get("GOOD").map(String::as_str), Some("x"));
1448 assert_eq!(props.len(), 1, "malformed line must be skipped");
1449 }
1450
1451 #[test]
1452 fn extract_tasks_empty_property_block_yields_none() {
1453 let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\n\n```\n";
1454 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1455 assert_eq!(tasks[0].properties, None);
1456 }
1457
1458 #[test]
1459 fn extract_tasks_property_info_with_extra_attrs_not_recognised() {
1460 let content =
1463 "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties extra\nK: v\n```\n";
1464 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1465 assert_eq!(tasks[0].properties, None);
1466 }
1467
1468 #[test]
1469 fn extract_tasks_clock_code_block_unaffected_by_properties() {
1470 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";
1473 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1474 assert_eq!(
1475 tasks[0]
1476 .properties
1477 .as_ref()
1478 .unwrap()
1479 .get("K")
1480 .map(String::as_str),
1481 Some("v")
1482 );
1483 assert_eq!(tasks[0].total_clock_time.as_deref(), Some("1:30"));
1484 }
1485
1486 #[test]
1487 fn extract_tasks_merges_multiple_property_blocks_last_wins() {
1488 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";
1489 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1490 let props = tasks[0].properties.as_ref().unwrap();
1491 assert_eq!(props.get("K").map(String::as_str), Some("two"));
1492 assert_eq!(props.get("L").map(String::as_str), Some("three"));
1493 }
1494}