basalt_core/obsidian/config.rs
1use dirs::{config_dir, home_dir};
2
3use serde::{Deserialize, Deserializer};
4use std::path::Path;
5use std::{collections::BTreeMap, fs, path::PathBuf};
6use std::{env, result};
7
8use crate::obsidian::{Error, Result, Vault};
9
10/// Represents the Obsidian configuration, typically loaded from an `obsidian.json` file.
11#[derive(Debug, Clone, Default, PartialEq)]
12pub struct ObsidianConfig {
13 /// A mapping of vault (folder) names to [`Vault`] definitions.
14 vaults: BTreeMap<String, Vault>,
15}
16
17impl ObsidianConfig {
18 /// Attempts to locate and load the system's `obsidian.json` file as an [`ObsidianConfig`].
19 ///
20 /// Returns an [`Error`] if the filepath doesn't exist or JSON parsing failed.
21 pub fn load() -> Result<Self> {
22 let config_locations = obsidian_global_config_locations();
23 let existing_config_locations = config_locations
24 .iter()
25 .filter(|path| path.is_dir())
26 .collect::<Vec<_>>();
27
28 if let Some(config_dir) = existing_config_locations.first() {
29 ObsidianConfig::load_from(config_dir)
30 } else {
31 Err(Error::PathNotFound(format!(
32 "Obsidian config directory was not found from these locations: {}",
33 config_locations
34 .iter()
35 .map(|path| path.to_string_lossy())
36 .collect::<Vec<_>>()
37 .join(", ")
38 )))
39 }
40 }
41
42 /// Attempts to load `obsidian.json` file as an [`ObsidianConfig`] from the given directory
43 /// [`Path`].
44 ///
45 /// Returns an [`Error`] if the filepath doesn't exist or JSON parsing failed.
46 ///
47 /// # Examples
48 ///
49 /// ```
50 /// use basalt_core::obsidian::ObsidianConfig;
51 /// use std::path::Path;
52 ///
53 /// _ = ObsidianConfig::load_from(Path::new("./dir-with-config-file"));
54 /// ```
55 pub fn load_from(config_path: &Path) -> Result<Self> {
56 let obsidian_json_path = config_path.join("obsidian.json");
57
58 if obsidian_json_path.try_exists()? {
59 let contents = fs::read_to_string(obsidian_json_path)?;
60 serde_json::from_str(&contents).map_err(Error::Json)
61 } else {
62 // TODO: Maybe a different error should be propagated in this case. E.g. 'unreadable'
63 // file.
64 Err(Error::PathNotFound(
65 obsidian_json_path.to_string_lossy().to_string(),
66 ))
67 }
68 }
69
70 /// Returns an iterator over the vaults in the configuration.
71 ///
72 /// # Examples
73 ///
74 /// ```
75 /// use basalt_core::obsidian::{ObsidianConfig, Vault};
76 ///
77 /// let config = ObsidianConfig::from([
78 /// ("Obsidian", Vault::default()),
79 /// ("Work", Vault::default()),
80 /// ]);
81 ///
82 /// let vaults = config.vaults();
83 ///
84 /// assert_eq!(vaults.len(), 2);
85 /// assert_eq!(vaults.get(0), Some(&Vault::default()).as_ref());
86 /// ```
87 pub fn vaults(&self) -> Vec<&Vault> {
88 self.vaults.values().collect()
89 }
90
91 /// Finds a vault by name, returning a reference if it exists.
92 ///
93 /// # Examples
94 ///
95 /// ```
96 /// use basalt_core::obsidian::{ObsidianConfig, Vault};
97 ///
98 /// let config = ObsidianConfig::from([
99 /// ("Obsidian", Vault::default()),
100 /// ("Work", Vault::default()),
101 /// ]);
102 ///
103 /// _ = config.get_vault_by_name("Obsidian");
104 /// ```
105 pub fn get_vault_by_name(&self, name: &str) -> Option<&Vault> {
106 self.vaults.get(name)
107 }
108
109 /// Gets the currently opened vault marked by Obsidian.
110 ///
111 /// # Examples
112 ///
113 /// ```
114 /// use basalt_core::obsidian::{ObsidianConfig, Vault};
115 ///
116 /// let config = ObsidianConfig::from([
117 /// (
118 /// "Obsidian",
119 /// Vault {
120 /// open: true,
121 /// ..Vault::default()
122 /// },
123 /// ),
124 /// ("Work", Vault::default()),
125 /// ]);
126 ///
127 /// _ = config.get_open_vault();
128 /// ```
129 pub fn get_open_vault(&self) -> Option<&Vault> {
130 self.vaults.values().find(|vault| vault.open)
131 }
132}
133
134impl<const N: usize> From<[(&str, Vault); N]> for ObsidianConfig {
135 /// # Examples
136 ///
137 /// ```
138 /// use basalt_core::obsidian::{ObsidianConfig, Vault};
139 ///
140 /// let config_1 = ObsidianConfig::from([
141 /// ("Obsidian", Vault::default()),
142 /// ("My Vault", Vault::default()),
143 /// ]);
144 ///
145 /// let config_2: ObsidianConfig = [
146 /// ("Obsidian", Vault::default()),
147 /// ("My Vault", Vault::default()),
148 /// ].into();
149 ///
150 /// assert_eq!(config_1, config_2);
151 /// ```
152 fn from(arr: [(&str, Vault); N]) -> Self {
153 Self {
154 vaults: BTreeMap::from(arr.map(|(name, vault)| (name.to_owned(), vault))),
155 }
156 }
157}
158
159impl<const N: usize> From<[(String, Vault); N]> for ObsidianConfig {
160 /// # Examples
161 ///
162 /// ```
163 /// use basalt_core::obsidian::{ObsidianConfig, Vault};
164 ///
165 /// let config_1 = ObsidianConfig::from([
166 /// (String::from("Obsidian"), Vault::default()),
167 /// (String::from("My Vault"), Vault::default()),
168 /// ]);
169 ///
170 /// let config_2: ObsidianConfig = [
171 /// (String::from("Obsidian"), Vault::default()),
172 /// (String::from("My Vault"), Vault::default()),
173 /// ].into();
174 ///
175 /// assert_eq!(config_1, config_2);
176 /// ```
177 fn from(arr: [(String, Vault); N]) -> Self {
178 Self {
179 vaults: BTreeMap::from(arr),
180 }
181 }
182}
183
184impl<'de> Deserialize<'de> for ObsidianConfig {
185 fn deserialize<D>(deserializer: D) -> result::Result<Self, D::Error>
186 where
187 D: Deserializer<'de>,
188 {
189 #[derive(Deserialize)]
190 struct Json {
191 vaults: BTreeMap<String, Vault>,
192 }
193
194 impl From<Json> for ObsidianConfig {
195 fn from(value: Json) -> Self {
196 ObsidianConfig {
197 vaults: value
198 .vaults
199 .into_values()
200 .map(|vault| (vault.name.clone(), vault))
201 .collect(),
202 }
203 }
204 }
205
206 let deserialized: Json = Deserialize::deserialize(deserializer)?;
207 Ok(deserialized.into())
208 }
209}
210
211/// Returns all existing configuration directory paths where Obsidian might store its global
212/// settings.
213///
214/// This function determines possible configuration locations by platform-specific conventions and
215/// installation methods. On all platforms, it first checks if the user has defined the
216/// `OBSIDIAN_CONFIG_DIR` environment variable. If so, that path is used, and any leading tilde (~)
217/// is expanded to the current user's home directory.
218///
219/// On Windows, it then resolves to the default Obsidian directory located under the system's
220/// application data folder, typically `%APPDATA%\Obsidian`. On macOS, the function expects to find
221/// the configuration under `~/Library/Application Support/obsidian`. On Linux, the standard config
222/// directory is assumed to be `$XDG_CONFIG_HOME/obsidian`, or `~/.config/obsidian`.
223///
224/// For Linux users, the function also accounts for sandboxed installations. If Obsidian is
225/// installed via Flatpak, the configuration is likely found in
226/// `~/.var/app/md.obsidian.Obsidian/config/obsidian`. For Snap installations, the relevant path is
227/// typically `~/snap/obsidian/common/.config/obsidian`.
228///
229/// For reference:
230/// - macOS: `/Users/username/Library/Application Support/obsidian`
231/// - Windows: `%APPDATA%\Obsidian\`
232/// - Linux: `$XDG_CONFIG_HOME/obsidian` or `~/.config/obsidian`
233/// flatpak: `$HOME/.var/app/md.obsidian.Obsidian/config/obsidian`
234/// snap: `$HOME/snap/obsidian/common/.config/obsidian`
235///
236/// More info: [https://help.obsidian.md/Files+and+folders/How+Obsidian+stores+data]
237pub fn obsidian_global_config_locations() -> Vec<PathBuf> {
238 #[cfg(any(target_os = "macos", target_os = "linux"))]
239 const OBSIDIAN_CONFIG_DIR_NAME: &str = "obsidian";
240
241 #[cfg(target_os = "windows")]
242 const OBSIDIAN_CONFIG_DIR_NAME: &str = "Obsidian";
243
244 let override_path =
245 env::var("OBSIDIAN_CONFIG_DIR")
246 .ok()
247 .zip(home_dir())
248 .map(|(path, home_dir)| {
249 PathBuf::from(path.replace("~", home_dir.to_string_lossy().as_ref()))
250 });
251
252 let default_config_path =
253 config_dir().map(|config_path| config_path.join(OBSIDIAN_CONFIG_DIR_NAME));
254
255 #[cfg(any(target_os = "macos", target_os = "windows"))]
256 let sandboxed_paths: [Option<PathBuf>; 0] = [];
257
258 // In cases where user has a sandboxes instance of Obsidian installed under either flatpak or
259 // snap, we must check if the configuration exists under these locations.
260 #[cfg(target_os = "linux")]
261 let sandboxed_paths = {
262 let flatpak_path = home_dir().map(|home_dir| {
263 home_dir
264 .join(".var/app/md.obsidian.Obsidian/config")
265 .join(OBSIDIAN_CONFIG_DIR_NAME)
266 });
267
268 let snap_path = home_dir().map(|home_dir| {
269 home_dir
270 .join("snap/obsidian/common/.config")
271 .join(OBSIDIAN_CONFIG_DIR_NAME)
272 });
273
274 [flatpak_path, snap_path]
275 };
276
277 let base_paths = [override_path, default_config_path];
278
279 base_paths
280 .into_iter()
281 .chain(sandboxed_paths)
282 .flatten()
283 .collect()
284}