use std::env;
use std::str::FromStr;
use chrono::{DateTime, Datelike, TimeZone, Utc};
use rust_decimal::Decimal;
use rust_decimal::prelude::FromPrimitive;
use tracing::warn;
pub const DEFAULT_RUN_MAX_COST_ENV: &str = "IRONFLOW_DEFAULT_RUN_MAX_COST_USD";
pub const MONTHLY_COST_LIMIT_ENV: &str = "IRONFLOW_MONTHLY_COST_LIMIT_USD";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BudgetConfig {
pub default_run_max_cost_usd: Option<Decimal>,
pub monthly_cost_limit_usd: Option<Decimal>,
}
impl BudgetConfig {
pub fn new() -> Self {
Self::default()
}
pub fn default_run_max_cost_usd(mut self, cap: Decimal) -> Self {
self.default_run_max_cost_usd = Some(cap);
self
}
pub fn monthly_cost_limit_usd(mut self, limit: Decimal) -> Self {
self.monthly_cost_limit_usd = Some(limit);
self
}
pub fn from_env() -> Self {
Self {
default_run_max_cost_usd: read_decimal_env(DEFAULT_RUN_MAX_COST_ENV),
monthly_cost_limit_usd: read_decimal_env(MONTHLY_COST_LIMIT_ENV),
}
}
pub fn resolve_run_cap(
&self,
requested: Option<Decimal>,
handler_default: Option<Decimal>,
) -> Option<Decimal> {
requested
.or(handler_default)
.or(self.default_run_max_cost_usd)
}
}
fn read_decimal_env(name: &str) -> Option<Decimal> {
let raw = env::var(name).ok()?;
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
match Decimal::from_str(trimmed) {
Ok(value) if value >= Decimal::ZERO => Some(value),
Ok(value) => {
warn!(env = name, value = %value, "ignoring negative budget limit");
None
}
Err(e) => {
warn!(env = name, value = trimmed, error = %e, "ignoring unparseable budget limit");
None
}
}
}
pub fn step_budget_usd(max_budget_usd: Option<f64>) -> Decimal {
max_budget_usd
.and_then(Decimal::from_f64)
.unwrap_or(Decimal::ZERO)
}
pub fn month_start(now: DateTime<Utc>) -> DateTime<Utc> {
Utc.with_ymd_and_hms(now.year(), now.month(), 1, 0, 0, 0)
.single()
.expect("first day of month at midnight UTC is always unambiguous")
}
#[cfg(test)]
mod tests {
use chrono::Timelike;
use super::*;
#[test]
fn new_disables_both_guardrails() {
let config = BudgetConfig::new();
assert!(config.default_run_max_cost_usd.is_none());
assert!(config.monthly_cost_limit_usd.is_none());
}
#[test]
fn resolve_run_cap_prefers_request_then_handler_then_server() {
let config = BudgetConfig::new().default_run_max_cost_usd(Decimal::ONE);
assert_eq!(
config.resolve_run_cap(Some(Decimal::TEN), Some(Decimal::TWO)),
Some(Decimal::TEN)
);
assert_eq!(
config.resolve_run_cap(None, Some(Decimal::TWO)),
Some(Decimal::TWO)
);
assert_eq!(config.resolve_run_cap(None, None), Some(Decimal::ONE));
}
#[test]
fn resolve_run_cap_without_server_default_is_none() {
let config = BudgetConfig::new();
assert_eq!(config.resolve_run_cap(None, None), None);
}
#[test]
fn resolve_run_cap_accepts_explicit_zero() {
let config = BudgetConfig::new().default_run_max_cost_usd(Decimal::TEN);
assert_eq!(
config.resolve_run_cap(Some(Decimal::ZERO), None),
Some(Decimal::ZERO)
);
}
#[test]
fn step_budget_usd_maps_missing_and_invalid_to_zero() {
assert_eq!(step_budget_usd(None), Decimal::ZERO);
assert_eq!(step_budget_usd(Some(f64::NAN)), Decimal::ZERO);
assert_eq!(step_budget_usd(Some(f64::INFINITY)), Decimal::ZERO);
assert_eq!(step_budget_usd(Some(f64::NEG_INFINITY)), Decimal::ZERO);
}
#[test]
fn step_budget_usd_converts_finite_values() {
assert_eq!(step_budget_usd(Some(0.0)), Decimal::ZERO);
assert_eq!(step_budget_usd(Some(0.25)), Decimal::new(25, 2));
assert_eq!(step_budget_usd(Some(1.5)), Decimal::new(15, 1));
}
#[test]
fn month_start_truncates_to_first_day_midnight() {
let now = Utc.with_ymd_and_hms(2026, 2, 17, 23, 59, 59).unwrap();
let start = month_start(now);
assert_eq!(start.year(), 2026);
assert_eq!(start.month(), 2);
assert_eq!(start.day(), 1);
assert_eq!(start.hour(), 0);
assert_eq!(start.minute(), 0);
assert_eq!(start.second(), 0);
}
#[test]
fn month_start_is_idempotent() {
let now = Utc.with_ymd_and_hms(2026, 12, 1, 0, 0, 0).unwrap();
assert_eq!(month_start(month_start(now)), month_start(now));
}
#[test]
fn read_decimal_env_rejects_unset_empty_negative_and_garbage() {
unsafe {
env::remove_var("IRONFLOW_TEST_BUDGET_UNSET");
env::set_var("IRONFLOW_TEST_BUDGET_EMPTY", " ");
env::set_var("IRONFLOW_TEST_BUDGET_NEGATIVE", "-1.5");
env::set_var("IRONFLOW_TEST_BUDGET_GARBAGE", "five dollars");
env::set_var("IRONFLOW_TEST_BUDGET_VALID", " 2.50 ");
}
assert_eq!(read_decimal_env("IRONFLOW_TEST_BUDGET_UNSET"), None);
assert_eq!(read_decimal_env("IRONFLOW_TEST_BUDGET_EMPTY"), None);
assert_eq!(read_decimal_env("IRONFLOW_TEST_BUDGET_NEGATIVE"), None);
assert_eq!(read_decimal_env("IRONFLOW_TEST_BUDGET_GARBAGE"), None);
assert_eq!(
read_decimal_env("IRONFLOW_TEST_BUDGET_VALID"),
Some(Decimal::new(250, 2))
);
unsafe {
env::remove_var("IRONFLOW_TEST_BUDGET_EMPTY");
env::remove_var("IRONFLOW_TEST_BUDGET_NEGATIVE");
env::remove_var("IRONFLOW_TEST_BUDGET_GARBAGE");
env::remove_var("IRONFLOW_TEST_BUDGET_VALID");
}
}
#[test]
fn read_decimal_env_accepts_zero() {
unsafe { env::set_var("IRONFLOW_TEST_BUDGET_ZERO", "0") };
assert_eq!(
read_decimal_env("IRONFLOW_TEST_BUDGET_ZERO"),
Some(Decimal::ZERO)
);
unsafe { env::remove_var("IRONFLOW_TEST_BUDGET_ZERO") };
}
}