// 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 }