use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use regex::Regex;
use crate::{LocatedTag, Location, NoteError, common};
use gray_matter::{Matter, Pod, engine::YAML};
use indexmap::IndexMap;
#[derive(Clone)]
pub struct Note {
pub path: PathBuf,
pub id: String,
pub title: Option<String>,
pub aliases: Vec<String>,
pub tags: Vec<LocatedTag>,
pub body: Option<String>,
pub links: Vec<crate::LocatedLink>,
pub frontmatter: Option<IndexMap<String, Pod>>,
pub frontmatter_line_count: usize,
}
#[derive(Clone)]
pub struct NoteBuilder {
pub path: PathBuf,
pub id: String,
pub title: Option<String>,
pub aliases: Vec<String>,
pub tags: Vec<LocatedTag>,
pub body: Option<String>,
}
impl NoteBuilder {
pub fn new(path: impl AsRef<Path>) -> Result<Self, NoteError> {
Ok(Self {
path: path.as_ref().to_path_buf(),
id: path
.as_ref()
.file_stem()
.ok_or(NoteError::InvalidPath(path.as_ref().to_path_buf()))?
.to_string_lossy()
.to_string(),
title: None,
aliases: Vec::new(),
tags: Vec::new(),
body: None,
})
}
pub fn id(mut self, id: &str) -> Self {
self.id = id.to_string();
self
}
pub fn title(mut self, title: &str) -> Self {
self.title = Some(title.to_string());
self
}
pub fn alias(mut self, alias: &str) -> Self {
self.aliases.push(alias.to_string());
self
}
pub fn aliases(mut self, aliases: &[String]) -> Self {
for alias in aliases {
self = self.alias(alias);
}
self
}
pub fn tag(mut self, tag: &str) -> Self {
self.tags.push(LocatedTag {
tag: tag.to_string(),
location: Location::Frontmatter,
});
self
}
pub fn tags(mut self, tags: &[&str]) -> Self {
for tag in tags {
self = self.tag(tag);
}
self
}
pub fn located_tag(mut self, tag: &LocatedTag) -> Self {
self.tags.push(tag.clone());
self
}
pub fn located_tags(mut self, tags: &[LocatedTag]) -> Self {
for tag in tags {
self = self.located_tag(tag);
}
self
}
pub fn body(mut self, body: &str) -> Self {
self.body = Some(body.to_string());
self
}
pub fn build(self) -> Result<Note, NoteError> {
let Self {
path,
id,
title,
aliases,
tags,
body,
} = self;
let mut note = Note {
path,
id,
title,
aliases,
tags,
body: None,
links: Vec::new(),
frontmatter: None,
frontmatter_line_count: 0,
};
note.update_content(body.as_deref(), None)?;
Ok(note)
}
}
impl Note {
pub fn builder(path: impl AsRef<Path>) -> Result<NoteBuilder, NoteError> {
NoteBuilder::new(path)
}
pub fn parse(path: impl AsRef<Path>, content: &str) -> Self {
Self::parse_impl(path, content, true)
}
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, NoteError> {
let path = common::normalize_path(path.as_ref(), None);
let raw = std::fs::read_to_string(&path)?;
Ok(Self::parse_impl(&path, &raw, false))
}
pub fn from_path_with_body(path: impl AsRef<Path>) -> Result<Self, NoteError> {
let path = common::normalize_path(path.as_ref(), None);
let raw = std::fs::read_to_string(&path)?;
Ok(Self::parse_impl(&path, &raw, true))
}
fn parse_impl(path: impl AsRef<Path>, content: &str, keep_body: bool) -> Self {
let matter = Matter::<YAML>::new();
let (body, frontmatter) = match matter.parse(content) {
Ok(parsed) => {
let fm = parsed.data.and_then(|pod: Pod| pod.as_hashmap().ok()).map(|hm| {
let mut entries: Vec<_> = hm.into_iter().collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
entries.into_iter().collect::<IndexMap<_, _>>()
});
(parsed.content, fm)
}
Err(_) => (content.to_string(), None),
};
let frontmatter_line_count = content.lines().count().saturating_sub(body.lines().count());
let id = frontmatter
.as_ref()
.and_then(|fm| fm.get("id"))
.and_then(|p| p.as_string().ok())
.or_else(|| {
path.as_ref()
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
})
.unwrap_or_default();
let mut title = frontmatter
.as_ref()
.and_then(|fm| fm.get("title"))
.and_then(|p| p.as_string().ok())
.or_else(|| find_h1(&body));
let aliases = {
let mut v: Vec<String> = frontmatter
.as_ref()
.and_then(|fm| fm.get("aliases"))
.and_then(|p| p.as_vec().ok())
.unwrap_or_default()
.into_iter()
.filter_map(|p| p.as_string().ok())
.collect();
if let Some(ref t) = title {
let clean = strip_title_md(t);
if !v.contains(&clean) {
v.push(clean);
}
} else if !v.is_empty() {
title = Some(v[0].clone());
}
v
};
let fm_tags: Vec<LocatedTag> = frontmatter
.as_ref()
.and_then(|fm| fm.get("tags"))
.and_then(|p| p.as_vec().ok())
.unwrap_or_default()
.into_iter()
.filter_map(|p| p.as_string().ok())
.map(|tag| LocatedTag {
tag,
location: Location::Frontmatter,
})
.collect();
let offset = frontmatter_line_count;
let links = crate::link::parse_links(&body)
.into_iter()
.map(|mut ll| {
ll.location.line += offset;
ll
})
.collect();
let inline_tags = crate::tag::parse_inline_tags(&body)
.into_iter()
.map(|mut lt| {
if let Location::Inline(ref mut loc) = lt.location {
loc.line += offset;
}
lt
})
.collect::<Vec<_>>();
let mut tags = fm_tags;
tags.extend(inline_tags);
Note {
path: path.as_ref().to_path_buf(),
id,
title,
aliases,
tags,
body: if keep_body { Some(body) } else { None },
links,
frontmatter,
frontmatter_line_count,
}
}
pub fn update_content(
&mut self,
body: Option<&str>,
frontmatter: Option<IndexMap<String, Pod>>,
) -> Result<(), NoteError> {
if body.is_none() && frontmatter.is_none() {
return Ok(());
}
if let Some(body) = body {
if let Some(frontmatter) = frontmatter {
self.frontmatter = Some(frontmatter);
}
let file_content = self.to_file_content(body)?;
let parsed = Self::parse_impl(&self.path, &file_content, true);
self.body = Some(body.to_string());
self.tags = parsed.tags;
self.links = parsed.links;
} else if let Some(frontmatter) = frontmatter {
let mut tags: Vec<LocatedTag> = frontmatter
.get("tags")
.and_then(|p| p.as_vec().ok())
.unwrap_or_default()
.into_iter()
.filter_map(|p| p.as_string().ok())
.map(|tag| LocatedTag {
tag,
location: Location::Frontmatter,
})
.collect();
for tag in &self.tags {
match tag.location {
Location::Frontmatter => {}
Location::Inline(_) => tags.push(tag.clone()),
}
}
self.frontmatter = Some(frontmatter);
self.tags = tags;
}
Ok(())
}
pub fn reload(self) -> Result<Self, NoteError> {
Self::from_path(&self.path)
}
pub fn reload_with_body(self) -> Result<Self, NoteError> {
Self::from_path_with_body(&self.path)
}
pub fn load_body(&mut self) -> Result<(), NoteError> {
if self.body.is_none() {
let raw = std::fs::read_to_string(&self.path)?;
let matter = Matter::<YAML>::new();
let body = match matter.parse::<Pod>(&raw) {
Ok(parsed) => parsed.content,
Err(_) => raw,
};
self.body = Some(body);
}
Ok(())
}
pub fn add_alias(&mut self, alias: String) {
if !self.aliases.contains(&alias) {
self.aliases.push(alias);
}
}
pub fn add_tag(&mut self, tag: impl Into<String>) {
let tag = crate::tag::clean_tag(&tag.into());
let already_present = self
.tags
.iter()
.any(|t| t.tag.eq_ignore_ascii_case(&tag) && matches!(t.location, Location::Frontmatter));
if !already_present {
self.tags.push(LocatedTag {
tag,
location: Location::Frontmatter,
});
}
}
pub fn remove_tag(&mut self, tag: &str) {
let tag = crate::tag::clean_tag(tag);
self.tags
.retain(|t| !(t.tag.eq_ignore_ascii_case(&tag) && matches!(t.location, Location::Frontmatter)));
}
pub fn set_field(&mut self, key: &str, value: &serde_yaml::Value) -> Result<(), NoteError> {
if key.contains('\n') {
return Err(NoteError::InvalidFieldName(
"field names cannot contain newlines".to_string(),
));
}
if ["id", "title", "aliases", "tags"].contains(&key) {
return Err(NoteError::InvalidFieldName(format!(
"'{}' is a reserved field name and cannot be set this way",
key
)));
}
if self.frontmatter.is_none() {
self.frontmatter = Some(IndexMap::new());
}
if value.is_null() {
self.frontmatter.as_mut().unwrap().shift_remove(key);
} else {
self.frontmatter
.as_mut()
.unwrap()
.insert(key.to_string(), yaml_to_pod_value(value));
}
Ok(())
}
pub fn write(&self) -> Result<(), NoteError> {
let content = self.read(true)?;
let parent = self.path.parent().unwrap_or_else(|| Path::new("."));
let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
tmp.write_all(content.as_bytes())?;
tmp.persist(&self.path).map_err(|e| e.error)?;
Ok(())
}
pub fn write_frontmatter(&self) -> Result<(), NoteError> {
let raw = std::fs::read_to_string(&self.path)?;
let matter = Matter::<YAML>::new();
let body = match matter.parse::<Pod>(&raw) {
Ok(parsed) => parsed.content,
Err(_) => raw.clone(),
};
let file_content = self.to_file_content(&body)?;
let parent = self.path.parent().unwrap_or_else(|| Path::new("."));
let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
tmp.write_all(file_content.as_bytes())?;
tmp.persist(&self.path).map_err(|e| e.error)?;
Ok(())
}
pub fn read(&self, include_frontmatter: bool) -> Result<String, NoteError> {
let body = self.body.as_deref().ok_or(NoteError::BodyNotLoaded)?;
if include_frontmatter {
let file_content = self.to_file_content(body)?;
Ok(file_content)
} else {
Ok(body.to_string())
}
}
pub fn frontmatter_map(&self) -> IndexMap<String, Pod> {
let mut fm = if let Some(fm) = &self.frontmatter {
fm.clone()
} else {
IndexMap::new()
};
fm.insert("id".to_string(), Pod::String(self.id.clone()));
if self.aliases.is_empty() {
fm.shift_remove("aliases");
} else {
fm.insert(
"aliases".to_string(),
Pod::Array(self.aliases.iter().cloned().map(Pod::String).collect()),
);
}
let fm_tags: Vec<String> = self
.tags
.iter()
.filter(|t| matches!(t.location, Location::Frontmatter))
.map(|t| t.tag.clone())
.collect();
if fm_tags.is_empty() {
fm.shift_remove("tags");
} else {
fm.insert(
"tags".to_string(),
Pod::Array(fm_tags.into_iter().map(Pod::String).collect()),
);
}
fm
}
pub fn frontmatter_yaml(&self) -> Result<serde_yaml::Mapping, serde_yaml::Error> {
let fm = self.frontmatter_map();
const PRIORITY_KEYS: &[&str] = &["id", "title", "aliases", "tags"];
let mut mapping = serde_yaml::Mapping::new();
for key in PRIORITY_KEYS {
if let Some(v) = fm.get(*key) {
mapping.insert(serde_yaml::Value::String((*key).to_string()), pod_to_yaml_value(v));
}
}
let mut rest: Vec<_> = fm
.iter()
.filter(|(k, _)| !PRIORITY_KEYS.contains(&k.as_str()))
.collect();
rest.sort_by(|a, b| a.0.cmp(b.0));
for (k, v) in rest {
mapping.insert(serde_yaml::Value::String(k.clone()), pod_to_yaml_value(v));
}
Ok(mapping)
}
pub fn frontmatter_json(&self) -> Result<serde_json::Map<String, serde_json::Value>, NoteError> {
let fm = self.frontmatter_map();
let mut mapping = serde_json::Map::new();
for (k, v) in fm {
mapping.insert(k, pod_to_json_value(&v)?);
}
Ok(mapping)
}
pub fn frontmatter_string(&self) -> Result<String, serde_yaml::Error> {
let fm = self.frontmatter_yaml()?;
let yaml = serde_yaml::to_string(&fm)?;
Ok(yaml.strip_prefix("---\n").unwrap_or(&yaml).to_string())
}
pub fn last_modified_time(&self) -> std::time::SystemTime {
std::fs::metadata(&self.path)
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
}
pub fn creation_time(&self) -> std::time::SystemTime {
std::fs::metadata(&self.path)
.and_then(|m| m.created())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
}
fn to_file_content(&self, body: &str) -> Result<String, serde_yaml::Error> {
let fm = self.frontmatter_string()?;
Ok(format!("---\n{}---\n\n{}", fm, body))
}
}
fn pod_to_yaml_value(pod: &Pod) -> serde_yaml::Value {
match pod {
Pod::Null => serde_yaml::Value::Null,
Pod::String(s) => serde_yaml::Value::String(s.clone()),
Pod::Integer(i) => serde_yaml::Value::Number((*i).into()),
Pod::Float(f) => serde_yaml::Value::Number(serde_yaml::Number::from(*f)),
Pod::Boolean(b) => serde_yaml::Value::Bool(*b),
Pod::Array(arr) => serde_yaml::Value::Sequence(arr.iter().map(pod_to_yaml_value).collect()),
Pod::Hash(map) => serde_yaml::Value::Mapping(
map.iter()
.map(|(k, v)| (serde_yaml::Value::String(k.clone()), pod_to_yaml_value(v)))
.collect(),
),
}
}
fn yaml_to_pod_value(yaml: &serde_yaml::Value) -> Pod {
match yaml {
serde_yaml::Value::Null => Pod::Null,
serde_yaml::Value::String(s) => Pod::String(s.clone()),
serde_yaml::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Pod::Integer(i)
} else if let Some(f) = n.as_f64() {
Pod::Float(f)
} else {
Pod::Null
}
}
serde_yaml::Value::Bool(b) => Pod::Boolean(*b),
serde_yaml::Value::Sequence(seq) => Pod::Array(seq.iter().map(yaml_to_pod_value).collect()),
serde_yaml::Value::Mapping(map) => Pod::Hash(
map.iter()
.filter_map(|(k, v)| k.as_str().map(|ks| (ks.to_string(), yaml_to_pod_value(v))))
.collect(),
),
serde_yaml::Value::Tagged(_) => {
Pod::Null
}
}
}
fn pod_to_json_value(pod: &Pod) -> Result<serde_json::Value, NoteError> {
match pod {
Pod::Null => Ok(serde_json::Value::Null),
Pod::String(s) => Ok(serde_json::Value::String(s.clone())),
Pod::Integer(i) => Ok(serde_json::Value::Number((*i).into())),
Pod::Float(f) => Ok(serde_json::Value::Number(
serde_json::Number::from_f64(*f).ok_or_else(|| NoteError::Json(format!("invalid float value: {}", f)))?,
)),
Pod::Boolean(b) => Ok(serde_json::Value::Bool(*b)),
Pod::Array(arr) => {
let result: Result<Vec<serde_json::Value>, NoteError> = arr.iter().map(pod_to_json_value).collect();
Ok(serde_json::Value::Array(result?))
}
Pod::Hash(map) => {
let result: Result<serde_json::Map<String, serde_json::Value>, NoteError> = map
.iter()
.map(|(k, v)| pod_to_json_value(v).map(|json_v| (k.clone(), json_v)))
.collect();
result.map(serde_json::Value::Object)
}
}
}
fn find_h1(content: &str) -> Option<String> {
content
.lines()
.find_map(|line| line.strip_prefix("# ").map(|t| t.trim_end().to_string()))
}
fn strip_title_md(s: &str) -> String {
static WIKI_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"!?\[\[([^\]#|]*?)(?:#[^\]|]*?)?(?:\|([^\]]*?))?\]\]").unwrap());
static MD_LINK_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\]]+?)\]\([^)]*?\)").unwrap());
static INLINE_CODE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`([^`\n]+)`").unwrap());
let s = WIKI_RE.replace_all(s, |caps: ®ex::Captures| {
caps.get(2)
.or_else(|| caps.get(1))
.map_or("", |m| m.as_str())
.to_string()
});
let s = MD_LINK_RE.replace_all(&s, "$1");
let s = INLINE_CODE_RE.replace_all(&s, "$1");
s.into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn parse_with_frontmatter() {
let input = "---\ntitle: My Note\ntags: [rust, obsidian]\n---\n\nHello, world!";
let note = Note::parse("/vault/my-note.md", input);
assert_eq!(note.path, PathBuf::from("/vault/my-note.md"));
assert_eq!(note.body.as_deref().unwrap().trim(), "Hello, world!");
let fm = note.frontmatter.expect("should have frontmatter");
assert!(fm.contains_key("title"));
assert!(fm.contains_key("tags"));
}
#[test]
fn parse_without_frontmatter() {
let input = "Just some plain markdown content.";
let note = Note::parse("/vault/plain.md", input);
assert!(note.frontmatter.is_none());
assert_eq!(note.body.as_deref().unwrap(), input);
}
#[test]
fn from_path_reads_file() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
write!(tmp, "---\nauthor: Pete\n---\n\nBody text.").unwrap();
let note = Note::from_path_with_body(tmp.path()).expect("should read file");
let fm = note.frontmatter.expect("should have frontmatter");
assert!(fm.contains_key("author"));
assert!(note.body.unwrap().contains("Body text."));
}
#[test]
fn id_from_frontmatter() {
let input = "---\nid: custom-id\n---\n\nContent.";
let note = Note::parse("/vault/my-note.md", input);
assert_eq!(note.id, "custom-id");
}
#[test]
fn id_falls_back_to_filename_stem() {
let input = "---\nauthor: Pete\n---\n\nContent.";
let note = Note::parse("/vault/my-note.md", input);
assert_eq!(note.id, "my-note");
}
#[test]
fn id_from_stem_when_no_frontmatter() {
let note = Note::parse("/vault/another-note.md", "Just content.");
assert_eq!(note.id, "another-note");
}
#[test]
fn title_from_frontmatter() {
let input = "---\ntitle: FM Title\n---\n\n# H1 Title\n\nContent.";
let note = Note::parse("/vault/note.md", input);
assert_eq!(note.title.as_deref(), Some("FM Title"));
}
#[test]
fn title_from_h1() {
let input = "# My Heading\n\nSome content.";
let note = Note::parse("/vault/note.md", input);
assert_eq!(note.title.as_deref(), Some("My Heading"));
}
#[test]
fn title_none_when_absent() {
let note = Note::parse("/vault/note.md", "No heading here.");
assert!(note.title.is_none());
}
#[test]
fn aliases_from_frontmatter_include_title() {
let input = "---\ntitle: My Note\naliases: [alias-one, alias-two]\n---\n\nContent.";
let note = Note::parse("/vault/note.md", input);
assert!(note.aliases.contains(&"alias-one".to_string()));
assert!(note.aliases.contains(&"alias-two".to_string()));
assert!(note.aliases.contains(&"My Note".to_string()));
}
#[test]
fn aliases_title_not_duplicated_when_already_present() {
let input = "---\ntitle: My Note\naliases: [My Note, other-alias]\n---\n\nContent.";
let note = Note::parse("/vault/note.md", input);
assert_eq!(note.aliases.iter().filter(|a| *a == "My Note").count(), 1);
}
#[test]
fn aliases_just_title_when_no_frontmatter_aliases() {
let input = "---\ntitle: My Note\n---\n\nContent.";
let note = Note::parse("/vault/note.md", input);
assert_eq!(note.aliases, vec!["My Note".to_string()]);
}
#[test]
fn aliases_empty_when_no_title_and_no_frontmatter_aliases() {
let note = Note::parse("/vault/note.md", "No heading here.");
assert!(note.aliases.is_empty());
}
#[test]
fn aliases_includes_h1_title_when_no_frontmatter() {
let input = "# H1 Title\n\nSome content.";
let note = Note::parse("/vault/note.md", input);
assert_eq!(note.aliases, vec!["H1 Title".to_string()]);
}
#[test]
fn tags_from_frontmatter() {
let input = "---\ntags: [rust, obsidian]\n---\n\nContent.";
let note = Note::parse("/vault/note.md", input);
let fm_tags: Vec<&str> = note
.tags
.iter()
.filter(|t| matches!(t.location, crate::Location::Frontmatter))
.map(|t| t.tag.as_str())
.collect();
assert_eq!(fm_tags, vec!["rust", "obsidian"]);
}
#[test]
fn tags_empty_when_absent() {
let note = Note::parse("/vault/note.md", "No frontmatter here.");
assert!(
!note
.tags
.iter()
.any(|t| matches!(t.location, crate::Location::Frontmatter))
);
}
#[test]
fn write_frontmatter_key_ordering() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(
tmp.path(),
"---\nzebra: last\ntags: [t]\naliases: [a]\ntitle: T\nid: my-id\nauthor: Pete\n---\n\nContent.",
)
.unwrap();
let note = Note::from_path_with_body(tmp.path()).unwrap();
note.write().unwrap();
let on_disk = std::fs::read_to_string(tmp.path()).unwrap();
let keys: Vec<&str> = on_disk
.lines()
.skip(1) .take_while(|l| *l != "---")
.filter(|l| !l.starts_with('-'))
.map(|l| l.split(':').next().unwrap())
.collect();
assert_eq!(keys, vec!["id", "title", "aliases", "tags", "author", "zebra"]);
}
#[test]
fn write_frontmatter_key_ordering_no_title() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), "---\ntags: [t]\nid: my-id\nzebra: last\n---\n\nContent.").unwrap();
let note = Note::from_path_with_body(tmp.path()).unwrap();
note.write().unwrap();
let on_disk = std::fs::read_to_string(tmp.path()).unwrap();
let keys: Vec<&str> = on_disk
.lines()
.skip(1)
.take_while(|l| *l != "---")
.filter(|l| !l.starts_with('-'))
.map(|l| l.split(':').next().unwrap())
.collect();
assert_eq!(keys, vec!["id", "tags", "zebra"]);
}
#[test]
fn write_round_trips_note_without_frontmatter() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let original = "Just some plain content.";
std::fs::write(tmp.path(), original).unwrap();
let note = Note::from_path_with_body(tmp.path()).unwrap();
note.write().unwrap();
let on_disk = std::fs::read_to_string(tmp.path()).unwrap();
assert_eq!(
on_disk,
format!(
"---\nid: {}\n---\n\n{}",
tmp.path().file_stem().unwrap().display().to_string(),
original
)
);
}
#[test]
fn write_round_trips_note_with_frontmatter() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let original = "---\ntitle: My Note\n---\n\nBody text.";
std::fs::write(tmp.path(), original).unwrap();
let note = Note::from_path_with_body(tmp.path()).unwrap();
note.write().unwrap();
let reparsed = Note::from_path_with_body(tmp.path()).unwrap();
assert_eq!(reparsed.title.as_deref(), Some("My Note"));
assert_eq!(reparsed.body.as_deref().unwrap().trim(), "Body text.");
}
#[test]
fn write_reflects_frontmatter_mutation() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), "---\ntitle: Old Title\n---\n\nContent.").unwrap();
let mut note = Note::from_path_with_body(tmp.path()).unwrap();
note.frontmatter
.as_mut()
.unwrap()
.insert("title".to_string(), Pod::String("New Title".to_string()));
note.write().unwrap();
let reparsed = Note::from_path(tmp.path()).unwrap();
assert_eq!(reparsed.title.as_deref(), Some("New Title"));
}
#[test]
fn strip_title_md_plain_is_unchanged() {
assert_eq!(strip_title_md("My Note"), "My Note");
}
#[test]
fn strip_title_md_wiki_link_no_alias() {
assert_eq!(strip_title_md("[[linked note]]"), "linked note");
}
#[test]
fn strip_title_md_wiki_link_with_alias() {
assert_eq!(strip_title_md("[[note|display text]]"), "display text");
}
#[test]
fn strip_title_md_wiki_link_with_heading() {
assert_eq!(strip_title_md("[[note#heading]]"), "note");
}
#[test]
fn strip_title_md_markdown_link() {
assert_eq!(strip_title_md("[text](https://example.com)"), "text");
}
#[test]
fn strip_title_md_inline_code() {
assert_eq!(strip_title_md("`code` stuff"), "code stuff");
}
#[test]
fn strip_title_md_mixed() {
assert_eq!(strip_title_md("My [[note|ref]] and `stuff`"), "My ref and stuff");
}
#[test]
fn alias_from_h1_with_wiki_link_no_alias() {
let input = "# [[linked note]]\n\nContent.";
let note = Note::parse("/vault/note.md", input);
assert_eq!(note.title.as_deref(), Some("[[linked note]]"));
assert!(note.aliases.contains(&"linked note".to_string()));
}
#[test]
fn alias_from_h1_with_wiki_link_with_alias() {
let input = "# [[note|display text]]\n\nContent.";
let note = Note::parse("/vault/note.md", input);
assert!(note.aliases.contains(&"display text".to_string()));
}
#[test]
fn alias_from_h1_with_markdown_link() {
let input = "# [text](https://example.com)\n\nContent.";
let note = Note::parse("/vault/note.md", input);
assert!(note.aliases.contains(&"text".to_string()));
}
#[test]
fn alias_from_h1_with_inline_code() {
let input = "# `code` stuff\n\nContent.";
let note = Note::parse("/vault/note.md", input);
assert!(note.aliases.contains(&"code stuff".to_string()));
}
#[test]
fn alias_from_h1_mixed_markdown() {
let input = "# My [[note|ref]] and `stuff`\n\nContent.";
let note = Note::parse("/vault/note.md", input);
assert!(note.aliases.contains(&"My ref and stuff".to_string()));
}
#[test]
fn alias_from_frontmatter_title_with_wiki_link() {
let input = "---\ntitle: \"[[note|display]]\"\n---\n\nContent.";
let note = Note::parse("/vault/note.md", input);
assert!(note.aliases.contains(&"display".to_string()));
}
#[test]
fn alias_plain_title_unchanged() {
let input = "# My Note\n\nContent.";
let note = Note::parse("/vault/note.md", input);
assert!(note.aliases.contains(&"My Note".to_string()));
}
#[test]
fn links_location_offset_by_frontmatter() {
let content = "---\ntitle: T\n---\n[[target]]\n[text](url)";
let note = Note::parse("/vault/note.md", content);
assert_eq!(note.links.len(), 2);
assert_eq!(note.links[0].location.line, 4);
assert_eq!(note.links[0].location.col_start, 0);
assert_eq!(note.links[0].location.col_end, 10);
assert_eq!(note.links[1].location.line, 5);
assert_eq!(note.links[1].location.col_start, 0);
assert_eq!(note.links[1].location.col_end, 11);
}
}