use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::path::PathBuf;
use crate::task_parser::ParsedTaskMetadata;
#[derive(Debug, Clone, Copy, Default, 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 created_date: Option<NaiveDate>,
pub scheduled_date: Option<NaiveDate>,
pub start_date: Option<NaiveDate>,
pub due_date: Option<NaiveDate>,
pub done_date: Option<NaiveDate>,
pub cancelled_date: Option<NaiveDate>,
#[serde(default)]
pub priority: TaskPriority,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recurrence: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_completion: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub depends_on: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub block_ref: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub metadata: HashMap<String, String>,
}
impl TaskItem {
#[must_use]
pub fn from_parsed_metadata(
parsed: crate::task_parser::ParsedTaskMetadata,
is_completed: bool,
position: SourcePosition,
) -> Self {
Self {
content: parsed.description,
is_completed,
position,
created_date: parse_date_opt(parsed.created.as_deref()),
scheduled_date: parse_date_opt(parsed.scheduled.as_deref()),
start_date: parse_date_opt(parsed.start.as_deref()),
due_date: parse_date_opt(parsed.due.as_deref()),
done_date: parse_date_opt(parsed.done.as_deref()),
cancelled_date: parse_date_opt(parsed.cancelled.as_deref()),
priority: parsed
.priority
.and_then(TaskPriority::from_char)
.unwrap_or_default(),
recurrence: parsed.recurrence,
on_completion: parsed.on_completion,
id: parsed.id,
depends_on: parsed.depends_on,
tags: parsed.tags,
block_ref: parsed.block_ref,
metadata: parsed.metadata,
}
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum TaskPriority {
Lowest,
Low,
#[default]
Normal,
Medium,
High,
Highest,
}
impl TaskPriority {
pub fn emoji(&self) -> &'static str {
match self {
TaskPriority::Lowest => "⏬",
TaskPriority::Low => "🔽",
TaskPriority::Normal => "",
TaskPriority::Medium => "🔼",
TaskPriority::High => "⏫",
TaskPriority::Highest => "🔺",
}
}
pub fn from_emoji(s: &str) -> Option<Self> {
Some(match s {
"⏬" => TaskPriority::Lowest,
"🔽" => TaskPriority::Low,
"" => TaskPriority::Normal,
"🔼" => TaskPriority::Medium,
"⏫" => TaskPriority::High,
"🔺" => TaskPriority::Highest,
_ => return None,
})
}
pub fn from_char(c: char) -> Option<Self> {
Self::from_emoji(c.encode_utf8(&mut [0; 4]))
}
pub fn is_valid_emoji(s: &str) -> bool {
Self::from_emoji(s).is_some()
}
}
impl fmt::Display for TaskPriority {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
TaskPriority::Lowest => "⏬",
TaskPriority::Low => "🔽",
TaskPriority::Normal => "",
TaskPriority::Medium => "🔼",
TaskPriority::High => "⏫",
TaskPriority::Highest => "🔺",
};
write!(f, "{}", s)
}
}
#[derive(Debug, Clone, Default)]
pub struct TaskBuilder {
pub content: String,
pub is_completed: bool,
pub position: SourcePosition,
}
impl TaskBuilder {
pub fn build(&mut self) -> TaskItem {
let parsed: ParsedTaskMetadata = crate::task_parser::parse_task_content(&self.content);
let task_item = TaskItem::from_parsed_metadata(
parsed,
std::mem::take(&mut self.is_completed),
std::mem::take(&mut self.position),
);
self.content.clear();
task_item
}
}
fn parse_date_opt(value: Option<&str>) -> Option<NaiveDate> {
NaiveDate::parse_from_str(value?, "%Y-%m-%d").ok()
}
#[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)); }
#[test]
fn test_task_item_from_parsed_metadata() {
let mut metadata = HashMap::new();
metadata.insert("project".to_string(), "[[Team Work]]".to_string());
let task = TaskItem::from_parsed_metadata(
crate::task_parser::ParsedTaskMetadata {
description: "Review PR".to_string(),
due: Some("2026-05-01".to_string()),
scheduled: Some("2026-04-30".to_string()),
start: Some("2026-04-29".to_string()),
done: None,
cancelled: None,
created: Some("2026-04-28".to_string()),
priority: Some('⏫'),
recurrence: Some("every weekday".to_string()),
on_completion: Some("delete".to_string()),
id: Some("pr-123".to_string()),
depends_on: vec!["abc123".to_string(), "def456".to_string()],
tags: vec!["review".to_string()],
block_ref: Some("pr-123".to_string()),
metadata,
},
false,
SourcePosition::start(),
);
assert_eq!(task.content, "Review PR");
assert_eq!(
task.due_date.map(|date| date.to_string()).as_deref(),
Some("2026-05-01")
);
assert_eq!(
task.scheduled_date.map(|date| date.to_string()).as_deref(),
Some("2026-04-30")
);
assert_eq!(
task.start_date.map(|date| date.to_string()).as_deref(),
Some("2026-04-29")
);
assert_eq!(
task.created_date.map(|date| date.to_string()).as_deref(),
Some("2026-04-28")
);
assert_eq!(task.priority, TaskPriority::High);
assert_eq!(task.recurrence.as_deref(), Some("every weekday"));
assert_eq!(task.on_completion.as_deref(), Some("delete"));
assert_eq!(task.id.as_deref(), Some("pr-123"));
assert_eq!(
task.depends_on,
vec!["abc123".to_string(), "def456".to_string()]
);
assert_eq!(task.tags, vec!["review".to_string()]);
assert_eq!(task.block_ref.as_deref(), Some("pr-123"));
assert_eq!(
task.metadata.get("project").map(String::as_str),
Some("[[Team Work]]")
);
}
#[test]
fn test_task_item_deserializes_without_metadata_fields() {
let task: TaskItem = serde_json::from_str(
r#"{
"content": "Legacy task",
"is_completed": false,
"position": {
"line": 1,
"column": 1,
"offset": 0,
"length": 13
}
}"#,
)
.unwrap();
assert_eq!(task.content, "Legacy task");
assert!(!task.is_completed);
assert_eq!(task.priority, TaskPriority::Normal);
assert!(task.due_date.is_none());
assert!(task.metadata.is_empty());
}
#[test]
fn test_task_item_deserializes_with_metadata_fields() {
let task: TaskItem = serde_json::from_str(
r#"{
"content": "Modern task",
"is_completed": true,
"position": {
"line": 2,
"column": 1,
"offset": 14,
"length": 42
},
"created_date": "2026-04-28",
"scheduled_date": "2026-04-29",
"start_date": "2026-04-30",
"due_date": "2026-05-01",
"done_date": "2026-05-02",
"cancelled_date": null,
"priority": "HIGH",
"recurrence": "every weekday",
"on_completion": "delete",
"id": "task-123",
"depends_on": ["abc123", "def456"],
"tags": ["review", "work"],
"block_ref": "block-123",
"metadata": {
"project": "[[Team Work]]"
}
}"#,
)
.unwrap();
assert_eq!(task.content, "Modern task");
assert!(task.is_completed);
assert_eq!(
task.created_date.map(|date| date.to_string()).as_deref(),
Some("2026-04-28")
);
assert_eq!(
task.scheduled_date.map(|date| date.to_string()).as_deref(),
Some("2026-04-29")
);
assert_eq!(
task.start_date.map(|date| date.to_string()).as_deref(),
Some("2026-04-30")
);
assert_eq!(
task.due_date.map(|date| date.to_string()).as_deref(),
Some("2026-05-01")
);
assert_eq!(
task.done_date.map(|date| date.to_string()).as_deref(),
Some("2026-05-02")
);
assert!(task.cancelled_date.is_none());
assert_eq!(task.priority, TaskPriority::High);
assert_eq!(task.recurrence.as_deref(), Some("every weekday"));
assert_eq!(task.on_completion.as_deref(), Some("delete"));
assert_eq!(task.id.as_deref(), Some("task-123"));
assert_eq!(
task.depends_on,
vec!["abc123".to_string(), "def456".to_string()]
);
assert_eq!(task.tags, vec!["review".to_string(), "work".to_string()]);
assert_eq!(task.block_ref.as_deref(), Some("block-123"));
assert_eq!(
task.metadata.get("project").map(String::as_str),
Some("[[Team Work]]")
);
}
#[test]
fn test_task_priority_serializes_as_stable_api_value() {
assert_eq!(
serde_json::to_value(TaskPriority::High).unwrap(),
serde_json::Value::String("HIGH".to_string())
);
}
}