1use std::collections::HashMap;
25use std::env;
26use std::fmt::Debug;
27use std::fs;
28use std::io::prelude::*;
29
30use configparser::ini::Ini;
31use once_cell::sync::Lazy;
32use tilde_expand::tilde_expand;
33
34#[doc(inline)]
35use crate::error::PathError;
36
37pub static DEFAULT_CONF: Lazy<String> =
39 Lazy::new(|| normalize_path("~/.timelogrc").expect("Default config file name must be valid"));
40pub static DEFAULT_DIR: Lazy<String> =
42 Lazy::new(|| normalize_path("~/timelog").expect("Default log directory must be valid"));
43pub static DEFAULT_EDITOR: Lazy<String> = Lazy::new(|| {
45 env::var("VISUAL")
46 .or_else(|_| env::var("EDITOR"))
47 .unwrap_or_else(|_| String::from("vim"))
48});
49
50#[cfg(target_os = "macos")]
52pub const DEFAULT_BROWSER: &str = "open";
53
54#[cfg(not(target_os = "macos"))]
56pub const DEFAULT_BROWSER: &str = "chromium";
57
58#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct Config {
61 configfile: String,
63 dir: String,
65 editor: String,
67 browser: String,
69 defcmd: String,
71 aliases: HashMap<String, String>
73}
74
75pub fn normalize_path(filename: &str) -> Result<String, PathError> {
81 String::from_utf8(tilde_expand(filename.as_bytes()))
83 .map_err(|e| PathError::InvalidPath(filename.to_string(), e.to_string()))
84}
85
86fn ensure_filename(file: &str) -> Result<String, PathError> {
93 use std::path::PathBuf;
94
95 if file.is_empty() {
96 return Err(PathError::FilenameMissing);
97 }
98 let mut dir = PathBuf::from(normalize_path(file)?);
99 let filename = dir
100 .file_name()
101 .ok_or(PathError::FilenameMissing)?
102 .to_os_string();
103 dir.pop();
104
105 let mut candir = fs::canonicalize(dir)
106 .map_err(|e| PathError::InvalidPath(file.to_string(), e.to_string()))?;
107 candir.push(filename);
108 Ok(candir.to_str().expect("The config filename must be a valid string").to_string())
109}
110
111impl Default for Config {
112 fn default() -> Self {
119 Self {
120 configfile: normalize_path("~/.timelogrc").expect("Invalid config file"),
121 dir: normalize_path("~/timelog").expect("Invalid user dir"),
122 editor: "vim".into(),
123 browser: DEFAULT_BROWSER.into(),
124 defcmd: "curr".into(),
125 aliases: HashMap::new()
126 }
127 }
128}
129
130fn conf_get<'a>(base: &'a HashMap<String, Option<String>>, key: &'static str) -> Option<&'a str> {
132 base.get(key).and_then(Option::as_deref)
133}
134
135impl Config {
136 pub fn new(
143 config: &str, dir: Option<&str>, editor: Option<&str>, browser: Option<&str>,
144 cmd: Option<&str>
145 ) -> crate::Result<Self> {
146 let dir = dir.unwrap_or("~/timelog");
147 Ok(Self {
148 configfile: normalize_path(config).map_err(|_| PathError::InvalidConfigPath)?,
149 dir: normalize_path(dir).map_err(|_| PathError::InvalidTimelogPath)?,
150 editor: editor.unwrap_or("vim").into(),
151 browser: browser.unwrap_or(DEFAULT_BROWSER).into(),
152 defcmd: cmd.unwrap_or("curr").into(),
153 aliases: HashMap::new()
154 })
155 }
156
157 pub fn from_file(filename: &str) -> crate::Result<Self> {
165 let configfile = ensure_filename(filename)?;
166 let mut parser = Ini::new();
167 let config = parser
168 .load(&configfile)
169 .map_err(|e| PathError::FileAccess(configfile.clone(), e))?;
170
171 let default = HashMap::new();
172 let base = config.get("default").unwrap_or(&default);
173 let mut conf = Config::new(
174 &configfile,
175 conf_get(base, "dir"),
176 conf_get(base, "editor"),
177 conf_get(base, "browser"),
178 conf_get(base, "defcmd")
179 )?;
180 if let Some(aliases) = config.get("alias") {
181 for (k, v) in aliases.iter() {
182 if let Some(val) = v.as_ref() {
183 conf.set_alias(k, val);
184 }
185 }
186 }
187
188 Ok(conf)
189 }
190
191 pub fn configfile(&self) -> &str { self.configfile.as_str() }
193
194 pub fn dir(&self) -> &str { self.dir.as_str() }
196
197 pub fn set_dir(&mut self, dir: &str) { self.dir = dir.to_string() }
199
200 pub fn editor(&self) -> &str { self.editor.as_str() }
202
203 pub fn set_editor(&mut self, editor: &str) { self.editor = editor.to_string() }
205
206 pub fn browser(&self) -> &str { self.browser.as_str() }
208
209 pub fn set_browser(&mut self, browser: &str) { self.browser = browser.to_string() }
211
212 pub fn defcmd(&self) -> &str { self.defcmd.as_str() }
214
215 pub fn logfile(&self) -> String { format!("{}/timelog.txt", self.dir) }
217
218 pub fn stackfile(&self) -> String { format!("{}/stack.txt", self.dir) }
220
221 pub fn reportfile(&self) -> String { format!("{}/report.html", self.dir) }
223
224 pub fn create(&self) -> Result<(), PathError> {
230 let configfile = self.configfile();
231 let file = fs::OpenOptions::new()
232 .create(true)
233 .write(true)
234 .truncate(true)
235 .open(configfile)
236 .map_err(|e| PathError::FileAccess(configfile.to_string(), e.to_string()))?;
237 let mut stream = std::io::BufWriter::new(file);
238
239 writeln!(&mut stream, "dir={}", self.dir())
240 .map_err(|e| PathError::FileWrite(configfile.to_string(), e.to_string()))?;
241 writeln!(&mut stream, "editor={}", self.editor())
242 .map_err(|e| PathError::FileWrite(configfile.to_string(), e.to_string()))?;
243 writeln!(&mut stream, "browser={}", self.browser())
244 .map_err(|e| PathError::FileWrite(configfile.to_string(), e.to_string()))?;
245 writeln!(&mut stream, "defcmd={}", self.defcmd())
246 .map_err(|e| PathError::FileWrite(configfile.to_string(), e.to_string()))?;
247
248 writeln!(&mut stream, "\n[alias]")
249 .map_err(|e| PathError::FileWrite(configfile.to_string(), e.to_string()))?;
250 for (key, val) in &self.aliases {
251 writeln!(stream, " {key} = {val}")
252 .map_err(|e| PathError::FileWrite(configfile.to_string(), e.to_string()))?;
253 }
254
255 stream
256 .flush()
257 .map_err(|e| PathError::FileWrite(configfile.to_string(), e.to_string()))?;
258 Ok(())
259 }
260
261 pub fn alias_names(&self) -> impl Iterator<Item = &'_ String> { self.aliases.keys() }
263
264 pub fn alias(&self, key: &str) -> Option<&str> { self.aliases.get(key).map(String::as_str) }
266
267 fn set_alias(&mut self, name: &str, val: &str) {
269 self.aliases.insert(name.to_string(), val.to_string());
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use std::fs::File;
276
277 use spectral::prelude::*;
278 use tempfile::TempDir;
279
280 use super::*;
281
282 #[test]
283 fn test_default() {
284 let config = Config::default();
285 assert_that!(config.dir()).is_equal_to(&*normalize_path("~/timelog").unwrap());
286 assert_that!(config.logfile())
287 .is_equal_to(&normalize_path("~/timelog/timelog.txt").unwrap());
288 assert_that!(config.stackfile())
289 .is_equal_to(&normalize_path("~/timelog/stack.txt").unwrap());
290 assert_that!(config.reportfile())
291 .is_equal_to(&normalize_path("~/timelog/report.html").unwrap());
292 assert_that!(config.editor()).is_equal_to("vim");
293 assert_that!(config.browser()).is_equal_to(DEFAULT_BROWSER);
294 assert_that!(config.defcmd()).is_equal_to("curr");
295 assert_that!(config.alias_names().count()).is_equal_to(0);
296 }
297
298 #[test]
299 fn test_new() {
300 #[rustfmt::skip]
301 let config = Config::new(
302 "~/.timelogrc", Some("~/timelog"), Some("vim"), Some("chromium"), Some("curr")
303 ).expect("Failed to create config");
304
305 assert_that!(config.dir()).is_equal_to(&*normalize_path("~/timelog").unwrap());
306 assert_that!(config.logfile())
307 .is_equal_to(&normalize_path("~/timelog/timelog.txt").unwrap());
308 assert_that!(config.stackfile())
309 .is_equal_to(&normalize_path("~/timelog/stack.txt").unwrap());
310 assert_that!(config.reportfile())
311 .is_equal_to(&normalize_path("~/timelog/report.html").unwrap());
312 assert_that!(config.editor()).is_equal_to("vim");
313 assert_that!(config.browser()).is_equal_to("chromium");
314 assert_that!(config.defcmd()).is_equal_to("curr");
315 assert_that!(config.alias_names().count()).is_equal_to(0);
316 }
317
318 #[test]
319 fn test_from_file_dir_only() {
320 let tmpdir = TempDir::new().expect("Cannot make tempfile");
321 let path = tmpdir.path();
322 let path_str = path.to_str().unwrap();
323
324 let filename = format!("{path_str}/.timerc");
325 let mut file = File::create(&filename).unwrap();
326 let _ = file.write_all(format!("dir = {path_str}").as_bytes());
327
328 let config = Config::from_file(&filename).expect("Failed to create config from file");
329 let expect_log = normalize_path(format!("{path_str}/timelog.txt").as_str())
330 .expect("Failed to create logfile name");
331 let expect_stack = normalize_path(format!("{path_str}/stack.txt").as_str())
332 .expect("Failed to create stackfile name");
333 let expect_report = normalize_path(format!("{path_str}/report.html").as_str())
334 .expect("Failed to create report file name");
335 assert_that!(config.dir()).is_equal_to(path_str);
336 assert_that!(config.logfile()).is_equal_to(&expect_log);
337 assert_that!(config.stackfile()).is_equal_to(&expect_stack);
338 assert_that!(config.reportfile()).is_equal_to(&expect_report);
339 assert_that!(config.editor()).is_equal_to("vim");
340 assert_that!(config.browser()).is_equal_to(DEFAULT_BROWSER);
341 assert_that!(config.defcmd()).is_equal_to("curr");
342 assert_that!(config.alias_names().count()).is_equal_to(0);
343 }
344
345 #[test]
346 fn test_from_file_base() {
347 let tmpdir = TempDir::new().expect("Cannot make tempfile");
348 let path = tmpdir.path();
349 let path_str = path.to_str().unwrap();
350
351 let filename = format!("{path_str}/.timerc");
352 let mut file = File::create(&filename).unwrap();
353 #[rustfmt::skip]
354 let output = format!("dir={path_str}\neditor=nano\nbrowser=firefox\ndefcmd=stop");
355 let _ = file.write_all(output.as_bytes());
356
357 let config = Config::from_file(&filename).expect("Failed to create config");
358 let expect_log = normalize_path(format!("{path_str}/timelog.txt").as_str())
359 .expect("Failed to create logfile name");
360 let expect_stack = normalize_path(format!("{path_str}/stack.txt").as_str())
361 .expect("Failed to create stackfile name");
362 let expect_report = normalize_path(format!("{path_str}/report.html").as_str())
363 .expect("Failed to create report file name");
364 assert_that!(config.dir()).is_equal_to(&*normalize_path(path_str).unwrap());
365 assert_that!(config.logfile()).is_equal_to(&expect_log);
366 assert_that!(config.stackfile()).is_equal_to(&expect_stack);
367 assert_that!(config.reportfile()).is_equal_to(&expect_report);
368 assert_that!(config.editor()).is_equal_to("nano");
369 assert_that!(config.browser()).is_equal_to("firefox");
370 assert_that!(config.defcmd()).is_equal_to("stop");
371 assert_that!(config.alias_names().count()).is_equal_to(0);
372 }
373
374 #[test]
375 fn test_from_file_aliases() {
376 let tmpdir = TempDir::new().expect("Cannot make tempfile");
377 let path = tmpdir.path();
378 let path_str = path.to_str().unwrap();
379
380 let filename = format!("{path_str}/.timerc");
381 let mut file = File::create(&filename).unwrap();
382 let _ = file.write_all(b"[alias]\na=start +play @A\nb=start +work @B");
383
384 let config = Config::from_file(&filename).expect("Failed to create config");
385 let mut names: Vec<&String> = config.alias_names().collect();
386 names.sort();
387 assert_that!(names).is_equal_to(vec![&"a".to_string(), &"b".to_string()]);
388 assert_that!(config.alias("a"))
389 .is_some()
390 .is_equal_to("start +play @A");
391 assert_that!(config.alias("b"))
392 .is_some()
393 .is_equal_to("start +work @B");
394 }
395}