1use chrono::NaiveDate;
12use serde::{Deserialize, Serialize};
13use std::collections::{HashMap, HashSet};
14use std::fmt;
15use std::path::PathBuf;
16
17use crate::task_parser::ParsedTaskMetadata;
18
19#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
21pub struct SourcePosition {
22 pub line: usize,
24 pub column: usize,
26 pub offset: usize,
28 pub length: usize,
30}
31
32impl SourcePosition {
33 pub fn new(line: usize, column: usize, offset: usize, length: usize) -> Self {
35 Self {
36 line,
37 column,
38 offset,
39 length,
40 }
41 }
42
43 pub fn start() -> Self {
45 Self {
46 line: 0,
47 column: 0,
48 offset: 0,
49 length: 0,
50 }
51 }
52
53 pub fn from_offset(content: &str, offset: usize, length: usize) -> Self {
60 let before = &content[..offset.min(content.len())];
61 let line = before.matches('\n').count() + 1;
62 let column = before
63 .rfind('\n')
64 .map(|pos| offset - pos)
65 .unwrap_or(offset + 1);
66
67 Self {
68 line,
69 column,
70 offset,
71 length,
72 }
73 }
74
75 pub fn from_offset_indexed(index: &LineIndex, offset: usize, length: usize) -> Self {
79 let (line, column) = index.line_col(offset);
80 Self {
81 line,
82 column,
83 offset,
84 length,
85 }
86 }
87}
88
89#[derive(Debug, Clone)]
107pub struct LineIndex {
108 line_starts: Vec<usize>,
110}
111
112impl LineIndex {
113 pub fn new(content: &str) -> Self {
115 let mut line_starts = vec![0];
116 for (i, ch) in content.char_indices() {
117 if ch == '\n' {
118 line_starts.push(i + 1);
119 }
120 }
121 Self { line_starts }
122 }
123
124 pub fn line_col(&self, offset: usize) -> (usize, usize) {
128 let line_idx = self.line_starts.partition_point(|&start| start <= offset);
130 let line = line_idx.max(1); let line_start = self
132 .line_starts
133 .get(line_idx.saturating_sub(1))
134 .copied()
135 .unwrap_or(0);
136 let column = offset - line_start + 1; (line, column)
138 }
139
140 pub fn line_start(&self, line: usize) -> Option<usize> {
142 if line == 0 {
143 return None;
144 }
145 self.line_starts.get(line - 1).copied()
146 }
147
148 pub fn line_count(&self) -> usize {
150 self.line_starts.len()
151 }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
156#[non_exhaustive]
157pub enum LinkType {
158 WikiLink,
160 Embed,
162 BlockRef,
164 HeadingRef,
166 Anchor,
168 MarkdownLink,
170 ExternalLink,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)]
176pub struct Link {
177 pub type_: LinkType,
179 pub source_file: PathBuf,
181 pub target: String,
184 pub display_text: Option<String>,
186 pub position: SourcePosition,
188 pub resolved_target: Option<PathBuf>,
190 pub is_valid: bool,
192}
193
194impl Link {
195 pub fn new(
197 type_: LinkType,
198 source_file: PathBuf,
199 target: String,
200 position: SourcePosition,
201 ) -> Self {
202 Self {
203 type_,
204 source_file,
205 target,
206 display_text: None,
207 position,
208 resolved_target: None,
209 is_valid: true,
210 }
211 }
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct Heading {
217 pub text: String,
219 pub level: u8,
221 pub position: SourcePosition,
223 pub anchor: Option<String>,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct Tag {
230 pub name: String,
232 pub position: SourcePosition,
234 pub is_nested: bool,
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
240#[non_exhaustive]
241pub struct TaskItem {
242 pub content: String,
244 pub is_completed: bool,
246 #[serde(default = "TaskItem::unchecked")]
249 pub status: char,
250 pub position: SourcePosition,
252 pub created_date: Option<NaiveDate>,
254 pub scheduled_date: Option<NaiveDate>,
256 pub start_date: Option<NaiveDate>,
258 pub due_date: Option<NaiveDate>,
260 pub done_date: Option<NaiveDate>,
262 pub cancelled_date: Option<NaiveDate>,
264 #[serde(default)]
266 pub priority: TaskPriority,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub recurrence: Option<String>,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub on_completion: Option<String>,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub id: Option<String>,
276 #[serde(default, skip_serializing_if = "Vec::is_empty")]
278 pub depends_on: Vec<String>,
279 #[serde(default, skip_serializing_if = "Vec::is_empty")]
281 pub tags: Vec<String>,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub block_ref: Option<String>,
285 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
290 pub metadata: HashMap<String, String>,
291}
292
293impl TaskItem {
294 fn unchecked() -> char {
295 ' '
296 }
297
298 #[must_use]
302 pub fn from_parsed_metadata(
303 parsed: crate::task_parser::ParsedTaskMetadata,
304 is_completed: bool,
305 position: SourcePosition,
306 ) -> Self {
307 Self {
308 content: parsed.description,
309 is_completed,
310 status: if is_completed { 'x' } else { ' ' },
311 position,
312 created_date: parse_date_opt(parsed.created.as_deref()),
313 scheduled_date: parse_date_opt(parsed.scheduled.as_deref()),
314 start_date: parse_date_opt(parsed.start.as_deref()),
315 due_date: parse_date_opt(parsed.due.as_deref()),
316 done_date: parse_date_opt(parsed.done.as_deref()),
317 cancelled_date: parse_date_opt(parsed.cancelled.as_deref()),
318 priority: parsed
319 .priority
320 .and_then(TaskPriority::from_char)
321 .unwrap_or_default(),
322 recurrence: parsed.recurrence,
323 on_completion: parsed.on_completion,
324 id: parsed.id,
325 depends_on: parsed.depends_on,
326 tags: parsed.tags,
327 block_ref: parsed.block_ref,
328 metadata: parsed.metadata,
329 }
330 }
331}
332
333#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
335#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
336pub enum TaskPriority {
337 Lowest,
339 Low,
341 #[default]
343 Normal,
344 Medium,
346 High,
348 Highest,
350}
351
352impl TaskPriority {
353 pub fn emoji(&self) -> &'static str {
355 match self {
356 TaskPriority::Lowest => "⏬",
357 TaskPriority::Low => "🔽",
358 TaskPriority::Normal => "",
359 TaskPriority::Medium => "🔼",
360 TaskPriority::High => "⏫",
361 TaskPriority::Highest => "🔺",
362 }
363 }
364
365 pub fn from_emoji(s: &str) -> Option<Self> {
367 Some(match s {
368 "⏬" => TaskPriority::Lowest,
369 "🔽" => TaskPriority::Low,
370 "" => TaskPriority::Normal,
371 "🔼" => TaskPriority::Medium,
372 "⏫" => TaskPriority::High,
373 "🔺" => TaskPriority::Highest,
374 _ => return None,
375 })
376 }
377
378 pub fn from_char(c: char) -> Option<Self> {
380 Self::from_emoji(c.encode_utf8(&mut [0; 4]))
381 }
382
383 pub fn is_valid_emoji(s: &str) -> bool {
385 Self::from_emoji(s).is_some()
386 }
387}
388
389impl fmt::Display for TaskPriority {
390 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
391 let s = match self {
392 TaskPriority::Lowest => "⏬",
393 TaskPriority::Low => "🔽",
394 TaskPriority::Normal => "",
395 TaskPriority::Medium => "🔼",
396 TaskPriority::High => "⏫",
397 TaskPriority::Highest => "🔺",
398 };
399 write!(f, "{}", s)
400 }
401}
402
403#[derive(Debug, Clone, Default)]
406pub struct TaskBuilder {
407 pub content: String,
409 pub is_completed: bool,
411 pub status: Option<char>,
413 pub position: SourcePosition,
415}
416
417impl TaskBuilder {
418 pub fn build(&mut self) -> TaskItem {
421 let parsed: ParsedTaskMetadata = crate::task_parser::parse_task_content(&self.content);
422 let mut task_item = TaskItem::from_parsed_metadata(
423 parsed,
424 std::mem::take(&mut self.is_completed),
425 std::mem::take(&mut self.position),
426 );
427 if let Some(status) = self.status.take() {
428 task_item.status = status;
429 }
430 self.content.clear();
431 task_item
432 }
433}
434
435fn parse_date_opt(value: Option<&str>) -> Option<NaiveDate> {
436 NaiveDate::parse_from_str(value?, "%Y-%m-%d").ok()
437}
438
439#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
441#[non_exhaustive]
442pub enum CalloutType {
443 Note,
445 Tip,
447 Info,
449 Todo,
451 Important,
453 Success,
455 Question,
457 Warning,
459 Failure,
461 Danger,
463 Bug,
465 Example,
467 Quote,
469}
470
471#[derive(Debug, Clone, Serialize, Deserialize)]
473pub struct Callout {
474 pub type_: CalloutType,
476 pub title: Option<String>,
478 pub content: String,
480 pub position: SourcePosition,
482 pub is_foldable: bool,
484}
485
486#[derive(Debug, Clone, Serialize, Deserialize)]
488pub struct Block {
489 pub content: String,
491 pub block_id: Option<String>,
493 pub position: SourcePosition,
495 pub type_: String,
497}
498
499#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
508#[serde(tag = "type", rename_all = "lowercase")]
509pub enum ContentBlock {
510 Heading {
512 level: usize,
514 content: String,
516 inline: Vec<InlineElement>,
518 anchor: Option<String>,
520 },
521 Paragraph {
523 content: String,
525 inline: Vec<InlineElement>,
527 },
528 Code {
530 language: Option<String>,
532 content: String,
534 start_line: usize,
536 end_line: usize,
538 },
539 List {
541 ordered: bool,
543 items: Vec<ListItem>,
545 },
546 Blockquote {
548 content: String,
550 blocks: Vec<ContentBlock>,
552 },
553 Table {
555 headers: Vec<String>,
557 alignments: Vec<TableAlignment>,
559 rows: Vec<Vec<String>>,
561 },
562 Image {
564 alt: String,
566 src: String,
568 title: Option<String>,
570 },
571 HorizontalRule,
573 Details {
575 summary: String,
577 content: String,
579 blocks: Vec<ContentBlock>,
581 },
582}
583
584impl ContentBlock {
585 #[must_use]
610 pub fn to_plain_text(&self) -> String {
611 match self {
612 Self::Heading { inline, .. } | Self::Paragraph { inline, .. } => {
613 inline.iter().map(InlineElement::to_plain_text).collect()
614 }
615 Self::Code { content, .. } => content.clone(),
616 Self::List { items, .. } => items
617 .iter()
618 .map(ListItem::to_plain_text)
619 .collect::<Vec<_>>()
620 .join("\n"),
621 Self::Blockquote { blocks, .. } => blocks
622 .iter()
623 .map(Self::to_plain_text)
624 .collect::<Vec<_>>()
625 .join("\n"),
626 Self::Table { headers, rows, .. } => {
627 let header_text = headers.join("\t");
628 let row_texts: Vec<String> = rows.iter().map(|row| row.join("\t")).collect();
629 if row_texts.is_empty() {
630 header_text
631 } else {
632 format!("{}\n{}", header_text, row_texts.join("\n"))
633 }
634 }
635 Self::Image { alt, .. } => alt.clone(),
636 Self::HorizontalRule => String::new(),
637 Self::Details {
638 summary, blocks, ..
639 } => {
640 let blocks_text: String = blocks
641 .iter()
642 .map(Self::to_plain_text)
643 .collect::<Vec<_>>()
644 .join("\n");
645 if blocks_text.is_empty() {
646 summary.clone()
647 } else {
648 format!("{}\n{}", summary, blocks_text)
649 }
650 }
651 }
652 }
653}
654
655#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
659#[serde(tag = "type", rename_all = "lowercase")]
660pub enum InlineElement {
661 Text {
663 value: String,
665 },
666 Strong {
668 value: String,
670 },
671 Emphasis {
673 value: String,
675 },
676 Code {
678 value: String,
680 },
681 Link {
683 text: String,
685 url: String,
687 title: Option<String>,
689 #[serde(default, skip_serializing_if = "Option::is_none")]
691 line_offset: Option<usize>,
692 },
693 Image {
695 alt: String,
697 src: String,
699 title: Option<String>,
701 #[serde(default, skip_serializing_if = "Option::is_none")]
703 line_offset: Option<usize>,
704 },
705 Strikethrough {
707 value: String,
709 },
710}
711
712impl InlineElement {
713 #[must_use]
732 pub fn to_plain_text(&self) -> &str {
733 match self {
734 Self::Text { value }
735 | Self::Strong { value }
736 | Self::Emphasis { value }
737 | Self::Code { value }
738 | Self::Strikethrough { value } => value,
739 Self::Link { text, .. } => text,
740 Self::Image { alt, .. } => alt,
741 }
742 }
743}
744
745#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
747pub struct ListItem {
748 pub checked: Option<bool>,
750 pub content: String,
752 pub inline: Vec<InlineElement>,
754 #[serde(default, skip_serializing_if = "Vec::is_empty")]
756 pub blocks: Vec<ContentBlock>,
757}
758
759impl ListItem {
760 #[must_use]
778 pub fn to_plain_text(&self) -> String {
779 let mut result = String::new();
780
781 for elem in &self.inline {
783 result.push_str(elem.to_plain_text());
784 }
785
786 for block in &self.blocks {
788 if !result.is_empty() && !result.ends_with('\n') {
789 result.push('\n');
790 }
791 result.push_str(&block.to_plain_text());
792 }
793
794 result
795 }
796}
797
798#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
800#[serde(rename_all = "lowercase")]
801pub enum TableAlignment {
802 Left,
804 Center,
806 Right,
808 None,
810}
811
812#[derive(Debug, Clone, Serialize, Deserialize)]
814pub struct Frontmatter {
815 pub data: HashMap<String, serde_json::Value>,
817 pub position: SourcePosition,
819}
820
821impl Frontmatter {
822 pub fn tags(&self) -> Vec<String> {
824 property_tags(self.data.get("tags"))
825 }
826
827 pub fn aliases(&self) -> Vec<String> {
829 property_list(self.data.get("aliases"))
830 }
831}
832
833pub fn property_list(value: Option<&serde_json::Value>) -> Vec<String> {
839 match value {
840 Some(serde_json::Value::Array(items)) => items
841 .iter()
842 .filter_map(|item| item.as_str())
843 .map(str::trim)
844 .filter(|item| !item.is_empty())
845 .map(str::to_string)
846 .collect(),
847 _ => Vec::new(),
848 }
849}
850
851pub fn property_tags(value: Option<&serde_json::Value>) -> Vec<String> {
854 property_list(value)
855 .into_iter()
856 .map(|tag| tag.strip_prefix('#').unwrap_or(&tag).to_string())
857 .filter(|tag| !tag.is_empty())
858 .collect()
859}
860
861#[derive(Debug, Clone, Serialize, Deserialize)]
863pub struct FileMetadata {
864 pub path: PathBuf,
866 pub size: u64,
868 pub created_at: f64,
872 pub modified_at: f64,
875 pub checksum: String,
878 pub is_attachment: bool,
880}
881
882#[derive(Debug, Clone, Serialize, Deserialize)]
884pub struct VaultFile {
885 pub path: PathBuf,
887 pub content: String,
889 pub metadata: FileMetadata,
891
892 pub frontmatter: Option<Frontmatter>,
894 pub headings: Vec<Heading>,
896 pub links: Vec<Link>,
898 pub backlinks: HashSet<Link>,
900 pub blocks: Vec<Block>,
902 pub tags: Vec<Tag>,
904 pub callouts: Vec<Callout>,
906 pub tasks: Vec<TaskItem>,
908
909 pub is_parsed: bool,
911 pub parse_error: Option<String>,
913 pub last_parsed: Option<f64>,
915}
916
917impl VaultFile {
918 pub fn new(path: PathBuf, content: String, metadata: FileMetadata) -> Self {
920 Self {
921 path,
922 content,
923 metadata,
924 frontmatter: None,
925 headings: vec![],
926 links: vec![],
927 backlinks: HashSet::new(),
928 blocks: vec![],
929 tags: vec![],
930 callouts: vec![],
931 tasks: vec![],
932 is_parsed: false,
933 parse_error: None,
934 last_parsed: None,
935 }
936 }
937
938 pub fn outgoing_links(&self) -> HashSet<&str> {
940 self.links
941 .iter()
942 .filter(|link| matches!(link.type_, LinkType::WikiLink | LinkType::Embed))
943 .map(|link| link.target.as_str())
944 .collect()
945 }
946
947 pub fn headings_by_text(&self) -> HashMap<&str, &Heading> {
949 self.headings.iter().map(|h| (h.text.as_str(), h)).collect()
950 }
951
952 pub fn blocks_with_ids(&self) -> HashMap<&str, &Block> {
954 self.blocks
955 .iter()
956 .filter_map(|b| b.block_id.as_deref().map(|id| (id, b)))
957 .collect()
958 }
959
960 pub fn has_tag(&self, tag: &str) -> bool {
962 if let Some(fm) = &self.frontmatter
963 && fm.tags().contains(&tag.to_string())
964 {
965 return true;
966 }
967
968 self.tags.iter().any(|t| t.name == tag)
969 }
970}
971
972#[cfg(test)]
973mod tests {
974 use super::*;
975
976 #[test]
977 fn test_source_position() {
978 let pos = SourcePosition::new(5, 10, 100, 20);
979 assert_eq!(pos.line, 5);
980 assert_eq!(pos.column, 10);
981 assert_eq!(pos.offset, 100);
982 assert_eq!(pos.length, 20);
983 }
984
985 #[test]
986 fn test_frontmatter_tags() {
987 let mut data = HashMap::new();
988 data.insert(
989 "tags".to_string(),
990 serde_json::Value::Array(vec![
991 serde_json::Value::String("rust".to_string()),
992 serde_json::Value::String("mcp".to_string()),
993 ]),
994 );
995
996 let fm = Frontmatter {
997 data,
998 position: SourcePosition::start(),
999 };
1000
1001 let tags = fm.tags();
1002 assert_eq!(tags.len(), 2);
1003 assert!(tags.contains(&"rust".to_string()));
1004 }
1005
1006 #[test]
1008 fn test_list_properties_read_as_obsidian_reads_them() {
1009 use serde_json::json;
1010 assert_eq!(
1011 property_tags(Some(&json!(["#work", " personal ", "", 3, "#"]))),
1012 ["work", "personal"]
1013 );
1014 assert_eq!(
1015 property_list(Some(&json!(["Home Base", "HB"]))),
1016 ["Home Base", "HB"]
1017 );
1018 assert!(property_tags(Some(&json!("work, personal"))).is_empty());
1020 assert!(property_list(Some(&json!("Home Base"))).is_empty());
1021 assert!(property_list(Some(&json!(null))).is_empty());
1022 assert!(property_list(None).is_empty());
1023 }
1024
1025 #[test]
1026 fn test_line_index_single_line() {
1027 let content = "Hello, world!";
1028 let index = LineIndex::new(content);
1029
1030 assert_eq!(index.line_count(), 1);
1031 assert_eq!(index.line_col(0), (1, 1)); assert_eq!(index.line_col(7), (1, 8)); }
1034
1035 #[test]
1036 fn test_line_index_multiline() {
1037 let content = "Line 1\nLine 2\nLine 3";
1038 let index = LineIndex::new(content);
1039
1040 assert_eq!(index.line_count(), 3);
1041
1042 assert_eq!(index.line_col(0), (1, 1)); assert_eq!(index.line_col(5), (1, 6)); assert_eq!(index.line_col(7), (2, 1)); assert_eq!(index.line_col(13), (2, 7)); assert_eq!(index.line_col(14), (3, 1)); }
1053
1054 #[test]
1055 fn test_line_index_line_start() {
1056 let content = "Line 1\nLine 2\nLine 3";
1057 let index = LineIndex::new(content);
1058
1059 assert_eq!(index.line_start(1), Some(0));
1060 assert_eq!(index.line_start(2), Some(7));
1061 assert_eq!(index.line_start(3), Some(14));
1062 assert_eq!(index.line_start(0), None); assert_eq!(index.line_start(4), None); }
1065
1066 #[test]
1067 fn test_source_position_from_offset() {
1068 let content = "Line 1\nLine 2 [[Link]] here\nLine 3";
1069
1070 let pos = SourcePosition::from_offset(content, 14, 8);
1072 assert_eq!(pos.line, 2);
1073 assert_eq!(pos.column, 8); assert_eq!(pos.offset, 14);
1075 assert_eq!(pos.length, 8);
1076 }
1077
1078 #[test]
1079 fn test_source_position_from_offset_indexed() {
1080 let content = "Line 1\nLine 2 [[Link]] here\nLine 3";
1081 let index = LineIndex::new(content);
1082
1083 let pos = SourcePosition::from_offset_indexed(&index, 14, 8);
1085 assert_eq!(pos.line, 2);
1086 assert_eq!(pos.column, 8);
1087 assert_eq!(pos.offset, 14);
1088 assert_eq!(pos.length, 8);
1089 }
1090
1091 #[test]
1092 fn test_source_position_first_line() {
1093 let content = "[[Link]] at start";
1094
1095 let pos = SourcePosition::from_offset(content, 0, 8);
1096 assert_eq!(pos.line, 1);
1097 assert_eq!(pos.column, 1);
1098 }
1099
1100 #[test]
1101 fn test_line_index_empty_content() {
1102 let content = "";
1103 let index = LineIndex::new(content);
1104
1105 assert_eq!(index.line_count(), 1); assert_eq!(index.line_col(0), (1, 1));
1107 }
1108
1109 #[test]
1110 fn test_line_index_trailing_newline() {
1111 let content = "Line 1\n";
1112 let index = LineIndex::new(content);
1113
1114 assert_eq!(index.line_count(), 2); assert_eq!(index.line_col(6), (1, 7)); assert_eq!(index.line_col(7), (2, 1)); }
1118
1119 #[test]
1120 fn test_task_item_from_parsed_metadata() {
1121 let mut metadata = HashMap::new();
1122 metadata.insert("project".to_string(), "[[Team Work]]".to_string());
1123
1124 let task = TaskItem::from_parsed_metadata(
1125 crate::task_parser::ParsedTaskMetadata {
1126 description: "Review PR".to_string(),
1127 due: Some("2026-05-01".to_string()),
1128 scheduled: Some("2026-04-30".to_string()),
1129 start: Some("2026-04-29".to_string()),
1130 done: None,
1131 cancelled: None,
1132 created: Some("2026-04-28".to_string()),
1133 priority: Some('⏫'),
1134 recurrence: Some("every weekday".to_string()),
1135 on_completion: Some("delete".to_string()),
1136 id: Some("pr-123".to_string()),
1137 depends_on: vec!["abc123".to_string(), "def456".to_string()],
1138 tags: vec!["review".to_string()],
1139 block_ref: Some("pr-123".to_string()),
1140 metadata,
1141 },
1142 false,
1143 SourcePosition::start(),
1144 );
1145
1146 assert_eq!(task.content, "Review PR");
1147 assert_eq!(
1148 task.due_date.map(|date| date.to_string()).as_deref(),
1149 Some("2026-05-01")
1150 );
1151 assert_eq!(
1152 task.scheduled_date.map(|date| date.to_string()).as_deref(),
1153 Some("2026-04-30")
1154 );
1155 assert_eq!(
1156 task.start_date.map(|date| date.to_string()).as_deref(),
1157 Some("2026-04-29")
1158 );
1159 assert_eq!(
1160 task.created_date.map(|date| date.to_string()).as_deref(),
1161 Some("2026-04-28")
1162 );
1163 assert_eq!(task.priority, TaskPriority::High);
1164 assert_eq!(task.recurrence.as_deref(), Some("every weekday"));
1165 assert_eq!(task.on_completion.as_deref(), Some("delete"));
1166 assert_eq!(task.id.as_deref(), Some("pr-123"));
1167 assert_eq!(
1168 task.depends_on,
1169 vec!["abc123".to_string(), "def456".to_string()]
1170 );
1171 assert_eq!(task.tags, vec!["review".to_string()]);
1172 assert_eq!(task.block_ref.as_deref(), Some("pr-123"));
1173 assert_eq!(
1174 task.metadata.get("project").map(String::as_str),
1175 Some("[[Team Work]]")
1176 );
1177 }
1178
1179 #[test]
1180 fn test_task_item_deserializes_without_metadata_fields() {
1181 let task: TaskItem = serde_json::from_str(
1182 r#"{
1183 "content": "Legacy task",
1184 "is_completed": false,
1185 "position": {
1186 "line": 1,
1187 "column": 1,
1188 "offset": 0,
1189 "length": 13
1190 }
1191 }"#,
1192 )
1193 .unwrap();
1194
1195 assert_eq!(task.content, "Legacy task");
1196 assert!(!task.is_completed);
1197 assert_eq!(task.priority, TaskPriority::Normal);
1198 assert!(task.due_date.is_none());
1199 assert!(task.metadata.is_empty());
1200 }
1201
1202 #[test]
1203 fn test_task_item_deserializes_with_metadata_fields() {
1204 let task: TaskItem = serde_json::from_str(
1205 r#"{
1206 "content": "Modern task",
1207 "is_completed": true,
1208 "position": {
1209 "line": 2,
1210 "column": 1,
1211 "offset": 14,
1212 "length": 42
1213 },
1214 "created_date": "2026-04-28",
1215 "scheduled_date": "2026-04-29",
1216 "start_date": "2026-04-30",
1217 "due_date": "2026-05-01",
1218 "done_date": "2026-05-02",
1219 "cancelled_date": null,
1220 "priority": "HIGH",
1221 "recurrence": "every weekday",
1222 "on_completion": "delete",
1223 "id": "task-123",
1224 "depends_on": ["abc123", "def456"],
1225 "tags": ["review", "work"],
1226 "block_ref": "block-123",
1227 "metadata": {
1228 "project": "[[Team Work]]"
1229 }
1230 }"#,
1231 )
1232 .unwrap();
1233
1234 assert_eq!(task.content, "Modern task");
1235 assert!(task.is_completed);
1236 assert_eq!(
1237 task.created_date.map(|date| date.to_string()).as_deref(),
1238 Some("2026-04-28")
1239 );
1240 assert_eq!(
1241 task.scheduled_date.map(|date| date.to_string()).as_deref(),
1242 Some("2026-04-29")
1243 );
1244 assert_eq!(
1245 task.start_date.map(|date| date.to_string()).as_deref(),
1246 Some("2026-04-30")
1247 );
1248 assert_eq!(
1249 task.due_date.map(|date| date.to_string()).as_deref(),
1250 Some("2026-05-01")
1251 );
1252 assert_eq!(
1253 task.done_date.map(|date| date.to_string()).as_deref(),
1254 Some("2026-05-02")
1255 );
1256 assert!(task.cancelled_date.is_none());
1257 assert_eq!(task.priority, TaskPriority::High);
1258 assert_eq!(task.recurrence.as_deref(), Some("every weekday"));
1259 assert_eq!(task.on_completion.as_deref(), Some("delete"));
1260 assert_eq!(task.id.as_deref(), Some("task-123"));
1261 assert_eq!(
1262 task.depends_on,
1263 vec!["abc123".to_string(), "def456".to_string()]
1264 );
1265 assert_eq!(task.tags, vec!["review".to_string(), "work".to_string()]);
1266 assert_eq!(task.block_ref.as_deref(), Some("block-123"));
1267 assert_eq!(
1268 task.metadata.get("project").map(String::as_str),
1269 Some("[[Team Work]]")
1270 );
1271 }
1272
1273 #[test]
1274 fn test_task_priority_serializes_as_stable_api_value() {
1275 assert_eq!(
1276 serde_json::to_value(TaskPriority::High).unwrap(),
1277 serde_json::Value::String("HIGH".to_string())
1278 );
1279 }
1280}