use costroid_core::{
format_money_usd, format_over_by_usd, BudgetExcludedTool, BudgetExclusion, BudgetPace,
BudgetRow, BudgetScope, BudgetView, ALERT_CRITICAL_FRACTION, ALERT_WARN_FRACTION,
};
use crate::app::{color_of, ASH, BONE};
use crate::format::percent;
use crate::meter::{self, MeterFill, MeterModel};
const BUDGET_CONFIG_HINT: &str = "no budget set - set targets in ~/.config/costroid/config.toml";
const BUDGET_NO_USABLE_TARGETS: &str =
"no usable budget targets - check ~/.config/costroid/config.toml";
const BUDGET_ESTIMATE_NOTE: &str = "figures are local estimates (your tokens x current prices); \
run `costroid reconcile` to compare against the provider invoice.";
pub fn draw(ui: &mut egui::Ui, view: &BudgetView) {
draw_header(ui, view);
text_line(ui, &scope_line(view), ASH, false);
if view.no_budget_set {
draw_empty_state(ui);
text_line(ui, BUDGET_ESTIMATE_NOTE, ASH, false);
return;
}
let mut any = false;
for row in &view.rows {
ui.add_space(4.0);
meter::paint(ui, &row_meter(row));
text_line(ui, &format!(" {}", pace_line(row, view)), ASH, false);
if let Some(over) = &row.over_by_usd {
text_line(
ui,
&format!(" over by {}", format_over_by_usd(over)),
ASH,
false,
);
}
any = true;
}
if !view.excluded_tools.is_empty() {
for excluded in &view.excluded_tools {
text_line(ui, &excluded_line(excluded), ASH, false);
}
any = true;
}
if !any {
text_line(ui, BUDGET_NO_USABLE_TARGETS, ASH, false);
}
ui.add_space(2.0);
text_line(ui, BUDGET_ESTIMATE_NOTE, ASH, false);
}
fn draw_header(ui: &mut egui::Ui, view: &BudgetView) {
ui.horizontal(|ui| {
ui.add_space(8.0);
ui.label(
egui::RichText::new("budget")
.monospace()
.color(color_of(ASH)),
);
ui.add_space(8.0);
ui.label(
egui::RichText::new(format_money_usd(&view.spent_total_usd, true))
.monospace()
.strong()
.color(color_of(BONE)),
);
ui.add_space(4.0);
ui.label(
egui::RichText::new("this month")
.monospace()
.size(11.0)
.color(color_of(ASH)),
);
});
}
fn row_meter(row: &BudgetRow) -> MeterModel {
MeterModel {
label: format!("{:<18}", scope_label(&row.scope)),
fill: MeterFill::Confident {
fraction: row.fraction,
step: budget_step(row),
},
detail: format!(
"{} / {} {}",
format_money_usd(&row.spent_usd, true),
format_money_usd(&row.target_usd, true),
percent(row.fraction),
),
stamp: String::new(),
caveat: None,
}
}
fn budget_step(row: &BudgetRow) -> u8 {
if row.over_by_usd.is_some() {
8
} else if row.fraction >= ALERT_CRITICAL_FRACTION {
6
} else if row.fraction >= ALERT_WARN_FRACTION {
4
} else {
2
}
}
fn scope_line(view: &BudgetView) -> String {
format!(
"scope: API-lane spend this month ({} of month elapsed)",
percent(view.month_elapsed_fraction)
)
}
fn pace_line(row: &BudgetRow, view: &BudgetView) -> String {
format!(
"pace: {} used vs {} of month elapsed ({})",
percent(row.fraction),
percent(view.month_elapsed_fraction),
pace_phrase(row.pace),
)
}
fn pace_phrase(pace: BudgetPace) -> &'static str {
match pace {
BudgetPace::OnTrack => "on track",
BudgetPace::AheadOfPace => "ahead of pace",
BudgetPace::OverBudget => "over budget",
}
}
fn scope_label(scope: &BudgetScope) -> String {
match scope {
BudgetScope::Total => "total (all tools)".to_string(),
BudgetScope::Tool(tool) => tool.clone(),
}
}
fn excluded_line(excluded: &BudgetExcludedTool) -> String {
match excluded.reason {
BudgetExclusion::FlatFeeSubscription => format!(
"{}: flat-fee subscription - no $ budget applies (not API-billed)",
excluded.tool
),
BudgetExclusion::NotApiBilled => format!(
"{}: no API-billed usage - a $ budget tracks API spend only",
excluded.tool
),
}
}
fn draw_empty_state(ui: &mut egui::Ui) {
for line in [
BUDGET_CONFIG_HINT,
"",
"[budget]",
"total_monthly_usd = 100.00",
"",
"[budget.per_tool]",
"claude-code = 60.00",
"codex = 40.00",
] {
text_line(ui, line, ASH, false);
}
}
fn text_line(ui: &mut egui::Ui, text: &str, ink: [u8; 4], strong: bool) {
ui.horizontal(|ui| {
ui.add_space(8.0);
let mut rich = egui::RichText::new(text).monospace().color(color_of(ink));
if strong {
rich = rich.strong();
}
ui.label(rich);
});
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{DateTime, Utc};
fn ts() -> DateTime<Utc> {
match DateTime::from_timestamp(1_900_000_000, 0) {
Some(dt) => dt,
None => panic!("valid ts"),
}
}
fn row(scope: BudgetScope, fraction: f64, over: bool, pace: BudgetPace) -> BudgetRow {
BudgetRow {
scope,
target_usd: Default::default(),
spent_usd: Default::default(),
fraction,
over_by_usd: over.then(Default::default),
pace,
}
}
fn view(
rows: Vec<BudgetRow>,
excluded: Vec<BudgetExcludedTool>,
no_budget: bool,
) -> BudgetView {
BudgetView {
generated_at: ts(),
rows,
excluded_tools: excluded,
no_budget_set: no_budget,
spent_total_usd: Default::default(),
month_elapsed_fraction: 0.5,
}
}
#[test]
fn budget_step_keys_on_state_not_raw_fraction() {
assert_eq!(
budget_step(&row(BudgetScope::Total, 0.5, false, BudgetPace::OnTrack)),
2
);
assert_eq!(
budget_step(&row(
BudgetScope::Total,
0.85,
false,
BudgetPace::AheadOfPace
)),
4
);
assert_eq!(
budget_step(&row(
BudgetScope::Total,
0.97,
false,
BudgetPace::AheadOfPace
)),
6
);
assert_eq!(
budget_step(&row(BudgetScope::Total, 1.2, true, BudgetPace::OverBudget)),
8
);
}
#[test]
fn over_row_paints_a_full_clamped_bar_at_the_critical_step() {
let over = row(
BudgetScope::Tool("codex".into()),
1.5,
true,
BudgetPace::OverBudget,
);
let model = row_meter(&over);
assert_eq!(
model.fill,
MeterFill::Confident {
fraction: 1.5,
step: 8
}
);
assert!(model.detail.contains("150%"), "detail: {}", model.detail);
}
#[test]
fn excluded_lines_name_the_honest_reason() {
assert!(excluded_line(&BudgetExcludedTool {
tool: "claude-code".into(),
reason: BudgetExclusion::FlatFeeSubscription,
})
.contains("flat-fee subscription"));
assert!(excluded_line(&BudgetExcludedTool {
tool: "codex".into(),
reason: BudgetExclusion::NotApiBilled,
})
.contains("no API-billed usage"));
}
#[test]
fn headless_draw_covers_every_state() {
let ctx = egui::Context::default();
crate::fonts::install(&ctx);
let states = [
view(Vec::new(), Vec::new(), true), view(
vec![
row(BudgetScope::Total, 0.4, false, BudgetPace::OnTrack),
row(
BudgetScope::Tool("codex".into()),
1.3,
true,
BudgetPace::OverBudget,
),
],
vec![BudgetExcludedTool {
tool: "claude-code".into(),
reason: BudgetExclusion::FlatFeeSubscription,
}],
false,
),
view(Vec::new(), Vec::new(), false), ];
for v in states {
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
draw(ui, &v);
});
}
}
}