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    /// 1-based line number (0 in [`SourcePosition::start`]).
23    pub line: usize,
24    /// 1-based column, in bytes from the line start.
25    pub column: usize,
26    /// Byte offset from the start of the content.
27    pub offset: usize,
28    /// Length in bytes of the element.
29    pub length: usize,
30}
31
32impl SourcePosition {
33    /// Create a new source position
34    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    /// Create position at start
44    pub fn start() -> Self {
45        Self {
46            line: 0,
47            column: 0,
48            offset: 0,
49            length: 0,
50        }
51    }
52
53    /// Create position from byte offset by computing line and column.
54    ///
55    /// This is O(n) where n is the offset - suitable for single-use cases.
56    /// For bulk operations, use `from_offset_indexed` with a pre-computed `LineIndex`.
57    ///
58    /// Line numbers start at 1, column numbers start at 1.
59    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    /// Create position from byte offset using a pre-computed line index.
76    ///
77    /// This is O(log n) - use for bulk parsing operations.
78    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/// Pre-computed line starts for O(log n) line/column lookup.
90///
91/// Build once per document, then use for all position lookups.
92/// This is essential for efficient parsing of documents with many OFM elements.
93///
94/// # Example
95/// ```
96/// use turbovault_core::{LineIndex, SourcePosition};
97///
98/// let content = "Line 1\nLine 2\nLine 3";
99/// let index = LineIndex::new(content);
100///
101/// // O(log n) lookup instead of O(n)
102/// let pos = SourcePosition::from_offset_indexed(&index, 7, 6);
103/// assert_eq!(pos.line, 2);
104/// assert_eq!(pos.column, 1);
105/// ```
106#[derive(Debug, Clone)]
107pub struct LineIndex {
108    /// Byte offsets where each line starts (line 1 = index 0)
109    line_starts: Vec<usize>,
110}
111
112impl LineIndex {
113    /// Build line index in O(n) - do once per document.
114    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    /// Get (line, column) for a byte offset in O(log n) via binary search.
125    ///
126    /// Line numbers start at 1, column numbers start at 1.
127    pub fn line_col(&self, offset: usize) -> (usize, usize) {
128        // Binary search to find which line contains this offset
129        let line_idx = self.line_starts.partition_point(|&start| start <= offset);
130        let line = line_idx.max(1); // Line numbers are 1-indexed
131        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; // Column numbers are 1-indexed
137        (line, column)
138    }
139
140    /// Get the byte offset where a line starts.
141    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    /// Get total number of lines.
149    pub fn line_count(&self) -> usize {
150        self.line_starts.len()
151    }
152}
153
154/// Type of link in Obsidian content
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
156#[non_exhaustive]
157pub enum LinkType {
158    /// Wikilink: `[[Note]]`
159    WikiLink,
160    /// Embedded note: `![[Note]]`
161    Embed,
162    /// Block reference: `[[Note#^block]]`
163    BlockRef,
164    /// Heading reference: `[[Note#Heading]]` or `file.md#section`
165    HeadingRef,
166    /// Same-document anchor: `#section` (no file reference)
167    Anchor,
168    /// Markdown link: `[text](url)` to relative file
169    MarkdownLink,
170    /// External URL: `http://...`, `https://...`, `mailto:...`
171    ExternalLink,
172}
173
174/// A link in vault content
175#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)]
176pub struct Link {
177    /// What form the link takes.
178    pub type_: LinkType,
179    /// The note the link is in.
180    pub source_file: PathBuf,
181    /// The target as written, with Markdown escapes decoded: `Note`,
182    /// `folder/Note#Heading`, `My Note.md`.
183    pub target: String,
184    /// The alias or link text, if the link has one.
185    pub display_text: Option<String>,
186    /// Where the whole link sits in the source.
187    pub position: SourcePosition,
188    /// The note the target resolves to, once resolved.
189    pub resolved_target: Option<PathBuf>,
190    /// False once resolution finds no note for the target.
191    pub is_valid: bool,
192}
193
194impl Link {
195    /// Create a new link
196    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/// A heading in vault content
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct Heading {
217    /// The heading's text as it renders, without the `#` markers.
218    pub text: String,
219    /// Heading level, 1 to 6.
220    pub level: u8,
221    /// Where the heading sits in the source.
222    pub position: SourcePosition,
223    /// The slug a `[[Note#Heading]]` link uses, if the heading has text.
224    pub anchor: Option<String>,
225}
226
227/// A tag in vault content
228#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct Tag {
230    /// The tag without its `#`, such as `project/alpha`.
231    pub name: String,
232    /// Where the tag sits in the source.
233    pub position: SourcePosition,
234    /// Whether the tag has a `/` level, like `#parent/child`.
235    pub is_nested: bool,
236}
237
238/// A task item in vault content
239#[derive(Debug, Clone, Serialize, Deserialize)]
240#[non_exhaustive]
241pub struct TaskItem {
242    /// Task description with trailing Obsidian Tasks metadata removed.
243    pub content: String,
244    /// Whether the checkbox is checked (`x` or `X`).
245    pub is_completed: bool,
246    /// The character in the task's checkbox: ` ` to do, `x` done, `/` in
247    /// progress, `-` cancelled, or a custom status a theme or plugin uses.
248    #[serde(default = "TaskItem::unchecked")]
249    pub status: char,
250    /// Where the task's line sits in the source.
251    pub position: SourcePosition,
252    /// Created date (`➕`).
253    pub created_date: Option<NaiveDate>,
254    /// Scheduled date (`⏳`).
255    pub scheduled_date: Option<NaiveDate>,
256    /// Start date (`🛫`).
257    pub start_date: Option<NaiveDate>,
258    /// Due date (`📅`).
259    pub due_date: Option<NaiveDate>,
260    /// Done date (`✅`).
261    pub done_date: Option<NaiveDate>,
262    /// Cancelled date (`❌`).
263    pub cancelled_date: Option<NaiveDate>,
264    /// Priority from its emoji; `Normal` when there's none.
265    #[serde(default)]
266    pub priority: TaskPriority,
267    /// Recurrence rule text, for example `every weekday`.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub recurrence: Option<String>,
270    /// On-completion action, for example `keep` or `delete`.
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub on_completion: Option<String>,
273    /// Tasks plugin dependency ID without the leading `🆔` marker.
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub id: Option<String>,
276    /// Tasks plugin dependency IDs from `⛔` or `[dependsOn:: ...]`.
277    #[serde(default, skip_serializing_if = "Vec::is_empty")]
278    pub depends_on: Vec<String>,
279    /// Inline task tags without the leading `#`.
280    #[serde(default, skip_serializing_if = "Vec::is_empty")]
281    pub tags: Vec<String>,
282    /// Obsidian block reference without the leading `^`.
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub block_ref: Option<String>,
285    /// Dataview inline fields parsed from trailing task metadata.
286    ///
287    /// Standard fields are also projected into the typed fields above; custom
288    /// fields remain available here.
289    #[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    /// Build a [`TaskItem`] from parsed Obsidian Tasks metadata. Its
299    /// `status` is `x` or ` ` from `is_completed`; a builder that saw the
300    /// checkbox sets the character it held.
301    #[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/// A task's priority, as the Obsidian Tasks plugin writes it with an emoji.
334#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
335#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
336pub enum TaskPriority {
337    /// `⏬`
338    Lowest,
339    /// `🔽`
340    Low,
341    /// No priority emoji.
342    #[default]
343    Normal,
344    /// `🔼`
345    Medium,
346    /// `⏫`
347    High,
348    /// `🔺`
349    Highest,
350}
351
352impl TaskPriority {
353    /// The emoji that marks this priority; empty for `Normal`.
354    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    /// The priority a priority emoji marks; the empty string is `Normal`.
366    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    /// [`TaskPriority::from_emoji`] for a single character.
379    pub fn from_char(c: char) -> Option<Self> {
380        Self::from_emoji(c.encode_utf8(&mut [0; 4]))
381    }
382
383    /// Whether `s` is a priority emoji (or empty, for `Normal`).
384    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/// Collects a task's parts while the parser walks its list item, then
404/// builds the [`TaskItem`].
405#[derive(Debug, Clone, Default)]
406pub struct TaskBuilder {
407    /// The task's text, metadata included; parsed when built.
408    pub content: String,
409    /// Whether the checkbox is checked.
410    pub is_completed: bool,
411    /// The checkbox character, when the parser read it.
412    pub status: Option<char>,
413    /// Where the task's line sits in the source.
414    pub position: SourcePosition,
415}
416
417impl TaskBuilder {
418    /// Parse the collected text into a [`TaskItem`] and reset the builder
419    /// for the next task.
420    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/// Type of callout block
440#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
441#[non_exhaustive]
442pub enum CalloutType {
443    /// `[!note]`, and any type TurboVault doesn't know.
444    Note,
445    /// `[!tip]`
446    Tip,
447    /// `[!info]`
448    Info,
449    /// `[!todo]`
450    Todo,
451    /// `[!important]`
452    Important,
453    /// `[!success]`
454    Success,
455    /// `[!question]`
456    Question,
457    /// `[!warning]`
458    Warning,
459    /// `[!failure]`, also `fail` and `missing`.
460    Failure,
461    /// `[!danger]`, also `error`.
462    Danger,
463    /// `[!bug]`
464    Bug,
465    /// `[!example]`
466    Example,
467    /// `[!quote]`, also `cite`.
468    Quote,
469}
470
471/// A callout block in vault content
472#[derive(Debug, Clone, Serialize, Deserialize)]
473pub struct Callout {
474    /// The callout's type, from `[!type]`.
475    pub type_: CalloutType,
476    /// The text after `[!type]` on the first line, if any.
477    pub title: Option<String>,
478    /// The callout's body, without the `>` markers.
479    pub content: String,
480    /// Where the callout sits in the source.
481    pub position: SourcePosition,
482    /// Whether it has a `+` or `-` fold marker.
483    pub is_foldable: bool,
484}
485
486/// A block in vault content (Obsidian block reference with ^id)
487#[derive(Debug, Clone, Serialize, Deserialize)]
488pub struct Block {
489    /// The block's text.
490    pub content: String,
491    /// The `^id` a `[[Note#^id]]` link uses, without the `^`.
492    pub block_id: Option<String>,
493    /// Where the block sits in the source.
494    pub position: SourcePosition,
495    /// The kind of block: `paragraph`, `heading`, `list_item` and so on.
496    pub type_: String,
497}
498
499// ============================================================================
500// Content Block Types (for full markdown parsing)
501// ============================================================================
502
503/// A parsed content block in a markdown document.
504///
505/// These represent the block-level structure of markdown content,
506/// similar to an AST but optimized for consumption by tools like treemd.
507#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
508#[serde(tag = "type", rename_all = "lowercase")]
509pub enum ContentBlock {
510    /// A heading (# H1, ## H2, etc.)
511    Heading {
512        /// Heading level, 1 to 6.
513        level: usize,
514        /// The heading's markdown, without the `#` markers.
515        content: String,
516        /// Its inline elements, parsed.
517        inline: Vec<InlineElement>,
518        /// The slug a link to the heading uses, if it has text.
519        anchor: Option<String>,
520    },
521    /// A paragraph of text
522    Paragraph {
523        /// The paragraph's markdown.
524        content: String,
525        /// Its inline elements, parsed.
526        inline: Vec<InlineElement>,
527    },
528    /// A fenced or indented code block
529    Code {
530        /// The fence's language, if it names one.
531        language: Option<String>,
532        /// The code, without the fences.
533        content: String,
534        /// Line the block starts on.
535        start_line: usize,
536        /// Line the block ends on.
537        end_line: usize,
538    },
539    /// An ordered or unordered list
540    List {
541        /// Whether it's numbered.
542        ordered: bool,
543        /// Its items, in order.
544        items: Vec<ListItem>,
545    },
546    /// A blockquote (> text)
547    Blockquote {
548        /// The quote's markdown, without the `>` markers.
549        content: String,
550        /// The blocks inside the quote.
551        blocks: Vec<ContentBlock>,
552    },
553    /// A table with headers and rows
554    Table {
555        /// The header cells.
556        headers: Vec<String>,
557        /// Each column's alignment.
558        alignments: Vec<TableAlignment>,
559        /// The body rows, each a list of cells.
560        rows: Vec<Vec<String>>,
561    },
562    /// An image (standalone, not inline)
563    Image {
564        /// The image's alt text.
565        alt: String,
566        /// Its source path or URL.
567        src: String,
568        /// Its title, if it has one.
569        title: Option<String>,
570    },
571    /// A horizontal rule (---, ***, ___)
572    HorizontalRule,
573    /// HTML `<details><summary>` block
574    Details {
575        /// The `<summary>` text.
576        summary: String,
577        /// The markdown inside the block.
578        content: String,
579        /// The blocks inside it, parsed.
580        blocks: Vec<ContentBlock>,
581    },
582}
583
584impl ContentBlock {
585    /// Extract plain text from this content block.
586    ///
587    /// Returns only the visible text content, stripping markdown syntax.
588    /// This is useful for search indexing, accessibility, and accurate word counts.
589    ///
590    /// # Example
591    /// ```
592    /// use turbovault_core::{ContentBlock, InlineElement};
593    ///
594    /// let block = ContentBlock::Paragraph {
595    ///     content: "[Overview](#overview) and **bold**".to_string(),
596    ///     inline: vec![
597    ///         InlineElement::Link {
598    ///             text: "Overview".to_string(),
599    ///             url: "#overview".to_string(),
600    ///             title: None,
601    ///             line_offset: None,
602    ///         },
603    ///         InlineElement::Text { value: " and ".to_string() },
604    ///         InlineElement::Strong { value: "bold".to_string() },
605    ///     ],
606    /// };
607    /// assert_eq!(block.to_plain_text(), "Overview and bold");
608    /// ```
609    #[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/// An inline element within a block.
656///
657/// These represent inline formatting and links within text content.
658#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
659#[serde(tag = "type", rename_all = "lowercase")]
660pub enum InlineElement {
661    /// Plain text
662    Text {
663        /// The text.
664        value: String,
665    },
666    /// Bold text (**text** or __text__)
667    Strong {
668        /// The text inside the markers.
669        value: String,
670    },
671    /// Italic text (*text* or _text_)
672    Emphasis {
673        /// The text inside the markers.
674        value: String,
675    },
676    /// Inline code (`code`)
677    Code {
678        /// The code inside the backticks.
679        value: String,
680    },
681    /// A link such as `[text](url)`
682    Link {
683        /// The link's text.
684        text: String,
685        /// Its destination as written.
686        url: String,
687        /// Its title, if it has one.
688        title: Option<String>,
689        /// Relative line offset within parent block (for nested list items)
690        #[serde(default, skip_serializing_if = "Option::is_none")]
691        line_offset: Option<usize>,
692    },
693    /// An inline image ![alt](src)
694    Image {
695        /// The image's alt text.
696        alt: String,
697        /// Its source path or URL.
698        src: String,
699        /// Its title, if it has one.
700        title: Option<String>,
701        /// Relative line offset within parent block (for nested list items)
702        #[serde(default, skip_serializing_if = "Option::is_none")]
703        line_offset: Option<usize>,
704    },
705    /// Strikethrough text (~~text~~)
706    Strikethrough {
707        /// The text inside the markers.
708        value: String,
709    },
710}
711
712impl InlineElement {
713    /// Extract plain text from this inline element.
714    ///
715    /// Returns only the visible text content, stripping markdown syntax.
716    /// For links, returns the link text (not the URL).
717    /// For images, returns the alt text.
718    ///
719    /// # Example
720    /// ```
721    /// use turbovault_core::InlineElement;
722    ///
723    /// let link = InlineElement::Link {
724    ///     text: "Overview".to_string(),
725    ///     url: "#overview".to_string(),
726    ///     title: None,
727    ///     line_offset: None,
728    /// };
729    /// assert_eq!(link.to_plain_text(), "Overview");
730    /// ```
731    #[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/// A list item with optional checkbox and nested content.
746#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
747pub struct ListItem {
748    /// For task lists: Some(true) = checked, Some(false) = unchecked, None = not a task
749    pub checked: Option<bool>,
750    /// Raw text content of the item
751    pub content: String,
752    /// Parsed inline elements
753    pub inline: Vec<InlineElement>,
754    /// Nested blocks (e.g., code blocks, sub-lists inside list items)
755    #[serde(default, skip_serializing_if = "Vec::is_empty")]
756    pub blocks: Vec<ContentBlock>,
757}
758
759impl ListItem {
760    /// Extract plain text from this list item.
761    ///
762    /// Returns the visible text content by joining inline elements.
763    /// Includes nested block content recursively.
764    ///
765    /// # Example
766    /// ```
767    /// use turbovault_core::{ListItem, InlineElement};
768    ///
769    /// let item = ListItem {
770    ///     checked: Some(false),
771    ///     content: "Todo item".to_string(),
772    ///     inline: vec![InlineElement::Text { value: "Todo item".to_string() }],
773    ///     blocks: vec![],
774    /// };
775    /// assert_eq!(item.to_plain_text(), "Todo item");
776    /// ```
777    #[must_use]
778    pub fn to_plain_text(&self) -> String {
779        let mut result = String::new();
780
781        // Extract text from inline elements
782        for elem in &self.inline {
783            result.push_str(elem.to_plain_text());
784        }
785
786        // Include nested blocks
787        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/// Table column alignment.
799#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
800#[serde(rename_all = "lowercase")]
801pub enum TableAlignment {
802    /// `:---`
803    Left,
804    /// `:---:`
805    Center,
806    /// `---:`
807    Right,
808    /// `---`, no alignment given.
809    None,
810}
811
812/// YAML frontmatter
813#[derive(Debug, Clone, Serialize, Deserialize)]
814pub struct Frontmatter {
815    /// The properties, keyed by name, with YAML values as JSON.
816    pub data: HashMap<String, serde_json::Value>,
817    /// Where the frontmatter block sits in the source.
818    pub position: SourcePosition,
819}
820
821impl Frontmatter {
822    /// The note's `tags` property, as Obsidian reads it (see [`property_tags`]).
823    pub fn tags(&self) -> Vec<String> {
824        property_tags(self.data.get("tags"))
825    }
826
827    /// The note's `aliases` property, as Obsidian reads it (see [`property_list`]).
828    pub fn aliases(&self) -> Vec<String> {
829        property_list(self.data.get("aliases"))
830    }
831}
832
833/// The values of a list property, read the way Obsidian 1.9 and later read
834/// `tags`, `aliases` and `cssclasses`: a YAML list, of which the non-empty
835/// strings count. Obsidian ignores any other value, a comma-separated string
836/// included, and so does this. Writers that want to keep such a value should
837/// convert it to a list rather than read it as one.
838pub 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
851/// The values of a `tags` property: [`property_list`], without the `#` a tag
852/// may be written with (`- "#project"` is the tag `project`).
853pub 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/// File metadata
862#[derive(Debug, Clone, Serialize, Deserialize)]
863pub struct FileMetadata {
864    /// The file's path.
865    pub path: PathBuf,
866    /// Size in bytes.
867    pub size: u64,
868    /// When the file was created, in seconds since the Unix epoch; its
869    /// modification time where the filesystem keeps no creation time, and
870    /// 0.0 when unknown.
871    pub created_at: f64,
872    /// When the file was last modified, in seconds since the Unix epoch;
873    /// 0.0 when unknown.
874    pub modified_at: f64,
875    /// [`crate::compute_hash`] of the content: the same hash `read_note`
876    /// reports and a Direct write's `expected_hash` is checked against.
877    pub checksum: String,
878    /// Whether the file is an attachment rather than a note.
879    pub is_attachment: bool,
880}
881
882/// A complete vault file with parsed content
883#[derive(Debug, Clone, Serialize, Deserialize)]
884pub struct VaultFile {
885    /// The file's path.
886    pub path: PathBuf,
887    /// Its full text.
888    pub content: String,
889    /// Size, times and content hash.
890    pub metadata: FileMetadata,
891
892    /// The YAML frontmatter, if there is any.
893    pub frontmatter: Option<Frontmatter>,
894    /// Headings, in document order.
895    pub headings: Vec<Heading>,
896    /// Outgoing links: wikilinks, embeds and Markdown links.
897    pub links: Vec<Link>,
898    /// Links from other notes to this one, when the caller filled them in.
899    pub backlinks: HashSet<Link>,
900    /// Blocks with a `^id`.
901    pub blocks: Vec<Block>,
902    /// Inline `#tags` in the body.
903    pub tags: Vec<Tag>,
904    /// Callout blocks.
905    pub callouts: Vec<Callout>,
906    /// Task list items.
907    pub tasks: Vec<TaskItem>,
908
909    /// Whether the parsed fields above are filled in.
910    pub is_parsed: bool,
911    /// Why parsing failed, if it did.
912    pub parse_error: Option<String>,
913    /// When it was last parsed, in seconds since the Unix epoch.
914    pub last_parsed: Option<f64>,
915}
916
917impl VaultFile {
918    /// Create a new vault file
919    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    /// Get outgoing links
939    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    /// Get headings indexed by text
948    pub fn headings_by_text(&self) -> HashMap<&str, &Heading> {
949        self.headings.iter().map(|h| (h.text.as_str(), h)).collect()
950    }
951
952    /// Get blocks with IDs
953    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    /// Check if file contains a tag
961    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    /// Obsidian 1.9 and later read list properties only as lists.
1007    #[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        // The pre-1.9 string form, and anything else, is not a list.
1019        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)); // 'H'
1032        assert_eq!(index.line_col(7), (1, 8)); // 'w'
1033    }
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        // Line 1
1043        assert_eq!(index.line_col(0), (1, 1)); // 'L' of Line 1
1044        assert_eq!(index.line_col(5), (1, 6)); // '1'
1045
1046        // Line 2 (offset 7 = first char after newline)
1047        assert_eq!(index.line_col(7), (2, 1)); // 'L' of Line 2
1048        assert_eq!(index.line_col(13), (2, 7)); // '2'
1049
1050        // Line 3 (offset 14 = first char after second newline)
1051        assert_eq!(index.line_col(14), (3, 1)); // 'L' of Line 3
1052    }
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); // Invalid line
1063        assert_eq!(index.line_start(4), None); // Beyond content
1064    }
1065
1066    #[test]
1067    fn test_source_position_from_offset() {
1068        let content = "Line 1\nLine 2 [[Link]] here\nLine 3";
1069
1070        // Position of [[Link]] starts at offset 14
1071        let pos = SourcePosition::from_offset(content, 14, 8);
1072        assert_eq!(pos.line, 2);
1073        assert_eq!(pos.column, 8); // "Line 2 " = 7 chars, so column 8
1074        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        // Same test as above but using indexed lookup
1084        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); // Even empty content has "line 1"
1106        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); // Line 1 + empty line 2
1115        assert_eq!(index.line_col(6), (1, 7)); // The newline itself
1116        assert_eq!(index.line_col(7), (2, 1)); // After newline
1117    }
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}