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 assert2::{assert, let_assert};
278 use tempfile::TempDir;
279
280 use super::*;
281
282 #[test]
283 fn test_default() {
284 let config = Config::default();
285 let_assert!(Ok(expect_dir) = normalize_path("~/timelog"));
286 let_assert!(Ok(expect_config) = normalize_path("~/.timelogrc"));
287 let_assert!(Ok(expect_log) = normalize_path("~/timelog/timelog.txt"));
288 let_assert!(Ok(expect_stack) = normalize_path("~/timelog/stack.txt"));
289 let_assert!(Ok(expect_report) = normalize_path("~/timelog/report.html"));
290
291 assert!(config.dir() == expect_dir);
292 assert!(config.configfile() == expect_config);
293 assert!(config.logfile() == expect_log);
294 assert!(config.stackfile() == expect_stack);
295 assert!(config.reportfile() == expect_report);
296 assert!(config.editor() == "vim");
297 assert!(config.browser() == DEFAULT_BROWSER);
298 assert!(config.defcmd() == "curr");
299 assert!(config.alias_names().count() == 0);
300 }
301
302 #[test]
303 fn test_new() {
304 #[rustfmt::skip]
305 let_assert!(Ok(config) = Config::new(
306 "~/.timelogrc", Some("~/timelog"), Some("vim"), Some("chromium"), Some("curr")
307 ));
308
309 let_assert!(Ok(expect_dir) = normalize_path("~/timelog"));
310 let_assert!(Ok(expect_config) = normalize_path("~/.timelogrc"));
311 let_assert!(Ok(expect_log) = normalize_path("~/timelog/timelog.txt"));
312 let_assert!(Ok(expect_stack) = normalize_path("~/timelog/stack.txt"));
313 let_assert!(Ok(expect_report) = normalize_path("~/timelog/report.html"));
314 assert!(config.dir() == expect_dir);
315 assert!(config.configfile() == expect_config);
316 assert!(config.logfile() == expect_log);
317 assert!(config.stackfile() == expect_stack);
318 assert!(config.reportfile() == expect_report);
319 assert!(config.editor() == "vim");
320 assert!(config.browser() == "chromium");
321 assert!(config.defcmd() == "curr");
322 assert!(config.alias_names().count() == 0);
323 }
324
325 #[test]
326 fn test_set_dir() {
327 let mut config = Config::default();
328 config.set_dir("~/.config/timelog");
329 let_assert!(Ok(expect_config) = normalize_path("~/.timelogrc"));
330 assert!(config.dir() == "~/.config/timelog");
331 assert!(config.configfile() == expect_config);
332 assert!(config.logfile() == String::from("~/.config/timelog/timelog.txt"));
333 assert!(config.stackfile() == String::from("~/.config/timelog/stack.txt"));
334 assert!(config.reportfile() == String::from("~/.config/timelog/report.html"));
335 assert!(config.editor() == "vim");
336 assert!(config.browser() == DEFAULT_BROWSER);
337 assert!(config.defcmd() == "curr");
338 assert!(config.alias_names().count() == 0);
339 }
340
341 #[test]
342 fn test_set_editor() {
343 let mut config = Config::default();
344 config.set_editor("nano");
345 let_assert!(Ok(expect_dir) = normalize_path("~/timelog"));
346 let_assert!(Ok(expect_log) = normalize_path("~/timelog/timelog.txt"));
347 let_assert!(Ok(expect_stack) = normalize_path("~/timelog/stack.txt"));
348 let_assert!(Ok(expect_report) = normalize_path("~/timelog/report.html"));
349 assert!(config.dir() == expect_dir);
350 assert!(config.logfile() == expect_log);
351 assert!(config.stackfile() == expect_stack);
352 assert!(config.reportfile() == expect_report);
353 assert!(config.editor() == "nano");
354 assert!(config.browser() == DEFAULT_BROWSER);
355 assert!(config.defcmd() == "curr");
356 assert!(config.alias_names().count() == 0);
357 }
358
359 #[test]
360 fn test_set_browser() {
361 let mut config = Config::default();
362 config.set_browser("lynx");
363 let_assert!(Ok(expect_dir) = normalize_path("~/timelog"));
364 let_assert!(Ok(expect_log) = normalize_path("~/timelog/timelog.txt"));
365 let_assert!(Ok(expect_stack) = normalize_path("~/timelog/stack.txt"));
366 let_assert!(Ok(expect_report) = normalize_path("~/timelog/report.html"));
367 assert!(config.dir() == expect_dir);
368 assert!(config.logfile() == expect_log);
369 assert!(config.stackfile() == expect_stack);
370 assert!(config.reportfile() == expect_report);
371 assert!(config.editor() == "vim");
372 assert!(config.browser() == "lynx");
373 assert!(config.defcmd() == "curr");
374 assert!(config.alias_names().count() == 0);
375 }
376
377 #[test]
378 fn test_from_file_dir_only() {
379 let_assert!(Ok(tmpdir) = TempDir::new());
380 let path = tmpdir.path();
381 let_assert!(Some(path_str) = path.to_str());
382
383 let filename = format!("{path_str}/.timerc");
384 let_assert!(Ok(mut file) = File::create(&filename));
385 let _ = file.write_all(format!("dir = {path_str}").as_bytes());
386
387 let_assert!(Ok(config) = Config::from_file(&filename));
388 let_assert!(Ok(expect_log) = normalize_path(format!("{path_str}/timelog.txt").as_str()));
389 let_assert!(Ok(expect_stack) = normalize_path(format!("{path_str}/stack.txt").as_str()));
390 let_assert!(Ok(expect_report) = normalize_path(format!("{path_str}/report.html").as_str()));
391 assert!(config.dir() == path_str);
392 assert!(config.logfile() == expect_log);
393 assert!(config.stackfile() == expect_stack);
394 assert!(config.reportfile() == expect_report);
395 assert!(config.editor() == "vim");
396 assert!(config.browser() == DEFAULT_BROWSER);
397 assert!(config.defcmd() == "curr");
398 assert!(config.alias_names().count() == 0);
399 }
400
401 #[test]
402 fn test_from_file_base() {
403 let_assert!(Ok(tmpdir) = TempDir::new());
404 let path = tmpdir.path();
405 let_assert!(Some(path_str) = path.to_str());
406
407 let filename = format!("{path_str}/.timerc");
408 let_assert!(Ok(mut file) = File::create(&filename));
409 #[rustfmt::skip]
410 let output = format!("dir={path_str}\neditor=nano\nbrowser=firefox\ndefcmd=stop");
411 let_assert!(Ok(_) = file.write_all(output.as_bytes()));
412
413 let_assert!(Ok(config) = Config::from_file(&filename));
414 let_assert!(Ok(expect_log) = normalize_path(format!("{path_str}/timelog.txt").as_str()));
415 let_assert!(Ok(expect_stack) = normalize_path(format!("{path_str}/stack.txt").as_str()));
416 let_assert!(Ok(expect_report) = normalize_path(format!("{path_str}/report.html").as_str()));
417 let_assert!(Ok(expect_dir) = normalize_path(path_str));
418 assert!(config.dir() == expect_dir);
419 assert!(config.logfile() == expect_log);
420 assert!(config.stackfile() == expect_stack);
421 assert!(config.reportfile() == expect_report);
422 assert!(config.editor() == "nano");
423 assert!(config.browser() == "firefox");
424 assert!(config.defcmd() == "stop");
425 assert!(config.alias_names().count() == 0);
426 }
427
428 #[test]
429 fn test_from_file_aliases() {
430 let_assert!(Ok(tmpdir) = TempDir::new());
431 let path = tmpdir.path();
432 let_assert!(Some(path_str) = path.to_str());
433
434 let filename = format!("{path_str}/.timerc");
435 let_assert!(Ok(mut file) = File::create(&filename));
436 let _ = file.write_all(b"[alias]\na=start +play @A\nb=start +work @B");
437
438 let_assert!(Ok(config) = Config::from_file(&filename));
439 let mut names: Vec<&String> = config.alias_names().collect();
440 names.sort();
441 assert!(names == vec![&"a".to_string(), &"b".to_string()]);
442 let_assert!(Some(alias1) = config.alias("a"));
443 assert!(alias1 == "start +play @A");
444 let_assert!(Some(alias2) = config.alias("b"));
445 assert!(alias2 == "start +work @B");
446 }
447
448 #[test]
449 fn test_create_config() {
450 let_assert!(Ok(tmpdir) = TempDir::new());
451 let path = tmpdir.path();
452 let_assert!(Some(path_str) = path.to_str());
453
454 let configfile = format!("{path_str}/.timelogrc");
455 let_assert!(Ok(config) = Config::new(&configfile, Some(path_str), None, None, None));
456 assert!(config.create().is_ok());
457
458 let mut cfg = String::new();
459 let_assert!(Ok(mut file) = File::open(configfile));
460 assert!(file.read_to_string(&mut cfg).is_ok());
461 let expected =
462 format!("dir={path_str}\neditor=vim\nbrowser={DEFAULT_BROWSER}\ndefcmd=curr\n\n[alias]\n");
463 assert!(cfg == expected);
464 }
465}