1use serde::{Deserialize, Serialize};
12use std::collections::{HashMap, HashSet};
13use std::path::PathBuf;
14
15#[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 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 pub fn start() -> Self {
37 Self {
38 line: 0,
39 column: 0,
40 offset: 0,
41 length: 0,
42 }
43 }
44
45 pub fn from_offset(content: &str, offset: usize, length: usize) -> Self {
52 let before = &content[..offset.min(content.len())];
53 let line = before.matches('\n').count() + 1;
54 let column = before
55 .rfind('\n')
56 .map(|pos| offset - pos)
57 .unwrap_or(offset + 1);
58
59 Self {
60 line,
61 column,
62 offset,
63 length,
64 }
65 }
66
67 pub fn from_offset_indexed(index: &LineIndex, offset: usize, length: usize) -> Self {
71 let (line, column) = index.line_col(offset);
72 Self {
73 line,
74 column,
75 offset,
76 length,
77 }
78 }
79}
80
81#[derive(Debug, Clone)]
99pub struct LineIndex {
100 line_starts: Vec<usize>,
102}
103
104impl LineIndex {
105 pub fn new(content: &str) -> Self {
107 let mut line_starts = vec![0];
108 for (i, ch) in content.char_indices() {
109 if ch == '\n' {
110 line_starts.push(i + 1);
111 }
112 }
113 Self { line_starts }
114 }
115
116 pub fn line_col(&self, offset: usize) -> (usize, usize) {
120 let line_idx = self.line_starts.partition_point(|&start| start <= offset);
122 let line = line_idx.max(1); let line_start = self
124 .line_starts
125 .get(line_idx.saturating_sub(1))
126 .copied()
127 .unwrap_or(0);
128 let column = offset - line_start + 1; (line, column)
130 }
131
132 pub fn line_start(&self, line: usize) -> Option<usize> {
134 if line == 0 {
135 return None;
136 }
137 self.line_starts.get(line - 1).copied()
138 }
139
140 pub fn line_count(&self) -> usize {
142 self.line_starts.len()
143 }
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
148pub enum LinkType {
149 WikiLink,
151 Embed,
153 BlockRef,
155 HeadingRef,
157 Anchor,
159 MarkdownLink,
161 ExternalLink,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)]
167pub struct Link {
168 pub type_: LinkType,
169 pub source_file: PathBuf,
170 pub target: String,
171 pub display_text: Option<String>,
172 pub position: SourcePosition,
173 pub resolved_target: Option<PathBuf>,
174 pub is_valid: bool,
175}
176
177impl Link {
178 pub fn new(
180 type_: LinkType,
181 source_file: PathBuf,
182 target: String,
183 position: SourcePosition,
184 ) -> Self {
185 Self {
186 type_,
187 source_file,
188 target,
189 display_text: None,
190 position,
191 resolved_target: None,
192 is_valid: true,
193 }
194 }
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct Heading {
200 pub text: String,
201 pub level: u8, pub position: SourcePosition,
203 pub anchor: Option<String>,
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct Tag {
209 pub name: String,
210 pub position: SourcePosition,
211 pub is_nested: bool, }
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct TaskItem {
217 pub content: String,
218 pub is_completed: bool,
219 pub position: SourcePosition,
220 pub due_date: Option<String>,
221}
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
225pub enum CalloutType {
226 Note,
227 Tip,
228 Info,
229 Todo,
230 Important,
231 Success,
232 Question,
233 Warning,
234 Failure,
235 Danger,
236 Bug,
237 Example,
238 Quote,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct Callout {
244 pub type_: CalloutType,
245 pub title: Option<String>,
246 pub content: String,
247 pub position: SourcePosition,
248 pub is_foldable: bool,
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct Block {
254 pub content: String,
255 pub block_id: Option<String>,
256 pub position: SourcePosition,
257 pub type_: String, }
259
260#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
269#[serde(tag = "type", rename_all = "lowercase")]
270pub enum ContentBlock {
271 Heading {
273 level: usize,
274 content: String,
275 inline: Vec<InlineElement>,
276 anchor: Option<String>,
277 },
278 Paragraph {
280 content: String,
281 inline: Vec<InlineElement>,
282 },
283 Code {
285 language: Option<String>,
286 content: String,
287 start_line: usize,
288 end_line: usize,
289 },
290 List { ordered: bool, items: Vec<ListItem> },
292 Blockquote {
294 content: String,
295 blocks: Vec<ContentBlock>,
296 },
297 Table {
299 headers: Vec<String>,
300 alignments: Vec<TableAlignment>,
301 rows: Vec<Vec<String>>,
302 },
303 Image {
305 alt: String,
306 src: String,
307 title: Option<String>,
308 },
309 HorizontalRule,
311 Details {
313 summary: String,
314 content: String,
315 blocks: Vec<ContentBlock>,
316 },
317}
318
319impl ContentBlock {
320 #[must_use]
345 pub fn to_plain_text(&self) -> String {
346 match self {
347 Self::Heading { inline, .. } | Self::Paragraph { inline, .. } => {
348 inline.iter().map(InlineElement::to_plain_text).collect()
349 }
350 Self::Code { content, .. } => content.clone(),
351 Self::List { items, .. } => items
352 .iter()
353 .map(ListItem::to_plain_text)
354 .collect::<Vec<_>>()
355 .join("\n"),
356 Self::Blockquote { blocks, .. } => blocks
357 .iter()
358 .map(Self::to_plain_text)
359 .collect::<Vec<_>>()
360 .join("\n"),
361 Self::Table { headers, rows, .. } => {
362 let header_text = headers.join("\t");
363 let row_texts: Vec<String> = rows.iter().map(|row| row.join("\t")).collect();
364 if row_texts.is_empty() {
365 header_text
366 } else {
367 format!("{}\n{}", header_text, row_texts.join("\n"))
368 }
369 }
370 Self::Image { alt, .. } => alt.clone(),
371 Self::HorizontalRule => String::new(),
372 Self::Details {
373 summary, blocks, ..
374 } => {
375 let blocks_text: String = blocks
376 .iter()
377 .map(Self::to_plain_text)
378 .collect::<Vec<_>>()
379 .join("\n");
380 if blocks_text.is_empty() {
381 summary.clone()
382 } else {
383 format!("{}\n{}", summary, blocks_text)
384 }
385 }
386 }
387 }
388}
389
390#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
394#[serde(tag = "type", rename_all = "lowercase")]
395pub enum InlineElement {
396 Text { value: String },
398 Strong { value: String },
400 Emphasis { value: String },
402 Code { value: String },
404 Link {
406 text: String,
407 url: String,
408 title: Option<String>,
409 #[serde(default, skip_serializing_if = "Option::is_none")]
411 line_offset: Option<usize>,
412 },
413 Image {
415 alt: String,
416 src: String,
417 title: Option<String>,
418 #[serde(default, skip_serializing_if = "Option::is_none")]
420 line_offset: Option<usize>,
421 },
422 Strikethrough { value: String },
424}
425
426impl InlineElement {
427 #[must_use]
446 pub fn to_plain_text(&self) -> &str {
447 match self {
448 Self::Text { value }
449 | Self::Strong { value }
450 | Self::Emphasis { value }
451 | Self::Code { value }
452 | Self::Strikethrough { value } => value,
453 Self::Link { text, .. } => text,
454 Self::Image { alt, .. } => alt,
455 }
456 }
457}
458
459#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
461pub struct ListItem {
462 pub checked: Option<bool>,
464 pub content: String,
466 pub inline: Vec<InlineElement>,
468 #[serde(default, skip_serializing_if = "Vec::is_empty")]
470 pub blocks: Vec<ContentBlock>,
471}
472
473impl ListItem {
474 #[must_use]
492 pub fn to_plain_text(&self) -> String {
493 let mut result = String::new();
494
495 for elem in &self.inline {
497 result.push_str(elem.to_plain_text());
498 }
499
500 for block in &self.blocks {
502 if !result.is_empty() && !result.ends_with('\n') {
503 result.push('\n');
504 }
505 result.push_str(&block.to_plain_text());
506 }
507
508 result
509 }
510}
511
512#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
514#[serde(rename_all = "lowercase")]
515pub enum TableAlignment {
516 Left,
517 Center,
518 Right,
519 None,
520}
521
522#[derive(Debug, Clone, Serialize, Deserialize)]
524pub struct Frontmatter {
525 pub data: HashMap<String, serde_json::Value>,
526 pub position: SourcePosition,
527}
528
529impl Frontmatter {
530 pub fn tags(&self) -> Vec<String> {
532 match self.data.get("tags") {
533 Some(serde_json::Value::String(s)) => vec![s.clone()],
534 Some(serde_json::Value::Array(arr)) => arr
535 .iter()
536 .filter_map(|v| v.as_str().map(|s| s.to_string()))
537 .collect(),
538 _ => vec![],
539 }
540 }
541
542 pub fn aliases(&self) -> Vec<String> {
544 match self.data.get("aliases") {
545 Some(serde_json::Value::String(s)) => vec![s.clone()],
546 Some(serde_json::Value::Array(arr)) => arr
547 .iter()
548 .filter_map(|v| v.as_str().map(|s| s.to_string()))
549 .collect(),
550 _ => vec![],
551 }
552 }
553}
554
555#[derive(Debug, Clone, Serialize, Deserialize)]
557pub struct FileMetadata {
558 pub path: PathBuf,
559 pub size: u64,
560 pub created_at: f64,
561 pub modified_at: f64,
562 pub checksum: String,
563 pub is_attachment: bool,
564}
565
566#[derive(Debug, Clone, Serialize, Deserialize)]
568pub struct VaultFile {
569 pub path: PathBuf,
570 pub content: String,
571 pub metadata: FileMetadata,
572
573 pub frontmatter: Option<Frontmatter>,
575 pub headings: Vec<Heading>,
576 pub links: Vec<Link>,
577 pub backlinks: HashSet<Link>,
578 pub blocks: Vec<Block>,
579 pub tags: Vec<Tag>,
580 pub callouts: Vec<Callout>,
581 pub tasks: Vec<TaskItem>,
582
583 pub is_parsed: bool,
585 pub parse_error: Option<String>,
586 pub last_parsed: Option<f64>,
587}
588
589impl VaultFile {
590 pub fn new(path: PathBuf, content: String, metadata: FileMetadata) -> Self {
592 Self {
593 path,
594 content,
595 metadata,
596 frontmatter: None,
597 headings: vec![],
598 links: vec![],
599 backlinks: HashSet::new(),
600 blocks: vec![],
601 tags: vec![],
602 callouts: vec![],
603 tasks: vec![],
604 is_parsed: false,
605 parse_error: None,
606 last_parsed: None,
607 }
608 }
609
610 pub fn outgoing_links(&self) -> HashSet<&str> {
612 self.links
613 .iter()
614 .filter(|link| matches!(link.type_, LinkType::WikiLink | LinkType::Embed))
615 .map(|link| link.target.as_str())
616 .collect()
617 }
618
619 pub fn headings_by_text(&self) -> HashMap<&str, &Heading> {
621 self.headings.iter().map(|h| (h.text.as_str(), h)).collect()
622 }
623
624 pub fn blocks_with_ids(&self) -> HashMap<&str, &Block> {
626 self.blocks
627 .iter()
628 .filter_map(|b| b.block_id.as_deref().map(|id| (id, b)))
629 .collect()
630 }
631
632 pub fn has_tag(&self, tag: &str) -> bool {
634 if let Some(fm) = &self.frontmatter
635 && fm.tags().contains(&tag.to_string())
636 {
637 return true;
638 }
639
640 self.tags.iter().any(|t| t.name == tag)
641 }
642}
643
644#[cfg(test)]
645mod tests {
646 use super::*;
647
648 #[test]
649 fn test_source_position() {
650 let pos = SourcePosition::new(5, 10, 100, 20);
651 assert_eq!(pos.line, 5);
652 assert_eq!(pos.column, 10);
653 assert_eq!(pos.offset, 100);
654 assert_eq!(pos.length, 20);
655 }
656
657 #[test]
658 fn test_frontmatter_tags() {
659 let mut data = HashMap::new();
660 data.insert(
661 "tags".to_string(),
662 serde_json::Value::Array(vec![
663 serde_json::Value::String("rust".to_string()),
664 serde_json::Value::String("mcp".to_string()),
665 ]),
666 );
667
668 let fm = Frontmatter {
669 data,
670 position: SourcePosition::start(),
671 };
672
673 let tags = fm.tags();
674 assert_eq!(tags.len(), 2);
675 assert!(tags.contains(&"rust".to_string()));
676 }
677
678 #[test]
679 fn test_line_index_single_line() {
680 let content = "Hello, world!";
681 let index = LineIndex::new(content);
682
683 assert_eq!(index.line_count(), 1);
684 assert_eq!(index.line_col(0), (1, 1)); assert_eq!(index.line_col(7), (1, 8)); }
687
688 #[test]
689 fn test_line_index_multiline() {
690 let content = "Line 1\nLine 2\nLine 3";
691 let index = LineIndex::new(content);
692
693 assert_eq!(index.line_count(), 3);
694
695 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)); }
706
707 #[test]
708 fn test_line_index_line_start() {
709 let content = "Line 1\nLine 2\nLine 3";
710 let index = LineIndex::new(content);
711
712 assert_eq!(index.line_start(1), Some(0));
713 assert_eq!(index.line_start(2), Some(7));
714 assert_eq!(index.line_start(3), Some(14));
715 assert_eq!(index.line_start(0), None); assert_eq!(index.line_start(4), None); }
718
719 #[test]
720 fn test_source_position_from_offset() {
721 let content = "Line 1\nLine 2 [[Link]] here\nLine 3";
722
723 let pos = SourcePosition::from_offset(content, 14, 8);
725 assert_eq!(pos.line, 2);
726 assert_eq!(pos.column, 8); assert_eq!(pos.offset, 14);
728 assert_eq!(pos.length, 8);
729 }
730
731 #[test]
732 fn test_source_position_from_offset_indexed() {
733 let content = "Line 1\nLine 2 [[Link]] here\nLine 3";
734 let index = LineIndex::new(content);
735
736 let pos = SourcePosition::from_offset_indexed(&index, 14, 8);
738 assert_eq!(pos.line, 2);
739 assert_eq!(pos.column, 8);
740 assert_eq!(pos.offset, 14);
741 assert_eq!(pos.length, 8);
742 }
743
744 #[test]
745 fn test_source_position_first_line() {
746 let content = "[[Link]] at start";
747
748 let pos = SourcePosition::from_offset(content, 0, 8);
749 assert_eq!(pos.line, 1);
750 assert_eq!(pos.column, 1);
751 }
752
753 #[test]
754 fn test_line_index_empty_content() {
755 let content = "";
756 let index = LineIndex::new(content);
757
758 assert_eq!(index.line_count(), 1); assert_eq!(index.line_col(0), (1, 1));
760 }
761
762 #[test]
763 fn test_line_index_trailing_newline() {
764 let content = "Line 1\n";
765 let index = LineIndex::new(content);
766
767 assert_eq!(index.line_count(), 2); assert_eq!(index.line_col(6), (1, 7)); assert_eq!(index.line_col(7), (2, 1)); }
771}