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