Skip to main content

turbovault_core/
models.rs

1//! Core data models representing Obsidian vault elements.
2//!
3//! These types are designed to be:
4//! - **Serializable**: All types derive Serialize/Deserialize
5//! - **Debuggable**: Derive Debug for easy inspection
6//! - **Cloneable**: `Arc<T>` friendly for shared ownership
7//! - **Type-Safe**: Enums replace magic strings
8//!
9//! The types roughly correspond to Python dataclasses in the reference implementation.
10
11use 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/// Position in source text (line, column, byte offset)
20#[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    /// Create a new source position
30    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    /// Create position at start
40    pub fn start() -> Self {
41        Self {
42            line: 0,
43            column: 0,
44            offset: 0,
45            length: 0,
46        }
47    }
48
49    /// Create position from byte offset by computing line and column.
50    ///
51    /// This is O(n) where n is the offset - suitable for single-use cases.
52    /// For bulk operations, use `from_offset_indexed` with a pre-computed `LineIndex`.
53    ///
54    /// Line numbers start at 1, column numbers start at 1.
55    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    /// Create position from byte offset using a pre-computed line index.
72    ///
73    /// This is O(log n) - use for bulk parsing operations.
74    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/// Pre-computed line starts for O(log n) line/column lookup.
86///
87/// Build once per document, then use for all position lookups.
88/// This is essential for efficient parsing of documents with many OFM elements.
89///
90/// # Example
91/// ```
92/// use turbovault_core::{LineIndex, SourcePosition};
93///
94/// let content = "Line 1\nLine 2\nLine 3";
95/// let index = LineIndex::new(content);
96///
97/// // O(log n) lookup instead of O(n)
98/// let pos = SourcePosition::from_offset_indexed(&index, 7, 6);
99/// assert_eq!(pos.line, 2);
100/// assert_eq!(pos.column, 1);
101/// ```
102#[derive(Debug, Clone)]
103pub struct LineIndex {
104    /// Byte offsets where each line starts (line 1 = index 0)
105    line_starts: Vec<usize>,
106}
107
108impl LineIndex {
109    /// Build line index in O(n) - do once per document.
110    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    /// Get (line, column) for a byte offset in O(log n) via binary search.
121    ///
122    /// Line numbers start at 1, column numbers start at 1.
123    pub fn line_col(&self, offset: usize) -> (usize, usize) {
124        // Binary search to find which line contains this offset
125        let line_idx = self.line_starts.partition_point(|&start| start <= offset);
126        let line = line_idx.max(1); // Line numbers are 1-indexed
127        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; // Column numbers are 1-indexed
133        (line, column)
134    }
135
136    /// Get the byte offset where a line starts.
137    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    /// Get total number of lines.
145    pub fn line_count(&self) -> usize {
146        self.line_starts.len()
147    }
148}
149
150/// Type of link in Obsidian content
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
152pub enum LinkType {
153    /// Wikilink: `[[Note]]`
154    WikiLink,
155    /// Embedded note: `![[Note]]`
156    Embed,
157    /// Block reference: `[[Note#^block]]`
158    BlockRef,
159    /// Heading reference: `[[Note#Heading]]` or `file.md#section`
160    HeadingRef,
161    /// Same-document anchor: `#section` (no file reference)
162    Anchor,
163    /// Markdown link: `[text](url)` to relative file
164    MarkdownLink,
165    /// External URL: `http://...`, `https://...`, `mailto:...`
166    ExternalLink,
167}
168
169/// A link in vault content
170#[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    /// Create a new link
183    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/// A heading in vault content
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct Heading {
204    pub text: String,
205    pub level: u8, // 1-6
206    pub position: SourcePosition,
207    pub anchor: Option<String>,
208}
209
210/// A tag in vault content
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct Tag {
213    pub name: String,
214    pub position: SourcePosition,
215    pub is_nested: bool, // #parent/child
216}
217
218/// A task item in vault content
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct TaskItem {
221    /// Task description with trailing Obsidian Tasks metadata removed.
222    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    /// Recurrence rule text, for example `every weekday`.
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub recurrence: Option<String>,
236    /// On-completion action, for example `keep` or `delete`.
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub on_completion: Option<String>,
239    /// Tasks plugin dependency ID without the leading `🆔` marker.
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub id: Option<String>,
242    /// Tasks plugin dependency IDs from `⛔` or `[dependsOn:: ...]`.
243    #[serde(default, skip_serializing_if = "Vec::is_empty")]
244    pub depends_on: Vec<String>,
245    /// Inline task tags without the leading `#`.
246    #[serde(default, skip_serializing_if = "Vec::is_empty")]
247    pub tags: Vec<String>,
248    /// Obsidian block reference without the leading `^`.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub block_ref: Option<String>,
251    /// Dataview inline fields parsed from trailing task metadata.
252    ///
253    /// Standard fields are also projected into the typed fields above; custom
254    /// fields remain available here.
255    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
256    pub metadata: HashMap<String, String>,
257}
258
259impl TaskItem {
260    /// Build a [`TaskItem`] from parsed Obsidian Tasks metadata.
261    #[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/// Type of callout block
376#[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/// A callout block in vault content
394#[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/// A block in vault content (Obsidian block reference with ^id)
404#[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, // paragraph, heading, list_item, etc.
410}
411
412// ============================================================================
413// Content Block Types (for full markdown parsing)
414// ============================================================================
415
416/// A parsed content block in a markdown document.
417///
418/// These represent the block-level structure of markdown content,
419/// similar to an AST but optimized for consumption by tools like treemd.
420#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
421#[serde(tag = "type", rename_all = "lowercase")]
422pub enum ContentBlock {
423    /// A heading (# H1, ## H2, etc.)
424    Heading {
425        level: usize,
426        content: String,
427        inline: Vec<InlineElement>,
428        anchor: Option<String>,
429    },
430    /// A paragraph of text
431    Paragraph {
432        content: String,
433        inline: Vec<InlineElement>,
434    },
435    /// A fenced or indented code block
436    Code {
437        language: Option<String>,
438        content: String,
439        start_line: usize,
440        end_line: usize,
441    },
442    /// An ordered or unordered list
443    List { ordered: bool, items: Vec<ListItem> },
444    /// A blockquote (> text)
445    Blockquote {
446        content: String,
447        blocks: Vec<ContentBlock>,
448    },
449    /// A table with headers and rows
450    Table {
451        headers: Vec<String>,
452        alignments: Vec<TableAlignment>,
453        rows: Vec<Vec<String>>,
454    },
455    /// An image (standalone, not inline)
456    Image {
457        alt: String,
458        src: String,
459        title: Option<String>,
460    },
461    /// A horizontal rule (---, ***, ___)
462    HorizontalRule,
463    /// HTML <details><summary> block
464    Details {
465        summary: String,
466        content: String,
467        blocks: Vec<ContentBlock>,
468    },
469}
470
471impl ContentBlock {
472    /// Extract plain text from this content block.
473    ///
474    /// Returns only the visible text content, stripping markdown syntax.
475    /// This is useful for search indexing, accessibility, and accurate word counts.
476    ///
477    /// # Example
478    /// ```
479    /// use turbovault_core::{ContentBlock, InlineElement};
480    ///
481    /// let block = ContentBlock::Paragraph {
482    ///     content: "[Overview](#overview) and **bold**".to_string(),
483    ///     inline: vec![
484    ///         InlineElement::Link {
485    ///             text: "Overview".to_string(),
486    ///             url: "#overview".to_string(),
487    ///             title: None,
488    ///             line_offset: None,
489    ///         },
490    ///         InlineElement::Text { value: " and ".to_string() },
491    ///         InlineElement::Strong { value: "bold".to_string() },
492    ///     ],
493    /// };
494    /// assert_eq!(block.to_plain_text(), "Overview and bold");
495    /// ```
496    #[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/// An inline element within a block.
543///
544/// These represent inline formatting and links within text content.
545#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
546#[serde(tag = "type", rename_all = "lowercase")]
547pub enum InlineElement {
548    /// Plain text
549    Text { value: String },
550    /// Bold text (**text** or __text__)
551    Strong { value: String },
552    /// Italic text (*text* or _text_)
553    Emphasis { value: String },
554    /// Inline code (`code`)
555    Code { value: String },
556    /// A link [text](url)
557    Link {
558        text: String,
559        url: String,
560        title: Option<String>,
561        /// Relative line offset within parent block (for nested list items)
562        #[serde(default, skip_serializing_if = "Option::is_none")]
563        line_offset: Option<usize>,
564    },
565    /// An inline image ![alt](src)
566    Image {
567        alt: String,
568        src: String,
569        title: Option<String>,
570        /// Relative line offset within parent block (for nested list items)
571        #[serde(default, skip_serializing_if = "Option::is_none")]
572        line_offset: Option<usize>,
573    },
574    /// Strikethrough text (~~text~~)
575    Strikethrough { value: String },
576}
577
578impl InlineElement {
579    /// Extract plain text from this inline element.
580    ///
581    /// Returns only the visible text content, stripping markdown syntax.
582    /// For links, returns the link text (not the URL).
583    /// For images, returns the alt text.
584    ///
585    /// # Example
586    /// ```
587    /// use turbovault_core::InlineElement;
588    ///
589    /// let link = InlineElement::Link {
590    ///     text: "Overview".to_string(),
591    ///     url: "#overview".to_string(),
592    ///     title: None,
593    ///     line_offset: None,
594    /// };
595    /// assert_eq!(link.to_plain_text(), "Overview");
596    /// ```
597    #[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/// A list item with optional checkbox and nested content.
612#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
613pub struct ListItem {
614    /// For task lists: Some(true) = checked, Some(false) = unchecked, None = not a task
615    pub checked: Option<bool>,
616    /// Raw text content of the item
617    pub content: String,
618    /// Parsed inline elements
619    pub inline: Vec<InlineElement>,
620    /// Nested blocks (e.g., code blocks, sub-lists inside list items)
621    #[serde(default, skip_serializing_if = "Vec::is_empty")]
622    pub blocks: Vec<ContentBlock>,
623}
624
625impl ListItem {
626    /// Extract plain text from this list item.
627    ///
628    /// Returns the visible text content by joining inline elements.
629    /// Includes nested block content recursively.
630    ///
631    /// # Example
632    /// ```
633    /// use turbovault_core::{ListItem, InlineElement};
634    ///
635    /// let item = ListItem {
636    ///     checked: Some(false),
637    ///     content: "Todo item".to_string(),
638    ///     inline: vec![InlineElement::Text { value: "Todo item".to_string() }],
639    ///     blocks: vec![],
640    /// };
641    /// assert_eq!(item.to_plain_text(), "Todo item");
642    /// ```
643    #[must_use]
644    pub fn to_plain_text(&self) -> String {
645        let mut result = String::new();
646
647        // Extract text from inline elements
648        for elem in &self.inline {
649            result.push_str(elem.to_plain_text());
650        }
651
652        // Include nested blocks
653        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/// Table column alignment.
665#[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/// YAML frontmatter
675#[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    /// Extract tags from frontmatter
683    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    /// Extract aliases from frontmatter
695    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/// File metadata
708#[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/// A complete vault file with parsed content
719#[derive(Debug, Clone, Serialize, Deserialize)]
720pub struct VaultFile {
721    pub path: PathBuf,
722    pub content: String,
723    pub metadata: FileMetadata,
724
725    // Parsed elements
726    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    // Cache status
736    pub is_parsed: bool,
737    pub parse_error: Option<String>,
738    pub last_parsed: Option<f64>,
739}
740
741impl VaultFile {
742    /// Create a new vault file
743    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    /// Get outgoing links
763    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    /// Get headings indexed by text
772    pub fn headings_by_text(&self) -> HashMap<&str, &Heading> {
773        self.headings.iter().map(|h| (h.text.as_str(), h)).collect()
774    }
775
776    /// Get blocks with IDs
777    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    /// Check if file contains a tag
785    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)); // 'H'
837        assert_eq!(index.line_col(7), (1, 8)); // 'w'
838    }
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        // Line 1
848        assert_eq!(index.line_col(0), (1, 1)); // 'L' of Line 1
849        assert_eq!(index.line_col(5), (1, 6)); // '1'
850
851        // Line 2 (offset 7 = first char after newline)
852        assert_eq!(index.line_col(7), (2, 1)); // 'L' of Line 2
853        assert_eq!(index.line_col(13), (2, 7)); // '2'
854
855        // Line 3 (offset 14 = first char after second newline)
856        assert_eq!(index.line_col(14), (3, 1)); // 'L' of Line 3
857    }
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); // Invalid line
868        assert_eq!(index.line_start(4), None); // Beyond content
869    }
870
871    #[test]
872    fn test_source_position_from_offset() {
873        let content = "Line 1\nLine 2 [[Link]] here\nLine 3";
874
875        // Position of [[Link]] starts at offset 14
876        let pos = SourcePosition::from_offset(content, 14, 8);
877        assert_eq!(pos.line, 2);
878        assert_eq!(pos.column, 8); // "Line 2 " = 7 chars, so column 8
879        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        // Same test as above but using indexed lookup
889        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); // Even empty content has "line 1"
911        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); // Line 1 + empty line 2
920        assert_eq!(index.line_col(6), (1, 7)); // The newline itself
921        assert_eq!(index.line_col(7), (2, 1)); // After newline
922    }
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}