obsidian_parser/obfile/
mod.rs

1//! Represents an Obsidian note file with frontmatter properties and content
2
3pub mod obfile_in_memory;
4pub mod obfile_on_disk;
5
6use crate::error::Error;
7use serde::{Serialize, de::DeserializeOwned};
8use std::{borrow::Cow, collections::HashMap, fs::OpenOptions, io::Write, path::Path};
9
10pub(crate) type DefaultProperties = HashMap<String, serde_yml::Value>;
11
12/// Represents an Obsidian note file with frontmatter properties and content
13///
14/// This trait provides a standardized interface for working with Obsidian markdown files,
15/// handling frontmatter parsing, content extraction, and file operations.
16///
17/// # Type Parameters
18/// - `T`: Frontmatter properties type
19///
20/// # Example
21/// ```no_run
22/// use obsidian_parser::prelude::*;
23/// use serde::Deserialize;
24///
25/// #[derive(Deserialize, Clone)]
26/// struct NoteProperties {
27///     topic: String,
28///     created: String,
29/// }
30///
31/// let note: ObFileInMemory<NoteProperties> = ObFile::from_file("note.md").unwrap();
32/// let properties = note.properties().unwrap().unwrap();
33/// println!("Note topic: {}", properties.topic);
34/// ```
35///
36/// # Other
37/// To write and modify ObFile to a file, use the [`ObFileFlush`] trait.
38pub trait ObFile<T = DefaultProperties>: Sized
39where
40    T: DeserializeOwned + Clone,
41{
42    /// Returns the main content body of the note (excluding frontmatter)
43    ///
44    /// # Implementation Notes
45    /// - Strips YAML frontmatter if present
46    /// - Preserves original formatting and whitespace
47    ///
48    /// # Errors
49    /// Usually errors are related to [`Error::Io`]
50    fn content(&self) -> Result<Cow<'_, str>, Error>;
51
52    /// Returns the source file path if available
53    ///
54    /// Returns [`None`] for in-memory notes without physical storage
55    fn path(&self) -> Option<Cow<'_, Path>>;
56
57    /// Returns the parsed properties of frontmatter
58    ///
59    /// Returns [`None`] if the note has no properties
60    ///
61    /// # Errors
62    /// Usually errors are related to [`Error::Io`]
63    fn properties(&self) -> Result<Option<Cow<'_, T>>, Error>;
64
65    /// Get note name
66    fn note_name(&self) -> Option<String> {
67        self.path().as_ref().map(|path| {
68            path.file_stem()
69                .expect("Path is not file")
70                .to_string_lossy()
71                .to_string()
72        })
73    }
74
75    /// Parses an Obsidian note from a string
76    ///
77    /// # Arguments
78    /// - `raw_text`: Raw markdown content with optional YAML frontmatter
79    /// - `path`: Optional source path for reference
80    ///
81    /// # Errors
82    /// - [`Error::InvalidFormat`] for malformed frontmatter
83    /// - [`Error::Yaml`] for invalid YAML syntax
84    fn from_string<P: AsRef<Path>>(raw_text: &str, path: Option<P>) -> Result<Self, Error>;
85
86    /// Parses an Obsidian note from a file
87    ///
88    /// # Arguments
89    /// - `path`: Filesystem path to markdown file
90    ///
91    /// # Errors
92    /// - [`Error::Io`] for filesystem errors
93    fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
94        let path_buf = path.as_ref().to_path_buf();
95
96        #[cfg(feature = "logging")]
97        log::trace!("Parse obsidian file from file: {}", path_buf.display());
98
99        let data = std::fs::read(path)?;
100
101        // SAFETY: Notes files in Obsidian (`*.md`) ensure that the file is encoded in UTF-8
102        let text = unsafe { String::from_utf8_unchecked(data) };
103
104        Self::from_string(&text, Some(path_buf))
105    }
106}
107
108/// Default implementation using [`HashMap`] for properties
109///
110/// Automatically implemented for all `ObFile<HashMap<..>>` types.
111/// Provides identical interface with explicitly named methods.
112pub trait ObFileDefault: ObFile<DefaultProperties> {
113    /// Same as [`ObFile::from_string`] with default properties type
114    ///
115    /// # Errors
116    /// - [`Error::InvalidFormat`] for malformed frontmatter
117    /// - [`Error::Yaml`] for invalid YAML syntax
118    fn from_string_default<P: AsRef<Path>>(text: &str, path: Option<P>) -> Result<Self, Error>;
119
120    /// Same as [`ObFile::from_file`] with default properties type
121    ///
122    /// # Errors
123    /// - [`Error::Io`] for filesystem errors
124    fn from_file_default<P: AsRef<Path>>(path: P) -> Result<Self, Error>;
125}
126
127/// Represents an Obsidian note file with frontmatter properties and content
128/// for flush to file
129///
130///To use this trait, `T` must implement [`serde::Serialize`]
131pub trait ObFileFlush<T = DefaultProperties>: ObFile<T>
132where
133    T: DeserializeOwned + Serialize + Clone,
134{
135    /// Flush only `content`
136    ///
137    /// Ignore if path is `None`
138    fn flush_content(&self, open_option: &OpenOptions) -> Result<(), Error> {
139        if let Some(path) = self.path() {
140            let text = std::fs::read_to_string(&path)?;
141            let parsed = parse_obfile(&text)?;
142
143            let mut file = open_option.open(path)?;
144
145            match parsed {
146                ResultParse::WithProperties {
147                    content: _,
148                    properties,
149                } => file.write_all(
150                    format!("---\n{}\n---\n{}", properties, self.content()?).as_bytes(),
151                )?,
152                ResultParse::WithoutProperties => file.write_all(self.content()?.as_bytes())?,
153            }
154        }
155
156        Ok(())
157    }
158
159    /// Flush only `content`
160    ///
161    /// Ignore if path is `None`
162    fn flush_properties(&self, open_option: &OpenOptions) -> Result<(), Error> {
163        if let Some(path) = self.path() {
164            let text = std::fs::read_to_string(&path)?;
165            let parsed = parse_obfile(&text)?;
166
167            let mut file = open_option.open(path)?;
168
169            match parsed {
170                ResultParse::WithProperties {
171                    content,
172                    properties: _,
173                } => match self.properties()? {
174                    Some(properties) => file.write_all(
175                        format!(
176                            "---\n{}\n---\n{}",
177                            serde_yml::to_string(&properties)?,
178                            content
179                        )
180                        .as_bytes(),
181                    )?,
182                    None => file.write_all(self.content()?.as_bytes())?,
183                },
184                ResultParse::WithoutProperties => file.write_all(self.content()?.as_bytes())?,
185            }
186        }
187
188        Ok(())
189    }
190
191    /// Flush [`ObFile`] to [`self.path()`]
192    ///
193    /// Ignore if path is `None`
194    fn flush(&self, open_option: &OpenOptions) -> Result<(), Error> {
195        if let Some(path) = self.path() {
196            let mut file = open_option.open(path)?;
197
198            match self.properties()? {
199                Some(properties) => file.write_all(
200                    format!(
201                        "---\n{}\n---\n{}",
202                        serde_yml::to_string(&properties)?,
203                        self.content()?
204                    )
205                    .as_bytes(),
206                )?,
207                None => file.write_all(self.content()?.as_bytes())?,
208            };
209        }
210
211        Ok(())
212    }
213}
214
215/// Parses Obsidian-style links in note content
216///
217/// Handles all link formats:
218/// - `[[Note]]`
219/// - `[[Note|Alias]]`
220/// - `[[Note^block]]`
221/// - `[[Note#heading]]`
222/// - `[[Note#heading|Alias]]`
223///
224/// # Example
225/// ```
226/// # use obsidian_parser::obfile::parse_links;
227/// let content = "[[Physics]] and [[Math|Mathematics]]";
228/// let links: Vec<_> = parse_links(content).collect();
229/// assert_eq!(links, vec!["Physics", "Math"]);
230/// ```
231pub fn parse_links(text: &str) -> impl Iterator<Item = &str> {
232    text.match_indices("[[").filter_map(move |(start_pos, _)| {
233        let end_pos = text[start_pos + 2..].find("]]")?;
234        let inner = &text[start_pos + 2..start_pos + 2 + end_pos];
235
236        let note_name = inner
237            .split('#')
238            .next()?
239            .split('^')
240            .next()?
241            .split('|')
242            .next()?
243            .trim();
244
245        Some(note_name)
246    })
247}
248
249impl<T> ObFileDefault for T
250where
251    T: ObFile<DefaultProperties>,
252{
253    fn from_string_default<P: AsRef<Path>>(text: &str, path: Option<P>) -> Result<Self, Error> {
254        Self::from_string(text, path)
255    }
256
257    fn from_file_default<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
258        Self::from_file(path)
259    }
260}
261
262#[derive(Debug, PartialEq)]
263enum ResultParse<'a> {
264    WithProperties {
265        content: &'a str,
266        properties: &'a str,
267    },
268    WithoutProperties,
269}
270
271fn parse_obfile(raw_text: &str) -> Result<ResultParse<'_>, Error> {
272    let have_start_properties = raw_text
273        .lines()
274        .next()
275        .is_some_and(|line| line.trim_end() == "---");
276
277    if have_start_properties {
278        let closed = raw_text["---".len()..]
279            .find("---")
280            .ok_or(Error::InvalidFormat)?;
281
282        return Ok(ResultParse::WithProperties {
283            content: raw_text[(closed + 2 * "...".len())..].trim(),
284            properties: raw_text["...".len()..(closed + "...".len())].trim(),
285        });
286    }
287
288    Ok(ResultParse::WithoutProperties)
289}
290
291#[cfg(test)]
292mod tests {
293    use super::{ResultParse, parse_obfile};
294    use crate::test_utils::init_test_logger;
295
296    #[test]
297    fn parse_obfile_without_properties() {
298        init_test_logger();
299        let test_data = "test_data";
300        let result = parse_obfile(test_data).unwrap();
301
302        assert_eq!(result, ResultParse::WithoutProperties);
303    }
304
305    #[test]
306    fn parse_obfile_with_properties() {
307        init_test_logger();
308        let test_data = "---\nproperties data\n---\ntest data";
309        let result = parse_obfile(test_data).unwrap();
310
311        assert_eq!(
312            result,
313            ResultParse::WithProperties {
314                content: "test data",
315                properties: "properties data"
316            }
317        );
318    }
319
320    #[test]
321    fn parse_obfile_without_properties_but_with_closed() {
322        init_test_logger();
323        let test_data1 = "test_data---";
324        let test_data2 = "test_data\n---\n";
325
326        let result1 = parse_obfile(test_data1).unwrap();
327        let result2 = parse_obfile(test_data2).unwrap();
328
329        assert_eq!(result1, ResultParse::WithoutProperties);
330        assert_eq!(result2, ResultParse::WithoutProperties);
331    }
332
333    #[test]
334    #[should_panic]
335    fn parse_obfile_with_properties_but_without_closed() {
336        init_test_logger();
337        let test_data = "---\nproperties data\ntest data";
338        let _ = parse_obfile(test_data).unwrap();
339    }
340
341    #[test]
342    fn parse_obfile_with_() {
343        init_test_logger();
344        let test_data = "---properties data";
345
346        let result = parse_obfile(test_data).unwrap();
347        assert_eq!(result, ResultParse::WithoutProperties);
348    }
349
350    #[test]
351    fn parse_obfile_without_properties_but_with_spaces() {
352        init_test_logger();
353        let test_data = "   ---\ndata";
354
355        let result = parse_obfile(test_data).unwrap();
356        assert_eq!(result, ResultParse::WithoutProperties);
357    }
358
359    #[test]
360    fn parse_obfile_with_properties_but_check_trim_end() {
361        init_test_logger();
362        let test_data = "---\r\nproperties data\r\n---\r   \ntest data";
363        let result = parse_obfile(test_data).unwrap();
364
365        assert_eq!(
366            result,
367            ResultParse::WithProperties {
368                content: "test data",
369                properties: "properties data"
370            }
371        );
372    }
373
374    #[test]
375    fn test_parse_links() {
376        init_test_logger();
377        let test_data =
378            "[[Note]] [[Note|Alias]] [[Note^block]] [[Note#Heading|Alias]] [[Note^block|Alias]]";
379
380        let ds: Vec<_> = super::parse_links(test_data).collect();
381
382        assert!(ds.iter().all(|x| *x == "Note"))
383    }
384}
385
386#[cfg(test)]
387pub(crate) mod impl_tests {
388    use super::*;
389    use crate::test_utils::init_test_logger;
390    use std::io::Write;
391    use tempfile::NamedTempFile;
392
393    pub(crate) static TEST_DATA: &str = "---\n\
394topic: life\n\
395created: 2025-03-16\n\
396---\n\
397Test data\n\
398---\n\
399Two test data";
400
401    pub(crate) fn from_string<T: ObFile>() -> Result<(), Error> {
402        init_test_logger();
403        let file = T::from_string(TEST_DATA, None::<&str>)?;
404        let properties = file.properties().unwrap().unwrap();
405
406        assert_eq!(properties["topic"], "life");
407        assert_eq!(properties["created"], "2025-03-16");
408        assert_eq!(file.content().unwrap(), "Test data\n---\nTwo test data");
409        Ok(())
410    }
411
412    pub(crate) fn from_string_note_name<T: ObFile>() -> Result<(), Error> {
413        init_test_logger();
414        let file1 = T::from_string(TEST_DATA, None::<&str>)?;
415        let file2 = T::from_string(TEST_DATA, Some("Super node.md"))?;
416
417        assert_eq!(file1.note_name(), None);
418        assert_eq!(file2.note_name(), Some("Super node".to_string()));
419        Ok(())
420    }
421
422    pub(crate) fn from_string_without_properties<T: ObFile>() -> Result<(), Error> {
423        init_test_logger();
424        let test_data = "TEST_DATA";
425        let file = T::from_string(test_data, None::<&str>)?;
426
427        assert_eq!(file.properties().unwrap(), None);
428        assert_eq!(file.content().unwrap(), test_data);
429        Ok(())
430    }
431
432    pub(crate) fn from_string_with_invalid_yaml<T: ObFile>() -> Result<(), Error> {
433        init_test_logger();
434        let broken_data = "---\n\
435    asdfv:--fs\n\
436    sfsf\n\
437    ---\n\
438    TestData";
439
440        assert!(matches!(
441            T::from_string(broken_data, None::<&str>),
442            Err(Error::Yaml(_))
443        ));
444        Ok(())
445    }
446
447    pub(crate) fn from_string_invalid_format<T: ObFile>() -> Result<(), Error> {
448        init_test_logger();
449        let broken_data = "---\n";
450
451        assert!(matches!(
452            T::from_string(broken_data, None::<&str>),
453            Err(Error::InvalidFormat)
454        ));
455        Ok(())
456    }
457
458    pub(crate) fn from_string_with_unicode<T: ObFile>() -> Result<(), Error> {
459        init_test_logger();
460        let data = "---\ndata: 💩\n---\nSuper data 💩💩💩";
461        let file = T::from_string(data, None::<&str>)?;
462        let properties = file.properties().unwrap().unwrap();
463
464        assert_eq!(properties["data"], "💩");
465        assert_eq!(file.content().unwrap(), "Super data 💩💩💩");
466        Ok(())
467    }
468
469    pub(crate) fn from_string_space_with_properties<T: ObFile>() -> Result<(), Error> {
470        init_test_logger();
471        let data = "  ---\ntest: test-data\n---\n";
472        let file = T::from_string(data, None::<&str>)?;
473        let properties = file.properties().unwrap();
474
475        assert_eq!(file.content().unwrap(), data);
476        assert_eq!(properties, None);
477        Ok(())
478    }
479
480    pub(crate) fn from_file<T: ObFile>() -> Result<(), Error> {
481        init_test_logger();
482        let mut temp_file = NamedTempFile::new().unwrap();
483        temp_file.write_all(b"TEST_DATA").unwrap();
484
485        let file = T::from_file(temp_file.path()).unwrap();
486        assert_eq!(file.content().unwrap(), "TEST_DATA");
487        assert_eq!(file.path().unwrap(), temp_file.path());
488        assert_eq!(file.properties().unwrap(), None);
489        Ok(())
490    }
491
492    pub(crate) fn from_file_note_name<T: ObFile>() -> Result<(), Error> {
493        init_test_logger();
494        let mut temp_file = NamedTempFile::new().unwrap();
495        temp_file.write_all(b"TEST_DATA").unwrap();
496
497        let name_temp_file = temp_file
498            .path()
499            .file_stem()
500            .unwrap()
501            .to_string_lossy()
502            .to_string();
503
504        let file = T::from_file(temp_file.path()).unwrap();
505
506        assert_eq!(file.note_name(), Some(name_temp_file));
507        Ok(())
508    }
509
510    pub(crate) fn from_file_without_properties<T: ObFile>() -> Result<(), Error> {
511        init_test_logger();
512        let test_data = "TEST_DATA";
513        let mut test_file = NamedTempFile::new().unwrap();
514        test_file.write_all(test_data.as_bytes()).unwrap();
515
516        let file = T::from_file(test_file.path())?;
517
518        assert_eq!(file.properties().unwrap(), None);
519        assert_eq!(file.content().unwrap(), test_data);
520        Ok(())
521    }
522
523    pub(crate) fn from_file_with_invalid_yaml<T: ObFile>() -> Result<(), Error> {
524        init_test_logger();
525        let broken_data = "---\n\
526    asdfv:--fs\n\
527    sfsf\n\
528    ---\n\
529    TestData";
530
531        let mut test_file = NamedTempFile::new().unwrap();
532        test_file.write_all(broken_data.as_bytes()).unwrap();
533
534        assert!(matches!(
535            T::from_file(test_file.path()),
536            Err(Error::Yaml(_))
537        ));
538        Ok(())
539    }
540
541    pub(crate) fn from_file_invalid_format<T: ObFile>() -> Result<(), Error> {
542        init_test_logger();
543        let broken_data = "---\n";
544        let mut test_file = NamedTempFile::new().unwrap();
545        test_file.write_all(broken_data.as_bytes()).unwrap();
546
547        assert!(matches!(
548            T::from_file(test_file.path()),
549            Err(Error::InvalidFormat)
550        ));
551        Ok(())
552    }
553
554    pub(crate) fn from_file_with_unicode<T: ObFile>() -> Result<(), Error> {
555        init_test_logger();
556        let data = "---\ndata: 💩\n---\nSuper data 💩💩💩";
557        let mut test_file = NamedTempFile::new().unwrap();
558        test_file.write_all(data.as_bytes()).unwrap();
559
560        let file = T::from_file(test_file.path())?;
561        let properties = file.properties().unwrap().unwrap();
562
563        assert_eq!(properties["data"], "💩");
564        assert_eq!(file.content().unwrap(), "Super data 💩💩💩");
565        Ok(())
566    }
567
568    pub(crate) fn from_file_space_with_properties<T: ObFile>() -> Result<(), Error> {
569        init_test_logger();
570        let data = "  ---\ntest: test-data\n---\n";
571        let mut test_file = NamedTempFile::new().unwrap();
572        test_file.write_all(data.as_bytes()).unwrap();
573
574        let file = T::from_string(data, None::<&str>)?;
575
576        assert_eq!(file.content().unwrap(), data);
577        assert_eq!(file.properties().unwrap(), None);
578        Ok(())
579    }
580
581    pub(crate) fn flush_properties<T: ObFileFlush>() -> Result<(), Error> {
582        let mut test_file = NamedTempFile::new().unwrap();
583        test_file.write_all(TEST_DATA.as_bytes()).unwrap();
584
585        let file = T::from_file_default(test_file.path())?;
586        let open_options = OpenOptions::new().write(true).create(false).clone();
587        file.flush_properties(&open_options)?;
588        drop(file);
589
590        let file = T::from_file_default(test_file.path())?;
591        let properties = file.properties()?.unwrap();
592        assert_eq!(properties["topic"], "life");
593        assert_eq!(properties["created"], "2025-03-16");
594        assert_eq!(file.content().unwrap(), "Test data\n---\nTwo test data");
595
596        Ok(())
597    }
598
599    pub(crate) fn flush_content<T: ObFileFlush>() -> Result<(), Error> {
600        let mut test_file = NamedTempFile::new().unwrap();
601        test_file.write_all(TEST_DATA.as_bytes()).unwrap();
602
603        let file = T::from_file_default(test_file.path())?;
604        let open_options = OpenOptions::new().write(true).create(false).clone();
605        file.flush_content(&open_options)?;
606        drop(file);
607
608        let file = T::from_file_default(test_file.path())?;
609        let properties = file.properties()?.unwrap();
610        assert_eq!(properties["topic"], "life");
611        assert_eq!(properties["created"], "2025-03-16");
612        assert_eq!(file.content().unwrap(), "Test data\n---\nTwo test data");
613
614        Ok(())
615    }
616
617    pub(crate) fn flush<T: ObFileFlush>() -> Result<(), Error> {
618        let mut test_file = NamedTempFile::new().unwrap();
619        test_file.write_all(TEST_DATA.as_bytes()).unwrap();
620
621        let file = T::from_file_default(test_file.path())?;
622        let open_options = OpenOptions::new().write(true).create(false).clone();
623        file.flush(&open_options)?;
624        drop(file);
625
626        let file = T::from_file_default(test_file.path())?;
627        let properties = file.properties()?.unwrap();
628        assert_eq!(properties["topic"], "life");
629        assert_eq!(properties["created"], "2025-03-16");
630        assert_eq!(file.content().unwrap(), "Test data\n---\nTwo test data");
631
632        Ok(())
633    }
634
635    macro_rules! impl_test_for_obfile {
636        ($name_test:ident, $fn_test:ident, $impl_obfile:path) => {
637            #[test]
638            fn $name_test() {
639                $fn_test::<$impl_obfile>().unwrap();
640            }
641        };
642    }
643
644    pub(crate) use impl_test_for_obfile;
645
646    macro_rules! impl_all_tests_from_string {
647        ($impl_obfile:path) => {
648            #[allow(unused_imports)]
649            use $crate::obfile::impl_tests::*;
650
651            impl_test_for_obfile!(impl_from_string, from_string, $impl_obfile);
652
653            impl_test_for_obfile!(
654                impl_from_string_note_name,
655                from_string_note_name,
656                $impl_obfile
657            );
658            impl_test_for_obfile!(
659                impl_from_string_without_properties,
660                from_string_without_properties,
661                $impl_obfile
662            );
663            impl_test_for_obfile!(
664                impl_from_string_with_invalid_yaml,
665                from_string_with_invalid_yaml,
666                $impl_obfile
667            );
668            impl_test_for_obfile!(
669                impl_from_string_invalid_format,
670                from_string_invalid_format,
671                $impl_obfile
672            );
673            impl_test_for_obfile!(
674                impl_from_string_with_unicode,
675                from_string_with_unicode,
676                $impl_obfile
677            );
678            impl_test_for_obfile!(
679                impl_from_string_space_with_properties,
680                from_string_space_with_properties,
681                $impl_obfile
682            );
683        };
684    }
685
686    macro_rules! impl_all_tests_from_file {
687        ($impl_obfile:path) => {
688            #[allow(unused_imports)]
689            use $crate::obfile::impl_tests::*;
690
691            impl_test_for_obfile!(impl_from_file, from_file, $impl_obfile);
692            impl_test_for_obfile!(impl_from_file_note_name, from_file_note_name, $impl_obfile);
693
694            impl_test_for_obfile!(
695                impl_from_file_without_properties,
696                from_file_without_properties,
697                $impl_obfile
698            );
699            impl_test_for_obfile!(
700                impl_from_file_with_invalid_yaml,
701                from_file_with_invalid_yaml,
702                $impl_obfile
703            );
704            impl_test_for_obfile!(
705                impl_from_file_invalid_format,
706                from_file_invalid_format,
707                $impl_obfile
708            );
709            impl_test_for_obfile!(
710                impl_from_file_with_unicode,
711                from_file_with_unicode,
712                $impl_obfile
713            );
714            impl_test_for_obfile!(
715                impl_from_file_space_with_properties,
716                from_file_space_with_properties,
717                $impl_obfile
718            );
719        };
720    }
721
722    macro_rules! impl_all_tests_flush {
723        ($impl_obfile:path) => {
724            #[allow(unused_imports)]
725            use $crate::obfile::impl_tests::*;
726
727            impl_test_for_obfile!(impl_flush, flush, $impl_obfile);
728            impl_test_for_obfile!(impl_flush_content, flush_content, $impl_obfile);
729            impl_test_for_obfile!(impl_flush_properties, flush_properties, $impl_obfile);
730        };
731    }
732
733    pub(crate) use impl_all_tests_flush;
734    pub(crate) use impl_all_tests_from_file;
735    pub(crate) use impl_all_tests_from_string;
736}