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        let data = std::fs::read(path)?;
81        let text = String::from_utf8(data)?;
82
83        Self::from_string(&text, Some(path_buf))
84    }
85}
86
87/// Default implementation using `HashMap` for properties
88///
89/// Automatically implemented for all `ObFile<HashMap<..>>` types.
90/// Provides identical interface with explicitly named methods.
91pub trait ObFileDefault: ObFile<HashMap<String, serde_yml::Value>> {
92    /// Same as `ObFile::from_string` with default properties type
93    ///
94    /// # Errors
95    /// - `Error::InvalidFormat` for malformed frontmatter
96    /// - `Error::Yaml` for invalid YAML syntax
97    fn from_string_default<P: AsRef<Path>>(text: &str, path: Option<P>) -> Result<Self, Error>;
98
99    /// Same as `ObFile::from_file` with default properties type
100    ///
101    /// # Errors
102    /// - `Error::Io` for filesystem errors
103    /// - `Error::FromUtf8` for non-UTF8 content
104    fn from_file_default<P: AsRef<Path>>(path: P) -> Result<Self, Error>;
105}
106
107impl<T> ObFileDefault for T
108where
109    T: ObFile<HashMap<String, serde_yml::Value>>,
110{
111    fn from_string_default<P: AsRef<Path>>(text: &str, path: Option<P>) -> Result<Self, Error> {
112        Self::from_string(text, path)
113    }
114
115    fn from_file_default<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
116        Self::from_file(path)
117    }
118}
119
120#[derive(Debug, PartialEq)]
121enum ResultParse<'a> {
122    WithProperties {
123        content: &'a str,
124        properties: &'a str,
125    },
126    WithoutProperties,
127}
128
129fn parse_obfile(raw_text: &str) -> Result<ResultParse, Error> {
130    let mut lines = raw_text.lines();
131    if lines.next().unwrap_or_default().trim_end() == "---" {
132        let closed = raw_text["---".len()..]
133            .find("---")
134            .ok_or(Error::InvalidFormat)?;
135
136        return Ok(ResultParse::WithProperties {
137            content: raw_text[(closed + 2 * "...".len())..].trim(),
138            properties: raw_text["...".len()..(closed + "...".len())].trim(),
139        });
140    }
141
142    Ok(ResultParse::WithoutProperties)
143}
144
145#[cfg(test)]
146mod tests {
147    use super::{ResultParse, parse_obfile};
148    use crate::test_utils::init_test_logger;
149
150    #[test]
151    fn parse_obfile_without_properties() {
152        init_test_logger();
153        let test_data = "test_data";
154        let result = parse_obfile(test_data).unwrap();
155
156        assert_eq!(result, ResultParse::WithoutProperties);
157    }
158
159    #[test]
160    fn parse_obfile_with_properties() {
161        init_test_logger();
162        let test_data = "---\nproperties data\n---\ntest data";
163        let result = parse_obfile(test_data).unwrap();
164
165        assert_eq!(
166            result,
167            ResultParse::WithProperties {
168                content: "test data",
169                properties: "properties data"
170            }
171        );
172    }
173
174    #[test]
175    fn parse_obfile_without_properties_but_with_closed() {
176        init_test_logger();
177        let test_data1 = "test_data---";
178        let test_data2 = "test_data\n---\n";
179
180        let result1 = parse_obfile(test_data1).unwrap();
181        let result2 = parse_obfile(test_data2).unwrap();
182
183        assert_eq!(result1, ResultParse::WithoutProperties);
184        assert_eq!(result2, ResultParse::WithoutProperties);
185    }
186
187    #[test]
188    #[should_panic]
189    fn parse_obfile_with_properties_but_without_closed() {
190        init_test_logger();
191        let test_data = "---\nproperties data\ntest data";
192        let _ = parse_obfile(test_data).unwrap();
193    }
194
195    #[test]
196    fn parse_obfile_without_properties_but_with_spaces() {
197        init_test_logger();
198        let test_data = "   ---\ndata";
199
200        let result = parse_obfile(test_data).unwrap();
201        assert_eq!(result, ResultParse::WithoutProperties);
202    }
203
204    #[test]
205    fn parse_obfile_with_properties_but_check_trim_end() {
206        init_test_logger();
207        let test_data = "---\r\nproperties data\r\n---\r   \ntest data";
208        let result = parse_obfile(test_data).unwrap();
209
210        assert_eq!(
211            result,
212            ResultParse::WithProperties {
213                content: "test data",
214                properties: "properties data"
215            }
216        );
217    }
218}
219
220#[cfg(test)]
221pub(crate) mod impl_tests {
222    use super::*;
223    use crate::test_utils::init_test_logger;
224    use serde::Deserialize;
225    use std::io::Write;
226    use tempfile::NamedTempFile;
227
228    pub(crate) static TEST_DATA: &str = "---\n\
229topic: life\n\
230created: 2025-03-16\n\
231---\n\
232Test data\n\
233---\n\
234Two test data";
235
236    #[derive(Debug, Deserialize, Default, PartialEq, Clone)]
237    pub(crate) struct TestProperties {
238        pub(crate) topic: String,
239        pub(crate) created: String,
240    }
241
242    pub(crate) fn from_string<T: ObFile>() -> Result<(), Error> {
243        init_test_logger();
244        let file = T::from_string(TEST_DATA, None::<&str>)?;
245        let properties = file.properties();
246
247        assert_eq!(properties["topic"], "life");
248        assert_eq!(properties["created"], "2025-03-16");
249        assert_eq!(file.content(), "Test data\n---\nTwo test data");
250        Ok(())
251    }
252
253    pub(crate) fn from_string_without_properties<T: ObFile>() -> Result<(), Error> {
254        init_test_logger();
255        let test_data = "TEST_DATA";
256        let file = T::from_string(test_data, None::<&str>)?;
257        let properties = file.properties();
258
259        assert_eq!(properties.len(), 0);
260        assert_eq!(file.content(), test_data);
261        Ok(())
262    }
263
264    pub(crate) fn from_string_with_invalid_yaml<T: ObFile>() -> Result<(), Error> {
265        init_test_logger();
266        let broken_data = "---\n\
267    asdfv:--fs\n\
268    sfsf\n\
269    ---\n\
270    TestData";
271
272        assert!(matches!(
273            T::from_string(broken_data, None::<&str>),
274            Err(Error::Yaml(_))
275        ));
276        Ok(())
277    }
278
279    pub(crate) fn from_string_invalid_format<T: ObFile>() -> Result<(), Error> {
280        init_test_logger();
281        let broken_data = "---\n";
282
283        assert!(matches!(
284            T::from_string(broken_data, None::<&str>),
285            Err(Error::InvalidFormat)
286        ));
287        Ok(())
288    }
289
290    pub(crate) fn from_string_with_unicode<T: ObFile>() -> Result<(), Error> {
291        init_test_logger();
292        let data = "---\ndata: 💩\n---\nSuper data 💩💩💩";
293        let file = T::from_string(data, None::<&str>)?;
294        let properties = file.properties();
295
296        assert_eq!(properties["data"], "💩");
297        assert_eq!(file.content(), "Super data 💩💩💩");
298        Ok(())
299    }
300
301    pub(crate) fn from_string_space_with_properties<T: ObFile>() -> Result<(), Error> {
302        init_test_logger();
303        let data = "  ---\ntest: test-data\n---\n";
304        let file = T::from_string(data, None::<&str>)?;
305        let properties = file.properties();
306
307        assert_eq!(file.content(), data);
308        assert_eq!(properties.len(), 0);
309        Ok(())
310    }
311
312    pub(crate) fn from_file<T: ObFile>() -> Result<(), Error> {
313        init_test_logger();
314        let mut temp_file = NamedTempFile::new().unwrap();
315        temp_file.write_all(b"TEST_DATA").unwrap();
316
317        let file = T::from_file(temp_file.path()).unwrap();
318        assert_eq!(file.content(), "TEST_DATA");
319        assert_eq!(file.path().unwrap(), temp_file.path());
320        assert_eq!(file.properties().len(), 0);
321        Ok(())
322    }
323
324    pub(crate) fn from_file_without_properties<T: ObFile>() -> Result<(), Error> {
325        init_test_logger();
326        let test_data = "TEST_DATA";
327        let mut test_file = NamedTempFile::new().unwrap();
328        test_file.write_all(test_data.as_bytes()).unwrap();
329
330        let file = T::from_file(test_file.path())?;
331        let properties = file.properties();
332
333        assert_eq!(properties.len(), 0);
334        assert_eq!(file.content(), test_data);
335        Ok(())
336    }
337
338    pub(crate) fn from_file_with_invalid_yaml<T: ObFile>() -> Result<(), Error> {
339        init_test_logger();
340        let broken_data = "---\n\
341    asdfv:--fs\n\
342    sfsf\n\
343    ---\n\
344    TestData";
345
346        let mut test_file = NamedTempFile::new().unwrap();
347        test_file.write_all(broken_data.as_bytes()).unwrap();
348
349        assert!(matches!(
350            T::from_file(test_file.path()),
351            Err(Error::Yaml(_))
352        ));
353        Ok(())
354    }
355
356    pub(crate) fn from_file_invalid_format<T: ObFile>() -> Result<(), Error> {
357        init_test_logger();
358        let broken_data = "---\n";
359        let mut test_file = NamedTempFile::new().unwrap();
360        test_file.write_all(broken_data.as_bytes()).unwrap();
361
362        assert!(matches!(
363            T::from_file(test_file.path()),
364            Err(Error::InvalidFormat)
365        ));
366        Ok(())
367    }
368
369    pub(crate) fn from_file_with_unicode<T: ObFile>() -> Result<(), Error> {
370        init_test_logger();
371        let data = "---\ndata: 💩\n---\nSuper data 💩💩💩";
372        let mut test_file = NamedTempFile::new().unwrap();
373        test_file.write_all(data.as_bytes()).unwrap();
374
375        let file = T::from_file(test_file.path())?;
376        let properties = file.properties();
377
378        assert_eq!(properties["data"], "💩");
379        assert_eq!(file.content(), "Super data 💩💩💩");
380        Ok(())
381    }
382
383    pub(crate) fn from_file_space_with_properties<T: ObFile>() -> Result<(), Error> {
384        init_test_logger();
385        let data = "  ---\ntest: test-data\n---\n";
386        let mut test_file = NamedTempFile::new().unwrap();
387        test_file.write_all(data.as_bytes()).unwrap();
388
389        let file = T::from_string(data, None::<&str>)?;
390        let properties = file.properties();
391
392        assert_eq!(file.content(), data);
393        assert_eq!(properties.len(), 0);
394        Ok(())
395    }
396
397    macro_rules! impl_test_for_obfile {
398        ($name_test:ident, $fn_test:ident, $impl_obfile:path) => {
399            #[test]
400            fn $name_test() {
401                $fn_test::<$impl_obfile>().unwrap();
402            }
403        };
404    }
405
406    pub(crate) use impl_test_for_obfile;
407
408    macro_rules! impl_all_tests_from_string {
409        ($impl_obfile:path) => {
410            #[allow(unused_imports)]
411            use crate::obfile::impl_tests::*;
412
413            impl_test_for_obfile!(impl_from_string, from_string, $impl_obfile);
414
415            impl_test_for_obfile!(
416                impl_from_string_without_properties,
417                from_string_without_properties,
418                $impl_obfile
419            );
420            impl_test_for_obfile!(
421                impl_from_string_with_invalid_yaml,
422                from_string_with_invalid_yaml,
423                $impl_obfile
424            );
425            impl_test_for_obfile!(
426                impl_from_string_invalid_format,
427                from_string_invalid_format,
428                $impl_obfile
429            );
430            impl_test_for_obfile!(
431                impl_from_string_with_unicode,
432                from_string_with_unicode,
433                $impl_obfile
434            );
435            impl_test_for_obfile!(
436                impl_from_string_space_with_properties,
437                from_string_space_with_properties,
438                $impl_obfile
439            );
440        };
441    }
442
443    macro_rules! impl_all_tests_from_file {
444        ($impl_obfile:path) => {
445            #[allow(unused_imports)]
446            use crate::obfile::impl_tests::*;
447
448            impl_test_for_obfile!(impl_from_file, from_file, $impl_obfile);
449
450            impl_test_for_obfile!(
451                impl_from_file_without_properties,
452                from_file_without_properties,
453                $impl_obfile
454            );
455            impl_test_for_obfile!(
456                impl_from_file_with_invalid_yaml,
457                from_file_with_invalid_yaml,
458                $impl_obfile
459            );
460            impl_test_for_obfile!(
461                impl_from_file_invalid_format,
462                from_file_invalid_format,
463                $impl_obfile
464            );
465            impl_test_for_obfile!(
466                impl_from_file_with_unicode,
467                from_file_with_unicode,
468                $impl_obfile
469            );
470            impl_test_for_obfile!(
471                impl_from_file_space_with_properties,
472                from_file_space_with_properties,
473                $impl_obfile
474            );
475        };
476    }
477
478    pub(crate) use impl_all_tests_from_file;
479    pub(crate) use impl_all_tests_from_string;
480}