Documentation
¶
Overview ¶
Package budget loads budget rules from TOML and evaluates events against them.
The evaluator is deliberately stateless and dependency-free: it takes a Config, a new event's (project, branch, timestamp), and a SpendFunc that queries current-period spend. It returns one Verdict per matching rule. The caller decides what to do (warn, kill, log).
Keeping the evaluator a pure function means:
- we can test it with synthetic spend fakes (no sqlite needed)
- it never accidentally mutates state
- warning de-duplication belongs to the enforcer/notifier, not here
Config format (TOML):
[general] timezone = "Asia/Bangkok" # optional; defaults to UTC [alerts.ntfy] # optional; re-exported for the ntfy client server = "https://ntfy.sh" topic = "some-token" min_cost_usd = 0.50 [goei] # optional; re-exported for `budgetclaw sync` token = "goei_dt_..." # device token from Goei settings endpoint = "https://goei.roninforge.org/api/ingest" # optional override [[limit]] project = "*" # "*" or glob (feature/*) or exact branch = "*" period = "daily" # daily | weekly | monthly cap_usd = 10.00 action = "warn" # warn | kill
Index ¶
- Variables
- func AddLimit(path string, r Rule) error
- func PeriodBounds(p Period, now time.Time, loc *time.Location) (time.Time, time.Time)
- func RemoveLimit(path string, project, branch string, period Period) (int, error)
- func SetNtfyConfig(path, server, topic string, minCostUSD float64) error
- func WriteDefault(path string) error
- type Action
- type Config
- type Period
- type Rule
- type SpendFunc
- type Verdict
Constants ¶
This section is empty.
Variables ¶
var ErrConfig = errors.New("budget config")
ErrConfig wraps all config validation errors. Callers can use errors.Is to distinguish parse/validation failures from I/O errors.
Functions ¶
func AddLimit ¶
AddLimit loads the config file, appends a new [[limit]] rule, and writes the whole file back atomically. If the file does not exist it is created with just the new rule (and no [general] or [alerts] sections).
Writing back destroys any hand-written comments — users who maintain their config by hand should prefer editing directly.
func PeriodBounds ¶
PeriodBounds returns the [start, end] range covering the period that contains `now`. Both bounds are in the given location.
start is the first second of the period. end is the last second of the period (23:59:59 on the final day), chosen instead of "start of next period" so SpendFunc's inclusive range query does not accidentally cover the next period's first day. Sub-second precision is not load-bearing.
Weeks start on Monday (ISO 8601). Months use calendar boundaries. nil location is normalized to UTC.
func RemoveLimit ¶
RemoveLimit removes every [[limit]] whose (project, branch, period) match the given selector after default-normalization (empty project/branch become "*", empty period becomes "daily"). Returns the number of rules removed.
Returns (0, nil) if the file does not exist, treating "nothing to remove" as a successful no-op.
func SetNtfyConfig ¶
SetNtfyConfig rewrites the [alerts.ntfy] section. An empty server or topic string clears the configured value, turning the alerts layer back into a noop.
func WriteDefault ¶
WriteDefault creates the config file at `path` containing a minimal documented default. Does nothing if `path` already exists — re-running `budgetclaw init` is safe and never overwrites a user's hand-edited config.
Parent directories are created with mode 0755.
Types ¶
type Config ¶
type Config struct {
// Timezone is used for period boundary computation. Defaults to
// time.UTC when [general].timezone is unset.
Timezone *time.Location
// Rules is the ordered list of [[limit]] entries. Config.Match
// returns matches sorted by specificity; Rules preserves input
// order for diagnostic purposes.
Rules []Rule
// Alerts config is re-exported from the [alerts.ntfy] section so
// the CLI only has to load one file. The budget package itself
// doesn't use these fields.
NtfyServer string
NtfyTopic string
NtfyMinCostUSD float64
// Goei config is re-exported from the [goei] section for the
// `budgetclaw sync` command. The budget package itself doesn't
// use these fields.
GoeiToken string
GoeiEndpoint string
}
Config is the parsed, validated budget configuration.
func LoadFile ¶
LoadFile reads and parses a TOML config file. Returns the underlying os error (e.g. os.ErrNotExist) for I/O failures and a wrapped ErrConfig for parse/validation failures.
func (*Config) Match ¶
Match returns every rule that applies to (project, branch), ordered most-specific first. Stable sort preserves config order among ties so users can predict which rule wins when specificities are equal.
Returns nil (not empty slice) when no rules match, so the common `if len(matches) == 0` check is cheap.
type Rule ¶
type Rule struct {
Project string // "*" or glob or exact project name
Branch string // "*" or glob or exact branch name
Period Period
CapUSD float64
Action Action
}
Rule is one [[limit]] entry from the config file.
func (Rule) Matches ¶
Matches reports whether this rule applies to the given (project, branch) pair. Glob semantics follow path.Match: "*" matches any sequence except "/", "?" matches one character, "[abc]" matches one of the characters in the set. The bare "*" and the empty string are both treated as "match anything, including paths with slashes" so users don't have to think about it.
func (Rule) Specificity ¶
Specificity returns a score used to order matching rules from most-specific to least-specific. Exact strings are worth more than wildcards. Ties are broken by config-order when Match sorts stably.
both exact → 3 only project → 2 only branch → 1 both wildcard → 0
type SpendFunc ¶
type SpendFunc func(ctx context.Context, project, branch string, start, end time.Time) (float64, error)
SpendFunc returns the total USD cost for (project, branch) across the given time range. It is the evaluator's only dependency on stored state: callers inject a function that wraps db.RollupSum (or a test fake) and the evaluator stays pure.
Both start and end are inclusive. The implementation is free to ignore sub-second precision; budget boundaries are day-aligned.
type Verdict ¶
type Verdict struct {
Rule Rule
CurrentUSD float64
CapUSD float64
Breach bool
PeriodStart time.Time
PeriodEnd time.Time
}
Verdict is the result of evaluating one rule against the current period. The caller inspects verdicts to decide what to do: typically, fire the most-severe action among those where Breach == true.
func Evaluate ¶
func Evaluate( ctx context.Context, cfg *Config, project, branch string, now time.Time, spend SpendFunc, ) ([]Verdict, error)
Evaluate computes one Verdict per rule that matches (project, branch). Returns them ordered most-specific first (matching Config.Match). An empty result means no rule applies; a nil config also yields nil verdicts.
A rule is "in breach" when current spend is strictly greater than the cap. Equality is not a breach: a $10/day cap lets the user hit exactly $10 without firing, but the next billable event will push past and trigger. This is the more forgiving interpretation and matches what users expect from "cap at $10".
If any SpendFunc call returns an error, Evaluate stops and returns that error wrapped with rule context. Partial verdicts are discarded so callers never have to reason about "half-evaluated".