Skip to main content

timelog/
config.rs

1//! Configuration file definition
2//!
3//! # Examples
4//!
5//! ```rust
6//! use timelog::{Config, Entry, Result};
7//!
8//! # fn main() -> Result<()> {
9//! # let events: Vec<Entry> = vec![];
10//! # let mut event_iter = events.into_iter();
11//! let conf = Config::from_file("~/.timelogrc")?;
12//!
13//! println!("Dir: {}", conf.dir());
14//! println!("Logfile: {}", conf.logfile());
15//! println!("Editor: {}", conf.editor());
16//! #   Ok(())
17//! #  }
18//! ```
19//!
20//! # Description
21//!
22//! The [`Config`] struct represents the configuration file information.
23
24use 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
37/// Default path to the timelog config file
38pub static DEFAULT_CONF: Lazy<String> =
39    Lazy::new(|| normalize_path("~/.timelogrc").expect("Default config file name must be valid"));
40/// Default path to the timelog log directory
41pub static DEFAULT_DIR: Lazy<String> =
42    Lazy::new(|| normalize_path("~/timelog").expect("Default log directory must be valid"));
43/// Default command to open the editor
44pub 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/// Default command to open the browser
51#[cfg(target_os = "macos")]
52pub const DEFAULT_BROWSER: &str = "open";
53
54/// Default command to open the browser
55#[cfg(not(target_os = "macos"))]
56pub const DEFAULT_BROWSER: &str = "chromium";
57
58/// Type specifying the configuration for the timelog program.
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct Config {
61    /// The name of the configuration file
62    configfile: String,
63    /// The path of the directory that stores the log and stack files
64    dir:        String,
65    /// The path to the editor used with the `edit` command
66    editor:     String,
67    /// The path to the browser used with the `chart` command
68    browser:    String,
69    /// The default command if none is entered
70    defcmd:     String,
71    /// List of aliases to extend the commands
72    aliases:    HashMap<String, String>
73}
74
75/// Utility function to handle tilde expansion of a path.
76///
77/// # Errors
78///
79/// - Return a [`PathError::InvalidPath`] if there are invalid characters in the path
80pub fn normalize_path(filename: &str) -> Result<String, PathError> {
81    // If this fails, the user directory must contain invalid characters.
82    String::from_utf8(tilde_expand(filename.as_bytes()))
83        .map_err(|e| PathError::InvalidPath(filename.to_string(), e.to_string()))
84}
85
86// Ensure the filename either exists or could be created in the supplied directory
87//
88// # Errors
89//
90// - Return [`PathError::FilenameMissing`] if the filename does not exist.
91// - Return [`PathError::InvalidPath`] if the dirname portion of the file is not valid.
92fn 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    /// Create a [`Config`] with all of the default parameters.
113    ///
114    /// ## Panics
115    ///
116    /// If any of the default valures are not legal in the current system, the code will
117    /// panic. These is a programmer-specified defaults, and should never fail.
118    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
130// Utility function for extracting conf data.
131fn 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    /// Create a new [`Config`] object with supplied parameters
137    ///
138    /// # Errors
139    ///
140    /// - Return [`PathError::InvalidConfigPath`] if the configuration filename is not valid.
141    /// - Return [`PathError::InvalidTimelogPath`] if the timelog directory is not valid.
142    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    /// Create a new [`Config`] object from the supplied config file
158    ///
159    /// # Errors
160    ///
161    /// - Return [`PathError::InvalidConfigPath`] if the configuration filename is not valid.
162    /// - Return [`PathError::InvalidTimelogPath`] if the timelog directory is not valid.
163    /// - Return [`PathError::FileAccess`] if the config file is not accessible.
164    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    /// Return a [`&str`] containing the name of the config file.
192    pub fn configfile(&self) -> &str { self.configfile.as_str() }
193
194    /// Return the path of the directory that stores the log and stack files
195    pub fn dir(&self) -> &str { self.dir.as_str() }
196
197    /// Set the path of the directory that stores the log and stack files
198    pub fn set_dir(&mut self, dir: &str) { self.dir = dir.to_string() }
199
200    /// Return the path to the editor used with the `edit` command
201    pub fn editor(&self) -> &str { self.editor.as_str() }
202
203    /// Set the path to the editor used with the `edit` command
204    pub fn set_editor(&mut self, editor: &str) { self.editor = editor.to_string() }
205
206    /// The path to the browser used with the `chart` command
207    pub fn browser(&self) -> &str { self.browser.as_str() }
208
209    /// The path to the browser used with the `chart` command
210    pub fn set_browser(&mut self, browser: &str) { self.browser = browser.to_string() }
211
212    /// Return the default command if none is entered
213    pub fn defcmd(&self) -> &str { self.defcmd.as_str() }
214
215    /// The file containing the timelog entries
216    pub fn logfile(&self) -> String { format!("{}/timelog.txt", self.dir) }
217
218    /// The file that holds the stack
219    pub fn stackfile(&self) -> String { format!("{}/stack.txt", self.dir) }
220
221    /// The file containing the timelog entries
222    pub fn reportfile(&self) -> String { format!("{}/report.html", self.dir) }
223
224    /// Write the configuration to disk in the file specified by the [`Config`]
225    ///
226    /// # Errors
227    ///
228    /// - Return [`PathError::FileAccess`] if we are unable to write the configuration.
229    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    /// Return an iterator over the alias names
262    pub fn alias_names(&self) -> impl Iterator<Item = &'_ String> { self.aliases.keys() }
263
264    /// Retrieve the value associate with the named alias, if one exists.
265    pub fn alias(&self, key: &str) -> Option<&str> { self.aliases.get(key).map(String::as_str) }
266
267    /// Set the value of the named alias
268    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}