use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct SourcePosition {
pub line: usize,
pub column: usize,
pub offset: usize,
pub length: usize,
}
impl SourcePosition {
pub fn new(line: usize, column: usize, offset: usize, length: usize) -> Self {
Self {
line,
column,
offset,
length,
}
}
pub fn start() -> Self {
Self {
line: 0,
column: 0,
offset: 0,
length: 0,
}
}
pub fn from_offset(content: &str, offset: usize, length: usize) -> Self {
let before = &content[..offset.min(content.len())];
let line = before.matches('\n').count() + 1;
let column = before
.rfind('\n')
.map(|pos| offset - pos)
.unwrap_or(offset + 1);
Self {
line,
column,
offset,
length,
}
}
pub fn from_offset_indexed(index: &LineIndex, offset: usize, length: usize) -> Self {
let (line, column) = index.line_col(offset);
Self {
line,
column,
offset,
length,
}
}
}
#[derive(Debug, Clone)]
pub struct LineIndex {
line_starts: Vec<usize>,
}
impl LineIndex {
pub fn new(content: &str) -> Self {
let mut line_starts = vec![0];
for (i, ch) in content.char_indices() {
if ch == '\n' {
line_starts.push(i + 1);
}
}
Self { line_starts }
}
pub fn line_col(&self, offset: usize) -> (usize, usize) {
let line_idx = self.line_starts.partition_point(|&start| start <= offset);
let line = line_idx.max(1); let line_start = self
.line_starts
.get(line_idx.saturating_sub(1))
.copied()
.unwrap_or(0);
let column = offset - line_start + 1; (line, column)
}
pub fn line_start(&self, line: usize) -> Option<usize> {
if line == 0 {
return None;
}
self.line_starts.get(line - 1).copied()
}
pub fn line_count(&self) -> usize {
self.line_starts.len()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum LinkType {
WikiLink,
Embed,
BlockRef,
HeadingRef,
Anchor,
MarkdownLink,
ExternalLink,
}
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)]
pub struct Link {
pub type_: LinkType,
pub source_file: PathBuf,
pub target: String,
pub display_text: Option<String>,
pub position: SourcePosition,
pub resolved_target: Option<PathBuf>,
pub is_valid: bool,
}
impl Link {
pub fn new(
type_: LinkType,
source_file: PathBuf,
target: String,
position: SourcePosition,
) -> Self {
Self {
type_,
source_file,
target,
display_text: None,
position,
resolved_target: None,
is_valid: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Heading {
pub text: String,
pub level: u8, pub position: SourcePosition,
pub anchor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tag {
pub name: String,
pub position: SourcePosition,
pub is_nested: bool, }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskItem {
pub content: String,
pub is_completed: bool,
pub position: SourcePosition,
pub due_date: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CalloutType {
Note,
Tip,
Info,
Todo,
Important,
Success,
Question,
Warning,
Failure,
Danger,
Bug,
Example,
Quote,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Callout {
pub type_: CalloutType,
pub title: Option<String>,
pub content: String,
pub position: SourcePosition,
pub is_foldable: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Block {
pub content: String,
pub block_id: Option<String>,
pub position: SourcePosition,
pub type_: String, }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum ContentBlock {
Heading {
level: usize,
content: String,
inline: Vec<InlineElement>,
anchor: Option<String>,
},
Paragraph {
content: String,
inline: Vec<InlineElement>,
},
Code {
language: Option<String>,
content: String,
start_line: usize,
end_line: usize,
},
List { ordered: bool, items: Vec<ListItem> },
Blockquote {
content: String,
blocks: Vec<ContentBlock>,
},
Table {
headers: Vec<String>,
alignments: Vec<TableAlignment>,
rows: Vec<Vec<String>>,
},
Image {
alt: String,
src: String,
title: Option<String>,
},
HorizontalRule,
Details {
summary: String,
content: String,
blocks: Vec<ContentBlock>,
},
}
impl ContentBlock {
#[must_use]
pub fn to_plain_text(&self) -> String {
match self {
Self::Heading { inline, .. } | Self::Paragraph { inline, .. } => {
inline.iter().map(InlineElement::to_plain_text).collect()
}
Self::Code { content, .. } => content.clone(),
Self::List { items, .. } => items
.iter()
.map(ListItem::to_plain_text)
.collect::<Vec<_>>()
.join("\n"),
Self::Blockquote { blocks, .. } => blocks
.iter()
.map(Self::to_plain_text)
.collect::<Vec<_>>()
.join("\n"),
Self::Table { headers, rows, .. } => {
let header_text = headers.join("\t");
let row_texts: Vec<String> = rows.iter().map(|row| row.join("\t")).collect();
if row_texts.is_empty() {
header_text
} else {
format!("{}\n{}", header_text, row_texts.join("\n"))
}
}
Self::Image { alt, .. } => alt.clone(),
Self::HorizontalRule => String::new(),
Self::Details {
summary, blocks, ..
} => {
let blocks_text: String = blocks
.iter()
.map(Self::to_plain_text)
.collect::<Vec<_>>()
.join("\n");
if blocks_text.is_empty() {
summary.clone()
} else {
format!("{}\n{}", summary, blocks_text)
}
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum InlineElement {
Text { value: String },
Strong { value: String },
Emphasis { value: String },
Code { value: String },
Link {
text: String,
url: String,
title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
line_offset: Option<usize>,
},
Image {
alt: String,
src: String,
title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
line_offset: Option<usize>,
},
Strikethrough { value: String },
}
impl InlineElement {
#[must_use]
pub fn to_plain_text(&self) -> &str {
match self {
Self::Text { value }
| Self::Strong { value }
| Self::Emphasis { value }
| Self::Code { value }
| Self::Strikethrough { value } => value,
Self::Link { text, .. } => text,
Self::Image { alt, .. } => alt,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ListItem {
pub checked: Option<bool>,
pub content: String,
pub inline: Vec<InlineElement>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub blocks: Vec<ContentBlock>,
}
impl ListItem {
#[must_use]
pub fn to_plain_text(&self) -> String {
let mut result = String::new();
for elem in &self.inline {
result.push_str(elem.to_plain_text());
}
for block in &self.blocks {
if !result.is_empty() && !result.ends_with('\n') {
result.push('\n');
}
result.push_str(&block.to_plain_text());
}
result
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum TableAlignment {
Left,
Center,
Right,
None,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Frontmatter {
pub data: HashMap<String, serde_json::Value>,
pub position: SourcePosition,
}
impl Frontmatter {
pub fn tags(&self) -> Vec<String> {
match self.data.get("tags") {
Some(serde_json::Value::String(s)) => vec![s.clone()],
Some(serde_json::Value::Array(arr)) => arr
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect(),
_ => vec![],
}
}
pub fn aliases(&self) -> Vec<String> {
match self.data.get("aliases") {
Some(serde_json::Value::String(s)) => vec![s.clone()],
Some(serde_json::Value::Array(arr)) => arr
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect(),
_ => vec![],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileMetadata {
pub path: PathBuf,
pub size: u64,
pub created_at: f64,
pub modified_at: f64,
pub checksum: String,
pub is_attachment: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VaultFile {
pub path: PathBuf,
pub content: String,
pub metadata: FileMetadata,
pub frontmatter: Option<Frontmatter>,
pub headings: Vec<Heading>,
pub links: Vec<Link>,
pub backlinks: HashSet<Link>,
pub blocks: Vec<Block>,
pub tags: Vec<Tag>,
pub callouts: Vec<Callout>,
pub tasks: Vec<TaskItem>,
pub is_parsed: bool,
pub parse_error: Option<String>,
pub last_parsed: Option<f64>,
}
impl VaultFile {
pub fn new(path: PathBuf, content: String, metadata: FileMetadata) -> Self {
Self {
path,
content,
metadata,
frontmatter: None,
headings: vec![],
links: vec![],
backlinks: HashSet::new(),
blocks: vec![],
tags: vec![],
callouts: vec![],
tasks: vec![],
is_parsed: false,
parse_error: None,
last_parsed: None,
}
}
pub fn outgoing_links(&self) -> HashSet<&str> {
self.links
.iter()
.filter(|link| matches!(link.type_, LinkType::WikiLink | LinkType::Embed))
.map(|link| link.target.as_str())
.collect()
}
pub fn headings_by_text(&self) -> HashMap<&str, &Heading> {
self.headings.iter().map(|h| (h.text.as_str(), h)).collect()
}
pub fn blocks_with_ids(&self) -> HashMap<&str, &Block> {
self.blocks
.iter()
.filter_map(|b| b.block_id.as_deref().map(|id| (id, b)))
.collect()
}
pub fn has_tag(&self, tag: &str) -> bool {
if let Some(fm) = &self.frontmatter
&& fm.tags().contains(&tag.to_string())
{
return true;
}
self.tags.iter().any(|t| t.name == tag)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_source_position() {
let pos = SourcePosition::new(5, 10, 100, 20);
assert_eq!(pos.line, 5);
assert_eq!(pos.column, 10);
assert_eq!(pos.offset, 100);
assert_eq!(pos.length, 20);
}
#[test]
fn test_frontmatter_tags() {
let mut data = HashMap::new();
data.insert(
"tags".to_string(),
serde_json::Value::Array(vec![
serde_json::Value::String("rust".to_string()),
serde_json::Value::String("mcp".to_string()),
]),
);
let fm = Frontmatter {
data,
position: SourcePosition::start(),
};
let tags = fm.tags();
assert_eq!(tags.len(), 2);
assert!(tags.contains(&"rust".to_string()));
}
#[test]
fn test_line_index_single_line() {
let content = "Hello, world!";
let index = LineIndex::new(content);
assert_eq!(index.line_count(), 1);
assert_eq!(index.line_col(0), (1, 1)); assert_eq!(index.line_col(7), (1, 8)); }
#[test]
fn test_line_index_multiline() {
let content = "Line 1\nLine 2\nLine 3";
let index = LineIndex::new(content);
assert_eq!(index.line_count(), 3);
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)); }
#[test]
fn test_line_index_line_start() {
let content = "Line 1\nLine 2\nLine 3";
let index = LineIndex::new(content);
assert_eq!(index.line_start(1), Some(0));
assert_eq!(index.line_start(2), Some(7));
assert_eq!(index.line_start(3), Some(14));
assert_eq!(index.line_start(0), None); assert_eq!(index.line_start(4), None); }
#[test]
fn test_source_position_from_offset() {
let content = "Line 1\nLine 2 [[Link]] here\nLine 3";
let pos = SourcePosition::from_offset(content, 14, 8);
assert_eq!(pos.line, 2);
assert_eq!(pos.column, 8); assert_eq!(pos.offset, 14);
assert_eq!(pos.length, 8);
}
#[test]
fn test_source_position_from_offset_indexed() {
let content = "Line 1\nLine 2 [[Link]] here\nLine 3";
let index = LineIndex::new(content);
let pos = SourcePosition::from_offset_indexed(&index, 14, 8);
assert_eq!(pos.line, 2);
assert_eq!(pos.column, 8);
assert_eq!(pos.offset, 14);
assert_eq!(pos.length, 8);
}
#[test]
fn test_source_position_first_line() {
let content = "[[Link]] at start";
let pos = SourcePosition::from_offset(content, 0, 8);
assert_eq!(pos.line, 1);
assert_eq!(pos.column, 1);
}
#[test]
fn test_line_index_empty_content() {
let content = "";
let index = LineIndex::new(content);
assert_eq!(index.line_count(), 1); assert_eq!(index.line_col(0), (1, 1));
}
#[test]
fn test_line_index_trailing_newline() {
let content = "Line 1\n";
let index = LineIndex::new(content);
assert_eq!(index.line_count(), 2); assert_eq!(index.line_col(6), (1, 7)); assert_eq!(index.line_col(7), (2, 1)); }
}