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