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,
23 pub column: usize,
24 pub offset: usize,
25 pub length: usize,
26}
27
28impl SourcePosition {
29 pub fn new(line: usize, column: usize, offset: usize, length: usize) -> Self {
31 Self {
32 line,
33 column,
34 offset,
35 length,
36 }
37 }
38
39 pub fn start() -> Self {
41 Self {
42 line: 0,
43 column: 0,
44 offset: 0,
45 length: 0,
46 }
47 }
48
49 pub fn from_offset(content: &str, offset: usize, length: usize) -> Self {
56 let before = &content[..offset.min(content.len())];
57 let line = before.matches('\n').count() + 1;
58 let column = before
59 .rfind('\n')
60 .map(|pos| offset - pos)
61 .unwrap_or(offset + 1);
62
63 Self {
64 line,
65 column,
66 offset,
67 length,
68 }
69 }
70
71 pub fn from_offset_indexed(index: &LineIndex, offset: usize, length: usize) -> Self {
75 let (line, column) = index.line_col(offset);
76 Self {
77 line,
78 column,
79 offset,
80 length,
81 }
82 }
83}
84
85#[derive(Debug, Clone)]
103pub struct LineIndex {
104 line_starts: Vec<usize>,
106}
107
108impl LineIndex {
109 pub fn new(content: &str) -> Self {
111 let mut line_starts = vec![0];
112 for (i, ch) in content.char_indices() {
113 if ch == '\n' {
114 line_starts.push(i + 1);
115 }
116 }
117 Self { line_starts }
118 }
119
120 pub fn line_col(&self, offset: usize) -> (usize, usize) {
124 let line_idx = self.line_starts.partition_point(|&start| start <= offset);
126 let line = line_idx.max(1); let line_start = self
128 .line_starts
129 .get(line_idx.saturating_sub(1))
130 .copied()
131 .unwrap_or(0);
132 let column = offset - line_start + 1; (line, column)
134 }
135
136 pub fn line_start(&self, line: usize) -> Option<usize> {
138 if line == 0 {
139 return None;
140 }
141 self.line_starts.get(line - 1).copied()
142 }
143
144 pub fn line_count(&self) -> usize {
146 self.line_starts.len()
147 }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
152pub enum LinkType {
153 WikiLink,
155 Embed,
157 BlockRef,
159 HeadingRef,
161 Anchor,
163 MarkdownLink,
165 ExternalLink,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)]
171pub struct Link {
172 pub type_: LinkType,
173 pub source_file: PathBuf,
174 pub target: String,
175 pub display_text: Option<String>,
176 pub position: SourcePosition,
177 pub resolved_target: Option<PathBuf>,
178 pub is_valid: bool,
179}
180
181impl Link {
182 pub fn new(
184 type_: LinkType,
185 source_file: PathBuf,
186 target: String,
187 position: SourcePosition,
188 ) -> Self {
189 Self {
190 type_,
191 source_file,
192 target,
193 display_text: None,
194 position,
195 resolved_target: None,
196 is_valid: true,
197 }
198 }
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct Heading {
204 pub text: String,
205 pub level: u8, pub position: SourcePosition,
207 pub anchor: Option<String>,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct Tag {
213 pub name: String,
214 pub position: SourcePosition,
215 pub is_nested: bool, }
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct TaskItem {
221 pub content: String,
223 pub is_completed: bool,
224 pub position: SourcePosition,
225 pub created_date: Option<NaiveDate>,
226 pub scheduled_date: Option<NaiveDate>,
227 pub start_date: Option<NaiveDate>,
228 pub due_date: Option<NaiveDate>,
229 pub done_date: Option<NaiveDate>,
230 pub cancelled_date: Option<NaiveDate>,
231 #[serde(default)]
232 pub priority: TaskPriority,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub recurrence: Option<String>,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub on_completion: Option<String>,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub id: Option<String>,
242 #[serde(default, skip_serializing_if = "Vec::is_empty")]
244 pub depends_on: Vec<String>,
245 #[serde(default, skip_serializing_if = "Vec::is_empty")]
247 pub tags: Vec<String>,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub block_ref: Option<String>,
251 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
256 pub metadata: HashMap<String, String>,
257}
258
259impl TaskItem {
260 #[must_use]
262 pub fn from_parsed_metadata(
263 parsed: crate::task_parser::ParsedTaskMetadata,
264 is_completed: bool,
265 position: SourcePosition,
266 ) -> Self {
267 Self {
268 content: parsed.description,
269 is_completed,
270 position,
271 created_date: parse_date_opt(parsed.created.as_deref()),
272 scheduled_date: parse_date_opt(parsed.scheduled.as_deref()),
273 start_date: parse_date_opt(parsed.start.as_deref()),
274 due_date: parse_date_opt(parsed.due.as_deref()),
275 done_date: parse_date_opt(parsed.done.as_deref()),
276 cancelled_date: parse_date_opt(parsed.cancelled.as_deref()),
277 priority: parsed
278 .priority
279 .and_then(TaskPriority::from_char)
280 .unwrap_or_default(),
281 recurrence: parsed.recurrence,
282 on_completion: parsed.on_completion,
283 id: parsed.id,
284 depends_on: parsed.depends_on,
285 tags: parsed.tags,
286 block_ref: parsed.block_ref,
287 metadata: parsed.metadata,
288 }
289 }
290}
291
292#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
293#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
294pub enum TaskPriority {
295 Lowest,
296 Low,
297 #[default]
298 Normal,
299 Medium,
300 High,
301 Highest,
302}
303
304impl TaskPriority {
305 pub fn emoji(&self) -> &'static str {
306 match self {
307 TaskPriority::Lowest => "⏬",
308 TaskPriority::Low => "🔽",
309 TaskPriority::Normal => "",
310 TaskPriority::Medium => "🔼",
311 TaskPriority::High => "⏫",
312 TaskPriority::Highest => "🔺",
313 }
314 }
315
316 pub fn from_emoji(s: &str) -> Option<Self> {
317 Some(match s {
318 "⏬" => TaskPriority::Lowest,
319 "🔽" => TaskPriority::Low,
320 "" => TaskPriority::Normal,
321 "🔼" => TaskPriority::Medium,
322 "⏫" => TaskPriority::High,
323 "🔺" => TaskPriority::Highest,
324 _ => return None,
325 })
326 }
327
328 pub fn from_char(c: char) -> Option<Self> {
329 Self::from_emoji(c.encode_utf8(&mut [0; 4]))
330 }
331
332 pub fn is_valid_emoji(s: &str) -> bool {
333 Self::from_emoji(s).is_some()
334 }
335}
336
337impl fmt::Display for TaskPriority {
338 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
339 let s = match self {
340 TaskPriority::Lowest => "⏬",
341 TaskPriority::Low => "🔽",
342 TaskPriority::Normal => "",
343 TaskPriority::Medium => "🔼",
344 TaskPriority::High => "⏫",
345 TaskPriority::Highest => "🔺",
346 };
347 write!(f, "{}", s)
348 }
349}
350
351#[derive(Debug, Clone, Default)]
352pub struct TaskBuilder {
353 pub content: String,
354 pub is_completed: bool,
355 pub position: SourcePosition,
356}
357
358impl TaskBuilder {
359 pub fn build(&mut self) -> TaskItem {
360 let parsed: ParsedTaskMetadata = crate::task_parser::parse_task_content(&self.content);
361 let task_item = TaskItem::from_parsed_metadata(
362 parsed,
363 std::mem::take(&mut self.is_completed),
364 std::mem::take(&mut self.position),
365 );
366 self.content.clear();
367 task_item
368 }
369}
370
371fn parse_date_opt(value: Option<&str>) -> Option<NaiveDate> {
372 NaiveDate::parse_from_str(value?, "%Y-%m-%d").ok()
373}
374
375#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
377pub enum CalloutType {
378 Note,
379 Tip,
380 Info,
381 Todo,
382 Important,
383 Success,
384 Question,
385 Warning,
386 Failure,
387 Danger,
388 Bug,
389 Example,
390 Quote,
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct Callout {
396 pub type_: CalloutType,
397 pub title: Option<String>,
398 pub content: String,
399 pub position: SourcePosition,
400 pub is_foldable: bool,
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize)]
405pub struct Block {
406 pub content: String,
407 pub block_id: Option<String>,
408 pub position: SourcePosition,
409 pub type_: String, }
411
412#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
421#[serde(tag = "type", rename_all = "lowercase")]
422pub enum ContentBlock {
423 Heading {
425 level: usize,
426 content: String,
427 inline: Vec<InlineElement>,
428 anchor: Option<String>,
429 },
430 Paragraph {
432 content: String,
433 inline: Vec<InlineElement>,
434 },
435 Code {
437 language: Option<String>,
438 content: String,
439 start_line: usize,
440 end_line: usize,
441 },
442 List { ordered: bool, items: Vec<ListItem> },
444 Blockquote {
446 content: String,
447 blocks: Vec<ContentBlock>,
448 },
449 Table {
451 headers: Vec<String>,
452 alignments: Vec<TableAlignment>,
453 rows: Vec<Vec<String>>,
454 },
455 Image {
457 alt: String,
458 src: String,
459 title: Option<String>,
460 },
461 HorizontalRule,
463 Details {
465 summary: String,
466 content: String,
467 blocks: Vec<ContentBlock>,
468 },
469}
470
471impl ContentBlock {
472 #[must_use]
497 pub fn to_plain_text(&self) -> String {
498 match self {
499 Self::Heading { inline, .. } | Self::Paragraph { inline, .. } => {
500 inline.iter().map(InlineElement::to_plain_text).collect()
501 }
502 Self::Code { content, .. } => content.clone(),
503 Self::List { items, .. } => items
504 .iter()
505 .map(ListItem::to_plain_text)
506 .collect::<Vec<_>>()
507 .join("\n"),
508 Self::Blockquote { blocks, .. } => blocks
509 .iter()
510 .map(Self::to_plain_text)
511 .collect::<Vec<_>>()
512 .join("\n"),
513 Self::Table { headers, rows, .. } => {
514 let header_text = headers.join("\t");
515 let row_texts: Vec<String> = rows.iter().map(|row| row.join("\t")).collect();
516 if row_texts.is_empty() {
517 header_text
518 } else {
519 format!("{}\n{}", header_text, row_texts.join("\n"))
520 }
521 }
522 Self::Image { alt, .. } => alt.clone(),
523 Self::HorizontalRule => String::new(),
524 Self::Details {
525 summary, blocks, ..
526 } => {
527 let blocks_text: String = blocks
528 .iter()
529 .map(Self::to_plain_text)
530 .collect::<Vec<_>>()
531 .join("\n");
532 if blocks_text.is_empty() {
533 summary.clone()
534 } else {
535 format!("{}\n{}", summary, blocks_text)
536 }
537 }
538 }
539 }
540}
541
542#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
546#[serde(tag = "type", rename_all = "lowercase")]
547pub enum InlineElement {
548 Text { value: String },
550 Strong { value: String },
552 Emphasis { value: String },
554 Code { value: String },
556 Link {
558 text: String,
559 url: String,
560 title: Option<String>,
561 #[serde(default, skip_serializing_if = "Option::is_none")]
563 line_offset: Option<usize>,
564 },
565 Image {
567 alt: String,
568 src: String,
569 title: Option<String>,
570 #[serde(default, skip_serializing_if = "Option::is_none")]
572 line_offset: Option<usize>,
573 },
574 Strikethrough { value: String },
576}
577
578impl InlineElement {
579 #[must_use]
598 pub fn to_plain_text(&self) -> &str {
599 match self {
600 Self::Text { value }
601 | Self::Strong { value }
602 | Self::Emphasis { value }
603 | Self::Code { value }
604 | Self::Strikethrough { value } => value,
605 Self::Link { text, .. } => text,
606 Self::Image { alt, .. } => alt,
607 }
608 }
609}
610
611#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
613pub struct ListItem {
614 pub checked: Option<bool>,
616 pub content: String,
618 pub inline: Vec<InlineElement>,
620 #[serde(default, skip_serializing_if = "Vec::is_empty")]
622 pub blocks: Vec<ContentBlock>,
623}
624
625impl ListItem {
626 #[must_use]
644 pub fn to_plain_text(&self) -> String {
645 let mut result = String::new();
646
647 for elem in &self.inline {
649 result.push_str(elem.to_plain_text());
650 }
651
652 for block in &self.blocks {
654 if !result.is_empty() && !result.ends_with('\n') {
655 result.push('\n');
656 }
657 result.push_str(&block.to_plain_text());
658 }
659
660 result
661 }
662}
663
664#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
666#[serde(rename_all = "lowercase")]
667pub enum TableAlignment {
668 Left,
669 Center,
670 Right,
671 None,
672}
673
674#[derive(Debug, Clone, Serialize, Deserialize)]
676pub struct Frontmatter {
677 pub data: HashMap<String, serde_json::Value>,
678 pub position: SourcePosition,
679}
680
681impl Frontmatter {
682 pub fn tags(&self) -> Vec<String> {
684 match self.data.get("tags") {
685 Some(serde_json::Value::String(s)) => vec![s.clone()],
686 Some(serde_json::Value::Array(arr)) => arr
687 .iter()
688 .filter_map(|v| v.as_str().map(|s| s.to_string()))
689 .collect(),
690 _ => vec![],
691 }
692 }
693
694 pub fn aliases(&self) -> Vec<String> {
696 match self.data.get("aliases") {
697 Some(serde_json::Value::String(s)) => vec![s.clone()],
698 Some(serde_json::Value::Array(arr)) => arr
699 .iter()
700 .filter_map(|v| v.as_str().map(|s| s.to_string()))
701 .collect(),
702 _ => vec![],
703 }
704 }
705}
706
707#[derive(Debug, Clone, Serialize, Deserialize)]
709pub struct FileMetadata {
710 pub path: PathBuf,
711 pub size: u64,
712 pub created_at: f64,
713 pub modified_at: f64,
714 pub checksum: String,
715 pub is_attachment: bool,
716}
717
718#[derive(Debug, Clone, Serialize, Deserialize)]
720pub struct VaultFile {
721 pub path: PathBuf,
722 pub content: String,
723 pub metadata: FileMetadata,
724
725 pub frontmatter: Option<Frontmatter>,
727 pub headings: Vec<Heading>,
728 pub links: Vec<Link>,
729 pub backlinks: HashSet<Link>,
730 pub blocks: Vec<Block>,
731 pub tags: Vec<Tag>,
732 pub callouts: Vec<Callout>,
733 pub tasks: Vec<TaskItem>,
734
735 pub is_parsed: bool,
737 pub parse_error: Option<String>,
738 pub last_parsed: Option<f64>,
739}
740
741impl VaultFile {
742 pub fn new(path: PathBuf, content: String, metadata: FileMetadata) -> Self {
744 Self {
745 path,
746 content,
747 metadata,
748 frontmatter: None,
749 headings: vec![],
750 links: vec![],
751 backlinks: HashSet::new(),
752 blocks: vec![],
753 tags: vec![],
754 callouts: vec![],
755 tasks: vec![],
756 is_parsed: false,
757 parse_error: None,
758 last_parsed: None,
759 }
760 }
761
762 pub fn outgoing_links(&self) -> HashSet<&str> {
764 self.links
765 .iter()
766 .filter(|link| matches!(link.type_, LinkType::WikiLink | LinkType::Embed))
767 .map(|link| link.target.as_str())
768 .collect()
769 }
770
771 pub fn headings_by_text(&self) -> HashMap<&str, &Heading> {
773 self.headings.iter().map(|h| (h.text.as_str(), h)).collect()
774 }
775
776 pub fn blocks_with_ids(&self) -> HashMap<&str, &Block> {
778 self.blocks
779 .iter()
780 .filter_map(|b| b.block_id.as_deref().map(|id| (id, b)))
781 .collect()
782 }
783
784 pub fn has_tag(&self, tag: &str) -> bool {
786 if let Some(fm) = &self.frontmatter
787 && fm.tags().contains(&tag.to_string())
788 {
789 return true;
790 }
791
792 self.tags.iter().any(|t| t.name == tag)
793 }
794}
795
796#[cfg(test)]
797mod tests {
798 use super::*;
799
800 #[test]
801 fn test_source_position() {
802 let pos = SourcePosition::new(5, 10, 100, 20);
803 assert_eq!(pos.line, 5);
804 assert_eq!(pos.column, 10);
805 assert_eq!(pos.offset, 100);
806 assert_eq!(pos.length, 20);
807 }
808
809 #[test]
810 fn test_frontmatter_tags() {
811 let mut data = HashMap::new();
812 data.insert(
813 "tags".to_string(),
814 serde_json::Value::Array(vec![
815 serde_json::Value::String("rust".to_string()),
816 serde_json::Value::String("mcp".to_string()),
817 ]),
818 );
819
820 let fm = Frontmatter {
821 data,
822 position: SourcePosition::start(),
823 };
824
825 let tags = fm.tags();
826 assert_eq!(tags.len(), 2);
827 assert!(tags.contains(&"rust".to_string()));
828 }
829
830 #[test]
831 fn test_line_index_single_line() {
832 let content = "Hello, world!";
833 let index = LineIndex::new(content);
834
835 assert_eq!(index.line_count(), 1);
836 assert_eq!(index.line_col(0), (1, 1)); assert_eq!(index.line_col(7), (1, 8)); }
839
840 #[test]
841 fn test_line_index_multiline() {
842 let content = "Line 1\nLine 2\nLine 3";
843 let index = LineIndex::new(content);
844
845 assert_eq!(index.line_count(), 3);
846
847 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)); }
858
859 #[test]
860 fn test_line_index_line_start() {
861 let content = "Line 1\nLine 2\nLine 3";
862 let index = LineIndex::new(content);
863
864 assert_eq!(index.line_start(1), Some(0));
865 assert_eq!(index.line_start(2), Some(7));
866 assert_eq!(index.line_start(3), Some(14));
867 assert_eq!(index.line_start(0), None); assert_eq!(index.line_start(4), None); }
870
871 #[test]
872 fn test_source_position_from_offset() {
873 let content = "Line 1\nLine 2 [[Link]] here\nLine 3";
874
875 let pos = SourcePosition::from_offset(content, 14, 8);
877 assert_eq!(pos.line, 2);
878 assert_eq!(pos.column, 8); assert_eq!(pos.offset, 14);
880 assert_eq!(pos.length, 8);
881 }
882
883 #[test]
884 fn test_source_position_from_offset_indexed() {
885 let content = "Line 1\nLine 2 [[Link]] here\nLine 3";
886 let index = LineIndex::new(content);
887
888 let pos = SourcePosition::from_offset_indexed(&index, 14, 8);
890 assert_eq!(pos.line, 2);
891 assert_eq!(pos.column, 8);
892 assert_eq!(pos.offset, 14);
893 assert_eq!(pos.length, 8);
894 }
895
896 #[test]
897 fn test_source_position_first_line() {
898 let content = "[[Link]] at start";
899
900 let pos = SourcePosition::from_offset(content, 0, 8);
901 assert_eq!(pos.line, 1);
902 assert_eq!(pos.column, 1);
903 }
904
905 #[test]
906 fn test_line_index_empty_content() {
907 let content = "";
908 let index = LineIndex::new(content);
909
910 assert_eq!(index.line_count(), 1); assert_eq!(index.line_col(0), (1, 1));
912 }
913
914 #[test]
915 fn test_line_index_trailing_newline() {
916 let content = "Line 1\n";
917 let index = LineIndex::new(content);
918
919 assert_eq!(index.line_count(), 2); assert_eq!(index.line_col(6), (1, 7)); assert_eq!(index.line_col(7), (2, 1)); }
923
924 #[test]
925 fn test_task_item_from_parsed_metadata() {
926 let mut metadata = HashMap::new();
927 metadata.insert("project".to_string(), "[[Team Work]]".to_string());
928
929 let task = TaskItem::from_parsed_metadata(
930 crate::task_parser::ParsedTaskMetadata {
931 description: "Review PR".to_string(),
932 due: Some("2026-05-01".to_string()),
933 scheduled: Some("2026-04-30".to_string()),
934 start: Some("2026-04-29".to_string()),
935 done: None,
936 cancelled: None,
937 created: Some("2026-04-28".to_string()),
938 priority: Some('⏫'),
939 recurrence: Some("every weekday".to_string()),
940 on_completion: Some("delete".to_string()),
941 id: Some("pr-123".to_string()),
942 depends_on: vec!["abc123".to_string(), "def456".to_string()],
943 tags: vec!["review".to_string()],
944 block_ref: Some("pr-123".to_string()),
945 metadata,
946 },
947 false,
948 SourcePosition::start(),
949 );
950
951 assert_eq!(task.content, "Review PR");
952 assert_eq!(
953 task.due_date.map(|date| date.to_string()).as_deref(),
954 Some("2026-05-01")
955 );
956 assert_eq!(
957 task.scheduled_date.map(|date| date.to_string()).as_deref(),
958 Some("2026-04-30")
959 );
960 assert_eq!(
961 task.start_date.map(|date| date.to_string()).as_deref(),
962 Some("2026-04-29")
963 );
964 assert_eq!(
965 task.created_date.map(|date| date.to_string()).as_deref(),
966 Some("2026-04-28")
967 );
968 assert_eq!(task.priority, TaskPriority::High);
969 assert_eq!(task.recurrence.as_deref(), Some("every weekday"));
970 assert_eq!(task.on_completion.as_deref(), Some("delete"));
971 assert_eq!(task.id.as_deref(), Some("pr-123"));
972 assert_eq!(
973 task.depends_on,
974 vec!["abc123".to_string(), "def456".to_string()]
975 );
976 assert_eq!(task.tags, vec!["review".to_string()]);
977 assert_eq!(task.block_ref.as_deref(), Some("pr-123"));
978 assert_eq!(
979 task.metadata.get("project").map(String::as_str),
980 Some("[[Team Work]]")
981 );
982 }
983
984 #[test]
985 fn test_task_item_deserializes_without_metadata_fields() {
986 let task: TaskItem = serde_json::from_str(
987 r#"{
988 "content": "Legacy task",
989 "is_completed": false,
990 "position": {
991 "line": 1,
992 "column": 1,
993 "offset": 0,
994 "length": 13
995 }
996 }"#,
997 )
998 .unwrap();
999
1000 assert_eq!(task.content, "Legacy task");
1001 assert!(!task.is_completed);
1002 assert_eq!(task.priority, TaskPriority::Normal);
1003 assert!(task.due_date.is_none());
1004 assert!(task.metadata.is_empty());
1005 }
1006
1007 #[test]
1008 fn test_task_item_deserializes_with_metadata_fields() {
1009 let task: TaskItem = serde_json::from_str(
1010 r#"{
1011 "content": "Modern task",
1012 "is_completed": true,
1013 "position": {
1014 "line": 2,
1015 "column": 1,
1016 "offset": 14,
1017 "length": 42
1018 },
1019 "created_date": "2026-04-28",
1020 "scheduled_date": "2026-04-29",
1021 "start_date": "2026-04-30",
1022 "due_date": "2026-05-01",
1023 "done_date": "2026-05-02",
1024 "cancelled_date": null,
1025 "priority": "HIGH",
1026 "recurrence": "every weekday",
1027 "on_completion": "delete",
1028 "id": "task-123",
1029 "depends_on": ["abc123", "def456"],
1030 "tags": ["review", "work"],
1031 "block_ref": "block-123",
1032 "metadata": {
1033 "project": "[[Team Work]]"
1034 }
1035 }"#,
1036 )
1037 .unwrap();
1038
1039 assert_eq!(task.content, "Modern task");
1040 assert!(task.is_completed);
1041 assert_eq!(
1042 task.created_date.map(|date| date.to_string()).as_deref(),
1043 Some("2026-04-28")
1044 );
1045 assert_eq!(
1046 task.scheduled_date.map(|date| date.to_string()).as_deref(),
1047 Some("2026-04-29")
1048 );
1049 assert_eq!(
1050 task.start_date.map(|date| date.to_string()).as_deref(),
1051 Some("2026-04-30")
1052 );
1053 assert_eq!(
1054 task.due_date.map(|date| date.to_string()).as_deref(),
1055 Some("2026-05-01")
1056 );
1057 assert_eq!(
1058 task.done_date.map(|date| date.to_string()).as_deref(),
1059 Some("2026-05-02")
1060 );
1061 assert!(task.cancelled_date.is_none());
1062 assert_eq!(task.priority, TaskPriority::High);
1063 assert_eq!(task.recurrence.as_deref(), Some("every weekday"));
1064 assert_eq!(task.on_completion.as_deref(), Some("delete"));
1065 assert_eq!(task.id.as_deref(), Some("task-123"));
1066 assert_eq!(
1067 task.depends_on,
1068 vec!["abc123".to_string(), "def456".to_string()]
1069 );
1070 assert_eq!(task.tags, vec!["review".to_string(), "work".to_string()]);
1071 assert_eq!(task.block_ref.as_deref(), Some("block-123"));
1072 assert_eq!(
1073 task.metadata.get("project").map(String::as_str),
1074 Some("[[Team Work]]")
1075 );
1076 }
1077
1078 #[test]
1079 fn test_task_priority_serializes_as_stable_api_value() {
1080 assert_eq!(
1081 serde_json::to_value(TaskPriority::High).unwrap(),
1082 serde_json::Value::String("HIGH".to_string())
1083 );
1084 }
1085}