budget

package
v0.1.9 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 10, 2026 License: MIT Imports: 9 Imported by: 0

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

Constants

This section is empty.

Variables

View Source
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

func AddLimit(path string, r Rule) error

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

func PeriodBounds(p Period, now time.Time, loc *time.Location) (time.Time, time.Time)

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

func RemoveLimit(path string, project, branch string, period Period) (int, error)

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

func SetNtfyConfig(path, server, topic string, minCostUSD float64) error

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

func WriteDefault(path string) error

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 Action

type Action int

Action is what the enforcer should do when a rule's cap is breached.

const (
	// ActionWarn fires a notification only. Non-destructive.
	ActionWarn Action = iota
	// ActionKill SIGTERMs the matching claude process and writes a
	// lockfile to prevent silent relaunch.
	ActionKill
)

func (Action) String

func (a Action) String() string

String returns the canonical TOML name for an action.

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

func LoadFile(path string) (*Config, error)

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 Parse

func Parse(data []byte) (*Config, error)

Parse decodes TOML bytes into a validated Config.

func (*Config) Match

func (c *Config) Match(project, branch string) []Rule

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 Period

type Period int

Period is the rolling window over which a cap is enforced.

const (
	// PeriodDaily resets at local-midnight each day.
	PeriodDaily Period = iota
	// PeriodWeekly resets Monday 00:00 local time (ISO 8601 week start).
	PeriodWeekly
	// PeriodMonthly resets on the 1st of each local-calendar month.
	PeriodMonthly
)

func (Period) String

func (p Period) String() string

String returns the canonical TOML name for a period.

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

func (r Rule) Matches(project, branch string) bool

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

func (r Rule) Specificity() int

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".

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL