use crate::client::{Account, Cashflow};
use crate::goals::Goals;
use serde::Serialize;
#[derive(Debug, Serialize, PartialEq)]
pub struct GoalsProgress {
#[serde(skip_serializing_if = "Option::is_none")]
pub savings_rate: Option<GoalStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub emergency_fund: Option<GoalStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guidance: Option<String>,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct GoalStatus {
pub status: String,
}
pub fn classify_goal(actual: f64, goal: f64) -> &'static str {
if actual >= goal {
"on track"
} else if actual < goal / 2.0 {
"off"
} else {
"drifting"
}
}
pub fn actual_savings_rate_pct(cashflow: &Cashflow) -> f64 {
if cashflow.income == 0.0 {
return 0.0;
}
(cashflow.income - cashflow.spending) / cashflow.income * 100.0
}
pub fn actual_reserve_months(accounts: &[Account], cashflow: &Cashflow) -> f64 {
if cashflow.spending == 0.0 {
return 0.0;
}
const LIQUID_TYPES: &[&str] = &["savings", "checking", "depository", "money_market"];
let liquid_balance: f64 = accounts
.iter()
.filter(|a| LIQUID_TYPES.contains(&a.account_type.name.as_str()))
.map(|a| a.current_balance)
.sum();
liquid_balance / cashflow.spending
}
pub fn compute_progress(goals: &Goals, accounts: &[Account], cashflow: &Cashflow) -> GoalsProgress {
let savings_rate = goals.savings_rate.as_ref().map(|g| {
let actual = actual_savings_rate_pct(cashflow);
GoalStatus {
status: classify_goal(actual, g.target_percent).to_string(),
}
});
let emergency_fund = goals.emergency_fund.as_ref().map(|g| {
let actual = actual_reserve_months(accounts, cashflow);
GoalStatus {
status: classify_goal(actual, g.target_months).to_string(),
}
});
let has_no_computable_goals = savings_rate.is_none() && emergency_fund.is_none();
let guidance = has_no_computable_goals.then(|| {
if goals.debt_payoff.is_some() {
"A debt-payoff goal is configured, but debt-payoff progress tracking is \
not available yet (tracked in #27). Set a savings-rate or emergency-fund \
goal to see progress now."
.to_string()
} else {
"No goals configured yet. Add a goals.toml file and set \
MONARCH_GOALS_FILE to its path to start tracking progress."
.to_string()
}
});
GoalsProgress {
savings_rate,
emergency_fund,
guidance,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::{AccountType, Cashflow};
use crate::goals::{EmergencyFundGoal, Goals, SavingsRateGoal};
#[test]
fn classify_at_goal_is_on_track() {
assert_eq!(classify_goal(20.0, 20.0), "on track");
}
#[test]
fn classify_above_goal_is_on_track() {
assert_eq!(classify_goal(25.0, 20.0), "on track");
}
#[test]
fn classify_at_half_goal_is_drifting() {
assert_eq!(classify_goal(10.0, 20.0), "drifting");
}
#[test]
fn classify_just_below_half_is_off() {
assert_eq!(classify_goal(9.9, 20.0), "off");
}
#[test]
fn classify_midrange_is_drifting() {
assert_eq!(classify_goal(17.0, 20.0), "drifting");
}
#[test]
fn classify_far_below_half_is_off() {
assert_eq!(classify_goal(6.0, 20.0), "off");
}
#[test]
fn classify_zero_actual_is_off() {
assert_eq!(classify_goal(0.0, 20.0), "off");
}
#[test]
fn classify_emergency_fund_at_goal_is_on_track() {
assert_eq!(classify_goal(6.0, 6.0), "on track");
}
#[test]
fn classify_emergency_fund_above_goal_is_on_track() {
assert_eq!(classify_goal(7.0, 6.0), "on track");
}
#[test]
fn classify_emergency_fund_drifting() {
assert_eq!(classify_goal(4.0, 6.0), "drifting");
}
#[test]
fn classify_emergency_fund_off() {
assert_eq!(classify_goal(2.0, 6.0), "off");
}
fn cashflow(income: f64, spending: f64) -> Cashflow {
Cashflow {
income,
spending,
prior_month_spending: 0.0,
}
}
#[test]
fn savings_rate_pct_is_correct() {
assert_eq!(actual_savings_rate_pct(&cashflow(10000.0, 7500.0)), 25.0);
}
#[test]
fn savings_rate_pct_zero_income_returns_zero() {
assert_eq!(actual_savings_rate_pct(&cashflow(0.0, 0.0)), 0.0);
}
#[test]
fn savings_rate_pct_full_spending_returns_zero() {
assert_eq!(actual_savings_rate_pct(&cashflow(10000.0, 10000.0)), 0.0);
}
fn savings_account(balance: f64) -> Account {
Account {
id: "s1".to_string(),
display_name: "Emergency Fund".to_string(),
current_balance: balance,
account_type: AccountType {
name: "savings".to_string(),
},
}
}
fn checking_account(balance: f64) -> Account {
Account {
id: "c1".to_string(),
display_name: "Checking".to_string(),
current_balance: balance,
account_type: AccountType {
name: "checking".to_string(),
},
}
}
fn money_market_account(balance: f64) -> Account {
Account {
id: "mm1".to_string(),
display_name: "HYSA".to_string(),
current_balance: balance,
account_type: AccountType {
name: "money_market".to_string(),
},
}
}
fn brokerage_account(balance: f64) -> Account {
Account {
id: "b1".to_string(),
display_name: "Brokerage".to_string(),
current_balance: balance,
account_type: AccountType {
name: "brokerage".to_string(),
},
}
}
fn retirement_account(balance: f64) -> Account {
Account {
id: "r1".to_string(),
display_name: "401k".to_string(),
current_balance: balance,
account_type: AccountType {
name: "retirement".to_string(),
},
}
}
#[test]
fn reserve_months_counts_money_market() {
let accounts = vec![money_market_account(20000.0)];
let cf = cashflow(6000.0, 5000.0);
assert_eq!(actual_reserve_months(&accounts, &cf), 4.0);
}
#[test]
fn reserve_months_counts_checking() {
let accounts = vec![checking_account(10000.0)];
let cf = cashflow(6000.0, 5000.0);
assert_eq!(actual_reserve_months(&accounts, &cf), 2.0);
}
#[test]
fn reserve_months_combines_all_liquid_types() {
let accounts = vec![
savings_account(30000.0),
checking_account(10000.0),
money_market_account(20000.0),
];
let cf = cashflow(6000.0, 5000.0);
assert_eq!(actual_reserve_months(&accounts, &cf), 12.0);
}
#[test]
fn reserve_months_excludes_brokerage() {
let accounts = vec![savings_account(30000.0), brokerage_account(100000.0)];
let cf = cashflow(6000.0, 5000.0);
assert_eq!(actual_reserve_months(&accounts, &cf), 6.0);
}
#[test]
fn reserve_months_excludes_retirement() {
let accounts = vec![savings_account(30000.0), retirement_account(200000.0)];
let cf = cashflow(6000.0, 5000.0);
assert_eq!(actual_reserve_months(&accounts, &cf), 6.0);
}
#[test]
fn reserve_months_divides_savings_by_spending() {
let accounts = vec![savings_account(30000.0)];
let cf = cashflow(6000.0, 5000.0);
assert_eq!(actual_reserve_months(&accounts, &cf), 6.0);
}
#[test]
fn reserve_months_ignores_non_liquid_accounts() {
let accounts = vec![savings_account(30000.0), brokerage_account(10000.0)];
let cf = cashflow(6000.0, 5000.0);
assert_eq!(actual_reserve_months(&accounts, &cf), 6.0);
}
#[test]
fn reserve_months_zero_spending_returns_zero() {
let accounts = vec![savings_account(30000.0)];
let cf = cashflow(0.0, 0.0);
assert_eq!(actual_reserve_months(&accounts, &cf), 0.0);
}
#[test]
fn reserve_months_no_liquid_accounts_returns_zero() {
let accounts = vec![brokerage_account(10000.0)];
let cf = cashflow(6000.0, 5000.0);
assert_eq!(actual_reserve_months(&accounts, &cf), 0.0);
}
fn goals_with_savings_rate(pct: f64) -> Goals {
Goals {
savings_rate: Some(SavingsRateGoal {
target_percent: pct,
}),
emergency_fund: None,
debt_payoff: None,
}
}
fn goals_with_emergency_fund(months: f64) -> Goals {
Goals {
savings_rate: None,
emergency_fund: Some(EmergencyFundGoal {
target_months: months,
}),
debt_payoff: None,
}
}
fn empty_goals() -> Goals {
Goals {
savings_rate: None,
emergency_fund: None,
debt_payoff: None,
}
}
#[test]
fn savings_rate_on_track_when_actual_exceeds_goal() {
let goals = goals_with_savings_rate(20.0);
let cf = cashflow(10000.0, 7500.0);
let result = compute_progress(&goals, &[], &cf);
assert_eq!(result.savings_rate.unwrap().status, "on track");
assert!(result.emergency_fund.is_none());
}
#[test]
fn savings_rate_drifting_when_between_half_and_goal() {
let goals = goals_with_savings_rate(20.0);
let cf = cashflow(10000.0, 8300.0);
let result = compute_progress(&goals, &[], &cf);
assert_eq!(result.savings_rate.unwrap().status, "drifting");
}
#[test]
fn savings_rate_off_when_below_half_goal() {
let goals = goals_with_savings_rate(20.0);
let cf = cashflow(10000.0, 9400.0);
let result = compute_progress(&goals, &[], &cf);
assert_eq!(result.savings_rate.unwrap().status, "off");
}
#[test]
fn emergency_fund_on_track_when_reserves_cover_goal() {
let goals = goals_with_emergency_fund(6.0);
let accounts = vec![savings_account(42000.0)]; let cf = cashflow(6000.0, 5000.0);
let result = compute_progress(&goals, &accounts, &cf);
assert_eq!(result.emergency_fund.unwrap().status, "on track");
}
#[test]
fn emergency_fund_drifting_when_between_half_and_goal() {
let goals = goals_with_emergency_fund(6.0);
let accounts = vec![savings_account(20000.0)]; let cf = cashflow(6000.0, 5000.0);
let result = compute_progress(&goals, &accounts, &cf);
assert_eq!(result.emergency_fund.unwrap().status, "drifting");
}
#[test]
fn emergency_fund_off_when_below_half_target() {
let goals = goals_with_emergency_fund(6.0);
let accounts = vec![savings_account(10000.0)]; let cf = cashflow(6000.0, 5000.0);
let result = compute_progress(&goals, &accounts, &cf);
assert_eq!(result.emergency_fund.unwrap().status, "off");
}
#[test]
fn unset_goal_is_not_reported() {
let goals = empty_goals();
let result = compute_progress(&goals, &[], &cashflow(10000.0, 8000.0));
assert!(result.savings_rate.is_none());
assert!(result.emergency_fund.is_none());
}
#[test]
fn empty_goals_returns_guidance_message() {
let goals = empty_goals();
let result = compute_progress(&goals, &[], &cashflow(10000.0, 8000.0));
let guidance = result.guidance.as_deref().unwrap_or("");
assert!(
guidance.contains("no goals") || guidance.contains("goals.toml"),
"expected guidance about missing goals, got: {guidance:?}"
);
}
#[test]
fn goals_present_means_no_guidance() {
let goals = goals_with_savings_rate(20.0);
let cf = cashflow(10000.0, 7500.0);
let result = compute_progress(&goals, &[], &cf);
assert!(
result.guidance.is_none(),
"guidance should be absent when goals are configured"
);
}
#[test]
fn debt_payoff_only_config_yields_guidance_mentioning_debt() {
use crate::goals::DebtPayoffGoal;
let goals = Goals {
savings_rate: None,
emergency_fund: None,
debt_payoff: Some(DebtPayoffGoal {
target_date: "2027-12-01".to_string(),
monthly_payment: Some(500.0),
}),
};
let cf = cashflow(10000.0, 8000.0);
let result = compute_progress(&goals, &[], &cf);
let guidance = result
.guidance
.as_deref()
.expect("guidance must be Some for debt-only config");
assert!(
guidance.contains("debt") || guidance.contains("#27"),
"guidance for debt-only config must mention debt or #27, got: {guidance:?}"
);
}
#[test]
fn savings_rate_only_config_has_no_guidance() {
let goals = goals_with_savings_rate(20.0);
let cf = cashflow(10000.0, 8000.0);
let result = compute_progress(&goals, &[], &cf);
assert!(
result.guidance.is_none(),
"guidance must be absent when savings_rate goal is set"
);
}
#[test]
fn unset_savings_rate_absent_even_when_emergency_fund_present() {
let goals = goals_with_emergency_fund(6.0);
let accounts = vec![savings_account(30000.0)];
let cf = cashflow(6000.0, 5000.0);
let result = compute_progress(&goals, &accounts, &cf);
assert!(
result.savings_rate.is_none(),
"savings_rate should be absent when goal not set"
);
assert!(result.emergency_fund.is_some());
}
}