use anyhow::{Context, Result, bail};
use directories::ProjectDirs;
use log::{debug, info};
use serde::{Deserialize, Serialize};
use std::{env, fs, path::PathBuf};
use crate::cli::{PeriodInput, args::GroupBy};
pub const APP_NAME: &str = "boat";
pub const CONFIG_VAR: &str = "BOAT_CONFIG";
pub const DEFAULT_CONFIG_PATH: &str = "config.toml";
pub const DEFAULT_DB_FILE: &str = "boat.db";
#[derive(Debug, Serialize, Deserialize, Default)]
pub enum OutputFormat {
#[serde(rename = "plain")]
#[default]
Plain,
#[serde(rename = "json")]
Json,
#[serde(rename = "csv")]
Csv,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Configuration {
#[serde(rename = "database_path")]
pub database_path: PathBuf,
#[serde(rename = "period")]
#[serde(skip_serializing_if = "Option::is_none")]
pub period: Option<PeriodInput>,
#[serde(rename = "commands")]
pub commands: CommandsConfig,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct CommandsConfig {
pub new: NewCommandConfig,
pub start: StartCommandConfig,
pub cancel: CancelCommandConfig,
pub modify: ModifyCommandConfig,
pub edit: EditCommandConfig,
pub delete: DeleteCommandConfig,
pub list: ListCommandConfig,
pub report: ReportCommandConfig,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct NewCommandConfig {
#[serde(rename = "auto_start")]
pub auto_start: bool,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct StartCommandConfig {
#[serde(rename = "quick_start")]
pub quick_start: bool,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct CancelCommandConfig {
pub confirm: bool,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct PauseCommandConfig;
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct ModifyCommandConfig {
pub confirm: bool,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct EditCommandConfig {
pub period: Option<PeriodInput>,
#[serde(rename = "show_instructions")]
pub show_instructions: bool,
#[serde(rename = "show_activity_definitions")]
pub show_activity_definitions: bool,
pub confirm: bool,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct DeleteCommandConfig {
pub confirm: bool,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct GetCommandConfig;
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct ListCommandConfig {
#[serde(rename = "period")]
#[serde(skip_serializing_if = "Option::is_none")]
pub period: Option<PeriodInput>,
#[serde(rename = "group_by")]
#[serde(skip_serializing_if = "Option::is_none")]
pub group_by: Option<GroupBy>,
#[serde(rename = "fields")]
#[serde(skip_serializing_if = "Option::is_none")]
pub fields: Option<Vec<String>>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct ReportCommandConfig {
#[serde(rename = "period")]
#[serde(skip_serializing_if = "Option::is_none")]
pub period: Option<PeriodInput>,
#[serde(rename = "fields")]
#[serde(skip_serializing_if = "Option::is_none")]
pub fields: Option<Vec<String>>,
}
impl Configuration {
pub fn create_default() -> Result<Self> {
let config_file = get_config_file_path()?;
let config_dir = config_file
.parent()
.context("config file should have a parent directory")?;
let database_path = config_dir.join(DEFAULT_DB_FILE);
Ok(Self {
database_path,
period: None,
commands: CommandsConfig {
new: NewCommandConfig { auto_start: false },
start: StartCommandConfig { quick_start: true },
cancel: CancelCommandConfig { confirm: true },
modify: ModifyCommandConfig { confirm: true },
edit: EditCommandConfig {
period: None,
show_instructions: true,
show_activity_definitions: true,
confirm: true,
},
delete: DeleteCommandConfig { confirm: true },
list: ListCommandConfig {
period: None,
group_by: None,
fields: None,
},
report: ReportCommandConfig {
period: None,
fields: None,
},
},
})
}
pub fn load_from_fs() -> Result<Configuration> {
let config_file_path = get_config_file_path()?;
let content = fs::read_to_string(config_file_path)?;
info!("parsing config toml");
let config: Configuration = toml::from_str(&content)?;
Ok(config)
}
pub fn to_toml_str(&self) -> Result<String> {
let toml = toml::to_string(&self)?;
debug!("config serialized to TOML: {}", toml);
Ok(toml)
}
}
pub fn get_config_file_path() -> Result<PathBuf> {
if let Ok(config_var) = env::var(CONFIG_VAR) {
let val = PathBuf::from(config_var);
debug!(
"get config from env: {} = {}",
CONFIG_VAR,
val.to_string_lossy()
);
return Ok(val);
}
if let Some(proj_dirs) = ProjectDirs::from("", "", APP_NAME) {
let config_dir = proj_dirs.config_dir();
let config_path = config_dir.join(DEFAULT_CONFIG_PATH);
debug!(
"get default config path from proj dirs: {}",
config_path.display()
);
return Ok(config_path);
}
bail!("could not get config directory")
}
pub fn initialize_config() -> Result<()> {
let config_path = get_config_file_path()?;
if let Some(parent) = config_path.parent() {
info!("creating config dir at: {}", parent.display());
fs::create_dir_all(parent)?;
}
let config = Configuration::create_default()?;
let toml = config.to_toml_str()?;
debug!("generating default config: {config:?}");
debug!("writing config to: {}", config_path.display());
fs::write(config_path, toml)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn get_config_file_path_uses_env_var() {
let expected = std::path::PathBuf::from("/tmp/boat_test_config.toml");
unsafe { std::env::set_var(CONFIG_VAR, &expected) };
let result = get_config_file_path().unwrap();
unsafe { std::env::remove_var(CONFIG_VAR) };
assert_eq!(result, expected);
}
#[test]
fn get_config_file_path_fallback_contains_app_name() {
unsafe { std::env::remove_var(CONFIG_VAR) };
let path = get_config_file_path().unwrap();
assert!(
path.to_string_lossy().contains(APP_NAME),
"default config path should contain '{APP_NAME}'"
);
}
#[test]
fn configuration_create_default_has_expected_defaults() {
unsafe { std::env::remove_var(CONFIG_VAR) };
let config = Configuration::create_default().unwrap();
assert!(!config.commands.new.auto_start);
assert!(config.commands.start.quick_start);
assert!(config.commands.cancel.confirm);
assert!(config.commands.delete.confirm);
assert!(config.commands.edit.confirm);
}
#[test]
fn configuration_to_toml_str_round_trips() {
unsafe { std::env::remove_var(CONFIG_VAR) };
let config = Configuration::create_default().unwrap();
let toml_str = config.to_toml_str().unwrap();
assert!(toml_str.contains("database_path"));
let restored: Configuration = toml::from_str(&toml_str).unwrap();
assert_eq!(restored.database_path, config.database_path);
}
}