1 //! # Configuration
2 //!
3 //! Utilities to configure the text editor.
4 
5 use std::io::{BufRead, BufReader};
6 use std::path::{Path, PathBuf};
7 use std::{fmt::Display, fs::File, str::FromStr, time::Duration};
8 
9 use crate::{sys::conf_dirs as cdirs, Error, Error::Config as ConfErr};
10 
11 /// The global Kibi configuration.
12 #[derive(Debug, PartialEq)]
13 pub struct Config {
14     /// The size of a tab. Must be > 0.
15     pub tab_stop: usize,
16     /// The number of confirmations needed before quitting, when changes have been made since the
17     /// file was last changed.
18     pub quit_times: usize,
19     /// The duration for which messages are shown in the status bar.
20     pub message_dur: Duration,
21     /// Whether to display line numbers.
22     pub show_line_num: bool,
23 }
24 
25 impl Default for Config {
26     /// Default configuration.
default() -> Self27     fn default() -> Self {
28         Self { tab_stop: 4, quit_times: 2, message_dur: Duration::new(3, 0), show_line_num: true }
29     }
30 }
31 
32 impl Config {
33     /// Load the configuration, potentially overridden using `config.ini` files that can be located
34     /// in the following directories:
35     ///   - On Linux, macOS, and other *nix systems:
36     ///     - `/etc/kibi` (system-wide configuration).
37     ///     - `$XDG_CONFIG_HOME/kibi` if environment variable `$XDG_CONFIG_HOME` is defined,
38     ///       `$HOME/.config/kibi` otherwise (user-level configuration).
39     ///   - On Windows:
40     ///     - `%APPDATA%\Kibi`
41     ///
42     /// # Errors
43     ///
44     /// Will return `Err` if one of the configuration file cannot be parsed properly.
load() -> Result<Self, Error>45     pub fn load() -> Result<Self, Error> {
46         let mut conf = Self::default();
47 
48         let paths: Vec<_> = cdirs().iter().map(|d| PathBuf::from(d).join("config.ini")).collect();
49 
50         for path in paths.iter().filter(|p| p.is_file()).rev() {
51             process_ini_file(path, &mut |key, value| {
52                 match key {
53                     "tab_stop" => match parse_value(value)? {
54                         0 => return Err("tab_stop must be > 0".into()),
55                         tab_stop => conf.tab_stop = tab_stop,
56                     },
57                     "quit_times" => conf.quit_times = parse_value(value)?,
58                     "message_duration" =>
59                         conf.message_dur = Duration::from_secs_f32(parse_value(value)?),
60                     "show_line_numbers" => conf.show_line_num = parse_value(value)?,
61                     _ => return Err(format!("Invalid key: {}", key)),
62                 };
63                 Ok(())
64             })?;
65         }
66 
67         Ok(conf)
68     }
69 }
70 
71 /// Process an INI file.
72 ///
73 /// The `kv_fn` function will be called for each key-value pair in the file. Typically, this
74 /// function will update a configuration instance.
process_ini_file<F>(path: &Path, kv_fn: &mut F) -> Result<(), Error> where F: FnMut(&str, &str) -> Result<(), String>75 pub fn process_ini_file<F>(path: &Path, kv_fn: &mut F) -> Result<(), Error>
76 where F: FnMut(&str, &str) -> Result<(), String> {
77     let file = File::open(path).map_err(|e| ConfErr(path.into(), 0, e.to_string()))?;
78     for (i, line) in BufReader::new(file).lines().enumerate() {
79         let (i, line) = (i + 1, line?);
80         let mut parts = line.trim_start().splitn(2, '=');
81         match (parts.next(), parts.next()) {
82             (Some(comment_line), _) if comment_line.starts_with(&['#', ';'][..]) => (),
83             (Some(k), Some(v)) => kv_fn(k.trim_end(), v).map_err(|r| ConfErr(path.into(), i, r))?,
84             (Some(""), None) | (None, _) => (), // Empty line
85             (Some(_), None) => return Err(ConfErr(path.into(), i, String::from("No '='"))),
86         }
87     }
88     Ok(())
89 }
90 
91 /// Trim a value (right-hand side of a key=value INI line) and parses it.
parse_value<T: FromStr<Err = E>, E: Display>(value: &str) -> Result<T, String>92 pub fn parse_value<T: FromStr<Err = E>, E: Display>(value: &str) -> Result<T, String> {
93     value.trim().parse().map_err(|e| format!("Parser error: {}", e))
94 }
95 
96 /// Split a comma-separated list of values (right-hand side of a key=value1,value2,... INI line) and
97 /// parse it as a Vec.
parse_values<T: FromStr<Err = E>, E: Display>(value: &str) -> Result<Vec<T>, String>98 pub fn parse_values<T: FromStr<Err = E>, E: Display>(value: &str) -> Result<Vec<T>, String> {
99     value.split(',').map(parse_value).collect()
100 }
101 
102 #[cfg(test)]
103 mod tests {
104     use std::ffi::OsStr;
105     use std::{env, fs};
106 
107     use serial_test::serial;
108     use tempfile::TempDir;
109 
110     use super::*;
111 
ini_processing_helper<F>(ini_content: &str, kv_fn: &mut F) -> Result<(), Error> where F: FnMut(&str, &str) -> Result<(), String>112     fn ini_processing_helper<F>(ini_content: &str, kv_fn: &mut F) -> Result<(), Error>
113     where F: FnMut(&str, &str) -> Result<(), String> {
114         let tmp_dir = TempDir::new().expect("Could not create temporary directory");
115         let file_path = tmp_dir.path().join("test_config.ini");
116         fs::write(&file_path, ini_content).expect("Could not write INI file");
117         process_ini_file(&file_path, kv_fn)
118     }
119 
120     #[test]
valid_ini_processing()121     fn valid_ini_processing() {
122         let ini_content = "# Comment A
123         ; Comment B
124         a = c
125             # Below is an empty line
126 
127            variable    = 4
128         a = d5
129         u = v = w ";
130         let expected = vec![
131             (String::from("a"), String::from(" c")),
132             (String::from("variable"), String::from(" 4")),
133             (String::from("a"), String::from(" d5")),
134             (String::from("u"), String::from(" v = w ")),
135         ];
136 
137         let mut kvs = Vec::new();
138         let kv_fn = &mut |key: &str, value: &str| {
139             kvs.push((String::from(key), String::from(value)));
140             Ok(())
141         };
142 
143         ini_processing_helper(ini_content, kv_fn).unwrap();
144 
145         assert_eq!(kvs, expected);
146     }
147 
148     #[test]
invalid_ini_processing()149     fn invalid_ini_processing() {
150         let ini_content = "# Comment A
151         ; Comment B
152         a = c
153             # Below is an empty line
154 
155            Invalid line
156         a = d5
157         u = v = w ";
158         let kv_fn = &mut |_: &str, _: &str| Ok(());
159         match ini_processing_helper(ini_content, kv_fn) {
160             Ok(_) => panic!("process_ini_file should return an error"),
161             Err(Error::Config(_, 6, s)) if s == "No '='" => (),
162             Err(e) => panic!("Unexpected error {:?}", e),
163         }
164     }
165 
166     #[test]
ini_processing_error_propagation()167     fn ini_processing_error_propagation() {
168         let ini_content = "# Comment A
169         ; Comment B
170         a = c
171             # Below is an empty line
172 
173            variable    = 4
174         a = d5
175         u = v = w ";
176         let kv_fn = &mut |_: &str, _: &str| Err(String::from("test error"));
177         match ini_processing_helper(ini_content, kv_fn) {
178             Ok(_) => panic!("process_ini_file should return an error"),
179             Err(Error::Config(_, 3, s)) if s == "test error" => (),
180             Err(e) => panic!("Unexpected error {:?}", e),
181         }
182     }
183 
184     #[test]
ini_processing_invalid_path()185     fn ini_processing_invalid_path() {
186         let kv_fn = &mut |_: &str, _: &str| Ok(());
187         let tmp_dir = TempDir::new().expect("Could not create temporary directory");
188         let tmp_path = tmp_dir.path().join("path_does_not_exist.ini");
189         match process_ini_file(&tmp_path, kv_fn) {
190             Ok(_) => panic!("process_ini_file should return an error"),
191             Err(Error::Config(path, 0, _)) if path == tmp_path => (),
192             Err(e) => panic!("Unexpected error {:?}", e),
193         }
194     }
195 
test_config_dir(env_key: &OsStr, env_val: &OsStr, kibi_config_home: &Path)196     fn test_config_dir(env_key: &OsStr, env_val: &OsStr, kibi_config_home: &Path) {
197         let custom_config = Config { tab_stop: 99, quit_times: 50, ..Config::default() };
198         let ini_content = format!(
199             "# Configuration file
200              tab_stop  = {}
201              quit_times={}",
202             custom_config.tab_stop, custom_config.quit_times
203         );
204 
205         fs::create_dir_all(&kibi_config_home).unwrap();
206 
207         fs::write(kibi_config_home.join("config.ini"), ini_content)
208             .expect("Could not write INI file");
209 
210         let config = Config::load().expect("Could not load configuration.");
211         assert_ne!(config, custom_config);
212 
213         let config = {
214             let orig_value = env::var_os(env_key);
215             env::set_var(env_key, env_val);
216             let config_res = Config::load();
217             match orig_value {
218                 Some(orig_value) => env::set_var(env_key, orig_value),
219                 None => env::remove_var(env_key),
220             }
221             config_res.expect("Could not load configuration.")
222         };
223 
224         assert_eq!(config, custom_config);
225     }
226 
227     #[cfg(unix)]
228     #[test]
229     #[serial]
xdg_config_home()230     fn xdg_config_home() {
231         let tmp_config_home = TempDir::new().expect("Could not create temporary directory");
232         test_config_dir(
233             "XDG_CONFIG_HOME".as_ref(),
234             tmp_config_home.path().as_os_str(),
235             &tmp_config_home.path().join("kibi"),
236         );
237     }
238 
239     #[cfg(unix)]
240     #[test]
241     #[serial]
config_home()242     fn config_home() {
243         let tmp_home = TempDir::new().expect("Could not create temporary directory");
244         test_config_dir(
245             "HOME".as_ref(),
246             tmp_home.path().as_os_str(),
247             &tmp_home.path().join(".config/kibi"),
248         );
249     }
250 
251     #[cfg(windows)]
252     #[test]
253     #[serial]
app_data()254     fn app_data() {
255         let tmp_home = TempDir::new().expect("Could not create temporary directory");
256         test_config_dir(
257             "APPDATA".as_ref(),
258             tmp_home.path().as_os_str(),
259             &tmp_home.path().join("Kibi"),
260         );
261     }
262 }
263