obsidian_parser/note/
note_once_cell.rs

1//! On-disk representation of an Obsidian note file with cache
2//!
3//! # Warning
4//! **It is not thread-safe!**
5//! Use [`NoteOnceLock`]
6//!
7//! [`NoteOnceLock`]: crate::note::note_once_lock::NoteOnceLock
8
9use crate::note::parser::{self, ResultParse, parse_note};
10use crate::note::{DefaultProperties, Note};
11use serde::de::DeserializeOwned;
12use std::borrow::Cow;
13use std::cell::OnceCell;
14use std::path::Path;
15use std::path::PathBuf;
16use thiserror::Error;
17
18/// On-disk representation of an Obsidian note file with cache
19///
20/// # Warning
21/// **It is not thread-safe!**
22/// Use [`NoteOnceLock`]
23///
24/// [`NoteOnceLock`]: crate::note::note_once_lock::NoteOnceLock
25#[derive(Debug, Default, PartialEq, Eq, Clone)]
26pub struct NoteOnceCell<T = DefaultProperties>
27where
28    T: Clone + DeserializeOwned,
29{
30    /// Absolute path to the source Markdown file
31    path: PathBuf,
32
33    /// Markdown content body (without frontmatter)
34    content: OnceCell<String>,
35
36    /// Parsed frontmatter properties
37    properties: OnceCell<Option<T>>,
38}
39
40/// Errors for [`NoteOnceCell`]
41#[derive(Debug, Error)]
42pub enum Error {
43    /// I/O operation failed (file reading, directory traversal, etc.)
44    #[error("IO error: {0}")]
45    IO(#[from] std::io::Error),
46
47    /// Invalid frontmatter format detected
48    ///
49    /// Occurs when:
50    /// - Frontmatter delimiters are incomplete (`---` missing)
51    /// - Content between delimiters is empty
52    ///
53    /// # Example
54    /// Parsing a file with malformed frontmatter:
55    /// ```text
56    /// ---
57    /// incomplete yaml
58    /// // Missing closing ---
59    /// ```
60    #[error("Invalid frontmatter format")]
61    InvalidFormat(#[from] parser::Error),
62
63    /// YAML parsing error in frontmatter properties
64    ///
65    /// # Example
66    /// Parsing invalid YAML syntax:
67    /// ```text
68    /// ---
69    /// key: @invalid_value
70    /// ---
71    /// ```
72    #[error("YAML parsing error: {0}")]
73    Yaml(#[from] serde_yml::Error),
74
75    /// Expected a file path
76    ///
77    /// # Example
78    /// ```no_run
79    /// use obsidian_parser::prelude::*;
80    ///
81    /// // Will fail if passed a directory path
82    /// NoteOnDisk::from_file_default("/home/test");
83    /// ```
84    #[error("Path: `{0}` is not a directory")]
85    IsNotFile(PathBuf),
86}
87
88impl<T> Note for NoteOnceCell<T>
89where
90    T: DeserializeOwned + Clone,
91{
92    type Properties = T;
93    type Error = self::Error;
94
95    /// Parses YAML frontmatter directly from disk
96    ///
97    /// # Errors
98    /// - [`Error::Yaml`] if properties can't be deserialized
99    /// - [`Error::IsNotFile`] If file doesn't exist
100    /// - [`Error::IO`] on filesystem error
101    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(path = %self.path.display())))]
102    fn properties(&self) -> Result<Option<Cow<'_, T>>, Error> {
103        #[cfg(feature = "tracing")]
104        tracing::trace!("Get properties from file");
105
106        if let Some(properties) = self.properties.get() {
107            return Ok(properties.as_ref().map(|value| Cow::Borrowed(value)));
108        }
109
110        let data = std::fs::read(&self.path)?;
111
112        // SAFETY: Notes files in Obsidian (`*.md`) ensure that the file is encoded in UTF-8
113        let raw_text = unsafe { String::from_utf8_unchecked(data) };
114
115        let result = match parse_note(&raw_text)? {
116            ResultParse::WithProperties {
117                content: _,
118                properties,
119            } => {
120                #[cfg(feature = "tracing")]
121                tracing::trace!("Frontmatter detected, parsing properties");
122
123                Some(serde_yml::from_str(properties)?)
124            }
125            ResultParse::WithoutProperties => {
126                #[cfg(feature = "tracing")]
127                tracing::trace!("No frontmatter found, storing raw content");
128
129                None
130            }
131        };
132
133        let _ = self.properties.set(result.clone()); // already check
134        Ok(result.map(|value| Cow::Owned(value)))
135    }
136
137    /// Returns the note's content body (without frontmatter)
138    ///
139    /// # Errors
140    /// - [`Error::IO`] on filesystem error
141    ///
142    /// # Performance
143    /// Performs disk read on every call. Suitable for:
144    /// - Single-pass processing (link extraction, analysis)
145    /// - Large files where in-memory storage is prohibitive
146    ///
147    /// For repeated access, consider caching or [`NoteInMemory`](crate::note::note_in_memory::NoteInMemory).
148    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(path = %self.path.display())))]
149    fn content(&self) -> Result<Cow<'_, str>, Error> {
150        #[cfg(feature = "tracing")]
151        tracing::trace!("Get content from file");
152
153        if let Some(content) = self.content.get() {
154            return Ok(Cow::Borrowed(content));
155        }
156
157        let data = std::fs::read(&self.path)?;
158
159        // SAFETY: Notes files in Obsidian (`*.md`) ensure that the file is encoded in UTF-8
160        let raw_text = unsafe { String::from_utf8_unchecked(data) };
161
162        let result = match parse_note(&raw_text)? {
163            ResultParse::WithProperties {
164                content,
165                properties: _,
166            } => {
167                #[cfg(feature = "tracing")]
168                tracing::trace!("Frontmatter detected, parsing properties");
169
170                content.to_string()
171            }
172            ResultParse::WithoutProperties => {
173                #[cfg(feature = "tracing")]
174                tracing::trace!("No frontmatter found, storing raw content");
175
176                raw_text
177            }
178        };
179
180        let _ = self.content.set(result.clone()); // already check
181        Ok(Cow::Owned(result))
182    }
183
184    /// Get path to note
185    #[inline]
186    fn path(&self) -> Option<Cow<'_, Path>> {
187        Some(Cow::Borrowed(&self.path))
188    }
189}
190
191impl<T> NoteOnceCell<T>
192where
193    T: DeserializeOwned + Clone,
194{
195    /// Set path to note
196    #[inline]
197    pub fn set_path(&mut self, path: PathBuf) {
198        self.path = path;
199    }
200}
201
202#[cfg(not(target_family = "wasm"))]
203impl<T> crate::prelude::NoteFromFile for NoteOnceCell<T>
204where
205    T: DeserializeOwned + Clone,
206{
207    /// Creates instance from file
208    fn from_file(path: impl AsRef<Path>) -> Result<Self, Error> {
209        let path = path.as_ref().to_path_buf();
210
211        if !path.is_file() {
212            return Err(Error::IsNotFile(path));
213        }
214
215        Ok(Self {
216            path,
217            content: OnceCell::default(),
218            properties: OnceCell::default(),
219        })
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::note::NoteDefault;
227    use crate::note::impl_tests::impl_test_for_note;
228    use crate::note::note_aliases::tests::{from_file_have_aliases, from_file_have_not_aliases};
229    use crate::note::note_is_todo::tests::{from_file_is_not_todo, from_file_is_todo};
230    use crate::note::note_read::tests::{from_file, from_file_with_unicode};
231    use crate::note::note_write::tests::impl_all_tests_flush;
232    use std::io::Write;
233    use tempfile::NamedTempFile;
234
235    impl_all_tests_flush!(NoteOnceCell);
236    impl_test_for_note!(impl_from_file, from_file, NoteOnceCell);
237
238    impl_test_for_note!(
239        impl_from_file_with_unicode,
240        from_file_with_unicode,
241        NoteOnceCell
242    );
243
244    impl_test_for_note!(impl_from_file_is_todo, from_file_is_todo, NoteOnceCell);
245    impl_test_for_note!(
246        impl_from_file_is_not_todo,
247        from_file_is_not_todo,
248        NoteOnceCell
249    );
250
251    impl_test_for_note!(
252        impl_from_file_have_aliases,
253        from_file_have_aliases,
254        NoteOnceCell
255    );
256    impl_test_for_note!(
257        impl_from_file_have_not_aliases,
258        from_file_have_not_aliases,
259        NoteOnceCell
260    );
261
262    #[cfg_attr(feature = "tracing", tracing_test::traced_test)]
263    #[test]
264    #[should_panic]
265    fn use_from_file_with_path_not_file() {
266        let temp_dir = tempfile::tempdir().unwrap();
267
268        NoteOnceCell::from_file_default(temp_dir.path()).unwrap();
269    }
270
271    #[cfg_attr(feature = "tracing", tracing_test::traced_test)]
272    #[test]
273    fn get_path() {
274        let test_file = NamedTempFile::new().unwrap();
275        let file = NoteOnceCell::from_file_default(test_file.path()).unwrap();
276
277        assert_eq!(file.path().unwrap(), test_file.path());
278        assert_eq!(file.path, test_file.path());
279    }
280
281    #[cfg_attr(feature = "tracing", tracing_test::traced_test)]
282    #[test]
283    fn get_content() {
284        let test_data = "DATA";
285        let mut test_file = NamedTempFile::new().unwrap();
286        test_file.write_all(test_data.as_bytes()).unwrap();
287
288        let file = NoteOnceCell::from_file_default(test_file.path()).unwrap();
289        assert_eq!(file.content().unwrap(), test_data);
290    }
291
292    #[cfg_attr(feature = "tracing", tracing_test::traced_test)]
293    #[test]
294    fn get_properties() {
295        let test_data = "---\ntime: now\n---\nDATA";
296        let mut test_file = NamedTempFile::new().unwrap();
297        test_file.write_all(test_data.as_bytes()).unwrap();
298
299        let file = NoteOnceCell::from_file_default(test_file.path()).unwrap();
300        let properties = file.properties().unwrap().unwrap();
301
302        assert_eq!(file.content().unwrap(), "DATA");
303        assert_eq!(properties["time"], "now");
304    }
305}