obsidian_parser/note/
note_in_memory.rs

1//! In-memory representation of an Obsidian note file
2
3use super::{DefaultProperties, Note, NoteFromReader, NoteFromString};
4use crate::note::parser::{self, ResultParse, parse_note};
5use serde::de::DeserializeOwned;
6use std::{
7    borrow::Cow,
8    fs::File,
9    path::{Path, PathBuf},
10};
11use thiserror::Error;
12
13/// In-memory representation of an Obsidian note file
14///
15/// This struct provides full access to parsed note content, properties, and path.
16/// It stores the entire file contents in memory, making it suitable for:
17/// - Frequent access to note content
18/// - Transformation or analysis workflows
19/// - Environments with fast storage (SSD/RAM disks)
20///
21/// # Performance Considerations
22/// - Uses ~2x memory of original file size (UTF-8 + deserialized properties)
23/// - Preferred for small-to-medium vaults (<10k notes)
24///
25/// For large vaults or read-heavy workflows, consider [`NoteOnDisk`].
26///
27/// [`NoteOnDisk`]: crate::note::note_on_disk::NoteOnDisk
28#[derive(Debug, Default, PartialEq, Eq, Clone)]
29pub struct NoteInMemory<T = DefaultProperties>
30where
31    T: Clone,
32{
33    /// Markdown content body (without frontmatter)
34    content: String,
35
36    /// Source file path (if loaded from disk)
37    path: Option<PathBuf>,
38
39    /// Parsed frontmatter properties
40    properties: Option<T>,
41}
42
43/// Errors in [`NoteInMemory`]
44#[derive(Debug, Error)]
45pub enum Error {
46    /// I/O operation failed (file reading, directory traversal, etc.)
47    #[error("IO error: {0}")]
48    IO(#[from] std::io::Error),
49
50    /// Invalid frontmatter format detected
51    ///
52    /// Occurs when:
53    /// - Frontmatter delimiters are incomplete (`---` missing)
54    /// - Content between delimiters is empty
55    ///
56    /// # Example
57    /// Parsing a file with malformed frontmatter:
58    /// ```text
59    /// ---
60    /// incomplete yaml
61    /// // Missing closing ---
62    /// ```
63    #[error("Invalid frontmatter format")]
64    InvalidFormat(#[from] parser::Error),
65
66    /// YAML parsing error in frontmatter properties
67    ///
68    /// # Example
69    /// Parsing invalid YAML syntax:
70    /// ```text
71    /// ---
72    /// key: @invalid_value
73    /// ---
74    /// ```
75    #[error("YAML parsing error: {0}")]
76    Yaml(#[from] serde_yml::Error),
77}
78
79impl<T> Note for NoteInMemory<T>
80where
81    T: Clone,
82{
83    type Properties = T;
84    type Error = self::Error;
85
86    /// Get [`Self::Properties`]
87    #[inline]
88    fn properties(&self) -> Result<Option<Cow<'_, T>>, Self::Error> {
89        Ok(self.properties.as_ref().map(|p| Cow::Borrowed(p)))
90    }
91
92    /// Get contents
93    #[inline]
94    fn content(&self) -> Result<Cow<'_, str>, Self::Error> {
95        Ok(Cow::Borrowed(&self.content))
96    }
97
98    /// Get path to file
99    #[inline]
100    fn path(&self) -> Option<Cow<'_, Path>> {
101        self.path.as_ref().map(|p| Cow::Borrowed(p.as_path()))
102    }
103}
104
105impl<T> NoteInMemory<T>
106where
107    T: Clone,
108{
109    /// Set path to note
110    #[inline]
111    pub fn set_path(&mut self, path: Option<PathBuf>) {
112        self.path = path;
113    }
114}
115
116impl<T> NoteFromString for NoteInMemory<T>
117where
118    T: DeserializeOwned + Clone,
119{
120    /// Parses a string into an in-memory Obsidian note representation
121    ///
122    /// # Arguments
123    /// * `raw_text` - Full note text including optional frontmatter
124    /// * `path` - Optional source path for reference
125    ///
126    /// # Process
127    /// 1. Splits text into frontmatter/content sections
128    /// 2. Parses YAML frontmatter if present
129    /// 3. Stores content without frontmatter delimiters
130    ///
131    /// # Errors
132    /// - [`Error::InvalidFormat`] for malformed frontmatter
133    /// - [`Error::Yaml`] for invalid YAML syntax
134    ///
135    /// # Example
136    /// ```rust
137    /// use obsidian_parser::prelude::*;
138    /// use serde::Deserialize;
139    ///
140    /// #[derive(Deserialize, Clone, Default)]
141    /// struct NoteProperties {
142    ///     title: String
143    /// }
144    ///
145    /// let text = r#"---
146    /// title: Example
147    /// ---
148    /// Content"#;
149    ///
150    /// let note: NoteInMemory<NoteProperties> = NoteInMemory::from_string(text).unwrap();
151    /// let properties = note.properties().unwrap().unwrap();
152    ///
153    /// assert_eq!(properties.title, "Example");
154    /// assert_eq!(note.content().unwrap(), "Content");
155    /// ```
156    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
157    fn from_string(raw_text: impl AsRef<str>) -> Result<Self, Self::Error> {
158        let raw_text = raw_text.as_ref();
159
160        #[cfg(feature = "tracing")]
161        tracing::trace!("Parsing in-memory note");
162
163        match parse_note(raw_text)? {
164            ResultParse::WithProperties {
165                content,
166                properties,
167            } => {
168                #[cfg(feature = "tracing")]
169                tracing::trace!("Frontmatter detected, parsing properties");
170
171                Ok(Self {
172                    content: content.to_string(),
173                    properties: Some(serde_yml::from_str(properties)?),
174                    path: None,
175                })
176            }
177            ResultParse::WithoutProperties => {
178                #[cfg(feature = "tracing")]
179                tracing::trace!("No frontmatter found, storing raw content");
180
181                Ok(Self {
182                    content: raw_text.to_string(),
183                    path: None,
184                    properties: None,
185                })
186            }
187        }
188    }
189}
190
191#[cfg(not(target_family = "wasm"))]
192impl<T> crate::prelude::NoteFromFile for NoteInMemory<T>
193where
194    T: DeserializeOwned + Clone,
195{
196    #[cfg_attr(feature = "tracing", tracing::instrument(skip_all, fields(path = %path.as_ref().display())))]
197    fn from_file(path: impl AsRef<Path>) -> Result<Self, Self::Error> {
198        let path_buf = path.as_ref().to_path_buf();
199
200        #[cfg(feature = "tracing")]
201        tracing::trace!("Parse obsidian file from file");
202
203        let mut file = File::open(&path_buf)?;
204        let mut note = Self::from_reader(&mut file)?;
205        note.set_path(Some(path_buf));
206
207        Ok(note)
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::note::{
215        note_aliases::tests::impl_all_tests_aliases,
216        note_is_todo::tests::impl_all_tests_is_todo,
217        note_read::tests::{
218            impl_all_tests_from_file, impl_all_tests_from_reader, impl_all_tests_from_string,
219        },
220        note_write::tests::impl_all_tests_flush,
221    };
222
223    impl_all_tests_from_reader!(NoteInMemory);
224    impl_all_tests_from_string!(NoteInMemory);
225    impl_all_tests_from_file!(NoteInMemory);
226    impl_all_tests_flush!(NoteInMemory);
227    impl_all_tests_is_todo!(NoteInMemory);
228    impl_all_tests_aliases!(NoteInMemory);
229}