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 [`String`] 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 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}