1use std::path::{Path, PathBuf};
10use std::{fmt::Display, fs::read_to_string, num::NonZeroUsize, str::FromStr, time::Duration};
11
12use crate::sys::conf_dirs as cdirs;
13
14#[derive(Debug, PartialEq, Eq)]
16pub struct Config {
17 pub tab_stop: NonZeroUsize,
19 pub quit_times: usize,
22 pub message_dur: Duration,
24 pub show_line_num: bool,
26}
27
28impl Default for Config {
29 fn default() -> Self {
31 Self {
32 #[expect(clippy::unwrap_used)]
33 tab_stop: NonZeroUsize::new(4).unwrap(),
34 quit_times: 2,
35 message_dur: Duration::new(3, 0),
36 show_line_num: true,
37 }
38 }
39}
40
41impl Config {
42 pub fn load() -> Self {
55 let mut conf = Self::default();
56
57 let paths: Vec<_> = cdirs().iter().map(|d| PathBuf::from(d).join("config.ini")).collect();
58
59 for path in paths.iter().filter(|p| p.is_file()).rev() {
60 process_ini_file(path, &mut |key, value| {
61 match key {
62 "tab_stop" => conf.tab_stop = parse_value(value)?,
63 "quit_times" => conf.quit_times = parse_value(value)?,
64 "message_duration" =>
65 conf.message_dur = Duration::try_from_secs_f32(parse_value(value)?)
66 .map_err(|x| x.to_string())?,
67 "show_line_numbers" => conf.show_line_num = parse_value(value)?,
68 _ => return Err(format!("Invalid key: {key}")),
69 }
70 Ok(())
71 });
72 }
73
74 conf
75 }
76}
77
78pub fn process_ini_file<F>(path: &Path, kv_fn: &mut F)
85where F: FnMut(&str, &str) -> Result<(), String> {
86 read_to_string(path).map_or_else(
87 |e| eprintln!("Could not read {}: {}", path.to_string_lossy(), e),
88 |config| {
89 for (i, line) in config.lines().enumerate().map(|(i, line)| (i, line.trim_start())) {
90 let warn = |msg: &str| eprintln!("{}:{}: {}", path.to_string_lossy(), i + 1, msg);
91 match (line.chars().next(), line.split_once('=')) {
92 (Some('#' | ';') | None, _) => (), (_, Some((k, v))) => kv_fn(k.trim_end(), v.trim()).unwrap_or_else(|r| warn(&format!("{k}: {r}"))),
94 (_, None) => warn("missing '='"),
95 }
96 }
97 },
98 );
99}
100
101pub fn parse_value<T: FromStr<Err=E>, E: Display>(value: &str) -> Result<T, String> {
103 value.parse().map_err(|e: E| e.to_string())
104}
105
106pub fn parse_values<T: FromStr<Err=E>, E: Display>(values: &str) -> Result<Vec<T>, String> {
109 values.split(',').map(|value| parse_value(value.trim())).collect()
110}
111
112#[cfg(test)]
113#[cfg(not(target_family = "wasm"))] mod tests {
115 use std::collections::HashMap;
116 use std::ffi::{OsStr, OsString};
117 use std::sync::{LazyLock, Mutex, MutexGuard};
118 use std::{env, fs};
119
120 use tempfile::TempDir;
121
122 use super::*;
123
124 fn ini_processing_helper<F>(ini_content: &str, kv_fn: &mut F)
125 where F: FnMut(&str, &str) -> Result<(), String> {
126 let tmp_dir = TempDir::new().expect("Could not create temporary directory");
127 let file_path = tmp_dir.path().join("test_config.ini");
128 fs::write(&file_path, ini_content).expect("Could not write INI file");
129 process_ini_file(&file_path, kv_fn);
130 }
131
132 #[test]
133 fn valid_ini_processing() {
134 let ini_content = "# Comment A
135 ; Comment B
136 a = c
137 # Below is an empty line
138
139 variable = 4
140 a = d5
141 u = v = w ";
142 let expected = vec![
143 (String::from("a"), String::from("c")),
144 (String::from("variable"), String::from("4")),
145 (String::from("a"), String::from("d5")),
146 (String::from("u"), String::from("v = w")),
147 ];
148
149 let mut kvs = Vec::new();
150 let kv_fn = &mut |key: &str, value: &str| {
151 kvs.push((String::from(key), String::from(value)));
152 Ok(())
153 };
154
155 ini_processing_helper(ini_content, kv_fn);
156
157 assert_eq!(kvs, expected);
158 }
159
160 #[test]
161 fn ini_processing_with_invalid_line() {
162 let ini_content = "# Comment A
163 ; Comment B
164 a = c
165 # Below is an empty line
166
167 Invalid line
168 a = d5
169 u = v = w ";
170 let mut parsed: Vec<(String, String)> = vec![];
171 let kv_fn = &mut |key: &str, value: &str| {
172 parsed.push((key.into(), value.into()));
173 Ok(())
174 };
175 ini_processing_helper(ini_content, kv_fn);
176 assert_eq!(parsed, vec![
177 (String::from("a"), String::from("c")),
178 (String::from("a"), String::from("d5")),
179 (String::from("u"), String::from("v = w"))
180 ]);
181 }
182 #[test]
183 fn ini_processing_invalid_path() {
184 let kv_fn = &mut |_: &str, _: &str| panic!("Should not be called");
185 let tmp_dir = TempDir::new().expect("Could not create temporary directory");
186 let tmp_path = tmp_dir.path().join("path_does_not_exist.ini");
187 process_ini_file(&tmp_path, kv_fn);
188 }
189
190 static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(Mutex::default);
192
193 struct TempEnvVars<'a> {
194 original_values: HashMap<&'static OsStr, Option<OsString>>,
195 _lock: MutexGuard<'a, ()>,
196 }
197
198 impl TempEnvVars<'_> {
199 fn new() -> Self {
200 Self {
201 original_values: HashMap::new(),
202 _lock: ENV_LOCK.lock().expect("Could not acquire lock."),
203 }
204 }
205
206 fn set(&mut self, key: &'static OsStr, value: Option<&OsStr>) {
207 let original_value = env::var_os(key);
208 assert!(self.original_values.insert(key, original_value).is_none());
209 #[expect(unsafe_code)]
212 unsafe {
213 match value {
214 Some(value) => env::set_var(key, value),
215 None => env::remove_var(key),
216 }
217 }
218 }
219 }
220
221 impl Drop for TempEnvVars<'_> {
222 fn drop(&mut self) {
223 #[expect(unsafe_code)]
226 unsafe {
227 for (key, original_value) in &self.original_values {
228 match original_value {
229 Some(original_value) => env::set_var(key, original_value),
230 None => env::remove_var(key),
231 }
232 }
233 }
234 }
235 }
236
237 #[cfg(unix)]
238 #[test]
239 #[expect(clippy::significant_drop_tightening, reason = "False positive")]
240 fn invalid_tab_stop() {
241 let tmp_config_home = TempDir::new().expect("Could not create temporary directory");
242
243 let mut vars = TempEnvVars::new();
244 vars.set(OsStr::new("XDG_CONFIG_HOME"), Some(tmp_config_home.path().as_os_str()));
245
246 let kibi_config_home = tmp_config_home.path().join("kibi");
247 fs::create_dir_all(&kibi_config_home).unwrap();
248 fs::write(kibi_config_home.join("config.ini"), "tab_stop=0")
249 .expect("Could not write INI file");
250
251 let config = Config::load();
252 assert_eq!(config.tab_stop.get(), 4);
254 }
255
256 fn test_config_dir(
257 env_key: &'static OsStr, env_val: &OsStr, kibi_config_home: &Path, vars: &mut TempEnvVars,
258 ) {
259 let custom_config = Config {
260 tab_stop: NonZeroUsize::new(99).unwrap(),
261 quit_times: 50,
262 ..Config::default()
263 };
264 let ini_content = format!(
265 "# Configuration file
266 tab_stop = {}
267 quit_times={}",
268 custom_config.tab_stop, custom_config.quit_times
269 );
270
271 fs::create_dir_all(kibi_config_home).unwrap();
272
273 fs::write(kibi_config_home.join("config.ini"), ini_content)
274 .expect("Could not write INI file");
275
276 let config = Config::load();
277 assert_ne!(config, custom_config);
278
279 vars.set(env_key, Some(env_val));
280 let config = Config::load();
281
282 assert_eq!(config, custom_config);
283 }
284
285 #[cfg(unix)]
286 #[test]
287 fn xdg_config_home() {
288 let mut vars = TempEnvVars::new();
289 let tmp_config_home = TempDir::new().expect("Could not create temporary directory");
290 test_config_dir(
291 OsStr::new("XDG_CONFIG_HOME"),
292 tmp_config_home.path().as_os_str(),
293 &tmp_config_home.path().join("kibi"),
294 &mut vars,
295 );
296 }
297
298 #[expect(clippy::significant_drop_tightening, reason = "Lock is needed until the end")]
299 #[cfg(unix)]
300 #[test]
301 fn config_home() {
302 let mut vars = TempEnvVars::new();
303 vars.set(OsStr::new("XDG_CONFIG_HOME"), None);
304 let tmp_home = TempDir::new().expect("Could not create temporary directory");
305 test_config_dir(
306 OsStr::new("HOME"),
307 tmp_home.path().as_os_str(),
308 &tmp_home.path().join(".config/kibi"),
309 &mut vars,
310 );
311 }
312
313 #[cfg(windows)]
314 #[test]
315 fn app_data() {
316 let mut vars = TempEnvVars::new();
317 let tmp_home = TempDir::new().expect("Could not create temporary directory");
318 test_config_dir(
319 OsStr::new("APPDATA"),
320 tmp_home.path().as_os_str(),
321 &tmp_home.path().join("Kibi"),
322 &mut vars,
323 );
324 }
325}