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