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::de::DeserializeOwned;
8use std::{borrow::Cow, collections::HashMap, 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/// ```
35pub trait ObFile<T = DefaultProperties>: Sized
36where
37    T: DeserializeOwned + Clone,
38{
39    /// Returns the main content body of the note (excluding frontmatter)
40    ///
41    /// # Implementation Notes
42    /// - Strips YAML frontmatter if present
43    /// - Preserves original formatting and whitespace
44    ///
45    /// # Errors
46    /// Usually errors are related to [`Error::Io`]
47    fn content(&self) -> Result<Cow<'_, str>, Error>;
48
49    /// Returns the source file path if available
50    ///
51    /// Returns [`None`] for in-memory notes without physical storage
52    fn path(&self) -> Option<Cow<'_, Path>>;
53
54    /// Returns the parsed properties of frontmatter
55    ///
56    /// Returns [`None`] if the note has no properties
57    ///
58    /// # Errors
59    /// Usually errors are related to [`Error::Io`]
60    fn properties(&self) -> Result<Option<Cow<'_, T>>, Error>;
61
62    /// Get note name
63    fn note_name(&self) -> Option<String> {
64        self.path().as_ref().map(|path| {
65            path.file_stem()
66                .expect("Path is not file")
67                .to_string_lossy()
68                .to_string()
69        })
70    }
71
72    /// Parses an Obsidian note from a string
73    ///
74    /// # Arguments
75    /// - `raw_text`: Raw markdown content with optional YAML frontmatter
76    /// - `path`: Optional source path for reference
77    ///
78    /// # Errors
79    /// - [`Error::InvalidFormat`] for malformed frontmatter
80    /// - [`Error::Yaml`] for invalid YAML syntax
81    fn from_string<P: AsRef<Path>>(raw_text: &str, path: Option<P>) -> Result<Self, Error>;
82
83    /// Parses an Obsidian note from a file
84    ///
85    /// # Arguments
86    /// - `path`: Filesystem path to markdown file
87    ///
88    /// # Errors
89    /// - [`Error::Io`] for filesystem errors
90    fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
91        let path_buf = path.as_ref().to_path_buf();
92
93        #[cfg(feature = "logging")]
94        log::trace!("Parse obsidian file from file: {}", path_buf.display());
95
96        let data = std::fs::read(path)?;
97        let text = unsafe { String::from_utf8_unchecked(data) };
98
99        Self::from_string(&text, Some(path_buf))
100    }
101}
102
103/// Default implementation using [`HashMap`] for properties
104///
105/// Automatically implemented for all `ObFile<HashMap<..>>` types.
106/// Provides identical interface with explicitly named methods.
107pub trait ObFileDefault: ObFile<DefaultProperties> {
108    /// Same as [`ObFile::from_string`] with default properties type
109    ///
110    /// # Errors
111    /// - [`Error::InvalidFormat`] for malformed frontmatter
112    /// - [`Error::Yaml`] for invalid YAML syntax
113    fn from_string_default<P: AsRef<Path>>(text: &str, path: Option<P>) -> Result<Self, Error>;
114
115    /// Same as [`ObFile::from_file`] with default properties type
116    ///
117    /// # Errors
118    /// - [`Error::Io`] for filesystem errors
119    fn from_file_default<P: AsRef<Path>>(path: P) -> Result<Self, Error>;
120}
121
122/// Parses Obsidian-style links in note content
123///
124/// Handles all link formats:
125/// - `[[Note]]`
126/// - `[[Note|Alias]]`
127/// - `[[Note^block]]`
128/// - `[[Note#heading]]`
129/// - `[[Note#heading|Alias]]`
130///
131/// # Example
132/// ```
133/// # use obsidian_parser::obfile::parse_links;
134/// let content = "[[Physics]] and [[Math|Mathematics]]";
135/// let links: Vec<_> = parse_links(content).collect();
136/// assert_eq!(links, vec!["Physics", "Math"]);
137/// ```
138pub fn parse_links(text: &str) -> impl Iterator<Item = &str> {
139    text.match_indices("[[").filter_map(move |(start_pos, _)| {
140        let end_pos = text[start_pos + 2..].find("]]")?;
141        let inner = &text[start_pos + 2..start_pos + 2 + end_pos];
142
143        let note_name = inner
144            .split('#')
145            .next()?
146            .split('^')
147            .next()?
148            .split('|')
149            .next()?
150            .trim();
151
152        Some(note_name)
153    })
154}
155
156impl<T> ObFileDefault for T
157where
158    T: ObFile<DefaultProperties>,
159{
160    fn from_string_default<P: AsRef<Path>>(text: &str, path: Option<P>) -> Result<Self, Error> {
161        Self::from_string(text, path)
162    }
163
164    fn from_file_default<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
165        Self::from_file(path)
166    }
167}
168
169#[derive(Debug, PartialEq)]
170enum ResultParse<'a> {
171    WithProperties {
172        content: &'a str,
173        properties: &'a str,
174    },
175    WithoutProperties,
176}
177
178fn parse_obfile(raw_text: &str) -> Result<ResultParse<'_>, Error> {
179    let have_start_properties = raw_text
180        .lines()
181        .next()
182        .is_some_and(|line| line.trim_end() == "---");
183
184    if have_start_properties {
185        let closed = raw_text["---".len()..]
186            .find("---")
187            .ok_or(Error::InvalidFormat)?;
188
189        return Ok(ResultParse::WithProperties {
190            content: raw_text[(closed + 2 * "...".len())..].trim(),
191            properties: raw_text["...".len()..(closed + "...".len())].trim(),
192        });
193    }
194
195    Ok(ResultParse::WithoutProperties)
196}
197
198#[cfg(test)]
199mod tests {
200    use super::{ResultParse, parse_obfile};
201    use crate::test_utils::init_test_logger;
202
203    #[test]
204    fn parse_obfile_without_properties() {
205        init_test_logger();
206        let test_data = "test_data";
207        let result = parse_obfile(test_data).unwrap();
208
209        assert_eq!(result, ResultParse::WithoutProperties);
210    }
211
212    #[test]
213    fn parse_obfile_with_properties() {
214        init_test_logger();
215        let test_data = "---\nproperties data\n---\ntest data";
216        let result = parse_obfile(test_data).unwrap();
217
218        assert_eq!(
219            result,
220            ResultParse::WithProperties {
221                content: "test data",
222                properties: "properties data"
223            }
224        );
225    }
226
227    #[test]
228    fn parse_obfile_without_properties_but_with_closed() {
229        init_test_logger();
230        let test_data1 = "test_data---";
231        let test_data2 = "test_data\n---\n";
232
233        let result1 = parse_obfile(test_data1).unwrap();
234        let result2 = parse_obfile(test_data2).unwrap();
235
236        assert_eq!(result1, ResultParse::WithoutProperties);
237        assert_eq!(result2, ResultParse::WithoutProperties);
238    }
239
240    #[test]
241    #[should_panic]
242    fn parse_obfile_with_properties_but_without_closed() {
243        init_test_logger();
244        let test_data = "---\nproperties data\ntest data";
245        let _ = parse_obfile(test_data).unwrap();
246    }
247
248    #[test]
249    fn parse_obfile_with_() {
250        init_test_logger();
251        let test_data = "---properties data";
252
253        let result = parse_obfile(test_data).unwrap();
254        assert_eq!(result, ResultParse::WithoutProperties);
255    }
256
257    #[test]
258    fn parse_obfile_without_properties_but_with_spaces() {
259        init_test_logger();
260        let test_data = "   ---\ndata";
261
262        let result = parse_obfile(test_data).unwrap();
263        assert_eq!(result, ResultParse::WithoutProperties);
264    }
265
266    #[test]
267    fn parse_obfile_with_properties_but_check_trim_end() {
268        init_test_logger();
269        let test_data = "---\r\nproperties data\r\n---\r   \ntest data";
270        let result = parse_obfile(test_data).unwrap();
271
272        assert_eq!(
273            result,
274            ResultParse::WithProperties {
275                content: "test data",
276                properties: "properties data"
277            }
278        );
279    }
280
281    #[test]
282    fn test_parse_links() {
283        init_test_logger();
284        let test_data =
285            "[[Note]] [[Note|Alias]] [[Note^block]] [[Note#Heading|Alias]] [[Note^block|Alias]]";
286
287        let ds: Vec<_> = super::parse_links(test_data).collect();
288
289        assert!(ds.iter().all(|x| *x == "Note"))
290    }
291}
292
293#[cfg(test)]
294pub(crate) mod impl_tests {
295    use super::*;
296    use crate::test_utils::init_test_logger;
297    use std::io::Write;
298    use tempfile::NamedTempFile;
299
300    pub(crate) static TEST_DATA: &str = "---\n\
301topic: life\n\
302created: 2025-03-16\n\
303---\n\
304Test data\n\
305---\n\
306Two test data";
307
308    pub(crate) fn from_string<T: ObFile>() -> Result<(), Error> {
309        init_test_logger();
310        let file = T::from_string(TEST_DATA, None::<&str>)?;
311        let properties = file.properties().unwrap().unwrap();
312
313        assert_eq!(properties["topic"], "life");
314        assert_eq!(properties["created"], "2025-03-16");
315        assert_eq!(file.content().unwrap(), "Test data\n---\nTwo test data");
316        Ok(())
317    }
318
319    pub(crate) fn from_string_note_name<T: ObFile>() -> Result<(), Error> {
320        init_test_logger();
321        let file1 = T::from_string(TEST_DATA, None::<&str>)?;
322        let file2 = T::from_string(TEST_DATA, Some("Super node.md"))?;
323
324        assert_eq!(file1.note_name(), None);
325        assert_eq!(file2.note_name(), Some("Super node".to_string()));
326        Ok(())
327    }
328
329    pub(crate) fn from_string_without_properties<T: ObFile>() -> Result<(), Error> {
330        init_test_logger();
331        let test_data = "TEST_DATA";
332        let file = T::from_string(test_data, None::<&str>)?;
333
334        assert_eq!(file.properties().unwrap(), None);
335        assert_eq!(file.content().unwrap(), test_data);
336        Ok(())
337    }
338
339    pub(crate) fn from_string_with_invalid_yaml<T: ObFile>() -> Result<(), Error> {
340        init_test_logger();
341        let broken_data = "---\n\
342    asdfv:--fs\n\
343    sfsf\n\
344    ---\n\
345    TestData";
346
347        assert!(matches!(
348            T::from_string(broken_data, None::<&str>),
349            Err(Error::Yaml(_))
350        ));
351        Ok(())
352    }
353
354    pub(crate) fn from_string_invalid_format<T: ObFile>() -> Result<(), Error> {
355        init_test_logger();
356        let broken_data = "---\n";
357
358        assert!(matches!(
359            T::from_string(broken_data, None::<&str>),
360            Err(Error::InvalidFormat)
361        ));
362        Ok(())
363    }
364
365    pub(crate) fn from_string_with_unicode<T: ObFile>() -> Result<(), Error> {
366        init_test_logger();
367        let data = "---\ndata: 💩\n---\nSuper data 💩💩💩";
368        let file = T::from_string(data, None::<&str>)?;
369        let properties = file.properties().unwrap().unwrap();
370
371        assert_eq!(properties["data"], "💩");
372        assert_eq!(file.content().unwrap(), "Super data 💩💩💩");
373        Ok(())
374    }
375
376    pub(crate) fn from_string_space_with_properties<T: ObFile>() -> Result<(), Error> {
377        init_test_logger();
378        let data = "  ---\ntest: test-data\n---\n";
379        let file = T::from_string(data, None::<&str>)?;
380        let properties = file.properties().unwrap();
381
382        assert_eq!(file.content().unwrap(), data);
383        assert_eq!(properties, None);
384        Ok(())
385    }
386
387    pub(crate) fn from_file<T: ObFile>() -> Result<(), Error> {
388        init_test_logger();
389        let mut temp_file = NamedTempFile::new().unwrap();
390        temp_file.write_all(b"TEST_DATA").unwrap();
391
392        let file = T::from_file(temp_file.path()).unwrap();
393        assert_eq!(file.content().unwrap(), "TEST_DATA");
394        assert_eq!(file.path().unwrap(), temp_file.path());
395        assert_eq!(file.properties().unwrap(), None);
396        Ok(())
397    }
398
399    pub(crate) fn from_file_note_name<T: ObFile>() -> Result<(), Error> {
400        init_test_logger();
401        let mut temp_file = NamedTempFile::new().unwrap();
402        temp_file.write_all(b"TEST_DATA").unwrap();
403
404        let name_temp_file = temp_file
405            .path()
406            .file_stem()
407            .unwrap()
408            .to_string_lossy()
409            .to_string();
410
411        let file = T::from_file(temp_file.path()).unwrap();
412
413        assert_eq!(file.note_name(), Some(name_temp_file));
414        Ok(())
415    }
416
417    pub(crate) fn from_file_without_properties<T: ObFile>() -> Result<(), Error> {
418        init_test_logger();
419        let test_data = "TEST_DATA";
420        let mut test_file = NamedTempFile::new().unwrap();
421        test_file.write_all(test_data.as_bytes()).unwrap();
422
423        let file = T::from_file(test_file.path())?;
424
425        assert_eq!(file.properties().unwrap(), None);
426        assert_eq!(file.content().unwrap(), test_data);
427        Ok(())
428    }
429
430    pub(crate) fn from_file_with_invalid_yaml<T: ObFile>() -> Result<(), Error> {
431        init_test_logger();
432        let broken_data = "---\n\
433    asdfv:--fs\n\
434    sfsf\n\
435    ---\n\
436    TestData";
437
438        let mut test_file = NamedTempFile::new().unwrap();
439        test_file.write_all(broken_data.as_bytes()).unwrap();
440
441        assert!(matches!(
442            T::from_file(test_file.path()),
443            Err(Error::Yaml(_))
444        ));
445        Ok(())
446    }
447
448    pub(crate) fn from_file_invalid_format<T: ObFile>() -> Result<(), Error> {
449        init_test_logger();
450        let broken_data = "---\n";
451        let mut test_file = NamedTempFile::new().unwrap();
452        test_file.write_all(broken_data.as_bytes()).unwrap();
453
454        assert!(matches!(
455            T::from_file(test_file.path()),
456            Err(Error::InvalidFormat)
457        ));
458        Ok(())
459    }
460
461    pub(crate) fn from_file_with_unicode<T: ObFile>() -> Result<(), Error> {
462        init_test_logger();
463        let data = "---\ndata: 💩\n---\nSuper data 💩💩💩";
464        let mut test_file = NamedTempFile::new().unwrap();
465        test_file.write_all(data.as_bytes()).unwrap();
466
467        let file = T::from_file(test_file.path())?;
468        let properties = file.properties().unwrap().unwrap();
469
470        assert_eq!(properties["data"], "💩");
471        assert_eq!(file.content().unwrap(), "Super data 💩💩💩");
472        Ok(())
473    }
474
475    pub(crate) fn from_file_space_with_properties<T: ObFile>() -> Result<(), Error> {
476        init_test_logger();
477        let data = "  ---\ntest: test-data\n---\n";
478        let mut test_file = NamedTempFile::new().unwrap();
479        test_file.write_all(data.as_bytes()).unwrap();
480
481        let file = T::from_string(data, None::<&str>)?;
482
483        assert_eq!(file.content().unwrap(), data);
484        assert_eq!(file.properties().unwrap(), None);
485        Ok(())
486    }
487
488    macro_rules! impl_test_for_obfile {
489        ($name_test:ident, $fn_test:ident, $impl_obfile:path) => {
490            #[test]
491            fn $name_test() {
492                $fn_test::<$impl_obfile>().unwrap();
493            }
494        };
495    }
496
497    pub(crate) use impl_test_for_obfile;
498
499    macro_rules! impl_all_tests_from_string {
500        ($impl_obfile:path) => {
501            #[allow(unused_imports)]
502            use crate::obfile::impl_tests::*;
503
504            impl_test_for_obfile!(impl_from_string, from_string, $impl_obfile);
505
506            impl_test_for_obfile!(
507                impl_from_string_note_name,
508                from_string_note_name,
509                $impl_obfile
510            );
511            impl_test_for_obfile!(
512                impl_from_string_without_properties,
513                from_string_without_properties,
514                $impl_obfile
515            );
516            impl_test_for_obfile!(
517                impl_from_string_with_invalid_yaml,
518                from_string_with_invalid_yaml,
519                $impl_obfile
520            );
521            impl_test_for_obfile!(
522                impl_from_string_invalid_format,
523                from_string_invalid_format,
524                $impl_obfile
525            );
526            impl_test_for_obfile!(
527                impl_from_string_with_unicode,
528                from_string_with_unicode,
529                $impl_obfile
530            );
531            impl_test_for_obfile!(
532                impl_from_string_space_with_properties,
533                from_string_space_with_properties,
534                $impl_obfile
535            );
536        };
537    }
538
539    macro_rules! impl_all_tests_from_file {
540        ($impl_obfile:path) => {
541            #[allow(unused_imports)]
542            use crate::obfile::impl_tests::*;
543
544            impl_test_for_obfile!(impl_from_file, from_file, $impl_obfile);
545            impl_test_for_obfile!(impl_from_file_note_name, from_file_note_name, $impl_obfile);
546
547            impl_test_for_obfile!(
548                impl_from_file_without_properties,
549                from_file_without_properties,
550                $impl_obfile
551            );
552            impl_test_for_obfile!(
553                impl_from_file_with_invalid_yaml,
554                from_file_with_invalid_yaml,
555                $impl_obfile
556            );
557            impl_test_for_obfile!(
558                impl_from_file_invalid_format,
559                from_file_invalid_format,
560                $impl_obfile
561            );
562            impl_test_for_obfile!(
563                impl_from_file_with_unicode,
564                from_file_with_unicode,
565                $impl_obfile
566            );
567            impl_test_for_obfile!(
568                impl_from_file_space_with_properties,
569                from_file_space_with_properties,
570                $impl_obfile
571            );
572        };
573    }
574
575    pub(crate) use impl_all_tests_from_file;
576    pub(crate) use impl_all_tests_from_string;
577}