1use std::io::Write;
2use std::path::{Path, PathBuf};
3use std::sync::LazyLock;
4
5use regex::Regex;
6
7use crate::{LocatedTag, Location, NoteError, common};
8
9use gray_matter::{Matter, Pod, engine::YAML};
10use indexmap::IndexMap;
11
12#[derive(Clone)]
13pub struct Note {
14 pub path: PathBuf,
15 pub id: String,
16 pub title: Option<String>,
17 pub aliases: Vec<String>,
18 pub tags: Vec<LocatedTag>,
21 pub body: Option<String>,
25 pub links: Vec<crate::LocatedLink>,
27 pub frontmatter: Option<IndexMap<String, Pod>>,
28 pub frontmatter_line_count: usize,
31}
32
33#[derive(Clone)]
34pub struct NoteBuilder {
35 pub path: PathBuf,
36 pub id: String,
37 pub title: Option<String>,
38 pub aliases: Vec<String>,
39 pub tags: Vec<LocatedTag>,
40 pub body: Option<String>,
41}
42
43impl NoteBuilder {
44 pub fn new(path: impl AsRef<Path>) -> Result<Self, NoteError> {
45 Ok(Self {
46 path: path.as_ref().to_path_buf(),
47 id: path
48 .as_ref()
49 .file_stem()
50 .ok_or(NoteError::InvalidPath(path.as_ref().to_path_buf()))?
51 .to_string_lossy()
52 .to_string(),
53 title: None,
54 aliases: Vec::new(),
55 tags: Vec::new(),
56 body: None,
57 })
58 }
59
60 pub fn id(mut self, id: &str) -> Self {
61 self.id = id.to_string();
62 self
63 }
64
65 pub fn title(mut self, title: &str) -> Self {
66 self.title = Some(title.to_string());
67 self
68 }
69
70 pub fn alias(mut self, alias: &str) -> Self {
71 self.aliases.push(alias.to_string());
72 self
73 }
74
75 pub fn aliases(mut self, aliases: &[String]) -> Self {
76 for alias in aliases {
77 self = self.alias(alias);
78 }
79 self
80 }
81
82 pub fn tag(mut self, tag: &str) -> Self {
83 self.tags.push(LocatedTag {
84 tag: tag.to_string(),
85 location: Location::Frontmatter,
86 });
87 self
88 }
89
90 pub fn tags(mut self, tags: &[&str]) -> Self {
91 for tag in tags {
92 self = self.tag(tag);
93 }
94 self
95 }
96
97 pub fn located_tag(mut self, tag: &LocatedTag) -> Self {
98 self.tags.push(tag.clone());
99 self
100 }
101
102 pub fn located_tags(mut self, tags: &[LocatedTag]) -> Self {
103 for tag in tags {
104 self = self.located_tag(tag);
105 }
106 self
107 }
108
109 pub fn body(mut self, body: &str) -> Self {
110 self.body = Some(body.to_string());
111 self
112 }
113
114 pub fn build(self) -> Result<Note, NoteError> {
115 let Self {
116 path,
117 id,
118 title,
119 aliases,
120 tags,
121 body,
122 } = self;
123
124 let mut note = Note {
125 path,
126 id,
127 title,
128 aliases,
129 tags,
130 body: None,
131 links: Vec::new(),
132 frontmatter: None,
133 frontmatter_line_count: 0,
134 };
135 note.update_content(body.as_deref(), None)?;
136 Ok(note)
137 }
138}
139
140impl Note {
141 pub fn builder(path: impl AsRef<Path>) -> Result<NoteBuilder, NoteError> {
142 NoteBuilder::new(path)
143 }
144
145 pub fn parse(path: impl AsRef<Path>, content: &str) -> Self {
151 Self::parse_impl(path, content, true)
152 }
153
154 pub fn from_path(path: impl AsRef<Path>) -> Result<Self, NoteError> {
161 let path = common::normalize_path(path.as_ref(), None);
162 let raw = std::fs::read_to_string(&path)?;
163 Ok(Self::parse_impl(&path, &raw, false))
164 }
165
166 pub fn from_path_with_body(path: impl AsRef<Path>) -> Result<Self, NoteError> {
168 let path = common::normalize_path(path.as_ref(), None);
169 let raw = std::fs::read_to_string(&path)?;
170 Ok(Self::parse_impl(&path, &raw, true))
171 }
172
173 fn parse_impl(path: impl AsRef<Path>, content: &str, keep_body: bool) -> Self {
174 let matter = Matter::<YAML>::new();
175 let (body, frontmatter) = match matter.parse(content) {
176 Ok(parsed) => {
177 let fm = parsed.data.and_then(|pod: Pod| pod.as_hashmap().ok()).map(|hm| {
178 let mut entries: Vec<_> = hm.into_iter().collect();
179 entries.sort_by(|a, b| a.0.cmp(&b.0));
180 entries.into_iter().collect::<IndexMap<_, _>>()
181 });
182 (parsed.content, fm)
183 }
184 Err(_) => (content.to_string(), None),
185 };
186 let frontmatter_line_count = content.lines().count().saturating_sub(body.lines().count());
187 let id = frontmatter
188 .as_ref()
189 .and_then(|fm| fm.get("id"))
190 .and_then(|p| p.as_string().ok())
191 .or_else(|| {
192 path.as_ref()
193 .file_stem()
194 .and_then(|s| s.to_str())
195 .map(|s| s.to_string())
196 })
197 .unwrap_or_default();
198 let mut title = frontmatter
199 .as_ref()
200 .and_then(|fm| fm.get("title"))
201 .and_then(|p| p.as_string().ok())
202 .or_else(|| find_h1(&body));
203 let aliases = {
204 let mut v: Vec<String> = frontmatter
205 .as_ref()
206 .and_then(|fm| fm.get("aliases"))
207 .and_then(|p| p.as_vec().ok())
208 .unwrap_or_default()
209 .into_iter()
210 .filter_map(|p| p.as_string().ok())
211 .collect();
212
213 if let Some(ref t) = title {
216 let clean = strip_title_md(t);
217 if !v.contains(&clean) {
218 v.push(clean);
219 }
220 } else if !v.is_empty() {
221 title = Some(v[0].clone());
222 }
223 v
224 };
225 let fm_tags: Vec<LocatedTag> = frontmatter
226 .as_ref()
227 .and_then(|fm| fm.get("tags"))
228 .and_then(|p| p.as_vec().ok())
229 .unwrap_or_default()
230 .into_iter()
231 .filter_map(|p| p.as_string().ok())
232 .map(|tag| LocatedTag {
233 tag,
234 location: Location::Frontmatter,
235 })
236 .collect();
237 let offset = frontmatter_line_count;
238 let links = crate::link::parse_links(&body)
239 .into_iter()
240 .map(|mut ll| {
241 ll.location.line += offset;
242 ll
243 })
244 .collect();
245 let inline_tags = crate::tag::parse_inline_tags(&body)
246 .into_iter()
247 .map(|mut lt| {
248 if let Location::Inline(ref mut loc) = lt.location {
249 loc.line += offset;
250 }
251 lt
252 })
253 .collect::<Vec<_>>();
254 let mut tags = fm_tags;
255 tags.extend(inline_tags);
256
257 Note {
258 path: path.as_ref().to_path_buf(),
259 id,
260 title,
261 aliases,
262 tags,
263 body: if keep_body { Some(body) } else { None },
264 links,
265 frontmatter,
266 frontmatter_line_count,
267 }
268 }
269
270 pub fn update_content(
271 &mut self,
272 body: Option<&str>,
273 frontmatter: Option<IndexMap<String, Pod>>,
274 ) -> Result<(), NoteError> {
275 if body.is_none() && frontmatter.is_none() {
276 return Ok(());
277 }
278
279 if let Some(body) = body {
280 if let Some(frontmatter) = frontmatter {
281 self.frontmatter = Some(frontmatter);
282 }
283 let file_content = self.to_file_content(body)?;
284 let parsed = Self::parse_impl(&self.path, &file_content, true);
285 self.body = Some(body.to_string());
286 self.tags = parsed.tags;
287 self.links = parsed.links;
288 } else if let Some(frontmatter) = frontmatter {
289 let mut tags: Vec<LocatedTag> = frontmatter
291 .get("tags")
292 .and_then(|p| p.as_vec().ok())
293 .unwrap_or_default()
294 .into_iter()
295 .filter_map(|p| p.as_string().ok())
296 .map(|tag| LocatedTag {
297 tag,
298 location: Location::Frontmatter,
299 })
300 .collect();
301
302 for tag in &self.tags {
303 match tag.location {
304 Location::Frontmatter => {}
305 Location::Inline(_) => tags.push(tag.clone()),
306 }
307 }
308
309 self.frontmatter = Some(frontmatter);
310 self.tags = tags;
311 }
312
313 Ok(())
314 }
315
316 pub fn reload(self) -> Result<Self, NoteError> {
318 Self::from_path(&self.path)
319 }
320
321 pub fn reload_with_body(self) -> Result<Self, NoteError> {
323 Self::from_path_with_body(&self.path)
324 }
325
326 pub fn load_body(&mut self) -> Result<(), NoteError> {
329 if self.body.is_none() {
330 let raw = std::fs::read_to_string(&self.path)?;
331 let matter = Matter::<YAML>::new();
332 let body = match matter.parse::<Pod>(&raw) {
333 Ok(parsed) => parsed.content,
334 Err(_) => raw,
335 };
336 self.body = Some(body);
337 }
338 Ok(())
339 }
340
341 pub fn add_alias(&mut self, alias: String) {
343 if !self.aliases.contains(&alias) {
344 self.aliases.push(alias);
345 }
346 }
347
348 pub fn add_tag(&mut self, tag: impl Into<String>) {
350 let tag = crate::tag::clean_tag(&tag.into());
351 let already_present = self
352 .tags
353 .iter()
354 .any(|t| t.tag.eq_ignore_ascii_case(&tag) && matches!(t.location, Location::Frontmatter));
355 if !already_present {
356 self.tags.push(LocatedTag {
357 tag,
358 location: Location::Frontmatter,
359 });
360 }
361 }
362
363 pub fn remove_tag(&mut self, tag: &str) {
365 let tag = crate::tag::clean_tag(tag);
366 self.tags
367 .retain(|t| !(t.tag.eq_ignore_ascii_case(&tag) && matches!(t.location, Location::Frontmatter)));
368 }
369
370 pub fn set_field(&mut self, key: &str, value: &serde_yaml::Value) -> Result<(), NoteError> {
373 if key.contains('\n') {
376 return Err(NoteError::InvalidFieldName(
377 "field names cannot contain newlines".to_string(),
378 ));
379 }
380 if ["id", "title", "aliases", "tags"].contains(&key) {
381 return Err(NoteError::InvalidFieldName(format!(
382 "'{}' is a reserved field name and cannot be set this way",
383 key
384 )));
385 }
386
387 if self.frontmatter.is_none() {
388 self.frontmatter = Some(IndexMap::new());
389 }
390
391 if value.is_null() {
392 self.frontmatter.as_mut().unwrap().shift_remove(key);
394 } else {
395 self.frontmatter
396 .as_mut()
397 .unwrap()
398 .insert(key.to_string(), yaml_to_pod_value(value));
399 }
400
401 Ok(())
402 }
403
404 pub fn write(&self) -> Result<(), NoteError> {
413 let content = self.read(true)?;
414 let parent = self.path.parent().unwrap_or_else(|| Path::new("."));
415 let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
416 tmp.write_all(content.as_bytes())?;
417 tmp.persist(&self.path).map_err(|e| e.error)?;
418 Ok(())
419 }
420
421 pub fn write_frontmatter(&self) -> Result<(), NoteError> {
424 let raw = std::fs::read_to_string(&self.path)?;
425 let matter = Matter::<YAML>::new();
426 let body = match matter.parse::<Pod>(&raw) {
427 Ok(parsed) => parsed.content,
428 Err(_) => raw.clone(),
429 };
430 let file_content = self.to_file_content(&body)?;
431 let parent = self.path.parent().unwrap_or_else(|| Path::new("."));
432 let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
433 tmp.write_all(file_content.as_bytes())?;
434 tmp.persist(&self.path).map_err(|e| e.error)?;
435 Ok(())
436 }
437
438 pub fn read(&self, include_frontmatter: bool) -> Result<String, NoteError> {
442 let body = self.body.as_deref().ok_or(NoteError::BodyNotLoaded)?;
443 if include_frontmatter {
444 let file_content = self.to_file_content(body)?;
445 Ok(file_content)
446 } else {
447 Ok(body.to_string())
448 }
449 }
450
451 pub fn frontmatter_map(&self) -> IndexMap<String, Pod> {
453 let mut fm = if let Some(fm) = &self.frontmatter {
454 fm.clone()
455 } else {
456 IndexMap::new()
458 };
459
460 fm.insert("id".to_string(), Pod::String(self.id.clone()));
462 if self.aliases.is_empty() {
463 fm.shift_remove("aliases");
465 } else {
466 fm.insert(
467 "aliases".to_string(),
468 Pod::Array(self.aliases.iter().cloned().map(Pod::String).collect()),
469 );
470 }
471 let fm_tags: Vec<String> = self
472 .tags
473 .iter()
474 .filter(|t| matches!(t.location, Location::Frontmatter))
475 .map(|t| t.tag.clone())
476 .collect();
477 if fm_tags.is_empty() {
478 fm.shift_remove("tags");
480 } else {
481 fm.insert(
482 "tags".to_string(),
483 Pod::Array(fm_tags.into_iter().map(Pod::String).collect()),
484 );
485 }
486 fm
487 }
488
489 pub fn frontmatter_yaml(&self) -> Result<serde_yaml::Mapping, serde_yaml::Error> {
491 let fm = self.frontmatter_map();
492
493 const PRIORITY_KEYS: &[&str] = &["id", "title", "aliases", "tags"];
494 let mut mapping = serde_yaml::Mapping::new();
495 for key in PRIORITY_KEYS {
497 if let Some(v) = fm.get(*key) {
498 mapping.insert(serde_yaml::Value::String((*key).to_string()), pod_to_yaml_value(v));
499 }
500 }
501 let mut rest: Vec<_> = fm
503 .iter()
504 .filter(|(k, _)| !PRIORITY_KEYS.contains(&k.as_str()))
505 .collect();
506 rest.sort_by(|a, b| a.0.cmp(b.0));
507 for (k, v) in rest {
508 mapping.insert(serde_yaml::Value::String(k.clone()), pod_to_yaml_value(v));
509 }
510 Ok(mapping)
511 }
512
513 pub fn frontmatter_json(&self) -> Result<serde_json::Map<String, serde_json::Value>, NoteError> {
515 let fm = self.frontmatter_map();
516 let mut mapping = serde_json::Map::new();
517 for (k, v) in fm {
518 mapping.insert(k, pod_to_json_value(&v)?);
519 }
520 Ok(mapping)
521 }
522
523 pub fn frontmatter_string(&self) -> Result<String, serde_yaml::Error> {
525 let fm = self.frontmatter_yaml()?;
526 let yaml = serde_yaml::to_string(&fm)?;
527 Ok(yaml.strip_prefix("---\n").unwrap_or(&yaml).to_string())
529 }
530
531 pub fn last_modified_time(&self) -> std::time::SystemTime {
533 std::fs::metadata(&self.path)
534 .and_then(|m| m.modified())
535 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
536 }
537
538 pub fn creation_time(&self) -> std::time::SystemTime {
540 std::fs::metadata(&self.path)
541 .and_then(|m| m.created())
542 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
543 }
544
545 fn to_file_content(&self, body: &str) -> Result<String, serde_yaml::Error> {
546 let fm = self.frontmatter_string()?;
547 Ok(format!("---\n{}---\n\n{}", fm, body))
548 }
549}
550
551fn pod_to_yaml_value(pod: &Pod) -> serde_yaml::Value {
552 match pod {
553 Pod::Null => serde_yaml::Value::Null,
554 Pod::String(s) => serde_yaml::Value::String(s.clone()),
555 Pod::Integer(i) => serde_yaml::Value::Number((*i).into()),
556 Pod::Float(f) => serde_yaml::Value::Number(serde_yaml::Number::from(*f)),
557 Pod::Boolean(b) => serde_yaml::Value::Bool(*b),
558 Pod::Array(arr) => serde_yaml::Value::Sequence(arr.iter().map(pod_to_yaml_value).collect()),
559 Pod::Hash(map) => serde_yaml::Value::Mapping(
560 map.iter()
561 .map(|(k, v)| (serde_yaml::Value::String(k.clone()), pod_to_yaml_value(v)))
562 .collect(),
563 ),
564 }
565}
566
567fn yaml_to_pod_value(yaml: &serde_yaml::Value) -> Pod {
568 match yaml {
569 serde_yaml::Value::Null => Pod::Null,
570 serde_yaml::Value::String(s) => Pod::String(s.clone()),
571 serde_yaml::Value::Number(n) => {
572 if let Some(i) = n.as_i64() {
573 Pod::Integer(i)
574 } else if let Some(f) = n.as_f64() {
575 Pod::Float(f)
576 } else {
577 Pod::Null
579 }
580 }
581 serde_yaml::Value::Bool(b) => Pod::Boolean(*b),
582 serde_yaml::Value::Sequence(seq) => Pod::Array(seq.iter().map(yaml_to_pod_value).collect()),
583 serde_yaml::Value::Mapping(map) => Pod::Hash(
584 map.iter()
585 .filter_map(|(k, v)| k.as_str().map(|ks| (ks.to_string(), yaml_to_pod_value(v))))
586 .collect(),
587 ),
588 serde_yaml::Value::Tagged(_) => {
589 Pod::Null
591 }
592 }
593}
594
595fn pod_to_json_value(pod: &Pod) -> Result<serde_json::Value, NoteError> {
596 match pod {
597 Pod::Null => Ok(serde_json::Value::Null),
598 Pod::String(s) => Ok(serde_json::Value::String(s.clone())),
599 Pod::Integer(i) => Ok(serde_json::Value::Number((*i).into())),
600 Pod::Float(f) => Ok(serde_json::Value::Number(
601 serde_json::Number::from_f64(*f).ok_or_else(|| NoteError::Json(format!("invalid float value: {}", f)))?,
602 )),
603 Pod::Boolean(b) => Ok(serde_json::Value::Bool(*b)),
604 Pod::Array(arr) => {
605 let result: Result<Vec<serde_json::Value>, NoteError> = arr.iter().map(pod_to_json_value).collect();
606 Ok(serde_json::Value::Array(result?))
607 }
608 Pod::Hash(map) => {
609 let result: Result<serde_json::Map<String, serde_json::Value>, NoteError> = map
610 .iter()
611 .map(|(k, v)| pod_to_json_value(v).map(|json_v| (k.clone(), json_v)))
612 .collect();
613 result.map(serde_json::Value::Object)
614 }
615 }
616}
617
618fn find_h1(content: &str) -> Option<String> {
619 content
620 .lines()
621 .find_map(|line| line.strip_prefix("# ").map(|t| t.trim_end().to_string()))
622}
623
624fn strip_title_md(s: &str) -> String {
625 static WIKI_RE: LazyLock<Regex> =
627 LazyLock::new(|| Regex::new(r"!?\[\[([^\]#|]*?)(?:#[^\]|]*?)?(?:\|([^\]]*?))?\]\]").unwrap());
628 static MD_LINK_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\]]+?)\]\([^)]*?\)").unwrap());
630 static INLINE_CODE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`([^`\n]+)`").unwrap());
632
633 let s = WIKI_RE.replace_all(s, |caps: ®ex::Captures| {
634 caps.get(2)
635 .or_else(|| caps.get(1))
636 .map_or("", |m| m.as_str())
637 .to_string()
638 });
639 let s = MD_LINK_RE.replace_all(&s, "$1");
640 let s = INLINE_CODE_RE.replace_all(&s, "$1");
641 s.into_owned()
642}
643
644#[cfg(test)]
645mod tests {
646 use super::*;
647 use std::io::Write;
648
649 #[test]
650 fn parse_with_frontmatter() {
651 let input = "---\ntitle: My Note\ntags: [rust, obsidian]\n---\n\nHello, world!";
652 let note = Note::parse("/vault/my-note.md", input);
653
654 assert_eq!(note.path, PathBuf::from("/vault/my-note.md"));
655 assert_eq!(note.body.as_deref().unwrap().trim(), "Hello, world!");
656
657 let fm = note.frontmatter.expect("should have frontmatter");
658 assert!(fm.contains_key("title"));
659 assert!(fm.contains_key("tags"));
660 }
661
662 #[test]
663 fn parse_without_frontmatter() {
664 let input = "Just some plain markdown content.";
665 let note = Note::parse("/vault/plain.md", input);
666
667 assert!(note.frontmatter.is_none());
668 assert_eq!(note.body.as_deref().unwrap(), input);
669 }
670
671 #[test]
672 fn from_path_reads_file() {
673 let mut tmp = tempfile::NamedTempFile::new().unwrap();
674 write!(tmp, "---\nauthor: Pete\n---\n\nBody text.").unwrap();
675
676 let note = Note::from_path_with_body(tmp.path()).expect("should read file");
677 let fm = note.frontmatter.expect("should have frontmatter");
678 assert!(fm.contains_key("author"));
679 assert!(note.body.unwrap().contains("Body text."));
680 }
681
682 #[test]
683 fn id_from_frontmatter() {
684 let input = "---\nid: custom-id\n---\n\nContent.";
685 let note = Note::parse("/vault/my-note.md", input);
686 assert_eq!(note.id, "custom-id");
687 }
688
689 #[test]
690 fn id_falls_back_to_filename_stem() {
691 let input = "---\nauthor: Pete\n---\n\nContent.";
692 let note = Note::parse("/vault/my-note.md", input);
693 assert_eq!(note.id, "my-note");
694 }
695
696 #[test]
697 fn id_from_stem_when_no_frontmatter() {
698 let note = Note::parse("/vault/another-note.md", "Just content.");
699 assert_eq!(note.id, "another-note");
700 }
701
702 #[test]
703 fn title_from_frontmatter() {
704 let input = "---\ntitle: FM Title\n---\n\n# H1 Title\n\nContent.";
705 let note = Note::parse("/vault/note.md", input);
706 assert_eq!(note.title.as_deref(), Some("FM Title"));
708 }
709
710 #[test]
711 fn title_from_h1() {
712 let input = "# My Heading\n\nSome content.";
713 let note = Note::parse("/vault/note.md", input);
714 assert_eq!(note.title.as_deref(), Some("My Heading"));
715 }
716
717 #[test]
718 fn title_none_when_absent() {
719 let note = Note::parse("/vault/note.md", "No heading here.");
720 assert!(note.title.is_none());
721 }
722
723 #[test]
724 fn aliases_from_frontmatter_include_title() {
725 let input = "---\ntitle: My Note\naliases: [alias-one, alias-two]\n---\n\nContent.";
726 let note = Note::parse("/vault/note.md", input);
727 assert!(note.aliases.contains(&"alias-one".to_string()));
728 assert!(note.aliases.contains(&"alias-two".to_string()));
729 assert!(note.aliases.contains(&"My Note".to_string()));
730 }
731
732 #[test]
733 fn aliases_title_not_duplicated_when_already_present() {
734 let input = "---\ntitle: My Note\naliases: [My Note, other-alias]\n---\n\nContent.";
735 let note = Note::parse("/vault/note.md", input);
736 assert_eq!(note.aliases.iter().filter(|a| *a == "My Note").count(), 1);
737 }
738
739 #[test]
740 fn aliases_just_title_when_no_frontmatter_aliases() {
741 let input = "---\ntitle: My Note\n---\n\nContent.";
742 let note = Note::parse("/vault/note.md", input);
743 assert_eq!(note.aliases, vec!["My Note".to_string()]);
744 }
745
746 #[test]
747 fn aliases_empty_when_no_title_and_no_frontmatter_aliases() {
748 let note = Note::parse("/vault/note.md", "No heading here.");
749 assert!(note.aliases.is_empty());
750 }
751
752 #[test]
753 fn aliases_includes_h1_title_when_no_frontmatter() {
754 let input = "# H1 Title\n\nSome content.";
755 let note = Note::parse("/vault/note.md", input);
756 assert_eq!(note.aliases, vec!["H1 Title".to_string()]);
757 }
758
759 #[test]
760 fn tags_from_frontmatter() {
761 let input = "---\ntags: [rust, obsidian]\n---\n\nContent.";
762 let note = Note::parse("/vault/note.md", input);
763 let fm_tags: Vec<&str> = note
764 .tags
765 .iter()
766 .filter(|t| matches!(t.location, crate::Location::Frontmatter))
767 .map(|t| t.tag.as_str())
768 .collect();
769 assert_eq!(fm_tags, vec!["rust", "obsidian"]);
770 }
771
772 #[test]
773 fn tags_empty_when_absent() {
774 let note = Note::parse("/vault/note.md", "No frontmatter here.");
775 assert!(
776 !note
777 .tags
778 .iter()
779 .any(|t| matches!(t.location, crate::Location::Frontmatter))
780 );
781 }
782
783 #[test]
784 fn write_frontmatter_key_ordering() {
785 let tmp = tempfile::NamedTempFile::new().unwrap();
786 std::fs::write(
788 tmp.path(),
789 "---\nzebra: last\ntags: [t]\naliases: [a]\ntitle: T\nid: my-id\nauthor: Pete\n---\n\nContent.",
790 )
791 .unwrap();
792
793 let note = Note::from_path_with_body(tmp.path()).unwrap();
794 note.write().unwrap();
795
796 let on_disk = std::fs::read_to_string(tmp.path()).unwrap();
797 let keys: Vec<&str> = on_disk
799 .lines()
800 .skip(1) .take_while(|l| *l != "---")
802 .filter(|l| !l.starts_with('-'))
803 .map(|l| l.split(':').next().unwrap())
804 .collect();
805 assert_eq!(keys, vec!["id", "title", "aliases", "tags", "author", "zebra"]);
806 }
807
808 #[test]
809 fn write_frontmatter_key_ordering_no_title() {
810 let tmp = tempfile::NamedTempFile::new().unwrap();
811 std::fs::write(tmp.path(), "---\ntags: [t]\nid: my-id\nzebra: last\n---\n\nContent.").unwrap();
812
813 let note = Note::from_path_with_body(tmp.path()).unwrap();
814 note.write().unwrap();
815
816 let on_disk = std::fs::read_to_string(tmp.path()).unwrap();
817 let keys: Vec<&str> = on_disk
818 .lines()
819 .skip(1)
820 .take_while(|l| *l != "---")
821 .filter(|l| !l.starts_with('-'))
822 .map(|l| l.split(':').next().unwrap())
823 .collect();
824 assert_eq!(keys, vec!["id", "tags", "zebra"]);
825 }
826
827 #[test]
828 fn write_round_trips_note_without_frontmatter() {
829 let tmp = tempfile::NamedTempFile::new().unwrap();
830 let original = "Just some plain content.";
831 std::fs::write(tmp.path(), original).unwrap();
832
833 let note = Note::from_path_with_body(tmp.path()).unwrap();
834 note.write().unwrap();
835
836 let on_disk = std::fs::read_to_string(tmp.path()).unwrap();
837 assert_eq!(
838 on_disk,
839 format!(
840 "---\nid: {}\n---\n\n{}",
841 tmp.path().file_stem().unwrap().display().to_string(),
842 original
843 )
844 );
845 }
846
847 #[test]
848 fn write_round_trips_note_with_frontmatter() {
849 let tmp = tempfile::NamedTempFile::new().unwrap();
850 let original = "---\ntitle: My Note\n---\n\nBody text.";
851 std::fs::write(tmp.path(), original).unwrap();
852
853 let note = Note::from_path_with_body(tmp.path()).unwrap();
854 note.write().unwrap();
855
856 let reparsed = Note::from_path_with_body(tmp.path()).unwrap();
858 assert_eq!(reparsed.title.as_deref(), Some("My Note"));
859 assert_eq!(reparsed.body.as_deref().unwrap().trim(), "Body text.");
860 }
861
862 #[test]
863 fn write_reflects_frontmatter_mutation() {
864 let tmp = tempfile::NamedTempFile::new().unwrap();
865 std::fs::write(tmp.path(), "---\ntitle: Old Title\n---\n\nContent.").unwrap();
866
867 let mut note = Note::from_path_with_body(tmp.path()).unwrap();
868 note.frontmatter
869 .as_mut()
870 .unwrap()
871 .insert("title".to_string(), Pod::String("New Title".to_string()));
872 note.write().unwrap();
873
874 let reparsed = Note::from_path(tmp.path()).unwrap();
875 assert_eq!(reparsed.title.as_deref(), Some("New Title"));
876 }
877
878 #[test]
881 fn strip_title_md_plain_is_unchanged() {
882 assert_eq!(strip_title_md("My Note"), "My Note");
883 }
884
885 #[test]
886 fn strip_title_md_wiki_link_no_alias() {
887 assert_eq!(strip_title_md("[[linked note]]"), "linked note");
888 }
889
890 #[test]
891 fn strip_title_md_wiki_link_with_alias() {
892 assert_eq!(strip_title_md("[[note|display text]]"), "display text");
893 }
894
895 #[test]
896 fn strip_title_md_wiki_link_with_heading() {
897 assert_eq!(strip_title_md("[[note#heading]]"), "note");
898 }
899
900 #[test]
901 fn strip_title_md_markdown_link() {
902 assert_eq!(strip_title_md("[text](https://example.com)"), "text");
903 }
904
905 #[test]
906 fn strip_title_md_inline_code() {
907 assert_eq!(strip_title_md("`code` stuff"), "code stuff");
908 }
909
910 #[test]
911 fn strip_title_md_mixed() {
912 assert_eq!(strip_title_md("My [[note|ref]] and `stuff`"), "My ref and stuff");
913 }
914
915 #[test]
918 fn alias_from_h1_with_wiki_link_no_alias() {
919 let input = "# [[linked note]]\n\nContent.";
920 let note = Note::parse("/vault/note.md", input);
921 assert_eq!(note.title.as_deref(), Some("[[linked note]]"));
922 assert!(note.aliases.contains(&"linked note".to_string()));
923 }
924
925 #[test]
926 fn alias_from_h1_with_wiki_link_with_alias() {
927 let input = "# [[note|display text]]\n\nContent.";
928 let note = Note::parse("/vault/note.md", input);
929 assert!(note.aliases.contains(&"display text".to_string()));
930 }
931
932 #[test]
933 fn alias_from_h1_with_markdown_link() {
934 let input = "# [text](https://example.com)\n\nContent.";
935 let note = Note::parse("/vault/note.md", input);
936 assert!(note.aliases.contains(&"text".to_string()));
937 }
938
939 #[test]
940 fn alias_from_h1_with_inline_code() {
941 let input = "# `code` stuff\n\nContent.";
942 let note = Note::parse("/vault/note.md", input);
943 assert!(note.aliases.contains(&"code stuff".to_string()));
944 }
945
946 #[test]
947 fn alias_from_h1_mixed_markdown() {
948 let input = "# My [[note|ref]] and `stuff`\n\nContent.";
949 let note = Note::parse("/vault/note.md", input);
950 assert!(note.aliases.contains(&"My ref and stuff".to_string()));
951 }
952
953 #[test]
954 fn alias_from_frontmatter_title_with_wiki_link() {
955 let input = "---\ntitle: \"[[note|display]]\"\n---\n\nContent.";
956 let note = Note::parse("/vault/note.md", input);
957 assert!(note.aliases.contains(&"display".to_string()));
958 }
959
960 #[test]
961 fn alias_plain_title_unchanged() {
962 let input = "# My Note\n\nContent.";
963 let note = Note::parse("/vault/note.md", input);
964 assert!(note.aliases.contains(&"My Note".to_string()));
965 }
966
967 #[test]
968 fn links_location_offset_by_frontmatter() {
969 let content = "---\ntitle: T\n---\n[[target]]\n[text](url)";
971 let note = Note::parse("/vault/note.md", content);
972 assert_eq!(note.links.len(), 2);
973 assert_eq!(note.links[0].location.line, 4);
974 assert_eq!(note.links[0].location.col_start, 0);
975 assert_eq!(note.links[0].location.col_end, 10);
976 assert_eq!(note.links[1].location.line, 5);
977 assert_eq!(note.links[1].location.col_start, 0);
978 assert_eq!(note.links[1].location.col_end, 11);
979 }
980}