obsidian_parser/note/
note_once_lock.rs

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