Skip to main content

runex_core/
config.rs

1use std::path::PathBuf;
2
3use crate::model::Config;
4
5#[derive(Debug, thiserror::Error)]
6pub enum ConfigError {
7    #[error("TOML parse error: {0}")]
8    Parse(#[from] toml::de::Error),
9    #[error("IO error: {0}")]
10    Io(#[from] std::io::Error),
11    #[error("cannot determine config directory")]
12    NoConfigDir,
13}
14
15/// Parse a TOML string into Config.
16pub fn parse_config(s: &str) -> Result<Config, ConfigError> {
17    let config: Config = toml::from_str(s)?;
18    Ok(config)
19}
20
21/// Default config file path: `$XDG_CONFIG_HOME/runex/config.toml`,
22/// falling back to `~/.config/runex/config.toml` when `XDG_CONFIG_HOME` is unset.
23/// All platforms use this same resolution order.
24/// Overridden by `RUNEX_CONFIG` env var.
25pub fn default_config_path() -> Result<PathBuf, ConfigError> {
26    if let Ok(p) = std::env::var("RUNEX_CONFIG") {
27        return Ok(PathBuf::from(p));
28    }
29    let dir = xdg_config_home();
30    Ok(dir.ok_or(ConfigError::NoConfigDir)?.join("runex").join("config.toml"))
31}
32
33/// Resolve `$XDG_CONFIG_HOME`, falling back to `~/.config`.
34pub(crate) fn xdg_config_home() -> Option<PathBuf> {
35    if let Ok(p) = std::env::var("XDG_CONFIG_HOME") {
36        if !p.is_empty() {
37            return Some(PathBuf::from(p));
38        }
39    }
40    dirs::home_dir().map(|h| h.join(".config"))
41}
42
43/// Load config from a file path.
44pub fn load_config(path: &std::path::Path) -> Result<Config, ConfigError> {
45    let content = std::fs::read_to_string(path)?;
46    parse_config(&content)
47}
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52    use crate::model::TriggerKey;
53    use serial_test::serial;
54
55    #[test]
56    fn parse_minimal_toml() {
57        let toml = r#"
58version = 1
59
60[[abbr]]
61key = "gcm"
62expand = "git commit -m"
63"#;
64        let config = parse_config(toml).unwrap();
65        assert_eq!(config.version, 1);
66        assert_eq!(config.abbr.len(), 1);
67        assert_eq!(config.abbr[0].key, "gcm");
68        assert_eq!(config.abbr[0].expand, "git commit -m");
69    }
70
71    #[test]
72    fn parse_with_when_command_exists() {
73        let toml = r#"
74version = 1
75
76[[abbr]]
77key = "ls"
78expand = "lsd"
79when_command_exists = ["lsd"]
80"#;
81        let config = parse_config(toml).unwrap();
82        assert_eq!(
83            config.abbr[0].when_command_exists,
84            Some(vec!["lsd".to_string()])
85        );
86    }
87
88    #[test]
89    fn parse_with_keybind() {
90        let toml = r#"
91version = 1
92
93[keybind]
94trigger = "space"
95bash = "alt-space"
96zsh = "space"
97pwsh = "tab"
98"#;
99        let config = parse_config(toml).unwrap();
100        assert_eq!(config.keybind.trigger, Some(TriggerKey::Space));
101        assert_eq!(config.keybind.bash, Some(TriggerKey::AltSpace));
102        assert_eq!(config.keybind.zsh, Some(TriggerKey::Space));
103        assert_eq!(config.keybind.pwsh, Some(TriggerKey::Tab));
104        assert_eq!(config.keybind.nu, None);
105    }
106
107    #[test]
108    fn parse_missing_version_is_err() {
109        let toml = r#"
110[[abbr]]
111key = "gcm"
112expand = "git commit -m"
113"#;
114        assert!(parse_config(toml).is_err());
115    }
116
117    #[test]
118    fn parse_empty_abbr_list() {
119        let toml = "version = 1\n";
120        let config = parse_config(toml).unwrap();
121        assert!(config.abbr.is_empty());
122    }
123
124    #[test]
125    fn load_config_from_file() {
126        let dir = std::env::temp_dir().join("runex_test_load");
127        std::fs::create_dir_all(&dir).unwrap();
128        let path = dir.join("config.toml");
129        std::fs::write(
130            &path,
131            r#"
132version = 1
133
134[[abbr]]
135key = "gcm"
136expand = "git commit -m"
137"#,
138        )
139        .unwrap();
140
141        let config = load_config(&path).unwrap();
142        assert_eq!(config.version, 1);
143        assert_eq!(config.abbr[0].key, "gcm");
144
145        std::fs::remove_dir_all(&dir).ok();
146    }
147
148    /// Safety: env mutation is serialized via `#[serial]`; no concurrent
149    /// env access within this test suite. External concurrent access is
150    /// not fully excluded but acceptable in test context.
151    #[test]
152    #[serial]
153    fn default_config_path_env_override() {
154        // RUNEX_CONFIG takes priority over XDG_CONFIG_HOME.
155        unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
156        unsafe { std::env::set_var("RUNEX_CONFIG", "/tmp/custom.toml") };
157        let path = default_config_path().unwrap();
158        unsafe { std::env::remove_var("RUNEX_CONFIG") };
159        assert_eq!(path, PathBuf::from("/tmp/custom.toml"));
160    }
161
162    /// Safety: see `default_config_path_env_override`.
163    #[test]
164    #[serial]
165    fn xdg_config_home_uses_env_var() {
166        unsafe { std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg-test") };
167        let dir = xdg_config_home().unwrap();
168        unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
169        assert_eq!(dir, PathBuf::from("/tmp/xdg-test"));
170    }
171
172    /// Safety: see `default_config_path_env_override`.
173    #[test]
174    #[serial]
175    fn xdg_config_home_empty_env_falls_back_to_home() {
176        unsafe { std::env::set_var("XDG_CONFIG_HOME", "") };
177        let dir = xdg_config_home().unwrap();
178        unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
179        // Falls back to home/.config — must end with .config
180        assert!(dir.ends_with(".config"), "expected ~/.config fallback, got {dir:?}");
181    }
182
183    /// Safety: see `default_config_path_env_override`.
184    #[test]
185    #[serial]
186    fn default_config_path_uses_xdg_config_home() {
187        unsafe { std::env::remove_var("RUNEX_CONFIG") };
188        unsafe { std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg-runex-test") };
189        let path = default_config_path().unwrap();
190        unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
191        assert_eq!(path, PathBuf::from("/tmp/xdg-runex-test/runex/config.toml"));
192    }
193}