Skip to main content

obsidian_core/
note.rs

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    /// All tags: frontmatter tags have `location: Location::Frontmatter`; inline tags have
19    /// `location: Location::Inline(...)`. Always populated, even when content is not loaded.
20    pub tags: Vec<LocatedTag>,
21    /// Body text stripped of frontmatter. `None` when the note was loaded without body
22    /// (the default). Use [`Note::from_path_with_body`] or [`Note::load_body`] to
23    /// populate this field. Required for [`Note::write`].
24    pub body: Option<String>,
25    /// Links extracted from the body at load time (always populated).
26    pub links: Vec<crate::LocatedLink>,
27    pub frontmatter: Option<IndexMap<String, Pod>>,
28    /// Number of lines occupied by the frontmatter block (including delimiters).
29    /// Used to offset link locations so they reflect positions in the original file.
30    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    /// Parses a note from a raw file string, always retaining the body content.
146    ///
147    /// Useful for constructing notes from in-memory strings (e.g. in tests).
148    /// For file-backed notes prefer [`Note::from_path`] (no content) or
149    /// [`Note::from_path_with_body`] (with content).
150    pub fn parse(path: impl AsRef<Path>, content: &str) -> Self {
151        Self::parse_impl(path, content, true)
152    }
153
154    /// Loads a note from disk without retaining the body content.
155    ///
156    /// Links and inline tags are still extracted and stored. This is the
157    /// memory-efficient default for bulk operations. Use
158    /// [`from_path_with_body`](Self::from_path_with_body) when the body
159    /// text is needed (e.g. for content search or writing).
160    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    /// Loads a note from disk, retaining the full body content in [`Note::body`].
167    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 there's a title, it should be an alias too, and if there's not a title we should
214            // infer it from the first alias
215            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            // Update tags from frontmatter.
290            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    /// Reloads the note from its path without retaining body content.
317    pub fn reload(self) -> Result<Self, NoteError> {
318        Self::from_path(&self.path)
319    }
320
321    /// Reloads the note from its path while retaining body content.
322    pub fn reload_with_body(self) -> Result<Self, NoteError> {
323        Self::from_path_with_body(&self.path)
324    }
325
326    /// Populates [`Note::body`] by reading the note's body from disk.
327    /// Does nothing if the body is already loaded.
328    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    /// Add an alias.
342    pub fn add_alias(&mut self, alias: String) {
343        if !self.aliases.contains(&alias) {
344            self.aliases.push(alias);
345        }
346    }
347
348    /// Add a frontmatter tag.
349    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    /// Remove a frontmatter tag.
364    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    /// Set an arbitrary frontmatter field to a value (which can be any YAML type).
371    /// A null value removes the field from the frontmatter.
372    pub fn set_field(&mut self, key: &str, value: &serde_yaml::Value) -> Result<(), NoteError> {
373        // Guard against invalid field names that would cause YAML serialization to fail (e.g. containing newlines),
374        // or that would be confusing to users (e.g. "id", "aliases", "tags" which are derived from other fields and would be ignored).
375        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            // Remove the field if value is null.
393            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    /// Atomically writes the note to `self.path`, including serialized frontmatter.
405    ///
406    /// Requires [`Note::body`] to be populated. Returns
407    /// [`NoteError::BodyNotLoaded`] if the body is `None`.
408    ///
409    /// Frontmatter keys are serialized in a deterministic order: `id` first, then
410    /// `title` (if present), then `aliases`, then `tags`, then all remaining keys
411    /// sorted alphabetically.
412    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    /// Atomically writes updated frontmatter to `self.path`, reading the current body
422    /// from disk. Use this when only frontmatter has changed but not the body.
423    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    /// Read the contents of the note as a string, optionally including frontmatter.
439    /// Requires [`Note::body`] to be populated. Returns
440    /// [`NoteError::BodyNotLoaded`] if the body is `None`.
441    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    /// Get the note's frontmatter map.
452    pub fn frontmatter_map(&self) -> IndexMap<String, Pod> {
453        let mut fm = if let Some(fm) = &self.frontmatter {
454            fm.clone()
455        } else {
456            // No frontmatter; create it.
457            IndexMap::new()
458        };
459
460        // Make sure fields are up-to-date.
461        fm.insert("id".to_string(), Pod::String(self.id.clone()));
462        if self.aliases.is_empty() {
463            // No aliases; remove the field to avoid emitting an empty array.
464            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            // No tags; remove the field to avoid emitting an empty array.
479            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    /// Get the note's frontmatter map in a form suitable for YAML serialization.
490    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        // Emit priority keys in fixed order, only if present.
496        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        // Emit remaining keys in alphabetical order.
502        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    /// Get the note's frontmatter map in a form suitable for JSON serialization.
514    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    /// Get the note's frontmatter as a YAML string (without delimiters).
524    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        // Strip leading "---\n" if emitted by serde_yaml, since we'll add our own delimiters.
528        Ok(yaml.strip_prefix("---\n").unwrap_or(&yaml).to_string())
529    }
530
531    /// Get the last modified time of the note's file on disk.
532    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    /// Get the creation time of the note.
539    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                // This should never happen since serde_yaml::Number can only be i64 or f64.
578                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            // YAML tags are not supported in our frontmatter; treat them as null.
590            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    // [[target|alias]] → alias, [[target]] or [[target#heading]] → target
626    static WIKI_RE: LazyLock<Regex> =
627        LazyLock::new(|| Regex::new(r"!?\[\[([^\]#|]*?)(?:#[^\]|]*?)?(?:\|([^\]]*?))?\]\]").unwrap());
628    // [text](url) → text
629    static MD_LINK_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\]]+?)\]\([^)]*?\)").unwrap());
630    // `code` → code
631    static INLINE_CODE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`([^`\n]+)`").unwrap());
632
633    let s = WIKI_RE.replace_all(s, |caps: &regex::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        // frontmatter takes precedence over H1
707        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        // Provide keys out of order; verify they are written in the canonical order.
787        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        // Extract only key lines (not list item lines that start with '-').
798        let keys: Vec<&str> = on_disk
799            .lines()
800            .skip(1) // skip opening "---"
801            .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        // Re-parse to verify the on-disk content is valid and retains key fields.
857        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    // strip_title_md unit tests
879
880    #[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    // Integration tests: aliases use cleaned title
916
917    #[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        // Frontmatter is lines 1-3; "[[target]]" is on line 4 and "[text](url)" on line 5.
970        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}