Skip to main content

obsidian_core/
note.rs

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    /// All tags: frontmatter tags have `location: Location::Frontmatter`; inline tags have
20    /// `location: Location::Inline(...)`. Always populated.
21    pub tags: Vec<LocatedTag>,
22    /// The entire, exact contents of the note *including* frontmatter. This is the single source
23    /// of truth for a note's contents; all other content-derived fields (`id`, `title`, `aliases`,
24    /// `tags`, `links`, `frontmatter`, `frontmatter_line_count`) are parsed from it. It is private
25    /// so callers cannot mutate it out of sync with the derived fields; use [`Note::text`] to read
26    /// it and [`Note::body`] for the frontmatter-stripped body.
27    text: String,
28    /// Links extracted from the body at load time (always populated).
29    pub links: Vec<crate::LocatedLink>,
30    pub frontmatter: Option<IndexMap<String, Pod>>,
31    /// Number of lines occupied by the frontmatter block (including delimiters).
32    /// Used to offset link locations so they reflect positions in the original file.
33    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        // Always build `text` (defaulting to an empty body) so the note is internally consistent.
172        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    /// Parses a note from a raw file string.
183    ///
184    /// Useful for constructing notes from in-memory strings (e.g. in tests). For file-backed
185    /// notes prefer [`Note::from_path`].
186    pub fn parse(path: impl AsRef<Path>, content: &str) -> Self {
187        Self::parse_impl(path, content)
188    }
189
190    /// Loads a note from disk, retaining the full contents in [`Note::text`].
191    ///
192    /// Links and inline tags are extracted and stored. Note the entire file is read and retained
193    /// in memory.
194    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        // Locate the body directly in the raw text so that a note's `text` and derived `body()`
210        // stay exactly consistent. Deriving the split from gray_matter's parsed content is unsafe
211        // because it trims trailing whitespace, which would inflate the line count and slice into
212        // the body.
213        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 there's a title, it should be an alias too, and if there's not a title we should
238            // infer it from the first alias
239            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    /// The entire, exact contents of the note including frontmatter.
295    pub fn text(&self) -> &str {
296        &self.text
297    }
298
299    /// The note's body — its contents with the frontmatter block stripped.
300    ///
301    /// Returned as a zero-copy slice of [`text`](Self::text).
302    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    /// Reserializes `text` from the note's current derived state (frontmatter fields plus `body`)
332    /// and reparses so every derived field stays in sync with `text`.
333    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    /// Reloads the note from its path.
340    pub fn reload(self) -> Result<Self, NoteError> {
341        Self::from_path(&self.path)
342    }
343
344    /// Add an alias, keeping [`text`](Self::text) in sync.
345    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    /// Add a frontmatter tag, keeping [`text`](Self::text) in sync.
355    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    /// Remove a frontmatter tag, keeping [`text`](Self::text) in sync.
373    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    /// Set an arbitrary frontmatter field to a value (which can be any YAML type).
386    /// A null value removes the field from the frontmatter.
387    pub fn set_field(&mut self, key: &str, value: &serde_yaml::Value) -> Result<(), NoteError> {
388        // Guard against invalid field names that would cause YAML serialization to fail (e.g. containing newlines),
389        // or that would be confusing to users (e.g. "id", "aliases", "tags" which are derived from other fields and would be ignored).
390        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            // Remove the field if value is null.
408            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    /// Atomically writes the note to `self.path`, including serialized frontmatter.
421    ///
422    /// Frontmatter keys are serialized in a deterministic order: `id` first, then
423    /// `title` (if present), then `aliases`, then `tags`, then all remaining keys
424    /// sorted alphabetically.
425    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    /// Atomically writes the note to `self.path`, reconstructing the file from the in-memory
435    /// [`text`](Self::text). Retained as a distinct entry point for frontmatter-only updates;
436    /// with `text` as the source of truth it is equivalent to [`write`](Self::write).
437    pub fn write_frontmatter(&self) -> Result<(), NoteError> {
438        self.write()
439    }
440
441    /// Read the contents of the note as a string, optionally including frontmatter.
442    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    /// Get the note's frontmatter map.
451    pub fn frontmatter_map(&self) -> IndexMap<String, Pod> {
452        let mut fm = if let Some(fm) = &self.frontmatter {
453            fm.clone()
454        } else {
455            // No frontmatter; create it.
456            IndexMap::new()
457        };
458
459        // Make sure fields are up-to-date.
460        fm.insert("id".to_string(), Pod::String(self.id.clone()));
461        if self.aliases.is_empty() {
462            // Preserve an explicitly empty aliases array, otherwise omit the field.
463            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            // Preserve an explicitly empty tags array, otherwise omit the field.
480            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    /// Get the note's frontmatter map in a form suitable for YAML serialization.
493    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        // Emit priority keys in fixed order, only if present.
499        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        // Emit remaining keys in alphabetical order.
505        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    /// Get the note's frontmatter map in a form suitable for JSON serialization.
517    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    /// Get the note's frontmatter as a YAML string (without delimiters).
527    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        // Strip leading "---\n" if emitted by serde_yaml, since we'll add our own delimiters.
531        Ok(yaml.strip_prefix("---\n").unwrap_or(&yaml).to_string())
532    }
533
534    /// Get the last modified time of the note's file on disk.
535    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    /// Get the creation time of the note.
542    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                // This should never happen since serde_yaml::Number can only be i64 or f64.
581                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            // YAML tags are not supported in our frontmatter; treat them as null.
593            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
621/// Returns the byte length of the leading frontmatter region of `content` — the opening `---`
622/// delimiter line through the closing `---` delimiter line and any blank separator lines that
623/// follow it. Returns `0` when `content` has no frontmatter block. The returned offset marks where
624/// the note body begins.
625fn 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        // No closing delimiter: not a frontmatter block.
645        return 0;
646    };
647
648    // Consume blank separator lines between the frontmatter and the body.
649    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    // [[target|alias]] → alias, [[target]] or [[target#heading]] → target
667    static WIKI_RE: LazyLock<Regex> =
668        LazyLock::new(|| Regex::new(r"!?\[\[([^\]#|]*?)(?:#[^\]|]*?)?(?:\|([^\]]*?))?\]\]").unwrap());
669    // [text](url) → text
670    static MD_LINK_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\]]+?)\]\([^)]*?\)").unwrap());
671    // `code` → code
672    static INLINE_CODE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`([^`\n]+)`").unwrap());
673
674    let s = WIKI_RE.replace_all(s, |caps: &regex::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        // frontmatter takes precedence over H1
748        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        // Provide keys out of order; verify they are written in the canonical order.
828        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        // Extract only key lines (not list item lines that start with '-').
839        let keys: Vec<&str> = on_disk
840            .lines()
841            .skip(1) // skip opening "---"
842            .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        // Re-parse to verify the on-disk content is valid and retains key fields.
922        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    // strip_title_md unit tests
962
963    #[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    // Integration tests: aliases use cleaned title
999
1000    #[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        // Frontmatter is lines 1-3; "[[target]]" is on line 4 and "[text](url)" on line 5.
1053        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}