obsidian_parser/obfile/
mod.rs

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