Skip to main content

basalt_core/obsidian/
config.rs

1//! This module provides functionality operating with Obsidian config.
2use dirs::{config_dir, home_dir};
3
4use serde::{Deserialize, Deserializer};
5use std::path::Path;
6use std::{collections::BTreeMap, fs, path::PathBuf};
7use std::{env, result};
8
9use crate::obsidian::{Error, Result, Vault};
10
11/// Represents the Obsidian configuration, typically loaded from an `obsidian.json` file.
12#[derive(Debug, Clone, Default, PartialEq)]
13pub struct ObsidianConfig {
14    /// A mapping of vault (folder) names to [`Vault`] definitions.
15    vaults: BTreeMap<String, Vault>,
16}
17
18/// Attempts to locate and load the system's `obsidian.json` file as an [`ObsidianConfig`].
19///
20/// Returns an [`Error`] if the file path doesn't exist or JSON parsing failed.
21pub fn load() -> Result<ObsidianConfig> {
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        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 file path doesn't exist or JSON parsing failed.
46///
47/// # Examples
48///
49/// ```
50/// use std::path::Path;
51/// use basalt_core::obsidian;
52///
53/// _ = obsidian::config::load_from(Path::new("./dir-with-config-file"));
54/// ```
55pub fn load_from(config_path: &Path) -> Result<ObsidianConfig> {
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
70impl ObsidianConfig {
71    /// Returns a vec of vaults in the configuration.
72    ///
73    /// # Examples
74    ///
75    /// ```
76    /// use basalt_core::obsidian::{ObsidianConfig, Vault};
77    ///
78    /// let config = ObsidianConfig::from([
79    ///     ("Obsidian", Vault::default()),
80    ///     ("Work", Vault::default()),
81    /// ]);
82    ///
83    /// let vaults = config.vaults();
84    ///
85    /// assert_eq!(vaults.len(), 2);
86    /// assert_eq!(vaults.get(0), Some(&Vault::default()).as_ref());
87    /// ```
88    pub fn vaults(&self) -> Vec<&Vault> {
89        self.vaults.values().collect()
90    }
91}
92
93impl<const N: usize> From<[(&str, Vault); N]> for ObsidianConfig {
94    fn from(arr: [(&str, Vault); N]) -> Self {
95        Self {
96            vaults: BTreeMap::from(arr.map(|(name, vault)| (name.to_owned(), vault))),
97        }
98    }
99}
100
101impl<const N: usize> From<[(String, Vault); N]> for ObsidianConfig {
102    fn from(arr: [(String, Vault); N]) -> Self {
103        Self {
104            vaults: BTreeMap::from(arr),
105        }
106    }
107}
108
109impl<'de> Deserialize<'de> for ObsidianConfig {
110    fn deserialize<D>(deserializer: D) -> result::Result<Self, D::Error>
111    where
112        D: Deserializer<'de>,
113    {
114        #[derive(Deserialize)]
115        struct Json {
116            vaults: BTreeMap<String, Vault>,
117        }
118
119        impl From<Json> for ObsidianConfig {
120            fn from(value: Json) -> Self {
121                ObsidianConfig {
122                    vaults: value
123                        .vaults
124                        .into_values()
125                        .map(|vault| (vault.name.clone(), vault))
126                        .collect(),
127                }
128            }
129        }
130
131        let deserialized: Json = Deserialize::deserialize(deserializer)?;
132        Ok(deserialized.into())
133    }
134}
135
136/// Returns all existing configuration directory paths where Obsidian might store its global
137/// settings.
138///
139/// This function determines possible configuration locations by platform-specific conventions and
140/// installation methods. On all platforms, it first checks if the user has defined the
141/// `OBSIDIAN_CONFIG_DIR` environment variable. If so, that path is used, and any leading tilde (~)
142/// is expanded to the current user's home directory.
143///
144/// On Windows, it then resolves to the default Obsidian directory located under the system's
145/// application data folder, typically `%APPDATA%\Obsidian`. On macOS, the function expects to find
146/// the configuration under `~/Library/Application Support/obsidian`. On Linux, the standard config
147/// directory is assumed to be `$XDG_CONFIG_HOME/obsidian`, or `~/.config/obsidian`.
148///
149/// For Linux users, the function also accounts for sandboxed installations. If Obsidian is
150/// installed via Flatpak, the configuration is likely found in
151/// `~/.var/app/md.obsidian.Obsidian/config/obsidian`. For Snap installations, the relevant path is
152/// typically `~/snap/obsidian/common/.config/obsidian`.
153///
154/// For reference:
155/// - macOS:     `/Users/username/Library/Application Support/obsidian`
156/// - Windows:   `%APPDATA%\Obsidian\`
157/// - Linux:     `$XDG_CONFIG_HOME/obsidian` or `~/.config/obsidian`
158///   - flatpak: `$HOME/.var/app/md.obsidian.Obsidian/config/obsidian`
159///   - snap:    `$HOME/snap/obsidian/current/.config/obsidian`
160///
161/// More info: [https://help.obsidian.md/Files+and+folders/How+Obsidian+stores+data]
162pub fn obsidian_global_config_locations() -> Vec<PathBuf> {
163    #[cfg(any(target_os = "macos", target_os = "linux"))]
164    const OBSIDIAN_CONFIG_DIR_NAME: &str = "obsidian";
165
166    #[cfg(target_os = "windows")]
167    const OBSIDIAN_CONFIG_DIR_NAME: &str = "Obsidian";
168
169    let override_path =
170        env::var("OBSIDIAN_CONFIG_DIR")
171            .ok()
172            .zip(home_dir())
173            .map(|(path, home_dir)| {
174                PathBuf::from(path.replace("~", home_dir.to_string_lossy().as_ref()))
175            });
176
177    let default_config_path =
178        config_dir().map(|config_path| config_path.join(OBSIDIAN_CONFIG_DIR_NAME));
179
180    #[cfg(any(target_os = "macos", target_os = "windows"))]
181    let sandboxed_paths: [Option<PathBuf>; 0] = [];
182
183    // In cases where user has a sandboxes instance of Obsidian installed under either flatpak or
184    // snap, we must check if the configuration exists under these locations.
185    #[cfg(target_os = "linux")]
186    let sandboxed_paths = {
187        let flatpak_path = home_dir().map(|home_dir| {
188            home_dir
189                .join(".var/app/md.obsidian.Obsidian/config")
190                .join(OBSIDIAN_CONFIG_DIR_NAME)
191        });
192
193        let snap_path = home_dir().map(|home_dir| {
194            home_dir
195                .join("snap/obsidian/current/.config")
196                .join(OBSIDIAN_CONFIG_DIR_NAME)
197        });
198
199        [flatpak_path, snap_path]
200    };
201
202    let base_paths = [override_path, default_config_path];
203
204    base_paths
205        .into_iter()
206        .chain(sandboxed_paths)
207        .flatten()
208        .collect()
209}