| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188 |
- // Package config loads and resolves ZenMux accounts from a YAML file.
- package config
- import (
- "errors"
- "fmt"
- "io"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "gopkg.in/yaml.v3"
- )
- // Account identifies a single ZenMux account the CLI can query.
- type Account struct {
- Name string `yaml:"name"`
- APIKey string `yaml:"api_key"`
- }
- // Config is the on-disk YAML schema.
- type Config struct {
- Accounts []Account `yaml:"accounts"`
- }
- // Sentinel errors. Wrapped with %w so callers can distinguish them.
- var (
- ErrParse = errors.New("config parse error")
- ErrAccountNotFound = errors.New("account not found")
- ErrNoAccount = errors.New("no account available")
- )
- // DefaultPath returns the default config file path, honoring XDG_CONFIG_HOME
- // when set, and falling back to ~/.config/zenmux-usage/config.yaml.
- // Returns an empty string when the home directory cannot be determined.
- func DefaultPath() string {
- if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
- return filepath.Join(xdg, "zenmux-usage", "config.yaml")
- }
- home, err := os.UserHomeDir()
- if err != nil || home == "" {
- return ""
- }
- return filepath.Join(home, ".config", "zenmux-usage", "config.yaml")
- }
- // Load reads path, parses the YAML, validates the schema, and writes any
- // non-fatal warnings (e.g. unknown fields) to warnW.
- //
- // Returns a wrapped ErrParse on any schema or validation failure.
- // Filesystem errors (missing file, permission denied) are returned as-is
- // so callers can distinguish "no config" from "bad config".
- func Load(path string, warnW io.Writer) (*Config, error) {
- raw, err := os.ReadFile(path)
- if err != nil {
- return nil, err
- }
- // Decode into a generic map to detect unknown keys, then into the typed
- // struct for the actual parse. yaml.v3's KnownFields(true) would fail
- // hard on unknowns, but we want a warning, not a fatal error.
- var loose map[string]any
- if err := yaml.Unmarshal(raw, &loose); err != nil {
- return nil, fmt.Errorf("%w: %v", ErrParse, err)
- }
- var cfg Config
- if err := yaml.Unmarshal(raw, &cfg); err != nil {
- return nil, fmt.Errorf("%w: %v", ErrParse, err)
- }
- warnUnknownFields(loose, warnW)
- if err := validate(&cfg); err != nil {
- return nil, err
- }
- return &cfg, nil
- }
- func validate(cfg *Config) error {
- if len(cfg.Accounts) == 0 {
- return fmt.Errorf("%w: accounts list must contain at least one entry", ErrParse)
- }
- seen := make(map[string]struct{}, len(cfg.Accounts))
- for i, acc := range cfg.Accounts {
- if strings.TrimSpace(acc.Name) == "" {
- return fmt.Errorf("%w: accounts[%d].name is empty", ErrParse, i)
- }
- if strings.TrimSpace(acc.APIKey) == "" {
- return fmt.Errorf("%w: accounts[%d] (%q) has empty api_key", ErrParse, i, acc.Name)
- }
- if _, dup := seen[acc.Name]; dup {
- return fmt.Errorf("%w: duplicate account name %q", ErrParse, acc.Name)
- }
- seen[acc.Name] = struct{}{}
- }
- return nil
- }
- var (
- knownTopLevel = map[string]struct{}{"accounts": {}}
- knownAccount = map[string]struct{}{"name": {}, "api_key": {}}
- )
- // warnUnknownFields writes one warning line to warnW for each unrecognized
- // top-level key and each unrecognized per-account key. Nothing is emitted
- // when warnW is nil.
- func warnUnknownFields(loose map[string]any, warnW io.Writer) {
- if warnW == nil {
- return
- }
- var unknownTop []string
- for k := range loose {
- if _, ok := knownTopLevel[k]; !ok {
- unknownTop = append(unknownTop, k)
- }
- }
- sort.Strings(unknownTop)
- for _, k := range unknownTop {
- fmt.Fprintf(warnW, "zenmux-usage: warning: unknown config field %q (ignored)\n", k)
- }
- rawAccounts, ok := loose["accounts"].([]any)
- if !ok {
- return
- }
- for i, raw := range rawAccounts {
- accMap, ok := raw.(map[string]any)
- if !ok {
- continue
- }
- var unknownAcc []string
- for k := range accMap {
- if _, ok := knownAccount[k]; !ok {
- unknownAcc = append(unknownAcc, k)
- }
- }
- sort.Strings(unknownAcc)
- for _, k := range unknownAcc {
- fmt.Fprintf(warnW, "zenmux-usage: warning: unknown field %q in accounts[%d] (ignored)\n", k, i)
- }
- }
- }
- // ResolveFlags captures the CLI inputs that influence account resolution.
- type ResolveFlags struct {
- APIKey string // --api-key value, empty when unset
- AccountName string // --account value, empty when unset
- EnvAPIKey string // ZENMUX_MANAGEMENT_API_KEY, empty when unset
- }
- // Resolve produces the ordered list of accounts to fetch, encoding the
- // precedence rules from the spec:
- //
- // 1. flags.APIKey set → a single synthetic "cli" account.
- // 2. cfg is non-nil → all accounts, optionally filtered by flags.AccountName.
- // 3. flags.EnvAPIKey set → a single synthetic "env" account.
- // 4. Otherwise → ErrNoAccount.
- //
- // An unknown AccountName returns a wrapped ErrAccountNotFound.
- func Resolve(cfg *Config, flags ResolveFlags) ([]Account, error) {
- if flags.APIKey != "" {
- return []Account{{Name: "cli", APIKey: flags.APIKey}}, nil
- }
- if cfg != nil {
- if flags.AccountName != "" {
- for _, acc := range cfg.Accounts {
- if acc.Name == flags.AccountName {
- return []Account{acc}, nil
- }
- }
- available := make([]string, len(cfg.Accounts))
- for i, acc := range cfg.Accounts {
- available[i] = acc.Name
- }
- return nil, fmt.Errorf("%w: %q (available: %s)",
- ErrAccountNotFound, flags.AccountName, strings.Join(available, ", "))
- }
- out := make([]Account, len(cfg.Accounts))
- copy(out, cfg.Accounts)
- return out, nil
- }
- if flags.EnvAPIKey != "" {
- return []Account{{Name: "env", APIKey: flags.EnvAPIKey}}, nil
- }
- return nil, ErrNoAccount
- }
|