basalt_core/obsidian/
vault.rs

1use std::{fs, ops::ControlFlow, path::PathBuf, result};
2
3use serde::{Deserialize, Deserializer};
4
5use crate::obsidian::{vault_entry::VaultEntry, Error, Note};
6
7/// Represents a single Obsidian vault.
8///
9/// A vault is a folder containing notes and other metadata.
10#[derive(Debug, Clone, Default, PartialEq)]
11pub struct Vault {
12    /// The name of the vault, inferred from its directory name.
13    pub name: String,
14
15    /// Filesystem path to the vault's directory.
16    pub path: PathBuf,
17
18    /// Whether the vault is marked 'open' by Obsidian.
19    pub open: bool,
20
21    /// Timestamp of last update or creation.
22    pub ts: u64,
23}
24
25impl Vault {
26    /// Returns a [`Vec`] of Markdown vault entries in this vault as [`VaultEntry`] structs.
27    /// Entries can be either directories or files (notes). If the directory is marked hidden with
28    /// a dot (`.`) prefix it will be filtered out from the resulting [`Vec`].
29    ///
30    /// The returned entries are not sorted.
31    ///
32    /// # Examples
33    ///
34    /// ```
35    /// use basalt_core::obsidian::{Vault, Note};
36    ///
37    /// let vault = Vault {
38    ///     name: "MyVault".into(),
39    ///     path: "path/to/my_vault".into(),
40    ///     ..Default::default()
41    /// };
42    ///
43    /// assert_eq!(vault.entries(), vec![]);
44    /// ```
45    pub fn entries(&self) -> Vec<VaultEntry> {
46        match self.path.as_path().try_into() {
47            Ok(VaultEntry::Directory { entries, .. }) => entries
48                .into_iter()
49                .filter(|entry| !entry.name().starts_with('.'))
50                .collect(),
51            _ => vec![],
52        }
53    }
54
55    /// Creates a new empty note with the provided name.
56    ///
57    /// If a note with the given name already exists, a numbered suffix will be appended
58    /// (e.g., "Note 1", "Note 2", etc.) to find an available name.
59    ///
60    /// # Errors
61    ///
62    /// Returns an error if:
63    /// - I/O operations fail (directory creation, file writing, or path checks)
64    /// - No available name is found after 999 attempts ([`Error::MaxAttemptsExceeded`])
65    ///
66    /// # Examples
67    ///
68    /// ```
69    /// use std::fs;
70    /// use tempfile::tempdir;
71    /// use basalt_core::obsidian::{Vault, Note, Error};
72    ///
73    /// let tmp_dir = tempdir()?;
74    ///
75    /// let vault = Vault {
76    ///   path: tmp_dir.path().to_path_buf(),
77    ///   ..Default::default()
78    /// };
79    ///
80    /// let note = vault.create_note("Arbitrary Name")?;
81    /// assert_eq!(fs::exists(&note.path)?, true);
82    ///
83    /// # Ok::<(), Error>(())
84    /// ```
85    pub fn create_note(&self, name: &str) -> result::Result<Note, Error> {
86        let base_path = self.path.join(name).with_extension("md");
87        if let Some(parent_dir) = base_path.parent() {
88            // Create necessary directory structures if we pass dir separated name like
89            // /vault/notes/sub-notes/name.md
90            fs::create_dir_all(parent_dir)?;
91        }
92
93        let (name, path) = self.find_available_note_name(name)?;
94
95        fs::write(&path, "")?;
96
97        Ok(Note {
98            name: name.to_string(),
99            path,
100        })
101    }
102
103    /// Find available note name by incrementing number suffix at the end.
104    ///
105    /// Increments until we find a 'free' name e.g. if "Untitled 1" exists we will
106    /// try next "Untitled 2", and then "Untitled 3" and so on.
107    ///
108    /// # Errors
109    ///
110    /// Returns [`Error::MaxAttemptsExceeded`] if no available name is found after 999 attempts.
111    ///
112    /// # Examples
113    ///
114    /// ```
115    /// use std::fs;
116    /// use tempfile::tempdir;
117    /// use basalt_core::obsidian::{Vault, Note, Error};
118    ///
119    /// let tmp_dir = tempdir()?;
120    /// let tmp_path = tmp_dir.path();
121    ///
122    /// let vault = Vault {
123    ///   path: tmp_path.to_path_buf(),
124    ///   ..Default::default()
125    /// };
126    ///
127    /// let note_name = "Arbitrary Name";
128    /// fs::write(tmp_path.join(note_name).with_extension("md"), "")?;
129    ///
130    /// let (name, path) = vault.find_available_note_name(note_name)?;
131    /// assert_eq!(&name, "Arbitrary Name 1");
132    /// assert_eq!(fs::exists(&path)?, false);
133    ///
134    /// # Ok::<(), Error>(())
135    /// ```
136    pub fn find_available_note_name(&self, name: &str) -> result::Result<(String, PathBuf), Error> {
137        let path = self.path.join(name).with_extension("md");
138        if !fs::exists(&path)? {
139            return Ok((name.to_string(), path));
140        }
141
142        // Maximum number of iterations
143        const MAX: usize = 999;
144
145        let candidate = (1..=MAX)
146            .map(|n| format!("{name} {n}"))
147            .try_fold((), |_, name| {
148                let path = self.path.join(&name).with_extension("md");
149                match fs::exists(&path).map_err(Error::from) {
150                    Ok(false) => ControlFlow::Break(Ok((name, path))),
151                    Err(e) => ControlFlow::Break(Err(e)),
152                    _ => ControlFlow::Continue(()),
153                }
154            });
155
156        match candidate {
157            ControlFlow::Break(r) => r,
158            ControlFlow::Continue(..) => Err(Error::MaxAttemptsExceeded {
159                name: name.to_string(),
160                max_attempts: MAX,
161            }),
162        }
163    }
164
165    /// Creates a new empty note with name "Untitled" or "Untitled {n}".
166    ///
167    /// This is a convenience method that calls [`Vault::create_note`] with "Untitled" as the name.
168    ///
169    /// # Errors
170    ///
171    /// Returns an error if:
172    /// - I/O operations fail (file writing or path checks)
173    /// - No available name is found after 999 attempts ([`Error::MaxAttemptsExceeded`])
174    ///
175    /// # Examples
176    ///
177    /// ```
178    /// use std::{fs, result};
179    /// use tempfile::tempdir;
180    /// use basalt_core::obsidian::{Vault, Note, Error};
181    ///
182    /// let tmp_dir = tempdir()?;
183    ///
184    /// let vault = Vault {
185    ///   path: tmp_dir.path().to_path_buf(),
186    ///   ..Default::default()
187    /// };
188    ///
189    /// let note = vault.create_untitled_note()?;
190    /// assert_eq!(&note.name, "Untitled");
191    /// assert_eq!(fs::exists(&note.path)?, true);
192    ///
193    /// (1..=100).try_for_each(|n| -> result::Result<(), Error> {
194    ///   let note = vault.create_untitled_note()?;
195    ///   assert_eq!(note.name, format!("Untitled {n}"));
196    ///   assert_eq!(fs::exists(&note.path)?, true);
197    ///   Ok(())
198    /// })?;
199    ///
200    /// # Ok::<(), Error>(())
201    /// ```
202    pub fn create_untitled_note(&self) -> result::Result<Note, Error> {
203        self.create_note("Untitled")
204    }
205}
206
207impl<'de> Deserialize<'de> for Vault {
208    fn deserialize<D>(deserializer: D) -> result::Result<Self, D::Error>
209    where
210        D: Deserializer<'de>,
211    {
212        #[derive(Deserialize)]
213        struct Json {
214            path: PathBuf,
215            open: Option<bool>,
216            ts: u64,
217        }
218
219        impl TryFrom<Json> for Vault {
220            type Error = String;
221            fn try_from(Json { path, open, ts }: Json) -> result::Result<Self, Self::Error> {
222                let name = path
223                    .file_name()
224                    .map(|file_name| file_name.to_string_lossy().to_string())
225                    .ok_or("unable to retrieve vault name")?;
226
227                Ok(Vault {
228                    name,
229                    path,
230                    open: open.unwrap_or(false),
231                    ts,
232                })
233            }
234        }
235
236        let deserialized: Json = Deserialize::deserialize(deserializer)?;
237        deserialized.try_into().map_err(serde::de::Error::custom)
238    }
239}