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
15pub fn parse_config(s: &str) -> Result<Config, ConfigError> {
17 let config: Config = toml::from_str(s)?;
18 Ok(config)
19}
20
21pub 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
33pub(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
43pub 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 #[test]
149 #[serial]
150 fn default_config_path_env_override() {
151 std::env::remove_var("XDG_CONFIG_HOME");
153 std::env::set_var("RUNEX_CONFIG", "/tmp/custom.toml");
154 let path = default_config_path().unwrap();
155 std::env::remove_var("RUNEX_CONFIG");
156 assert_eq!(path, PathBuf::from("/tmp/custom.toml"));
157 }
158
159 #[test]
160 #[serial]
161 fn xdg_config_home_uses_env_var() {
162 std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg-test");
163 let dir = xdg_config_home().unwrap();
164 std::env::remove_var("XDG_CONFIG_HOME");
165 assert_eq!(dir, PathBuf::from("/tmp/xdg-test"));
166 }
167
168 #[test]
169 #[serial]
170 fn xdg_config_home_empty_env_falls_back_to_home() {
171 std::env::set_var("XDG_CONFIG_HOME", "");
172 let dir = xdg_config_home().unwrap();
173 std::env::remove_var("XDG_CONFIG_HOME");
174 assert!(dir.ends_with(".config"), "expected ~/.config fallback, got {dir:?}");
176 }
177
178 #[test]
179 #[serial]
180 fn default_config_path_uses_xdg_config_home() {
181 std::env::remove_var("RUNEX_CONFIG");
182 std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg-runex-test");
183 let path = default_config_path().unwrap();
184 std::env::remove_var("XDG_CONFIG_HOME");
185 assert_eq!(path, PathBuf::from("/tmp/xdg-runex-test/runex/config.toml"));
186 }
187}