use std::collections::HashMap;
use std::env;
use std::fmt::Debug;
use std::fs;
use std::io::prelude::*;
use configparser::ini::Ini;
use once_cell::sync::Lazy;
use tilde_expand::tilde_expand;
#[doc(inline)]
use crate::error::PathError;
pub static DEFAULT_CONF: Lazy<String> =
Lazy::new(|| normalize_path("~/.timelogrc").expect("Default config file name must be valid"));
pub static DEFAULT_DIR: Lazy<String> =
Lazy::new(|| normalize_path("~/timelog").expect("Default log directory must be valid"));
pub static DEFAULT_EDITOR: Lazy<String> = Lazy::new(|| {
env::var("VISUAL")
.or_else(|_| env::var("EDITOR"))
.unwrap_or_else(|_| String::from("vim"))
});
#[cfg(target_os = "macos")]
pub const DEFAULT_BROWSER: &str = "open";
#[cfg(not(target_os = "macos"))]
pub const DEFAULT_BROWSER: &str = "chromium";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Config {
configfile: String,
dir: String,
editor: String,
browser: String,
defcmd: String,
aliases: HashMap<String, String>
}
pub fn normalize_path(filename: &str) -> Result<String, PathError> {
String::from_utf8(tilde_expand(filename.as_bytes()))
.map_err(|e| PathError::InvalidPath(filename.to_string(), e.to_string()))
}
fn ensure_filename(file: &str) -> Result<String, PathError> {
use std::path::PathBuf;
if file.is_empty() {
return Err(PathError::FilenameMissing);
}
let mut dir = PathBuf::from(normalize_path(file)?);
let filename = dir
.file_name()
.ok_or(PathError::FilenameMissing)?
.to_os_string();
dir.pop();
let mut candir = fs::canonicalize(dir)
.map_err(|e| PathError::InvalidPath(file.to_string(), e.to_string()))?;
candir.push(filename);
Ok(candir.to_str().expect("The config filename must be a valid string").to_string())
}
impl Default for Config {
fn default() -> Self {
Self {
configfile: normalize_path("~/.timelogrc").expect("Invalid config file"),
dir: normalize_path("~/timelog").expect("Invalid user dir"),
editor: "vim".into(),
browser: DEFAULT_BROWSER.into(),
defcmd: "curr".into(),
aliases: HashMap::new()
}
}
}
fn conf_get<'a>(base: &'a HashMap<String, Option<String>>, key: &'static str) -> Option<&'a str> {
base.get(key).and_then(Option::as_deref)
}
impl Config {
pub fn new(
config: &str, dir: Option<&str>, editor: Option<&str>, browser: Option<&str>,
cmd: Option<&str>
) -> crate::Result<Self> {
let dir = dir.unwrap_or("~/timelog");
Ok(Self {
configfile: normalize_path(config).map_err(|_| PathError::InvalidConfigPath)?,
dir: normalize_path(dir).map_err(|_| PathError::InvalidTimelogPath)?,
editor: editor.unwrap_or("vim").into(),
browser: browser.unwrap_or(DEFAULT_BROWSER).into(),
defcmd: cmd.unwrap_or("curr").into(),
aliases: HashMap::new()
})
}
pub fn from_file(filename: &str) -> crate::Result<Self> {
let configfile = ensure_filename(filename)?;
let mut parser = Ini::new();
let config = parser
.load(&configfile)
.map_err(|e| PathError::FileAccess(configfile.clone(), e))?;
let default = HashMap::new();
let base = config.get("default").unwrap_or(&default);
let mut conf = Config::new(
&configfile,
conf_get(base, "dir"),
conf_get(base, "editor"),
conf_get(base, "browser"),
conf_get(base, "defcmd")
)?;
if let Some(aliases) = config.get("alias") {
for (k, v) in aliases.iter() {
if let Some(val) = v.as_ref() {
conf.set_alias(k, val);
}
}
}
Ok(conf)
}
pub fn configfile(&self) -> &str { self.configfile.as_str() }
pub fn dir(&self) -> &str { self.dir.as_str() }
pub fn set_dir(&mut self, dir: &str) { self.dir = dir.to_string() }
pub fn editor(&self) -> &str { self.editor.as_str() }
pub fn set_editor(&mut self, editor: &str) { self.editor = editor.to_string() }
pub fn browser(&self) -> &str { self.browser.as_str() }
pub fn set_browser(&mut self, browser: &str) { self.browser = browser.to_string() }
pub fn defcmd(&self) -> &str { self.defcmd.as_str() }
pub fn logfile(&self) -> String { format!("{}/timelog.txt", self.dir) }
pub fn stackfile(&self) -> String { format!("{}/stack.txt", self.dir) }
pub fn reportfile(&self) -> String { format!("{}/report.html", self.dir) }
pub fn create(&self) -> Result<(), PathError> {
let configfile = self.configfile();
let file = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(configfile)
.map_err(|e| PathError::FileAccess(configfile.to_string(), e.to_string()))?;
let mut stream = std::io::BufWriter::new(file);
writeln!(&mut stream, "dir={}", self.dir())
.map_err(|e| PathError::FileWrite(configfile.to_string(), e.to_string()))?;
writeln!(&mut stream, "editor={}", self.editor())
.map_err(|e| PathError::FileWrite(configfile.to_string(), e.to_string()))?;
writeln!(&mut stream, "browser={}", self.browser())
.map_err(|e| PathError::FileWrite(configfile.to_string(), e.to_string()))?;
writeln!(&mut stream, "defcmd={}", self.defcmd())
.map_err(|e| PathError::FileWrite(configfile.to_string(), e.to_string()))?;
writeln!(&mut stream, "\n[alias]")
.map_err(|e| PathError::FileWrite(configfile.to_string(), e.to_string()))?;
for (key, val) in &self.aliases {
writeln!(stream, " {key} = {val}")
.map_err(|e| PathError::FileWrite(configfile.to_string(), e.to_string()))?;
}
stream
.flush()
.map_err(|e| PathError::FileWrite(configfile.to_string(), e.to_string()))?;
Ok(())
}
pub fn alias_names(&self) -> impl Iterator<Item = &'_ String> { self.aliases.keys() }
pub fn alias(&self, key: &str) -> Option<&str> { self.aliases.get(key).map(String::as_str) }
fn set_alias(&mut self, name: &str, val: &str) {
self.aliases.insert(name.to_string(), val.to_string());
}
}
#[cfg(test)]
mod tests {
use std::fs::File;
use assert2::{assert, let_assert};
use tempfile::TempDir;
use super::*;
#[test]
fn test_default() {
let config = Config::default();
let_assert!(Ok(expect_dir) = normalize_path("~/timelog"));
let_assert!(Ok(expect_config) = normalize_path("~/.timelogrc"));
let_assert!(Ok(expect_log) = normalize_path("~/timelog/timelog.txt"));
let_assert!(Ok(expect_stack) = normalize_path("~/timelog/stack.txt"));
let_assert!(Ok(expect_report) = normalize_path("~/timelog/report.html"));
assert!(config.dir() == expect_dir);
assert!(config.configfile() == expect_config);
assert!(config.logfile() == expect_log);
assert!(config.stackfile() == expect_stack);
assert!(config.reportfile() == expect_report);
assert!(config.editor() == "vim");
assert!(config.browser() == DEFAULT_BROWSER);
assert!(config.defcmd() == "curr");
assert!(config.alias_names().count() == 0);
}
#[test]
fn test_new() {
#[rustfmt::skip]
let_assert!(Ok(config) = Config::new(
"~/.timelogrc", Some("~/timelog"), Some("vim"), Some("chromium"), Some("curr")
));
let_assert!(Ok(expect_dir) = normalize_path("~/timelog"));
let_assert!(Ok(expect_config) = normalize_path("~/.timelogrc"));
let_assert!(Ok(expect_log) = normalize_path("~/timelog/timelog.txt"));
let_assert!(Ok(expect_stack) = normalize_path("~/timelog/stack.txt"));
let_assert!(Ok(expect_report) = normalize_path("~/timelog/report.html"));
assert!(config.dir() == expect_dir);
assert!(config.configfile() == expect_config);
assert!(config.logfile() == expect_log);
assert!(config.stackfile() == expect_stack);
assert!(config.reportfile() == expect_report);
assert!(config.editor() == "vim");
assert!(config.browser() == "chromium");
assert!(config.defcmd() == "curr");
assert!(config.alias_names().count() == 0);
}
#[test]
fn test_set_dir() {
let mut config = Config::default();
config.set_dir("~/.config/timelog");
let_assert!(Ok(expect_config) = normalize_path("~/.timelogrc"));
assert!(config.dir() == "~/.config/timelog");
assert!(config.configfile() == expect_config);
assert!(config.logfile() == String::from("~/.config/timelog/timelog.txt"));
assert!(config.stackfile() == String::from("~/.config/timelog/stack.txt"));
assert!(config.reportfile() == String::from("~/.config/timelog/report.html"));
assert!(config.editor() == "vim");
assert!(config.browser() == DEFAULT_BROWSER);
assert!(config.defcmd() == "curr");
assert!(config.alias_names().count() == 0);
}
#[test]
fn test_set_editor() {
let mut config = Config::default();
config.set_editor("nano");
let_assert!(Ok(expect_dir) = normalize_path("~/timelog"));
let_assert!(Ok(expect_log) = normalize_path("~/timelog/timelog.txt"));
let_assert!(Ok(expect_stack) = normalize_path("~/timelog/stack.txt"));
let_assert!(Ok(expect_report) = normalize_path("~/timelog/report.html"));
assert!(config.dir() == expect_dir);
assert!(config.logfile() == expect_log);
assert!(config.stackfile() == expect_stack);
assert!(config.reportfile() == expect_report);
assert!(config.editor() == "nano");
assert!(config.browser() == DEFAULT_BROWSER);
assert!(config.defcmd() == "curr");
assert!(config.alias_names().count() == 0);
}
#[test]
fn test_set_browser() {
let mut config = Config::default();
config.set_browser("lynx");
let_assert!(Ok(expect_dir) = normalize_path("~/timelog"));
let_assert!(Ok(expect_log) = normalize_path("~/timelog/timelog.txt"));
let_assert!(Ok(expect_stack) = normalize_path("~/timelog/stack.txt"));
let_assert!(Ok(expect_report) = normalize_path("~/timelog/report.html"));
assert!(config.dir() == expect_dir);
assert!(config.logfile() == expect_log);
assert!(config.stackfile() == expect_stack);
assert!(config.reportfile() == expect_report);
assert!(config.editor() == "vim");
assert!(config.browser() == "lynx");
assert!(config.defcmd() == "curr");
assert!(config.alias_names().count() == 0);
}
#[test]
fn test_from_file_dir_only() {
let_assert!(Ok(tmpdir) = TempDir::new());
let path = tmpdir.path();
let_assert!(Some(path_str) = path.to_str());
let filename = format!("{path_str}/.timerc");
let_assert!(Ok(mut file) = File::create(&filename));
let _ = file.write_all(format!("dir = {path_str}").as_bytes());
let_assert!(Ok(config) = Config::from_file(&filename));
let_assert!(Ok(expect_log) = normalize_path(format!("{path_str}/timelog.txt").as_str()));
let_assert!(Ok(expect_stack) = normalize_path(format!("{path_str}/stack.txt").as_str()));
let_assert!(Ok(expect_report) = normalize_path(format!("{path_str}/report.html").as_str()));
assert!(config.dir() == path_str);
assert!(config.logfile() == expect_log);
assert!(config.stackfile() == expect_stack);
assert!(config.reportfile() == expect_report);
assert!(config.editor() == "vim");
assert!(config.browser() == DEFAULT_BROWSER);
assert!(config.defcmd() == "curr");
assert!(config.alias_names().count() == 0);
}
#[test]
fn test_from_file_base() {
let_assert!(Ok(tmpdir) = TempDir::new());
let path = tmpdir.path();
let_assert!(Some(path_str) = path.to_str());
let filename = format!("{path_str}/.timerc");
let_assert!(Ok(mut file) = File::create(&filename));
#[rustfmt::skip]
let output = format!("dir={path_str}\neditor=nano\nbrowser=firefox\ndefcmd=stop");
let_assert!(Ok(_) = file.write_all(output.as_bytes()));
let_assert!(Ok(config) = Config::from_file(&filename));
let_assert!(Ok(expect_log) = normalize_path(format!("{path_str}/timelog.txt").as_str()));
let_assert!(Ok(expect_stack) = normalize_path(format!("{path_str}/stack.txt").as_str()));
let_assert!(Ok(expect_report) = normalize_path(format!("{path_str}/report.html").as_str()));
let_assert!(Ok(expect_dir) = normalize_path(path_str));
assert!(config.dir() == expect_dir);
assert!(config.logfile() == expect_log);
assert!(config.stackfile() == expect_stack);
assert!(config.reportfile() == expect_report);
assert!(config.editor() == "nano");
assert!(config.browser() == "firefox");
assert!(config.defcmd() == "stop");
assert!(config.alias_names().count() == 0);
}
#[test]
fn test_from_file_aliases() {
let_assert!(Ok(tmpdir) = TempDir::new());
let path = tmpdir.path();
let_assert!(Some(path_str) = path.to_str());
let filename = format!("{path_str}/.timerc");
let_assert!(Ok(mut file) = File::create(&filename));
let _ = file.write_all(b"[alias]\na=start +play @A\nb=start +work @B");
let_assert!(Ok(config) = Config::from_file(&filename));
let mut names: Vec<&String> = config.alias_names().collect();
names.sort();
assert!(names == vec![&"a".to_string(), &"b".to_string()]);
let_assert!(Some(alias1) = config.alias("a"));
assert!(alias1 == "start +play @A");
let_assert!(Some(alias2) = config.alias("b"));
assert!(alias2 == "start +work @B");
}
#[test]
fn test_create_config() {
let_assert!(Ok(tmpdir) = TempDir::new());
let path = tmpdir.path();
let_assert!(Some(path_str) = path.to_str());
let configfile = format!("{path_str}/.timelogrc");
let_assert!(Ok(config) = Config::new(&configfile, Some(path_str), None, None, None));
assert!(config.create().is_ok());
let mut cfg = String::new();
let_assert!(Ok(mut file) = File::open(configfile));
assert!(file.read_to_string(&mut cfg).is_ok());
let expected =
format!("dir={path_str}\neditor=vim\nbrowser={DEFAULT_BROWSER}\ndefcmd=curr\n\n[alias]\n");
assert!(cfg == expected);
}
}