#![allow(dead_code)]
use crate::error::MonarchError;
use serde::Deserialize;
use std::path::Path;
#[derive(Debug, Default, Deserialize, PartialEq)]
pub struct Goals {
pub savings_rate: Option<SavingsRateGoal>,
pub emergency_fund: Option<EmergencyFundGoal>,
pub debt_payoff: Option<DebtPayoffGoal>,
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct SavingsRateGoal {
pub target_percent: f64,
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct EmergencyFundGoal {
pub target_months: f64,
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct DebtPayoffGoal {
pub target_date: String,
pub monthly_payment: Option<f64>,
}
impl Goals {
pub fn load_from_path(path: &Path) -> Result<Self, MonarchError> {
let contents = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Goals::default()),
Err(e) => {
return Err(MonarchError::GoalsFile(format!(
"cannot read {}: {e}",
path.display()
)))
}
};
if contents.trim().is_empty() {
return Ok(Goals::default());
}
toml::from_str::<Goals>(&contents)
.map_err(|e| MonarchError::GoalsFile(format!("TOML parse error: {e}")))
}
pub fn load_from_env() -> Result<Self, MonarchError> {
match std::env::var("MONARCH_GOALS_FILE") {
Ok(path) if !path.is_empty() => Self::load_from_path(Path::new(&path)),
_ => Ok(Goals::default()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn write_goals_file(contents: &str) -> NamedTempFile {
let mut f = NamedTempFile::new().unwrap();
write!(f, "{contents}").unwrap();
f
}
#[test]
fn empty_file_yields_default_goals() {
let f = write_goals_file("");
let goals = Goals::load_from_path(f.path()).unwrap();
assert_eq!(goals, Goals::default());
}
#[test]
fn whitespace_only_file_yields_default_goals() {
let f = write_goals_file(" \n\t ");
let goals = Goals::load_from_path(f.path()).unwrap();
assert_eq!(goals, Goals::default());
}
#[test]
fn savings_rate_goal_is_parsed() {
let toml = "[savings_rate]\ntarget_percent = 20.0\n";
let f = write_goals_file(toml);
let goals = Goals::load_from_path(f.path()).unwrap();
assert_eq!(goals.savings_rate.unwrap().target_percent, 20.0);
assert!(goals.emergency_fund.is_none());
assert!(goals.debt_payoff.is_none());
}
#[test]
fn emergency_fund_goal_is_parsed() {
let toml = "[emergency_fund]\ntarget_months = 6.0\n";
let f = write_goals_file(toml);
let goals = Goals::load_from_path(f.path()).unwrap();
assert_eq!(goals.emergency_fund.unwrap().target_months, 6.0);
assert!(goals.savings_rate.is_none());
}
#[test]
fn both_numeric_goals_are_parsed_together() {
let toml =
"[savings_rate]\ntarget_percent = 15.0\n\n[emergency_fund]\ntarget_months = 3.0\n";
let f = write_goals_file(toml);
let goals = Goals::load_from_path(f.path()).unwrap();
assert_eq!(goals.savings_rate.unwrap().target_percent, 15.0);
assert_eq!(goals.emergency_fund.unwrap().target_months, 3.0);
}
#[test]
fn debt_payoff_goal_is_parsed() {
let toml = "[debt_payoff]\ntarget_date = \"2027-12-01\"\nmonthly_payment = 500.0\n";
let f = write_goals_file(toml);
let goals = Goals::load_from_path(f.path()).unwrap();
let dp = goals.debt_payoff.unwrap();
assert_eq!(dp.target_date, "2027-12-01");
assert_eq!(dp.monthly_payment, Some(500.0));
}
#[test]
fn debt_payoff_without_payment_is_parsed() {
let toml = "[debt_payoff]\ntarget_date = \"2028-06-01\"\n";
let f = write_goals_file(toml);
let goals = Goals::load_from_path(f.path()).unwrap();
let dp = goals.debt_payoff.unwrap();
assert_eq!(dp.target_date, "2028-06-01");
assert_eq!(dp.monthly_payment, None);
}
#[test]
fn absent_debt_payoff_is_none() {
let toml = "[savings_rate]\ntarget_percent = 10.0\n";
let f = write_goals_file(toml);
let goals = Goals::load_from_path(f.path()).unwrap();
assert!(goals.debt_payoff.is_none());
}
#[test]
fn invalid_toml_returns_goals_file_error() {
let f = write_goals_file("this is not toml %%% !!!");
let err = Goals::load_from_path(f.path()).unwrap_err();
assert!(
matches!(err, MonarchError::GoalsFile(_)),
"expected GoalsFile error, got: {err:?}"
);
}
#[test]
fn missing_goals_file_returns_default() {
let goals = Goals::load_from_path(Path::new("/nonexistent/goals.toml")).unwrap();
assert_eq!(goals, Goals::default());
}
#[test]
fn permission_denied_goals_file_returns_error() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
let uid_output = Command::new("id").arg("-u").output().unwrap();
let uid_str = String::from_utf8_lossy(&uid_output.stdout);
if uid_str.trim() == "0" {
return;
}
let f = write_goals_file("[savings_rate]\ntarget_percent = 20.0\n");
let path = f.path().to_path_buf();
fs::set_permissions(&path, fs::Permissions::from_mode(0o000)).unwrap();
let err = Goals::load_from_path(&path).unwrap_err();
assert!(
matches!(err, MonarchError::GoalsFile(_)),
"expected GoalsFile error for permission-denied, got: {err:?}"
);
}
#[test]
fn unset_env_var_yields_default_goals() {
temp_env::with_var_unset("MONARCH_GOALS_FILE", || {
let goals = Goals::load_from_env().unwrap();
assert_eq!(goals, Goals::default());
});
}
#[test]
fn env_var_pointing_at_missing_file_returns_default() {
temp_env::with_var(
"MONARCH_GOALS_FILE",
Some("/nonexistent/monarch/goals.toml"),
|| {
let goals = Goals::load_from_env().unwrap();
assert_eq!(goals, Goals::default());
},
);
}
#[test]
fn empty_env_var_yields_default_goals() {
temp_env::with_var("MONARCH_GOALS_FILE", Some(""), || {
let goals = Goals::load_from_env().unwrap();
assert_eq!(goals, Goals::default());
});
}
}