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(¬e.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!(¬e.name, "Untitled");
191 /// assert_eq!(fs::exists(¬e.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(¬e.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}