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}