367 lines
11 KiB
Rust
367 lines
11 KiB
Rust
use std::path::{Path, PathBuf};
|
|
use std::fs::{File, create_dir_all};
|
|
use std::io::{Read, Write};
|
|
use std::str::FromStr;
|
|
use std::collections::HashMap;
|
|
|
|
use directories::{UserDirs, ProjectDirs};
|
|
use serde::{Serialize, Deserialize};
|
|
use toml::to_string;
|
|
use chrono::Weekday;
|
|
|
|
use crate::{error::{Result, Error::{self, *}}, formatters::Formatter};
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Copy, Clone, PartialEq, Eq)]
|
|
pub enum WeekDay {
|
|
Monday,
|
|
Tuesday,
|
|
Wednesday,
|
|
Thursday,
|
|
Friday,
|
|
Saturday,
|
|
Sunday,
|
|
}
|
|
|
|
impl FromStr for WeekDay {
|
|
type Err = Error;
|
|
|
|
fn from_str(s: &str) -> Result<WeekDay> {
|
|
Ok(match s.to_lowercase().as_str() {
|
|
"monday" => WeekDay::Monday,
|
|
"tuesday" => WeekDay::Tuesday,
|
|
"wednesday" => WeekDay::Wednesday,
|
|
"thursday" => WeekDay::Thursday,
|
|
"friday" => WeekDay::Friday,
|
|
"saturday" => WeekDay::Saturday,
|
|
"sunday" => WeekDay::Sunday,
|
|
x => return Err(InvalidWeekDaySpec(x.to_owned())),
|
|
})
|
|
}
|
|
}
|
|
|
|
impl From<Weekday> for WeekDay {
|
|
fn from(wd: Weekday) -> WeekDay {
|
|
match wd {
|
|
Weekday::Mon => WeekDay::Monday,
|
|
Weekday::Tue => WeekDay::Tuesday,
|
|
Weekday::Wed => WeekDay::Wednesday,
|
|
Weekday::Thu => WeekDay::Thursday,
|
|
Weekday::Fri => WeekDay::Friday,
|
|
Weekday::Sat => WeekDay::Saturday,
|
|
Weekday::Sun => WeekDay::Sunday,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct ChartFormatterSettings {
|
|
/// This setting is used to highlight hours that go beyond the daily goal.
|
|
/// If unset all hours will look the same.
|
|
pub daily_goal_hours: u32,
|
|
|
|
/// If set, weekly hour count will be highlighted in green if equal or
|
|
/// higher than the weekly goal or in red if lower. If not set number will
|
|
/// be displayed in the default color.
|
|
pub weekly_goal_hours: u32,
|
|
|
|
/// This is the amount of minutes that each character represents in the
|
|
/// chart
|
|
pub character_equals_minutes: usize,
|
|
}
|
|
|
|
impl Default for ChartFormatterSettings {
|
|
fn default() -> Self {
|
|
Self {
|
|
daily_goal_hours: 0,
|
|
weekly_goal_hours: 0,
|
|
character_equals_minutes: 30,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct FormattersSettings {
|
|
pub chart: ChartFormatterSettings,
|
|
|
|
#[serde(flatten)]
|
|
pub extra: HashMap<String, serde_json::Value>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct BaseCommandSettings {
|
|
pub default_formatter: Option<Formatter>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct CommandsSettings {
|
|
pub display: BaseCommandSettings,
|
|
pub month: BaseCommandSettings,
|
|
pub today: BaseCommandSettings,
|
|
pub week: BaseCommandSettings,
|
|
pub yesterday: BaseCommandSettings,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct Config {
|
|
#[serde(skip)]
|
|
pub path: Option<PathBuf>,
|
|
|
|
/// Absolute path to the sqlite database
|
|
pub database_file: PathBuf, // "/home/user/.timetrap.db"
|
|
|
|
/// The duration of time to use for rounding with the -r flag
|
|
pub round_in_seconds: u32, // 900
|
|
|
|
/// delimiter used when appending notes via t edit --append
|
|
pub append_notes_delimiter: String, //" "
|
|
|
|
/// an array of directories to search for user defined fomatter classes
|
|
pub formatter_search_paths: Vec<PathBuf>, //- "/home/user/.timetrap/formatters"
|
|
|
|
/// The format to use when display is invoked without a --format option
|
|
pub default_formatter: Formatter, //text
|
|
|
|
/// Which auto sheet module to use.
|
|
pub auto_sheet: String, //dotfiles
|
|
|
|
/// an array of directories to search for user defined auto_sheet classes
|
|
pub auto_sheet_search_paths: Vec<PathBuf>, // - "/home/user/.timetrap/auto_sheets"
|
|
|
|
/// The default command to invoke when you call t
|
|
pub default_command: Option<String>,
|
|
|
|
/// Automatically check out of running entries when you check in or out
|
|
pub auto_checkout: bool, // false
|
|
|
|
/// Prompt for a note if one isn't provided when checking in
|
|
pub require_note: bool, // true
|
|
|
|
/// The command to start editing notes. Defaults to false which means no
|
|
/// external editor is used. Please see the section below on Notes Editing
|
|
/// for tips on using non-terminal based editors. Example: note_editor:
|
|
/// "vim"
|
|
pub note_editor: Option<String>, // nvim
|
|
|
|
/// The day of the week to use as the start of the week for t week.
|
|
pub week_start: WeekDay, // Monday
|
|
|
|
/// How many unique entries to show when choosing interactively (for resume
|
|
/// and kill)
|
|
pub interactive_entries: usize,
|
|
|
|
/// Individual settings for each formatter
|
|
pub formatters: FormattersSettings,
|
|
|
|
/// Settings for each command
|
|
pub commands: CommandsSettings,
|
|
|
|
#[serde(flatten)]
|
|
pub extra: HashMap<String, serde_json::Value>,
|
|
}
|
|
|
|
impl Config {
|
|
/// Tries as hard as possible to read the current configuration. Retrieving
|
|
/// the path to it from the environment or common locations.
|
|
pub fn read(timetrap_config_file: Option<&str>) -> Result<Config> {
|
|
// first try from env variable TIMETRAP_CONFIG_FILE
|
|
if let Some(value) = timetrap_config_file {
|
|
return if value.ends_with(".toml") {
|
|
let config_path = PathBuf::from(&value);
|
|
|
|
if config_path.is_file() {
|
|
Self::read_from_toml(value)
|
|
} else {
|
|
Self::create_and_return_config(config_path.parent().unwrap(), &config_path)
|
|
}
|
|
} else {
|
|
Self::read_from_yaml(value)
|
|
};
|
|
}
|
|
|
|
// Next try from some known directories
|
|
if let Some(user_dirs) = UserDirs::new() {
|
|
let old_location = {
|
|
let mut p = user_dirs.home_dir().to_owned();
|
|
p.push(".timetrap.yml");
|
|
p
|
|
};
|
|
|
|
if old_location.is_file() {
|
|
return Self::read_from_yaml(old_location);
|
|
}
|
|
|
|
if let Some(project_dirs) = ProjectDirs::from("tk", "categulario", "tiempo") {
|
|
let config_filename = {
|
|
let mut conf = project_dirs.config_dir().to_owned();
|
|
conf.push("config.toml");
|
|
conf
|
|
};
|
|
|
|
if config_filename.is_file() {
|
|
Self::read_from_toml(config_filename)
|
|
} else {
|
|
let config_dir = project_dirs.config_dir();
|
|
|
|
create_dir_all(config_dir).map_err(|e| CouldntCreateConfigDir {
|
|
path: config_dir.to_owned(),
|
|
error: e.to_string(),
|
|
})?;
|
|
Self::create_and_return_config(project_dirs.config_dir(), &config_filename)
|
|
}
|
|
} else {
|
|
Err(NoHomeDir)
|
|
}
|
|
} else {
|
|
Err(NoHomeDir)
|
|
}
|
|
}
|
|
|
|
pub fn write<P: AsRef<Path>>(&self, path: P) -> Result<String> {
|
|
let path = path.as_ref();
|
|
let ext = path.extension().and_then(|e| e.to_str());
|
|
|
|
let output = match ext {
|
|
Some("toml") => {
|
|
toml::to_string(self).unwrap()
|
|
},
|
|
Some("yaml" | "yml") => {
|
|
serde_yaml::to_string(self).unwrap()
|
|
},
|
|
Some(ext) => {
|
|
return Err(Error::GenericFailure(format!("\
|
|
Your config file has '{}' extension which I don't understand, so I'd rather not
|
|
mess with its contents. If its formatted as toml use '.toml' extension. If it is
|
|
yaml use '.yml' or '.yaml'", ext)));
|
|
},
|
|
None => {
|
|
return Err(Error::GenericFailure(format!("\
|
|
Your config file, located at {} has no extension so I'll not write to it. Please
|
|
set it an extension like '.toml' or '.yaml' (and ensure it matches the content's
|
|
format)", path.display())));
|
|
},
|
|
};
|
|
|
|
let mut f = File::create(path)?;
|
|
|
|
f.write_all(output.as_bytes())?;
|
|
|
|
Ok(output)
|
|
}
|
|
|
|
fn read_from_yaml<P: AsRef<Path>>(path: P) -> Result<Config> {
|
|
let path: PathBuf = path.as_ref().into();
|
|
|
|
let mut contents = String::new();
|
|
let mut file = File::open(&path).map_err(|e| CouldntReadConfigFile {
|
|
path: path.clone(),
|
|
error: e,
|
|
})?;
|
|
|
|
file.read_to_string(&mut contents).map_err(|e| CouldntReadConfigFile {
|
|
path: path.clone(),
|
|
error: e,
|
|
})?;
|
|
|
|
let mut config: Config = serde_yaml::from_str(&contents).map_err(|error| YamlError {
|
|
path: path.clone(), error
|
|
})?;
|
|
|
|
config.path = Some(path);
|
|
|
|
Ok(config)
|
|
}
|
|
|
|
fn read_from_toml<P: AsRef<Path>>(path: P) -> Result<Config> {
|
|
let path: PathBuf = path.as_ref().into();
|
|
|
|
let mut contents = String::new();
|
|
let mut file = File::open(&path).map_err(|e| CouldntReadConfigFile {
|
|
path: path.clone(),
|
|
error: e,
|
|
})?;
|
|
|
|
file.read_to_string(&mut contents).map_err(|e| CouldntReadConfigFile {
|
|
path: path.clone(),
|
|
error: e,
|
|
})?;
|
|
|
|
let mut config: Config = toml::from_str(&contents).map_err(|error| TomlError {
|
|
path: path.clone(), error
|
|
})?;
|
|
|
|
config.path = Some(path);
|
|
|
|
Ok(config)
|
|
}
|
|
|
|
/// Assume the configuration file does not exist, create a default one and
|
|
/// return it.
|
|
fn create_and_return_config(project_dir: &Path, config_filename: &Path) -> Result<Config> {
|
|
let database_filename = {
|
|
let mut p = project_dir.to_owned();
|
|
p.push("database.sqlite3");
|
|
p
|
|
};
|
|
let formatter_search_paths = {
|
|
let mut p = project_dir.to_owned();
|
|
p.push("formatters");
|
|
p
|
|
};
|
|
let auto_sheet_search_paths = {
|
|
let mut p = project_dir.to_owned();
|
|
p.push("auto_sheets");
|
|
p
|
|
};
|
|
|
|
let config = Config {
|
|
path: Some(config_filename.to_owned()),
|
|
database_file: database_filename,
|
|
formatter_search_paths: vec![formatter_search_paths],
|
|
auto_sheet_search_paths: vec![auto_sheet_search_paths],
|
|
..Default::default()
|
|
};
|
|
|
|
let mut config_file = File::create(config_filename).map_err(|e| CouldntEditConfigFile {
|
|
path: config_filename.to_owned(),
|
|
error: e.to_string(),
|
|
})?;
|
|
|
|
config_file.write_all(to_string(&config).unwrap().as_bytes()).map_err(|e| CouldntEditConfigFile {
|
|
path: config_filename.to_owned(),
|
|
error: e.to_string(),
|
|
})?;
|
|
|
|
Ok(config)
|
|
}
|
|
}
|
|
|
|
impl Default for Config {
|
|
fn default() -> Config {
|
|
Config {
|
|
path: None,
|
|
extra: HashMap::new(),
|
|
|
|
database_file: PathBuf::new(),
|
|
round_in_seconds: 900,
|
|
append_notes_delimiter: " ".into(),
|
|
formatter_search_paths: Vec::new(),
|
|
default_formatter: Formatter::Text,
|
|
auto_sheet: "dotfiles".into(),
|
|
auto_sheet_search_paths: Vec::new(),
|
|
default_command: None,
|
|
auto_checkout: false,
|
|
require_note: true,
|
|
note_editor: None,
|
|
week_start: WeekDay::Monday,
|
|
interactive_entries: 5,
|
|
formatters: Default::default(),
|
|
commands: Default::default(),
|
|
}
|
|
}
|
|
}
|