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