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 serde::{Deserialize, Serialize};
12use std::collections::{HashMap, HashSet};
13use std::path::PathBuf;
14
15/// Position in source text (line, column, byte offset)
16#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
17pub struct SourcePosition {
18    pub line: usize,
19    pub column: usize,
20    pub offset: usize,
21    pub length: usize,
22}
23
24impl SourcePosition {
25    /// Create a new source position
26    pub fn new(line: usize, column: usize, offset: usize, length: usize) -> Self {
27        Self {
28            line,
29            column,
30            offset,
31            length,
32        }
33    }
34
35    /// Create position at start
36    pub fn start() -> Self {
37        Self {
38            line: 0,
39            column: 0,
40            offset: 0,
41            length: 0,
42        }
43    }
44}
45
46/// Type of link in Obsidian content
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
48pub enum LinkType {
49    /// Wikilink: `[[Note]]`
50    WikiLink,
51    /// Embedded note: `![[Note]]`
52    Embed,
53    /// Block reference: `[[Note#^block]]`
54    BlockRef,
55    /// Heading reference: `[[Note#Heading]]`
56    HeadingRef,
57    /// Markdown link: `[text](url)`
58    MarkdownLink,
59    /// External URL: http://...
60    ExternalLink,
61}
62
63/// A link in vault content
64#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)]
65pub struct Link {
66    pub type_: LinkType,
67    pub source_file: PathBuf,
68    pub target: String,
69    pub display_text: Option<String>,
70    pub position: SourcePosition,
71    pub resolved_target: Option<PathBuf>,
72    pub is_valid: bool,
73}
74
75impl Link {
76    /// Create a new link
77    pub fn new(
78        type_: LinkType,
79        source_file: PathBuf,
80        target: String,
81        position: SourcePosition,
82    ) -> Self {
83        Self {
84            type_,
85            source_file,
86            target,
87            display_text: None,
88            position,
89            resolved_target: None,
90            is_valid: true,
91        }
92    }
93}
94
95/// A heading in vault content
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct Heading {
98    pub text: String,
99    pub level: u8, // 1-6
100    pub position: SourcePosition,
101    pub anchor: Option<String>,
102}
103
104/// A tag in vault content
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct Tag {
107    pub name: String,
108    pub position: SourcePosition,
109    pub is_nested: bool, // #parent/child
110}
111
112/// A task item in vault content
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct TaskItem {
115    pub content: String,
116    pub is_completed: bool,
117    pub position: SourcePosition,
118    pub due_date: Option<String>,
119}
120
121/// Type of callout block
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
123pub enum CalloutType {
124    Note,
125    Tip,
126    Info,
127    Todo,
128    Important,
129    Success,
130    Question,
131    Warning,
132    Failure,
133    Danger,
134    Bug,
135    Example,
136    Quote,
137}
138
139/// A callout block in vault content
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct Callout {
142    pub type_: CalloutType,
143    pub title: Option<String>,
144    pub content: String,
145    pub position: SourcePosition,
146    pub is_foldable: bool,
147}
148
149/// A block in vault content
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct Block {
152    pub content: String,
153    pub block_id: Option<String>,
154    pub position: SourcePosition,
155    pub type_: String, // paragraph, heading, list_item, etc.
156}
157
158/// YAML frontmatter
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct Frontmatter {
161    pub data: HashMap<String, serde_json::Value>,
162    pub position: SourcePosition,
163}
164
165impl Frontmatter {
166    /// Extract tags from frontmatter
167    pub fn tags(&self) -> Vec<String> {
168        match self.data.get("tags") {
169            Some(serde_json::Value::String(s)) => vec![s.clone()],
170            Some(serde_json::Value::Array(arr)) => arr
171                .iter()
172                .filter_map(|v| v.as_str().map(|s| s.to_string()))
173                .collect(),
174            _ => vec![],
175        }
176    }
177
178    /// Extract aliases from frontmatter
179    pub fn aliases(&self) -> Vec<String> {
180        match self.data.get("aliases") {
181            Some(serde_json::Value::String(s)) => vec![s.clone()],
182            Some(serde_json::Value::Array(arr)) => arr
183                .iter()
184                .filter_map(|v| v.as_str().map(|s| s.to_string()))
185                .collect(),
186            _ => vec![],
187        }
188    }
189}
190
191/// File metadata
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct FileMetadata {
194    pub path: PathBuf,
195    pub size: u64,
196    pub created_at: f64,
197    pub modified_at: f64,
198    pub checksum: String,
199    pub is_attachment: bool,
200}
201
202/// A complete vault file with parsed content
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct VaultFile {
205    pub path: PathBuf,
206    pub content: String,
207    pub metadata: FileMetadata,
208
209    // Parsed elements
210    pub frontmatter: Option<Frontmatter>,
211    pub headings: Vec<Heading>,
212    pub links: Vec<Link>,
213    pub backlinks: HashSet<Link>,
214    pub blocks: Vec<Block>,
215    pub tags: Vec<Tag>,
216    pub callouts: Vec<Callout>,
217    pub tasks: Vec<TaskItem>,
218
219    // Cache status
220    pub is_parsed: bool,
221    pub parse_error: Option<String>,
222    pub last_parsed: Option<f64>,
223}
224
225impl VaultFile {
226    /// Create a new vault file
227    pub fn new(path: PathBuf, content: String, metadata: FileMetadata) -> Self {
228        Self {
229            path,
230            content,
231            metadata,
232            frontmatter: None,
233            headings: vec![],
234            links: vec![],
235            backlinks: HashSet::new(),
236            blocks: vec![],
237            tags: vec![],
238            callouts: vec![],
239            tasks: vec![],
240            is_parsed: false,
241            parse_error: None,
242            last_parsed: None,
243        }
244    }
245
246    /// Get outgoing links
247    pub fn outgoing_links(&self) -> HashSet<&str> {
248        self.links
249            .iter()
250            .filter(|link| matches!(link.type_, LinkType::WikiLink | LinkType::Embed))
251            .map(|link| link.target.as_str())
252            .collect()
253    }
254
255    /// Get headings indexed by text
256    pub fn headings_by_text(&self) -> HashMap<&str, &Heading> {
257        self.headings.iter().map(|h| (h.text.as_str(), h)).collect()
258    }
259
260    /// Get blocks with IDs
261    pub fn blocks_with_ids(&self) -> HashMap<&str, &Block> {
262        self.blocks
263            .iter()
264            .filter_map(|b| b.block_id.as_deref().map(|id| (id, b)))
265            .collect()
266    }
267
268    /// Check if file contains a tag
269    pub fn has_tag(&self, tag: &str) -> bool {
270        if let Some(fm) = &self.frontmatter
271            && fm.tags().contains(&tag.to_string())
272        {
273            return true;
274        }
275
276        self.tags.iter().any(|t| t.name == tag)
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn test_source_position() {
286        let pos = SourcePosition::new(5, 10, 100, 20);
287        assert_eq!(pos.line, 5);
288        assert_eq!(pos.column, 10);
289        assert_eq!(pos.offset, 100);
290        assert_eq!(pos.length, 20);
291    }
292
293    #[test]
294    fn test_frontmatter_tags() {
295        let mut data = HashMap::new();
296        data.insert(
297            "tags".to_string(),
298            serde_json::Value::Array(vec![
299                serde_json::Value::String("rust".to_string()),
300                serde_json::Value::String("mcp".to_string()),
301            ]),
302        );
303
304        let fm = Frontmatter {
305            data,
306            position: SourcePosition::start(),
307        };
308
309        let tags = fm.tags();
310        assert_eq!(tags.len(), 2);
311        assert!(tags.contains(&"rust".to_string()));
312    }
313}