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
73fn warn_unusable_exception(
84 counter: &mut usize,
85 path: &Path,
86 line: u32,
87 key: &str,
88 value: &str,
89 problem: &str,
90) {
91 let n = *counter;
92 *counter = counter.saturating_add(1);
93 if n < MAX_DIAGNOSTIC_ITEMS {
94 tracing::warn!(
95 file = %path.display(),
96 line,
97 key,
98 value = value.trim(),
99 problem,
100 "exception property cannot be used as written"
101 );
102 } else if n == MAX_DIAGNOSTIC_ITEMS {
103 tracing::warn!(
104 limit = MAX_DIAGNOSTIC_ITEMS,
105 "more unusable exception properties suppressed (showed first {MAX_DIAGNOSTIC_ITEMS})"
106 );
107 }
108}
109
110static HEADING_TODO_RE: LazyLock<Regex> =
119 LazyLock::new(|| compile_bounded(r"^(TODO|DONE|CANCELLED|CANCELED)\s+"));
120
121static HEADING_PRIORITY_RE: LazyLock<Regex> =
136 LazyLock::new(|| compile_bounded(r"\[#([A-Z]|6[0-4]|[1-5][0-9]|[0-9])\] ?"));
137
138pub fn extract_tasks_with_counter(
155 path: &Path,
156 content: &str,
157 mappings: &[(&str, &str)],
158 max_tasks: usize,
159 ts_warning_counter: &mut usize,
160 prop_warning_counter: &mut usize,
161) -> Vec<Task> {
162 let arena = Arena::new();
163 let root = parse_document(&arena, content, &safe_comrak_options());
164
165 let mut tasks = Vec::new();
166 let mut current_heading: Option<HeadingInfo> = None;
167
168 for node in root.children() {
169 process_node(
170 node,
171 path,
172 &mut tasks,
173 &mut current_heading,
174 mappings,
175 ts_warning_counter,
176 prop_warning_counter,
177 );
178
179 if tasks.len() >= max_tasks {
180 tracing::warn!(
181 file = %path.display(),
182 limit = max_tasks,
183 "reached per-file task limit"
184 );
185 break;
186 }
187 }
188
189 if let Some(info) = current_heading.take() {
191 if let Some(task) = finalize_task(path, info, ts_warning_counter, prop_warning_counter) {
192 tasks.push(task);
193 }
194 }
195
196 tracing::debug!(
197 file = %path.display(),
198 bytes = content.len(),
199 tasks = tasks.len(),
200 "parsed file"
201 );
202
203 tasks
204}
205
206#[cfg_attr(not(test), allow(dead_code))]
214pub fn extract_tasks(
215 path: &Path,
216 content: &str,
217 mappings: &[(&str, &str)],
218 max_tasks: usize,
219) -> Vec<Task> {
220 let mut counter = 0_usize;
221 let mut prop_counter = 0_usize;
222 extract_tasks_with_counter(
223 path,
224 content,
225 mappings,
226 max_tasks,
227 &mut counter,
228 &mut prop_counter,
229 )
230}
231
232fn safe_comrak_options() -> Options<'static> {
244 Options::default()
245}
246
247struct HeadingInfo {
249 heading: String,
250 task_type: Option<TaskType>,
251 priority: Option<Priority>,
252 line: u32,
253 content: String,
254 created: Option<String>,
255 timestamp: Option<String>,
256 clocks: Vec<crate::types::ClockEntry>,
257 properties: BTreeMap<String, String>,
258}
259
260fn process_node<'a>(
262 node: &'a AstNode<'a>,
263 path: &Path,
264 tasks: &mut Vec<Task>,
265 current_heading: &mut Option<HeadingInfo>,
266 mappings: &[(&str, &str)],
267 ts_warning_counter: &mut usize,
268 prop_warning_counter: &mut usize,
269) {
270 let (value_clone, line) = {
274 let data = node.data.borrow();
275 (data.value.clone(), data.sourcepos.start.line as u32)
276 };
277 match value_clone {
278 NodeValue::Heading(_) => {
279 if let Some(info) = current_heading.take() {
281 if let Some(task) =
282 finalize_task(path, info, ts_warning_counter, prop_warning_counter)
283 {
284 tasks.push(task);
285 }
286 }
287
288 let text = extract_text(node);
289 let (task_type, priority, heading) = parse_heading(&text);
290 *current_heading = Some(HeadingInfo {
291 heading,
292 task_type,
293 priority,
294 line,
295 content: String::new(),
296 created: None,
297 timestamp: None,
298 clocks: Vec::new(),
299 properties: BTreeMap::new(),
300 });
301 }
302 NodeValue::Paragraph => {
303 if let Some(ref mut info) = current_heading {
304 let (created, timestamp) = extract_timestamps_from_node(node, mappings);
305 let content = extract_paragraph_text(node);
306
307 for child in node.children() {
308 if let NodeValue::Code(code) = &child.data.borrow().value {
309 info.clocks.extend(extract_clocks(&code.literal));
310 }
311 }
312
313 if created.is_some() {
314 info.created = created;
315 }
316 if timestamp.is_some() {
317 info.timestamp = timestamp;
318 }
319 if !content.is_empty() {
320 if info.content.is_empty() {
321 info.content = content;
322 } else {
323 info.content.push_str("\n\n");
324 info.content.push_str(&content);
325 }
326 }
327 }
328 }
329 NodeValue::CodeBlock(code) => {
330 if let Some(ref mut info) = current_heading {
331 if code.info.trim() == "org-properties" {
338 parse_org_properties(
339 &code.literal,
340 &mut info.properties,
341 path,
342 line,
343 prop_warning_counter,
344 );
345 } else {
346 let raw = code.literal.trim();
347 let literal = strip_wrapping_backticks(raw);
355 let normalized = normalize_weekdays(literal, mappings);
356 let created = extract_created_normalized(&normalized);
357 let timestamp = extract_timestamp_normalized(&normalized);
358
359 info.clocks.extend(extract_clocks(literal));
360
361 if created.is_some() {
362 info.created = created;
363 }
364 if timestamp.is_some() {
365 info.timestamp = timestamp;
366 }
367 }
368 }
369 }
370 _ => {}
371 }
372}
373
374fn finalize_task(
375 path: &Path,
376 info: HeadingInfo,
377 ts_warning_counter: &mut usize,
378 prop_warning_counter: &mut usize,
379) -> Option<Task> {
380 if info.task_type.is_none() && info.created.is_none() && info.timestamp.is_none() {
381 return None;
382 }
383
384 let line = info.line;
385 let (ts_type, ts_date, ts_time, ts_end_time, ts_active, ts_repeater) =
386 if let Some(ref ts) = info.timestamp {
387 let parsed = parse_timestamp_fields_normalized(ts);
392 if parsed.1.is_none() {
393 warn_invalid_timestamp(ts_warning_counter, path, line, ts);
394 }
395 let repeater = extract_repeater_normalized(ts);
401 (parsed.0, parsed.1, parsed.2, parsed.3, parsed.4, repeater)
402 } else {
403 (None, None, None, None, None, None)
404 };
405
406 let (clocks_opt, total_time) = if !info.clocks.is_empty() {
407 let total = calculate_total_minutes(&info.clocks).map(format_duration);
408 (Some(info.clocks), total)
409 } else {
410 (None, None)
411 };
412
413 let properties = if info.properties.is_empty() {
414 None
415 } else {
416 Some(info.properties)
417 };
418
419 let exceptions = exception_fields(path, line, properties.as_ref(), prop_warning_counter);
424
425 Some(Task {
426 file: path.display().to_string(),
427 root: None,
430 line,
431 heading: info.heading,
432 content: info.content,
433 task_type: info.task_type,
434 priority: info.priority,
435 created: info.created,
436 timestamp: info.timestamp,
437 timestamp_type: ts_type,
438 timestamp_active: ts_active,
439 timestamp_date: ts_date,
440 timestamp_time: ts_time,
441 timestamp_end_time: ts_end_time,
442 timestamp_repeater: ts_repeater,
443 timestamp_next: None,
446 timestamp_next_after: None,
447 clocks: clocks_opt,
448 total_clock_time: total_time,
449 properties,
450 excluded_dates: exceptions.excluded_dates,
451 recurrence_id: exceptions.recurrence_id,
452 series_id: exceptions.series_id,
453 })
454}
455
456#[derive(Debug, Default, PartialEq, Eq)]
472struct ExceptionFields {
473 excluded_dates: Option<Vec<String>>,
475 recurrence_id: Option<String>,
477 series_id: Option<String>,
479}
480
481fn exception_fields(
482 path: &Path,
483 line: u32,
484 properties: Option<&BTreeMap<String, String>>,
485 prop_warning_counter: &mut usize,
486) -> ExceptionFields {
487 use crate::exceptions::{
488 parse_excluded_dates, parse_recurrence_id, EXDATE_KEY, RECURRENCE_ID_KEY, SERIES_ID_KEY,
489 };
490
491 let Some(props) = properties else {
492 return ExceptionFields::default();
493 };
494
495 let excluded = props.get(EXDATE_KEY).map(|raw| {
496 let mut rejected = 0_usize;
497 let dates = parse_excluded_dates(raw, |field| {
498 rejected += 1;
499 warn_unusable_exception(
500 prop_warning_counter,
501 path,
502 line,
503 EXDATE_KEY,
504 field,
505 "not a date in YYYY-MM-DD form",
506 );
507 });
508 if dates.is_empty() && rejected == 0 {
512 warn_unusable_exception(
513 prop_warning_counter,
514 path,
515 line,
516 EXDATE_KEY,
517 raw,
518 "no date to cancel an occurrence on",
519 );
520 }
521 dates
522 });
523 let excluded = excluded.filter(|dates| !dates.is_empty());
524
525 let recurrence = props.get(RECURRENCE_ID_KEY).and_then(|raw| {
526 let parsed = parse_recurrence_id(raw, |dropped| {
527 warn_unusable_exception(
528 prop_warning_counter,
529 path,
530 line,
531 RECURRENCE_ID_KEY,
532 dropped,
533 "not a time in HH:MM form, so the date alone is kept",
534 );
535 });
536 if parsed.is_none() {
537 warn_unusable_exception(
538 prop_warning_counter,
539 path,
540 line,
541 RECURRENCE_ID_KEY,
542 raw,
543 "not a date, optionally followed by a time",
544 );
545 }
546 parsed
547 });
548
549 let series = props
550 .get(SERIES_ID_KEY)
551 .map(|raw| raw.trim().to_string())
552 .filter(|id| !id.is_empty());
553
554 warn_about_half_a_pair(
555 path,
556 line,
557 props,
558 series.as_deref(),
559 recurrence.as_deref(),
560 prop_warning_counter,
561 );
562
563 ExceptionFields {
564 excluded_dates: excluded,
565 recurrence_id: recurrence,
566 series_id: series,
567 }
568}
569
570fn warn_about_half_a_pair(
578 path: &Path,
579 line: u32,
580 props: &BTreeMap<String, String>,
581 series: Option<&str>,
582 recurrence: Option<&str>,
583 prop_warning_counter: &mut usize,
584) {
585 use crate::exceptions::{RECURRENCE_ID_KEY, SERIES_ID_KEY};
586
587 let incomplete = match (
588 props.contains_key(SERIES_ID_KEY),
589 props.contains_key(RECURRENCE_ID_KEY),
590 ) {
591 (true, true) => series.is_none() || recurrence.is_none(),
592 (true, false) | (false, true) => true,
593 (false, false) => false,
594 };
595 if !incomplete {
596 return;
597 }
598
599 let (key, value) = match (series, recurrence) {
600 (Some(id), None) => (SERIES_ID_KEY, id),
601 (None, Some(occurrence)) => (RECURRENCE_ID_KEY, occurrence),
602 _ => (SERIES_ID_KEY, ""),
606 };
607 warn_unusable_exception(
608 prop_warning_counter,
609 path,
610 line,
611 key,
612 value,
613 "an exception needs both SERIES_ID and RECURRENCE_ID, and only one of them is usable here",
614 );
615}
616
617fn parse_heading(text: &str) -> (Option<TaskType>, Option<Priority>, String) {
644 let (task_type, rest) = if let Some(caps) = HEADING_TODO_RE.captures(text) {
646 let kw = caps.get(1).map(|m| m.as_str()).unwrap_or("");
647 let m = caps
648 .get(0)
649 .expect("Captures::get(0) is Some when captures() succeeds");
650 (TaskType::from_keyword(kw), &text[m.end()..])
651 } else {
652 (None, text)
653 };
654 let title = rest.trim();
655
656 if let Some(caps) = HEADING_PRIORITY_RE.captures(title) {
658 let value = caps.get(1).map(|m| m.as_str()).unwrap_or("");
659 if let Some(priority) = Priority::parse(value) {
660 let whole = caps
661 .get(0)
662 .expect("Captures::get(0) is Some when captures() succeeds");
663 let heading = if whole.start() == 0 {
665 title[whole.end()..].trim()
666 } else {
667 title
668 };
669 return (task_type, Some(priority), heading.to_string());
670 }
671 }
672
673 (task_type, None, title.to_string())
674}
675
676static HEADING_HASHES_RE: LazyLock<Regex> = LazyLock::new(|| compile_bounded(r"^(#{1,6})[ \t]+"));
684
685#[derive(Debug, Clone, PartialEq)]
691pub struct HeadingToken<T> {
692 pub range: Range<usize>,
694 pub value: T,
696}
697
698#[derive(Debug, Clone, PartialEq)]
717pub struct HeadingLine {
718 pub level: usize,
720 pub status: Option<HeadingToken<TaskType>>,
722 pub priority: Option<HeadingToken<Priority>>,
724 pub title_start: usize,
729}
730
731pub fn parse_heading_line(line: &str) -> Option<HeadingLine> {
745 let hashes = HEADING_HASHES_RE.captures(line)?;
746 let level = hashes
747 .get(1)
748 .expect("group 1 is Some when captures() succeeds")
749 .len();
750 let after_hashes = hashes
751 .get(0)
752 .expect("Captures::get(0) is Some when captures() succeeds")
753 .end();
754
755 let (status, after_status) = match HEADING_TODO_RE.captures(&line[after_hashes..]) {
756 Some(caps) => {
757 let keyword = caps
758 .get(1)
759 .expect("group 1 is Some when captures() succeeds");
760 let whole = caps
761 .get(0)
762 .expect("Captures::get(0) is Some when captures() succeeds");
763 let token = TaskType::from_keyword(keyword.as_str()).map(|value| HeadingToken {
764 range: after_hashes + keyword.start()..after_hashes + keyword.end(),
765 value,
766 });
767 (token, after_hashes + whole.end())
768 }
769 None => (None, after_hashes),
770 };
771
772 let priority = HEADING_PRIORITY_RE
775 .captures(&line[after_status..])
776 .and_then(|caps| {
777 let value = caps
778 .get(1)
779 .expect("group 1 is Some when captures() succeeds");
780 let parsed = Priority::parse(value.as_str())?;
783 Some(HeadingToken {
784 range: after_status + value.start() - "[#".len()
785 ..after_status + value.end() + "]".len(),
786 value: parsed,
787 })
788 });
789
790 let after_tokens = priority
795 .as_ref()
796 .filter(|cookie| line[after_status..cookie.range.start].trim().is_empty())
797 .map_or(after_status, |cookie| cookie.range.end);
798 let title_start = after_tokens
799 + line[after_tokens..]
800 .find(|c: char| !c.is_whitespace())
801 .unwrap_or(line.len() - after_tokens);
802
803 Some(HeadingLine {
804 level,
805 status,
806 priority,
807 title_start,
808 })
809}
810
811fn strip_wrapping_backticks(s: &str) -> &str {
823 let bytes = s.as_bytes();
824 let n_leading = bytes.iter().take_while(|&&b| b == b'`').count();
825 if n_leading == 0 {
826 return s;
827 }
828 let n_trailing = bytes.iter().rev().take_while(|&&b| b == b'`').count();
829 if n_trailing != n_leading || bytes.len() < 2 * n_leading + 1 {
833 return s;
834 }
835 s[n_leading..bytes.len() - n_leading].trim()
836}
837
838fn parse_org_properties(
849 literal: &str,
850 props: &mut BTreeMap<String, String>,
851 path: &Path,
852 block_start_line: u32,
853 prop_warning_counter: &mut usize,
854) {
855 for (offset, line) in literal.lines().enumerate() {
856 if line.trim().is_empty() {
857 continue;
858 }
859 let src_line = block_start_line
861 .saturating_add(1)
862 .saturating_add(offset as u32);
863 match line.split_once(':') {
864 Some((key, value)) => {
865 let key = key.trim();
866 if key.is_empty() {
867 warn_invalid_property_line(prop_warning_counter, path, src_line, line);
868 continue;
869 }
870 props.insert(key.to_string(), value.trim().to_string());
871 }
872 None => {
873 warn_invalid_property_line(prop_warning_counter, path, src_line, line);
874 }
875 }
876 }
877}
878
879fn extract_timestamps_from_node<'a>(
881 node: &'a AstNode<'a>,
882 mappings: &[(&str, &str)],
883) -> (Option<String>, Option<String>) {
884 let mut created = None;
885 let mut timestamp = None;
886
887 if let NodeValue::Paragraph = &node.data.borrow().value {
888 for child in node.children() {
889 if let NodeValue::Code(code) = &child.data.borrow().value {
890 let normalized = normalize_weekdays(&code.literal, mappings);
893 if created.is_none() {
894 created = extract_created_normalized(&normalized);
895 }
896 if timestamp.is_none() {
897 timestamp = extract_timestamp_normalized(&normalized);
898 }
899 }
900 }
901 }
902 (created, timestamp)
903}
904
905fn extract_paragraph_text<'a>(node: &'a AstNode<'a>) -> String {
912 let mut text = String::new();
913 collect_text_recursive(node, &mut text, InlineCode::Drop);
914 text.trim().to_string()
915}
916
917fn extract_text<'a>(node: &'a AstNode<'a>) -> String {
920 let mut text = String::new();
921 collect_text_recursive(node, &mut text, InlineCode::Keep);
922 text
923}
924
925#[derive(Clone, Copy, PartialEq, Eq)]
927enum InlineCode {
928 Keep,
931 Drop,
933}
934
935fn collect_text_recursive<'a>(node: &'a AstNode<'a>, out: &mut String, code: InlineCode) {
936 for child in node.children() {
937 let value = child.data.borrow().value.clone();
938 match value {
939 NodeValue::Text(t) => out.push_str(&t),
940 NodeValue::Code(inline) if code == InlineCode::Keep => out.push_str(&inline.literal),
941 NodeValue::Emph | NodeValue::Strong | NodeValue::Link(_) | NodeValue::Strikethrough => {
942 collect_text_recursive(child, out, code)
943 }
944 _ => {}
945 }
946 }
947}
948
949pub fn display_text(markdown: &str) -> String {
966 let arena = Arena::new();
967 let root = parse_document(&arena, markdown, &safe_comrak_options());
968
969 let mut text = String::new();
970 collect_block_text(root, &mut text);
971 text.trim().to_string()
972}
973
974fn collect_block_text<'a>(node: &'a AstNode<'a>, out: &mut String) {
980 for child in node.children() {
981 let value = child.data.borrow().value.clone();
982 match value {
983 NodeValue::Paragraph | NodeValue::Heading(_) => {
984 collect_text_recursive(child, out, InlineCode::Keep)
985 }
986 _ => collect_block_text(child, out),
987 }
988 }
989}
990
991#[cfg(test)]
995mod tests {
996 use super::*;
997 use crate::types::{CancelledSpelling, DEFAULT_MAX_TASKS};
998
999 #[test]
1000 fn warn_invalid_timestamp_advances_per_call_counter() {
1001 let mut counter = 0_usize;
1007 let path = Path::new("t.md");
1008 for i in 1..=25 {
1009 warn_invalid_timestamp(&mut counter, path, i, "<bad>");
1010 }
1011 assert_eq!(counter, 25);
1012 }
1013
1014 #[test]
1015 fn warn_invalid_property_line_advances_per_call_counter() {
1016 let mut counter = 0_usize;
1020 let path = Path::new("t.md");
1021 for i in 1..=25 {
1022 warn_invalid_property_line(&mut counter, path, i, "no-colon-here");
1023 }
1024 assert_eq!(counter, 25);
1025 }
1026
1027 #[test]
1028 fn warn_invalid_timestamp_counters_are_independent() {
1029 let mut counter_a = 0_usize;
1035 let mut counter_b = 0_usize;
1036 let path = Path::new("t.md");
1037 for _ in 0..MAX_DIAGNOSTIC_ITEMS {
1038 warn_invalid_timestamp(&mut counter_a, path, 1, "<bad>");
1039 }
1040 warn_invalid_timestamp(&mut counter_b, path, 1, "<bad>");
1041 assert_eq!(counter_a, MAX_DIAGNOSTIC_ITEMS);
1042 assert_eq!(counter_b, 1);
1043 }
1044
1045 #[test]
1046 fn test_parse_heading_with_priority() {
1047 let (task_type, priority, heading) = parse_heading("TODO [#A] Important task");
1048 assert_eq!(task_type, Some(TaskType::Todo));
1049 assert_eq!(priority, Some(Priority::A));
1050 assert_eq!(heading, "Important task");
1051 }
1052
1053 #[test]
1054 fn test_parse_heading_without_priority() {
1055 let (task_type, priority, heading) = parse_heading("DONE Simple task");
1056 assert_eq!(task_type, Some(TaskType::Done));
1057 assert_eq!(priority, None);
1058 assert_eq!(heading, "Simple task");
1059 }
1060
1061 #[test]
1062 fn test_parse_heading_no_task() {
1063 let (task_type, priority, heading) = parse_heading("Regular heading");
1064 assert_eq!(task_type, None);
1065 assert_eq!(priority, None);
1066 assert_eq!(heading, "Regular heading");
1067 }
1068
1069 #[test]
1075 fn parse_heading_priority_without_todo() {
1076 let (tt, p, h) = parse_heading("[#A] Заголовок");
1078 assert_eq!(tt, None);
1079 assert_eq!(p, Some(Priority::A));
1080 assert_eq!(h, "Заголовок");
1081 }
1082
1083 #[test]
1084 fn parse_heading_todo_with_priority() {
1085 let (tt, p, h) = parse_heading("TODO [#A] Заголовок");
1087 assert_eq!(tt, Some(TaskType::Todo));
1088 assert_eq!(p, Some(Priority::A));
1089 assert_eq!(h, "Заголовок");
1090 }
1091
1092 #[test]
1093 fn parse_heading_done_with_priority_b() {
1094 let (tt, p, h) = parse_heading("DONE [#B] Заголовок");
1096 assert_eq!(tt, Some(TaskType::Done));
1097 assert_eq!(p, Some(Priority::B));
1098 assert_eq!(h, "Заголовок");
1099 }
1100
1101 #[test]
1102 fn parse_heading_plain_text_no_markers() {
1103 let (tt, p, h) = parse_heading("Заголовок");
1105 assert_eq!(tt, None);
1106 assert_eq!(p, None);
1107 assert_eq!(h, "Заголовок");
1108 }
1109
1110 #[test]
1111 fn parse_heading_todo_no_priority() {
1112 let (tt, p, h) = parse_heading("TODO Заголовок");
1114 assert_eq!(tt, Some(TaskType::Todo));
1115 assert_eq!(p, None);
1116 assert_eq!(h, "Заголовок");
1117 }
1118
1119 #[test]
1120 fn parse_heading_numeric_priority() {
1121 let (tt, p, h) = parse_heading("[#1] Заголовок");
1123 assert_eq!(tt, None);
1124 assert_eq!(p, Some(Priority::Numeric(1)));
1125 assert_eq!(h, "Заголовок");
1126 }
1127
1128 #[test]
1129 fn parse_heading_extra_whitespace_around_priority() {
1130 let (tt, p, h) = parse_heading("[#A] Заголовок");
1135 assert_eq!(tt, None);
1136 assert_eq!(p, Some(Priority::A));
1137 assert_eq!(h, "Заголовок");
1138 }
1139
1140 #[test]
1141 fn parse_heading_priority_in_the_middle_org_semantics() {
1142 let (tt, p, h) = parse_heading("Без приоритета и [#A] внутри");
1147 assert_eq!(tt, None);
1148 assert_eq!(p, Some(Priority::A));
1149 assert_eq!(h, "Без приоритета и [#A] внутри");
1150 }
1151
1152 #[test]
1153 fn parse_heading_trailing_cookie_leaves_a_title_behind() {
1154 let (tt, p, h) = parse_heading("TODO Заголовок с cookie в конце [#A]");
1158 assert_eq!(tt, Some(TaskType::Todo));
1159 assert_eq!(p, Some(Priority::A));
1160 assert_eq!(h, "Заголовок с cookie в конце [#A]");
1161 }
1162
1163 #[test]
1164 fn parse_heading_two_digit_numeric_priority() {
1165 let (tt, p, h) = parse_heading("[#15] Mid range");
1166 assert_eq!(tt, None);
1167 assert_eq!(p, Some(Priority::Numeric(15)));
1168 assert_eq!(h, "Mid range");
1169
1170 let (tt, p, h) = parse_heading("[#64] At upper bound");
1171 assert_eq!(tt, None);
1172 assert_eq!(p, Some(Priority::Numeric(64)));
1173 assert_eq!(h, "At upper bound");
1174 }
1175
1176 #[test]
1177 fn parse_heading_rejects_numeric_out_of_range() {
1178 let (tt, p, h) = parse_heading("[#65] Above range");
1181 assert_eq!(tt, None);
1182 assert_eq!(p, None);
1183 assert_eq!(h, "[#65] Above range");
1184 }
1185
1186 #[test]
1187 fn parse_heading_rejects_lowercase_priority() {
1188 let (tt, p, h) = parse_heading("[#a] Lowercase");
1189 assert_eq!(tt, None);
1190 assert_eq!(p, None);
1191 assert_eq!(h, "[#a] Lowercase");
1192 }
1193
1194 #[test]
1195 fn parse_heading_todo_then_priority_with_intervening_text() {
1196 let (tt, p, h) = parse_heading("TODO Купить [#A] фильтр");
1200 assert_eq!(tt, Some(TaskType::Todo));
1201 assert_eq!(p, Some(Priority::A));
1202 assert_eq!(h, "Купить [#A] фильтр");
1203 }
1204
1205 #[test]
1206 fn parse_heading_priority_without_trailing_space() {
1207 let (tt, p, h) = parse_heading("[#A]NoSpace");
1209 assert_eq!(tt, None);
1210 assert_eq!(p, Some(Priority::A));
1211 assert_eq!(h, "NoSpace");
1212 }
1213
1214 #[test]
1215 fn parse_heading_cancelled_simple() {
1216 let (tt, p, h) = parse_heading("CANCELLED Foo");
1217 assert_eq!(tt, Some(TaskType::Cancelled(CancelledSpelling::DoubleL)));
1218 assert_eq!(p, None);
1219 assert_eq!(h, "Foo");
1220 }
1221
1222 #[test]
1223 fn parse_heading_cancelled_with_priority() {
1224 let (tt, p, h) = parse_heading("CANCELLED [#A] Foo");
1225 assert_eq!(tt, Some(TaskType::Cancelled(CancelledSpelling::DoubleL)));
1226 assert_eq!(p, Some(Priority::A));
1227 assert_eq!(h, "Foo");
1228 }
1229
1230 #[test]
1231 fn parse_heading_cancelled_without_whitespace() {
1232 let (tt, p, h) = parse_heading("CANCELLEDFoo");
1234 assert_eq!(tt, None);
1235 assert_eq!(p, None);
1236 assert_eq!(h, "CANCELLEDFoo");
1237 }
1238
1239 #[test]
1240 fn parse_heading_cancelled_lowercase_not_recognised() {
1241 let (tt, p, h) = parse_heading("cancelled Foo");
1243 assert_eq!(tt, None);
1244 assert_eq!(p, None);
1245 assert_eq!(h, "cancelled Foo");
1246 }
1247
1248 #[test]
1249 fn parse_heading_todo_cancelled_first_keyword_wins() {
1250 let (tt, p, h) = parse_heading("TODO CANCELLED Foo");
1252 assert_eq!(tt, Some(TaskType::Todo));
1253 assert_eq!(p, None);
1254 assert_eq!(h, "CANCELLED Foo");
1255 }
1256
1257 #[test]
1258 fn parse_heading_canceled_single_l() {
1259 let (tt, p, h) = parse_heading("CANCELED Foo");
1262 assert_eq!(tt, Some(TaskType::Cancelled(CancelledSpelling::SingleL)));
1263 assert_eq!(p, None);
1264 assert_eq!(h, "Foo");
1265 }
1266
1267 #[test]
1268 fn parse_heading_canceled_with_priority() {
1269 let (tt, p, h) = parse_heading("CANCELED [#A] Foo");
1270 assert_eq!(tt, Some(TaskType::Cancelled(CancelledSpelling::SingleL)));
1271 assert_eq!(p, Some(Priority::A));
1272 assert_eq!(h, "Foo");
1273 }
1274
1275 #[test]
1276 fn parse_heading_canceled_lowercase_not_recognised() {
1277 let (tt, p, h) = parse_heading("canceled Foo");
1279 assert_eq!(tt, None);
1280 assert_eq!(p, None);
1281 assert_eq!(h, "canceled Foo");
1282 }
1283
1284 #[test]
1285 fn parse_heading_canceled_without_whitespace_not_recognised() {
1286 let (tt, p, h) = parse_heading("CANCELEDfoo");
1288 assert_eq!(tt, None);
1289 assert_eq!(p, None);
1290 assert_eq!(h, "CANCELEDfoo");
1291 }
1292
1293 #[test]
1294 fn extract_tasks_marks_scheduled_angle_bracket_as_active() {
1295 let content = "### TODO Pin me\n`SCHEDULED: <2026-05-21 Thu>`\n";
1300 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1301 assert_eq!(tasks.len(), 1);
1302 assert_eq!(tasks[0].timestamp_active, Some(true));
1303 }
1304
1305 #[test]
1306 fn extract_tasks_marks_missing_timestamp_active_as_none() {
1307 let content = "### Project kickoff\n\n`CREATED: [2025-09-01 Mon]`\n";
1311 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1312 assert_eq!(tasks.len(), 1);
1313 assert_eq!(tasks[0].timestamp_active, None);
1314 }
1315
1316 #[test]
1317 fn extract_tasks_basic_todo_with_deadline() {
1318 let content = "\
1319### TODO [#A] Write docs\n\
1320`DEADLINE: <2025-12-10 Wed>`\n";
1321 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1322 assert_eq!(tasks.len(), 1);
1323 let t = &tasks[0];
1324 assert_eq!(t.task_type, Some(TaskType::Todo));
1325 assert_eq!(t.priority, Some(Priority::A));
1326 assert_eq!(t.heading, "Write docs");
1327 assert_eq!(t.timestamp_type, Some("DEADLINE".to_string()));
1328 assert_eq!(t.timestamp_date, Some("2025-12-10".to_string()));
1329 }
1330
1331 #[test]
1332 fn extract_tasks_extracts_emph_text_in_heading() {
1333 let content = "### TODO **Important** task\n`DEADLINE: <2025-12-10 Wed>`\n";
1335 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1336 assert_eq!(tasks.len(), 1);
1337 assert_eq!(tasks[0].heading, "Important task");
1338 }
1339
1340 #[test]
1341 fn extract_tasks_ignores_non_task_headings_without_timestamps() {
1342 let content = "### Just a heading\n\nSome text.\n";
1343 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1344 assert!(tasks.is_empty());
1345 }
1346
1347 #[test]
1348 fn extract_tasks_keeps_created_without_todo() {
1349 let content = "### Project kickoff\n\n`CREATED: [2025-09-01 Mon]`\n";
1351 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1352 assert_eq!(tasks.len(), 1);
1353 assert_eq!(tasks[0].task_type, None);
1354 assert_eq!(tasks[0].created, Some("CREATED: [2025-09-01 Mon]".into()));
1355 }
1356
1357 #[test]
1358 fn extract_tasks_concatenates_multiple_paragraphs() {
1359 let content = "\
1361### TODO Multi-line task\n\
1362First paragraph.\n\
1363\n\
1364Second paragraph.\n\
1365";
1366 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1367 assert_eq!(tasks.len(), 1);
1368 assert!(tasks[0].content.contains("First paragraph"));
1369 assert!(tasks[0].content.contains("Second paragraph"));
1370 }
1371
1372 #[test]
1373 fn extract_tasks_extracts_clock_from_inline_code() {
1374 let content = "\
1375### TODO Track time\n\
1376`CLOCK: [2025-09-01 Mon 10:00]--[2025-09-01 Mon 11:30] => 1:30`\n\
1377";
1378 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1379 assert_eq!(tasks.len(), 1);
1380 let t = &tasks[0];
1381 assert!(t.clocks.is_some());
1382 assert_eq!(t.total_clock_time.as_deref(), Some("1:30"));
1383 }
1384
1385 #[test]
1386 fn extract_tasks_handles_done_priority() {
1387 let content = "### DONE [#B] Wrap up\n";
1388 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1389 assert_eq!(tasks.len(), 1);
1390 assert_eq!(tasks[0].task_type, Some(TaskType::Done));
1391 assert_eq!(tasks[0].priority, Some(Priority::B));
1392 }
1393
1394 #[test]
1395 fn extract_tasks_priority_without_todo_with_scheduled() {
1396 let content = "\
1400### [#A] Поменять резину до 16.05.2026\n\
1401`SCHEDULED: <2026-05-09 Sat>`\n";
1402 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1403 assert_eq!(tasks.len(), 1);
1404 let t = &tasks[0];
1405 assert_eq!(t.task_type, None);
1406 assert_eq!(t.priority, Some(Priority::A));
1407 assert_eq!(t.heading, "Поменять резину до 16.05.2026");
1408 assert_eq!(t.timestamp_type, Some("SCHEDULED".to_string()));
1409 assert_eq!(t.timestamp_date, Some("2026-05-09".to_string()));
1410 }
1411
1412 #[test]
1413 fn extract_tasks_numeric_priority_with_deadline() {
1414 let content = "\
1416### [#1] Numeric priority task\n\
1417`DEADLINE: <2026-05-09 Sat>`\n";
1418 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1419 assert_eq!(tasks.len(), 1);
1420 let t = &tasks[0];
1421 assert_eq!(t.task_type, None);
1422 assert_eq!(t.priority, Some(Priority::Numeric(1)));
1423 assert_eq!(t.heading, "Numeric priority task");
1424 }
1425
1426 #[test]
1427 fn extract_tasks_priority_in_middle_keeps_the_prefix() {
1428 let content = "\
1431### Без приоритета и [#A] внутри\n\
1432`SCHEDULED: <2026-05-09 Sat>`\n";
1433 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1434 assert_eq!(tasks.len(), 1);
1435 let t = &tasks[0];
1436 assert_eq!(t.task_type, None);
1437 assert_eq!(t.priority, Some(Priority::A));
1438 assert_eq!(t.heading, "Без приоритета и [#A] внутри");
1439 }
1440
1441 #[test]
1442 fn extract_tasks_bug_report_minimal_reproduction() {
1443 let content = "\
1447### [#A] Поменять резину до 16.05.2026\n\
1448`SCHEDULED: <2026-05-09 Sat>`\n\
1449\n\
1450### TODO [#A] Поменять масло\n\
1451`SCHEDULED: <2026-05-09 Sat>`\n\
1452\n\
1453### Купить фильтр\n\
1454`SCHEDULED: <2026-05-09 Sat>`\n";
1455 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1456 assert_eq!(tasks.len(), 3);
1457
1458 assert_eq!(tasks[0].task_type, None);
1459 assert_eq!(tasks[0].priority, Some(Priority::A));
1460 assert_eq!(tasks[0].heading, "Поменять резину до 16.05.2026");
1461
1462 assert_eq!(tasks[1].task_type, Some(TaskType::Todo));
1463 assert_eq!(tasks[1].priority, Some(Priority::A));
1464 assert_eq!(tasks[1].heading, "Поменять масло");
1465
1466 assert_eq!(tasks[2].task_type, None);
1467 assert_eq!(tasks[2].priority, None);
1468 assert_eq!(tasks[2].heading, "Купить фильтр");
1469 }
1470
1471 #[test]
1480 fn extract_tasks_indented_inline_code_deadline() {
1481 let content = "#### Birthday\n `DEADLINE: <2026-05-07 Thu +1y>`\n";
1482 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1483 assert_eq!(tasks.len(), 1, "task should not be dropped");
1484 let t = &tasks[0];
1485 assert_eq!(t.timestamp_type.as_deref(), Some("DEADLINE"));
1486 assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1487 }
1488
1489 #[test]
1490 fn extract_tasks_todo_indented_inline_code_deadline() {
1491 let content = "#### TODO Birthday\n `DEADLINE: <2026-05-07 Thu +1y>`\n";
1492 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1493 assert_eq!(tasks.len(), 1);
1494 let t = &tasks[0];
1495 assert_eq!(t.task_type, Some(TaskType::Todo));
1496 assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1497 }
1498
1499 #[test]
1500 fn extract_tasks_indented_inline_code_blank_lines_between() {
1501 let content = "#### Birthday\n\n \t \n `DEADLINE: <2026-05-07 Thu +1y>`\n";
1504 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1505 assert_eq!(tasks.len(), 1);
1506 let t = &tasks[0];
1507 assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1508 }
1509
1510 #[test]
1511 fn extract_tasks_with_ru_mappings_reproduces_cli_pipeline() {
1512 let content = "#### TODO Birthday\n `DEADLINE: <2026-05-07 Thu +1y>`\n";
1517 let tasks = extract_tasks(
1518 Path::new("t.md"),
1519 content,
1520 crate::locale::RU_WEEKDAY_MAPPINGS,
1521 DEFAULT_MAX_TASKS,
1522 );
1523 assert_eq!(tasks.len(), 1);
1524 let t = &tasks[0];
1525 assert_eq!(t.task_type, Some(TaskType::Todo));
1526 assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1527 }
1528
1529 #[test]
1530 fn extract_tasks_inline_code_scheduled_no_indent() {
1531 let content = "#### Followup\n`SCHEDULED: <2026-05-07 Thu>`\n";
1532 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1533 assert_eq!(tasks.len(), 1);
1534 let t = &tasks[0];
1535 assert_eq!(t.timestamp_type.as_deref(), Some("SCHEDULED"));
1536 assert_eq!(t.timestamp_date.as_deref(), Some("2026-05-07"));
1537 }
1538
1539 #[test]
1540 fn extract_tasks_indented_inline_code_created() {
1541 let content = "#### Project kickoff\n `CREATED: [2025-09-01 Mon]`\n";
1542 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1543 assert_eq!(tasks.len(), 1);
1544 let t = &tasks[0];
1545 assert_eq!(t.created.as_deref(), Some("CREATED: [2025-09-01 Mon]"));
1546 }
1547
1548 #[test]
1549 fn extract_tasks_parses_single_property() {
1550 let content = "### TODO Ship release\n`SCHEDULED: <2026-06-01 Mon 10:00>`\n```org-properties\nGCAL_EVENT_ID: abc123/primary\n```\n";
1551 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1552 assert_eq!(tasks.len(), 1);
1553 let props = tasks[0].properties.as_ref().expect("properties present");
1554 assert_eq!(
1555 props.get("GCAL_EVENT_ID").map(String::as_str),
1556 Some("abc123/primary")
1557 );
1558 assert!(!tasks[0].content.contains("GCAL_EVENT_ID"));
1560 assert!(!tasks[0].content.contains("org-properties"));
1561 }
1562
1563 #[test]
1564 fn extract_tasks_parses_multiple_properties() {
1565 let content =
1566 "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nA: 1\nB: 2\n```\n";
1567 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1568 let props = tasks[0].properties.as_ref().unwrap();
1569 assert_eq!(props.get("A").map(String::as_str), Some("1"));
1570 assert_eq!(props.get("B").map(String::as_str), Some("2"));
1571 }
1572
1573 #[test]
1574 fn extract_tasks_property_duplicate_keys_last_wins() {
1575 let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nK: first\nK: second\n```\n";
1576 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1577 assert_eq!(
1578 tasks[0]
1579 .properties
1580 .as_ref()
1581 .unwrap()
1582 .get("K")
1583 .map(String::as_str),
1584 Some("second")
1585 );
1586 }
1587
1588 #[test]
1589 fn extract_tasks_property_empty_value_allowed() {
1590 let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nK:\n```\n";
1591 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1592 assert_eq!(
1593 tasks[0]
1594 .properties
1595 .as_ref()
1596 .unwrap()
1597 .get("K")
1598 .map(String::as_str),
1599 Some("")
1600 );
1601 }
1602
1603 fn counted(content: &str) -> (Vec<Task>, usize, usize) {
1607 let mut timestamps = 0_usize;
1608 let mut properties = 0_usize;
1609 let tasks = extract_tasks_with_counter(
1610 Path::new("t.md"),
1611 content,
1612 &[],
1613 DEFAULT_MAX_TASKS,
1614 &mut timestamps,
1615 &mut properties,
1616 );
1617 (tasks, timestamps, properties)
1618 }
1619
1620 fn with_properties(properties: &str) -> String {
1622 format!(
1623 "### TODO T\n`SCHEDULED: <2026-08-13 Thu +1w>`\n```org-properties\n{properties}\n```\n"
1624 )
1625 }
1626
1627 #[test]
1628 fn a_date_in_an_exdate_that_cannot_be_read_is_not_a_broken_timestamp() {
1629 let (tasks, timestamps, properties) =
1634 counted(&with_properties("EXDATE: 2026-08-20, next-thursday"));
1635
1636 assert_eq!(
1637 tasks[0].excluded_dates.as_deref(),
1638 Some(["2026-08-20".to_string()].as_slice()),
1639 "the date that reads is kept"
1640 );
1641 assert_eq!(timestamps, 0, "nothing here is a timestamp");
1642 assert_eq!(properties, 1, "the field that does not read is reported");
1643 }
1644
1645 #[test]
1646 fn an_exdate_that_holds_no_date_at_all_is_reported() {
1647 let (tasks, _, properties) = counted(&with_properties("EXDATE:"));
1651
1652 assert_eq!(tasks[0].excluded_dates, None);
1653 assert_eq!(properties, 1, "a key with nothing usable in it is reported");
1654 }
1655
1656 #[test]
1657 fn an_exdate_of_separators_alone_is_reported() {
1658 let (tasks, _, properties) = counted(&with_properties("EXDATE: , ,"));
1659
1660 assert_eq!(tasks[0].excluded_dates, None);
1661 assert_eq!(properties, 1);
1662 }
1663
1664 #[test]
1665 fn half_of_an_exception_pair_is_reported() {
1666 let (tasks, _, properties) = counted(&with_properties("SERIES_ID: series-1"));
1669
1670 assert_eq!(tasks[0].series_id.as_deref(), Some("series-1"));
1671 assert_eq!(tasks[0].recurrence_id, None);
1672 assert_eq!(properties, 1, "the missing half is reported");
1673 }
1674
1675 #[test]
1676 fn the_other_half_of_an_exception_pair_is_reported_too() {
1677 let (tasks, _, properties) = counted(&with_properties("RECURRENCE_ID: 2026-08-20 15:00"));
1678
1679 assert_eq!(tasks[0].recurrence_id.as_deref(), Some("2026-08-20 15:00"));
1680 assert_eq!(tasks[0].series_id, None);
1681 assert_eq!(properties, 1, "the missing half is reported");
1682 }
1683
1684 #[test]
1685 fn an_empty_series_id_leaves_the_pair_incomplete_and_is_reported() {
1686 let (tasks, _, properties) =
1687 counted(&with_properties("SERIES_ID:\nRECURRENCE_ID: 2026-08-20"));
1688
1689 assert_eq!(tasks[0].series_id, None, "an empty id names no series");
1690 assert_eq!(properties, 1);
1691 }
1692
1693 #[test]
1694 fn a_time_in_a_recurrence_id_that_cannot_be_read_is_reported() {
1695 let (tasks, _, properties) = counted(&with_properties(
1699 "SERIES_ID: series-1\nRECURRENCE_ID: 2026-08-20 15-00",
1700 ));
1701
1702 assert_eq!(tasks[0].recurrence_id.as_deref(), Some("2026-08-20"));
1703 assert_eq!(properties, 1, "the dropped time is reported");
1704 }
1705
1706 #[test]
1707 fn a_recurrence_id_written_with_seconds_keeps_its_time() {
1708 let (tasks, _, properties) = counted(&with_properties(
1711 "SERIES_ID: series-1\nRECURRENCE_ID: 2026-08-20 15:00:00",
1712 ));
1713
1714 assert_eq!(tasks[0].recurrence_id.as_deref(), Some("2026-08-20 15:00"));
1715 assert_eq!(properties, 0, "nothing was lost, so nothing is reported");
1716 }
1717
1718 #[test]
1719 fn a_recurrence_id_that_is_not_a_date_is_reported_as_the_broken_pair_it_leaves() {
1720 let (tasks, timestamps, properties) = counted(&with_properties(
1723 "SERIES_ID: series-1\nRECURRENCE_ID: whenever",
1724 ));
1725
1726 assert_eq!(tasks[0].recurrence_id, None);
1727 assert_eq!(timestamps, 0, "a property value is not a timestamp");
1728 assert_eq!(properties, 2, "the unreadable value, then the broken pair");
1729 }
1730
1731 #[test]
1732 fn extract_tasks_property_malformed_line_skipped() {
1733 let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\nGOOD: x\nno colon here\n```\n";
1734 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1735 let props = tasks[0].properties.as_ref().unwrap();
1736 assert_eq!(props.get("GOOD").map(String::as_str), Some("x"));
1737 assert_eq!(props.len(), 1, "malformed line must be skipped");
1738 }
1739
1740 #[test]
1741 fn extract_tasks_empty_property_block_yields_none() {
1742 let content = "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties\n\n```\n";
1743 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1744 assert_eq!(tasks[0].properties, None);
1745 }
1746
1747 #[test]
1748 fn extract_tasks_property_info_with_extra_attrs_not_recognised() {
1749 let content =
1752 "### TODO T\n`SCHEDULED: <2026-06-01 Mon>`\n```org-properties extra\nK: v\n```\n";
1753 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1754 assert_eq!(tasks[0].properties, None);
1755 }
1756
1757 #[test]
1758 fn extract_tasks_clock_code_block_unaffected_by_properties() {
1759 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";
1762 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1763 assert_eq!(
1764 tasks[0]
1765 .properties
1766 .as_ref()
1767 .unwrap()
1768 .get("K")
1769 .map(String::as_str),
1770 Some("v")
1771 );
1772 assert_eq!(tasks[0].total_clock_time.as_deref(), Some("1:30"));
1773 }
1774
1775 #[test]
1776 fn extract_tasks_merges_multiple_property_blocks_last_wins() {
1777 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";
1778 let tasks = extract_tasks(Path::new("t.md"), content, &[], DEFAULT_MAX_TASKS);
1779 let props = tasks[0].properties.as_ref().unwrap();
1780 assert_eq!(props.get("K").map(String::as_str), Some("two"));
1781 assert_eq!(props.get("L").map(String::as_str), Some("three"));
1782 }
1783
1784 #[test]
1789 fn an_exdate_of_nothing_readable_leaves_no_field_at_all() {
1790 let (tasks, _, properties) = counted(&with_properties("EXDATE: next-thursday, sometime"));
1795
1796 assert_eq!(tasks[0].excluded_dates, None);
1797 assert_eq!(properties, 2, "each field that does not read is reported");
1798 }
1799
1800 #[test]
1801 fn the_budget_for_unusable_exceptions_spans_the_file() {
1802 let content = format!(
1807 "{}{}",
1808 with_properties("EXDATE: never"),
1809 with_properties("EXDATE: sometime, whenever")
1810 );
1811 let (tasks, timestamps, properties) = counted(&content);
1812
1813 assert_eq!(tasks.len(), 2);
1814 assert_eq!(timestamps, 0);
1815 assert_eq!(
1816 properties, 3,
1817 "one field in the first entry, two in the second"
1818 );
1819 }
1820
1821 #[test]
1822 fn a_task_without_properties_asks_nothing_of_the_exception_keys() {
1823 let mut counter = 0_usize;
1824 let fields = exception_fields(Path::new("t.md"), 1, None, &mut counter);
1825
1826 assert_eq!(fields, ExceptionFields::default());
1827 assert_eq!(counter, 0);
1828 }
1829}