open_sesame/config/
loader.rs

1//! Configuration loading with XDG inheritance
2//!
3//! Loads configuration from multiple sources with proper merging.
4
5use crate::config::schema::Config;
6use crate::util::{Error, Result};
7use std::io::Read;
8use std::path::{Path, PathBuf};
9
10/// Returns the XDG config home directory.
11///
12/// Falls back to $HOME/.config if XDG_CONFIG_HOME is not set.
13/// Returns None if neither XDG_CONFIG_HOME nor HOME is available.
14fn xdg_config_home() -> Option<PathBuf> {
15    dirs::config_dir().or_else(|| {
16        // Falls back to $HOME/.config (properly expanded, not literal "~")
17        dirs::home_dir().map(|h| h.join(".config"))
18    })
19}
20
21/// Returns the system config directory.
22fn system_config_dir() -> PathBuf {
23    PathBuf::from("/etc/open-sesame")
24}
25
26/// Returns the user config directory.
27///
28/// Returns None if HOME is not set (unusual but possible in containers).
29pub fn user_config_dir() -> Option<PathBuf> {
30    xdg_config_home().map(|c| c.join("open-sesame"))
31}
32
33/// Returns the user config file path.
34///
35/// Returns None if HOME is not set.
36pub fn user_config_path() -> Option<PathBuf> {
37    user_config_dir().map(|d| d.join("config.toml"))
38}
39
40/// Returns the user config.d directory path.
41fn user_config_d_path() -> Option<PathBuf> {
42    user_config_dir().map(|d| d.join("config.d"))
43}
44
45/// Performs deep merge of overlay config into base config.
46fn deep_merge(base: &mut Config, overlay: Config) {
47    let defaults = Config::default();
48
49    // Merges settings (overriding if different from defaults)
50    if overlay.settings.activation_key != defaults.settings.activation_key {
51        base.settings.activation_key = overlay.settings.activation_key;
52    }
53    if overlay.settings.activation_delay != defaults.settings.activation_delay {
54        base.settings.activation_delay = overlay.settings.activation_delay;
55    }
56    if overlay.settings.overlay_delay != defaults.settings.overlay_delay {
57        base.settings.overlay_delay = overlay.settings.overlay_delay;
58    }
59    if overlay.settings.quick_switch_threshold != defaults.settings.quick_switch_threshold {
60        base.settings.quick_switch_threshold = overlay.settings.quick_switch_threshold;
61    }
62    if overlay.settings.border_width != defaults.settings.border_width {
63        base.settings.border_width = overlay.settings.border_width;
64    }
65    if overlay.settings.border_color != defaults.settings.border_color {
66        base.settings.border_color = overlay.settings.border_color;
67    }
68    if overlay.settings.background_color != defaults.settings.background_color {
69        base.settings.background_color = overlay.settings.background_color;
70    }
71    if overlay.settings.card_color != defaults.settings.card_color {
72        base.settings.card_color = overlay.settings.card_color;
73    }
74    if overlay.settings.text_color != defaults.settings.text_color {
75        base.settings.text_color = overlay.settings.text_color;
76    }
77    if overlay.settings.hint_color != defaults.settings.hint_color {
78        base.settings.hint_color = overlay.settings.hint_color;
79    }
80    if overlay.settings.hint_matched_color != defaults.settings.hint_matched_color {
81        base.settings.hint_matched_color = overlay.settings.hint_matched_color;
82    }
83    if !overlay.settings.env_files.is_empty() {
84        base.settings.env_files = overlay.settings.env_files;
85    }
86
87    // Merges keys additively (overlay keys override or add to base)
88    for (key, binding) in overlay.keys {
89        base.keys.insert(key, binding);
90    }
91}
92
93/// Merges config from TOML content string.
94fn merge_from_content(base: &mut Config, content: &str, source: &str) -> Result<()> {
95    let overlay: Config = toml::from_str(content)?;
96    deep_merge(base, overlay);
97    tracing::debug!("Merged config from {}", source);
98    Ok(())
99}
100
101/// Merges config from file if it exists.
102fn merge_config_file(base: &mut Config, path: &Path) -> Result<bool> {
103    if !path.exists() {
104        return Ok(false);
105    }
106
107    let content = std::fs::read_to_string(path).map_err(|source| Error::ConfigRead {
108        path: path.to_path_buf(),
109        source,
110    })?;
111
112    merge_from_content(base, &content, &path.display().to_string())?;
113    Ok(true)
114}
115
116/// Reads config from stdin.
117fn read_stdin() -> Result<String> {
118    let mut content = String::new();
119    std::io::stdin().read_to_string(&mut content)?;
120    Ok(content)
121}
122
123/// Loads config from explicit paths (for --config flag).
124///
125/// Paths are canonicalized to resolve symlinks and relative components.
126/// Only regular files are accepted (not directories or special files).
127pub fn load_config_from_paths(paths: &[String]) -> Result<Config> {
128    let mut config = Config::default();
129
130    for path in paths {
131        if path == "-" {
132            let content = read_stdin()?;
133            merge_from_content(&mut config, &content, "stdin")?;
134            tracing::info!("Loaded config from stdin");
135        } else {
136            let path = PathBuf::from(path);
137            if !path.exists() {
138                return Err(Error::ConfigRead {
139                    path: path.clone(),
140                    source: std::io::Error::new(
141                        std::io::ErrorKind::NotFound,
142                        "Config file not found",
143                    ),
144                });
145            }
146
147            // Canonicalizes path to resolve symlinks and relative components
148            let canonical = path.canonicalize().map_err(|source| Error::ConfigRead {
149                path: path.clone(),
150                source,
151            })?;
152
153            // Ensures path is a regular file, not a directory or special file
154            if !canonical.is_file() {
155                return Err(Error::ConfigRead {
156                    path: canonical,
157                    source: std::io::Error::new(
158                        std::io::ErrorKind::InvalidInput,
159                        "Path is not a regular file",
160                    ),
161                });
162            }
163
164            if merge_config_file(&mut config, &canonical)? {
165                tracing::info!("Loaded config from {:?}", canonical);
166            }
167        }
168    }
169
170    Ok(config)
171}
172
173/// Loads configuration with XDG inheritance.
174///
175/// Load order (later overrides earlier):
176/// 1. /etc/open-sesame/config.toml (system defaults)
177/// 2. ~/.config/open-sesame/config.toml (user config)
178/// 3. ~/.config/open-sesame/config.d/*.toml (user overrides, alphabetical)
179pub fn load_config() -> Result<Config> {
180    let mut config = Config::default();
181    let mut loaded_any = false;
182
183    // 1. System config
184    let system_path = system_config_dir().join("config.toml");
185    if merge_config_file(&mut config, &system_path)? {
186        loaded_any = true;
187        tracing::info!("Loaded system config: {:?}", system_path);
188    }
189
190    // 2. User config
191    if let Some(user_path) = user_config_path()
192        && merge_config_file(&mut config, &user_path)?
193    {
194        loaded_any = true;
195        tracing::info!("Loaded user config: {:?}", user_path);
196    }
197
198    // 3. User config.d directory
199    if let Some(config_d) = user_config_d_path()
200        && config_d.exists()
201        && config_d.is_dir()
202    {
203        let mut entries: Vec<_> = std::fs::read_dir(&config_d)
204            .map_err(|source| Error::ConfigRead {
205                path: config_d.clone(),
206                source,
207            })?
208            .filter_map(|e| e.ok())
209            .filter(|e| {
210                e.path()
211                    .extension()
212                    .map(|ext| ext == "toml")
213                    .unwrap_or(false)
214            })
215            .collect();
216
217        entries.sort_by_key(|e| e.path());
218
219        for entry in entries {
220            let path = entry.path();
221            if merge_config_file(&mut config, &path)? {
222                loaded_any = true;
223                tracing::info!("Loaded config.d: {:?}", path);
224            }
225        }
226    }
227
228    if !loaded_any {
229        tracing::debug!("No config files found, using defaults");
230    }
231
232    Ok(config)
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn test_load_default_config() {
241        // Should not fail even with no config files
242        let config = load_config().unwrap();
243        assert!(!config.keys.is_empty());
244    }
245
246    #[test]
247    fn test_user_config_paths() {
248        // These may return None in unusual environments (no HOME set)
249        if let Some(dir) = user_config_dir() {
250            assert!(dir.to_string_lossy().contains("open-sesame"));
251        }
252
253        if let Some(path) = user_config_path() {
254            assert!(path.to_string_lossy().contains("config.toml"));
255        }
256    }
257}