use std::path::{Path, PathBuf};
use std::{fmt::Display, fs};
use chrono_tz::Tz;
use getset::{Getters, MutGetters};
use serde_derive::{Deserialize, Serialize};
use directories::ProjectDirs;
use strum_macros::EnumString;
use crate::{
domain::{priority::ItemPriorityKind, reflection::ReflectionsFormatKind},
error::{PaceErrorKind, PaceResult},
};
#[derive(Debug, Deserialize, Default, Serialize, Getters, Clone, MutGetters)]
#[serde(deny_unknown_fields)]
#[serde(rename_all = "kebab-case")]
#[getset(get = "pub")]
pub struct PaceConfig {
#[getset(get = "pub", get_mut = "pub")]
general: GeneralConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[getset(get = "pub", get_mut = "pub")]
reflections: Option<ReflectionsConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[getset(get = "pub", get_mut = "pub")]
export: Option<ExportConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[getset(get = "pub", get_mut = "pub")]
database: Option<DatabaseConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[getset(get = "pub", get_mut = "pub")]
pomodoro: Option<PomodoroConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[getset(get = "pub", get_mut = "pub")]
inbox: Option<InboxConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[getset(get = "pub", get_mut = "pub")]
auto_archival: Option<AutoArchivalConfig>,
}
impl PaceConfig {
pub fn set_activity_log_path(&mut self, activity_log: impl AsRef<Path>) {
*self.general_mut().activity_log_options_mut().path_mut() =
activity_log.as_ref().to_path_buf();
}
pub fn set_time_zone(&mut self, time_zone: Tz) {
*self.general_mut().default_time_zone_mut() = Some(time_zone);
}
}
#[derive(Debug, Deserialize, Serialize, Getters, MutGetters, Clone)]
#[getset(get = "pub")]
#[serde(rename_all = "kebab-case")]
pub struct GeneralConfig {
#[serde(flatten)]
#[getset(get = "pub", get_mut = "pub")]
activity_log_options: ActivityLogOptions,
#[serde(default, skip_serializing_if = "Option::is_none")]
category_separator: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
default_priority: Option<ItemPriorityKind>,
#[serde(default, skip_serializing_if = "Option::is_none")]
most_recent_count: Option<u8>,
#[getset(get = "pub", get_mut = "pub", set = "pub")]
#[serde(default, skip_serializing_if = "Option::is_none")]
default_time_zone: Option<Tz>,
}
#[derive(Debug, Deserialize, Serialize, Getters, MutGetters, Clone, Default)]
#[getset(get = "pub")]
#[serde(rename_all = "kebab-case")]
pub struct ActivityLogOptions {
#[getset(get_mut = "pub")]
path: PathBuf,
#[getset(get_mut = "pub")]
format_kind: Option<ActivityLogFormatKind>,
storage_kind: ActivityLogStorageKind,
}
#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default, EnumString)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum ActivityLogFormatKind {
#[default]
Toml,
}
#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum ActivityLogStorageKind {
#[default]
File,
Database,
#[cfg(test)]
InMemory,
}
impl Default for GeneralConfig {
fn default() -> Self {
Self {
activity_log_options: ActivityLogOptions::default(),
category_separator: Some("::".to_string()),
default_priority: Some(ItemPriorityKind::default()),
most_recent_count: Some(9),
default_time_zone: Some(Tz::UTC),
}
}
}
#[derive(Debug, Deserialize, Default, Serialize, Getters, Clone)]
#[getset(get = "pub")]
#[serde(rename_all = "kebab-case")]
pub struct ReflectionsConfig {
directory: PathBuf,
format: ReflectionsFormatKind,
}
#[derive(Debug, Deserialize, Default, Serialize, Getters, Clone)]
#[getset(get = "pub")]
#[serde(rename_all = "kebab-case")]
pub struct ExportConfig {
include_descriptions: bool,
include_tags: bool,
time_format: String,
}
#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum DatabaseEngineKind {
#[default]
Sqlite,
Postgres,
Mysql,
SqlServer,
}
#[derive(Debug, Deserialize, Default, Serialize, Getters, Clone)]
#[getset(get = "pub")]
#[serde(rename_all = "kebab-case")]
pub struct DatabaseConfig {
connection_string: String,
engine: DatabaseEngineKind,
}
#[derive(Debug, Deserialize, Serialize, Getters, Clone, Copy)]
#[getset(get = "pub")]
#[serde(rename_all = "kebab-case")]
pub struct PomodoroConfig {
break_duration_minutes: u32,
long_break_duration_minutes: u32,
sessions_before_long_break: u32,
work_duration_minutes: u32,
}
impl Default for PomodoroConfig {
fn default() -> Self {
Self {
break_duration_minutes: 5,
long_break_duration_minutes: 15,
sessions_before_long_break: 4,
work_duration_minutes: 25,
}
}
}
#[derive(Debug, Deserialize, Default, Serialize, Getters, Clone)]
#[getset(get = "pub")]
#[serde(rename_all = "kebab-case")]
pub struct InboxConfig {
auto_archive_after_days: u32,
default_priority: String,
max_size: u32,
}
#[derive(Debug, Deserialize, Default, Serialize, Getters, Clone)]
#[getset(get = "pub")]
#[serde(rename_all = "kebab-case")]
pub struct AutoArchivalConfig {
archive_after_days: u32,
archive_path: String,
enabled: bool,
}
pub fn find_root_project_file(
starting_directory: impl AsRef<Path>,
file_name: &str,
) -> Option<PathBuf> {
let mut current_dir = starting_directory.as_ref();
loop {
let config_path = current_dir.join(file_name);
if fs::metadata(&config_path).is_ok() {
return Some(config_path);
}
match current_dir.parent() {
Some(parent) => current_dir = parent,
None => break, }
}
None }
#[tracing::instrument(skip(current_dir))]
pub fn find_root_config_file_path(
current_dir: impl AsRef<Path>,
file_name: &str,
) -> PaceResult<PathBuf> {
find_root_project_file(¤t_dir, file_name).ok_or_else(|| {
PaceErrorKind::ConfigFileNotFound {
current_dir: current_dir.as_ref().to_string_lossy().to_string(),
file_name: file_name.to_string(),
}
.into()
})
}
#[must_use]
#[tracing::instrument]
pub fn get_activity_log_paths(filename: &str) -> Vec<PathBuf> {
vec![
ProjectDirs::from("org", "pace-rs", "pace").map(|project_dirs| {
project_dirs
.data_local_dir()
.to_path_buf()
.join("activities")
}),
Some(PathBuf::from(".")),
]
.into_iter()
.filter_map(|path| path.map(|p| p.join(filename)))
.collect::<Vec<_>>()
}
#[must_use]
#[tracing::instrument]
pub fn get_config_paths(filename: &str) -> Vec<PathBuf> {
#[allow(unused_mut)]
let mut paths = vec![
get_home_config_path(),
ProjectDirs::from("org", "pace-rs", "pace")
.map(|project_dirs| project_dirs.config_dir().to_path_buf()),
get_global_config_path(),
Some(PathBuf::from(".")),
];
#[cfg(target_os = "windows")]
{
if let Some(win_compatibility_paths) = get_windows_portability_config_directories() {
paths.extend(win_compatibility_paths);
};
}
paths
.into_iter()
.filter_map(|path| path.map(|p| p.join(filename)))
.collect::<Vec<_>>()
}
#[tracing::instrument]
pub fn get_home_activity_log_path() -> Option<PathBuf> {
std::env::var_os("PACE_HOME").map(|home_dir| PathBuf::from(home_dir).join("activities"))
}
#[tracing::instrument]
pub fn get_home_config_path() -> Option<PathBuf> {
std::env::var_os("PACE_HOME").map(|home_dir| PathBuf::from(home_dir).join("config"))
}
#[tracing::instrument]
#[cfg(target_os = "windows")]
fn get_windows_portability_config_directories() -> Option<Vec<Option<PathBuf>>> {
std::env::var_os("USERPROFILE").map(|path| {
vec![
Some(PathBuf::from(path.clone()).join(r".config\pace")),
Some(PathBuf::from(path).join(".pace")),
]
})
}
#[tracing::instrument]
#[cfg(target_os = "windows")]
fn get_global_config_path() -> Option<PathBuf> {
std::env::var_os("PROGRAMDATA")
.map(|program_data| PathBuf::from(program_data).join(r"pace\config"))
}
#[tracing::instrument]
#[cfg(any(target_os = "ios", target_arch = "wasm32"))]
fn get_global_config_path() -> Option<PathBuf> {
None
}
#[tracing::instrument]
#[cfg(not(any(target_os = "windows", target_os = "ios", target_arch = "wasm32")))]
fn get_global_config_path() -> Option<PathBuf> {
Some(PathBuf::from("/etc/pace"))
}
impl Display for PaceConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let Ok(config) = toml::to_string_pretty(self) else {
return write!(f, "Error: Could not serialize config to TOML");
};
write!(f, "{config}")
}
}
#[cfg(test)]
mod tests {
use crate::error::TestResult;
use super::*;
use rstest::*;
use std::{fs, path::PathBuf};
#[rstest]
fn test_parse_pace_config_passes(
#[files("../../config/pace.toml")] config_path: PathBuf,
) -> TestResult<()> {
let toml_string = fs::read_to_string(config_path)?;
let _ = toml::from_str::<PaceConfig>(&toml_string)?;
Ok(())
}
#[test]
fn test_add_activity_log_path_passes() {
let mut config = PaceConfig::default();
let activity_log = "activity.log";
config.set_activity_log_path(activity_log);
assert_eq!(
config.general().activity_log_options().path(),
Path::new(activity_log)
);
}
#[test]
fn test_pomodoro_default_values_passes() {
let pomodoro = PomodoroConfig::default();
assert_eq!(*pomodoro.break_duration_minutes(), 5);
assert_eq!(*pomodoro.long_break_duration_minutes(), 15);
assert_eq!(*pomodoro.sessions_before_long_break(), 4);
assert_eq!(*pomodoro.work_duration_minutes(), 25);
}
}