kibi/
config.rs

1// SPDX-FileCopyrightText: 2020 Ilaï Deutel & Kibi Contributors
2//
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! # Configuration
6//!
7//! Utilities to configure the text editor.
8
9use 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/// The global Kibi configuration.
15#[derive(Debug, PartialEq, Eq)]
16pub struct Config {
17    /// The size of a tab. Must be > 0.
18    pub tab_stop: NonZeroUsize,
19    /// The number of confirmations needed before quitting, when changes have
20    /// been made since the file was last changed.
21    pub quit_times: usize,
22    /// The duration for which messages are shown in the status bar.
23    pub message_dur: Duration,
24    /// Whether to display line numbers.
25    pub show_line_num: bool,
26}
27
28impl Default for Config {
29    /// Default configuration.
30    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    /// Load the configuration, potentially overridden using `config.ini` files
43    /// that can be located in the following directories:
44    ///   - On Linux, macOS, and other *nix systems:
45    ///     - `/etc/kibi` (system-wide configuration).
46    ///     - `$XDG_CONFIG_HOME/kibi` if environment variable `$XDG_CONFIG_HOME`
47    ///       is defined, `$HOME/.config/kibi` otherwise (user-level
48    ///       configuration).
49    ///   - On Windows:
50    ///     - `%APPDATA%\Kibi`
51    ///
52    /// Will print warnings to stderr if a file or line cannot be parsed
53    /// properly.
54    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
78/// Process an INI file.
79///
80/// The `kv_fn` function will be called for each key-value pair in the file.
81/// Typically, this function will update a configuration instance.
82///
83/// Will print warnings to stderr for invalid lines
84pub 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, _) => (), // Comment or empty line
93                    (_, 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
101/// Trim a value (right-hand side of a key=value INI line) and parses it.
102pub 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
106/// Split a comma-separated list of values (right-hand side of a
107/// key=value1,value2,... INI line) and parse it as a Vec.
108pub 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"))] // No filesystem on wasm
114mod 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    /// Lock for modifying environment variables.
191    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            // SAFETY: Only one test at a time may set or remove an environment variable, as
210            // enforced by ENV_LOCK.
211            #[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            // SAFETY: Only one test at a time may set or remove an environment variable, as
224            // enforced by ENV_LOCK.
225            #[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        // Tab stop value is still the default
253        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}