panfigure

package module
v0.1.4 Latest Latest
Warning

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

Go to latest
Published: Jul 28, 2026 License: Apache-2.0 Imports: 16 Imported by: 0

README

* Panfigure

An opinionated, declarative configuration library for Go CLI applications, built
on Viper and Cobra. Panfigure declares configuration as option packages, merges
them from defaults, CLI, file, and environment in a predictable precedence, nests
keys by command path or group, tracks the source of every value, and reads the
result into a plain typed struct — no magic strings at read sites.

** Model

- An application owns one =*panfigure.App=, which owns its cobra root command, its
  own =*viper.Viper=, a registry of declared options, and source metadata. No
  package-level state; build one with =panfigure.New=.
- Configuration is declared as =CommandOptions= (the source of truth) and
  registered on the app in packages: =Root= (flat globals), =RootGroup=
  (namespaced, cross-cutting packages like =db= / =mail= that inherit to every
  subcommand), and =On= (command-local, auto-namespaced from the command path).
- After =Run= (or =Configure=), =App.Unmarshal(&cfg)= populates a plain typed
  struct: =db.host= maps to =cfg.DB.Host=, =server.start.addr= to
  =cfg.Server.Start.Addr=, tag-free.
- =App.StatusTable= shows every key, its value, and where it came from.

** Quickstart

#+begin_src go
  func main() {
      app := panfigure.New(cmd.Root())
      cmd.Configure(app)               // register option packages on the instance
      cobra.CheckErr(app.Run())        // configure + parse + finalize + validate
      var cfg config.Config
      cobra.CheckErr(app.Unmarshal(&cfg))
      cobra.CheckErr(cmd.Run(&cfg))    // thread typed config to db/server/mail
  }
#+end_src

Registering packages (typically in a consumer =cmd.Configure=):

#+begin_src go
  func Configure(app *panfigure.App) {
      app.Root(
          &panfigure.CommandOptions{LongOpt: "env-prefix", DefaultValue: "APP"},
          &panfigure.CommandOptions{LongOpt: "log-level", DefaultValue: "info"},
      )
      // a reusable, namespaced package; keys nest under "db" and inherit to all
      // subcommands (db.host, db.port, ...).
      app.RootGroup("db",
          &panfigure.CommandOptions{LongOpt: "db-host"},
          &panfigure.CommandOptions{LongOpt: "db-port", DefaultValue: 5432, OptType: panfigure.OptInt},
      )
      // command-local; keys nest under the command path (server.start.addr).
      app.On(serverStartCmd,
          &panfigure.CommandOptions{LongOpt: "addr", DefaultValue: "127.0.0.1:8080"},
      )
  }
#+end_src

See =examples/= for a runnable program.

** CommandOptions fields

| Field         | Meaning                                                                              |
|---------------|--------------------------------------------------------------------------------------|
| =LongOpt=       | long CLI flag name without =--= (e.g. ="db-host"=). Required unless =OptName= is set.    |
| =ShortOpt=      | optional one-letter flag (e.g. ="a"=).                                                |
| =OptName=       | config key leaf; if empty, derived from =LongOpt= (see Key derivation).              |
| =Description=   | shown in =--help=.                                                                   |
| =OptType=       | =OptString= (default), =OptInt=, =OptBool=, =OptCount=, =OptStringSlice=, =OptDuration=. |
| =NoCLI=         | file/env only; no CLI flag.                                                          |
| =Persistent=    | (command-local only) expose the flag on subcommands too. Root options are always persistent. |
| =Required=      | =Run= fails if the resolved value is empty, regardless of source.                    |
| =DefaultValue=  | applied when no source provides the option.                                          |

** Key derivation

Keys are computed at =Configure= time (never at registration), so registration
order and command-tree topology do not matter.

- flag = =--LongOpt= (consumer-controlled).
- leaf = =OptName= if set; otherwise =LongOpt= with a leading ="<namespace>_"=
  prefix removed when present (so ="db-host"= under namespace ="db"= → ="host"=),
  then remaining =-= → =_=.
- key = ="<namespace>.<leaf>"= (=db.host=, =server.start.addr=); flat for root.

** Precedence

From highest to lowest: CLI flag > environment > config file > default. (Viper
ignores unchanged flags, so a flag only wins when actually passed.) Each value's
source is recorded as it merges and shown by =StatusTable= / =Source=.

When multiple config files are read, later files override earlier ones *within
the file tier* (still below env and CLI). The merge is at key granularity — an
override file can change one key of a nested map without dropping its siblings —
and =Source= names the file that supplied each value (e.g. =file(user.json)=).

** Reserved keys

These are ordinary options you declare; panfigure treats their values specially:

- =env-prefix= (flat) :: the prefix for environment-variable lookup. Env is the
  default source. =db.host= with prefix =APP= → =APP_DB_HOST=. This is the only
  reserved key.

** Typed config and the drift check

=App.Unmarshal(&cfg)= matches config keys to struct fields case- and
separator-insensitively (snake_case keys ↔ CamelCase fields), so consumer code
needs no struct tags and no =viper.GetString=. Declarations and the struct are
two artifacts; keep them aligned with a CI test:

#+begin_src go
  func TestConfigSync(t *testing.T) {
      app := panfigure.New(cmd.Root())
      cmd.Configure(app)
      panfigure.AssertSync(t, app, &config.Config{})
  }
#+end_src

=AssertSync= reports a declared option with no matching field, a type mismatch,
or a struct field with no declaration (catches typos). A config-struct generator
that writes the struct from the declarations is planned for a later release.

** File configuration

Env is the default; files are secondary. Configure files to read during
=Configure= (each merged into the defaults/env state). Files are read in the
order added; a later file overrides an earlier one at key granularity.

#+begin_src go
  // Explicit files, in override order. A missing file is silently skipped.
  app.AddConfigFile("managed.json")          // e.g. a file panfigure wrote
  app.AddConfigFile("/etc/myapp/user.json")  // optional user override (wins)

  // A "package.d" directory: every top-level file with a recognized extension,
  // merged in ascending filename order (prefix with 00-, 10- to order).
  app.AddConfigDir("/etc/myapp/conf.d")
#+end_src

Files occupy the single "file" precedence tier (below env, below CLI). Within
that tier, sources are merged in the order added; within a directory, files are
read in ascending filename order. A missing file or directory is not an error; a
malformed or unreadable file is. =Source= names the file that supplied each
value (e.g. =file(user.json)=), so overrides are visible in =StatusTable=.

The classic search form is retained: =AddConfigPath= (search directory) plus
=SetConfigName= / =UseConfigFile= (file name; type inferred from extension, or
set explicitly with =SetConfigType=). Viper reads the first match.

#+begin_src go
  app.AddConfigPath("/etc/myapp").AddConfigPath(".")
  app.SetConfigName("config")   // type inferred from extension, or:
  app.SetConfigType("yaml")
#+end_src

** Writing configuration

Panfigure can also write the merged configuration back out, as a clean library
primitive (it never exposes a separate viper for the caller to populate). Two
methods cover it:

#+begin_src go
  app.Set("install.installed", true)                  // inject any value (declared or not)
  app.WriteSubset("install.", "managed.json")         // write just the "install.*" keys
#+end_src

- =App.Set= assigns a value in the merged configuration. Use it to inject values
  no source supplies or that are not declared options (such as a managed
  ="installed"= marker). =Set= values have no source attribution (=Status= reports
  them as ="unknown"=).
- =App.WriteSubset(prefix, path)= writes every merged key whose name begins with
  =prefix= to =path=, inferring the format from the file's extension (=.json= ->
  JSON). An empty prefix writes the whole merged configuration. The directory at
  =path= must exist; an existing file is overwritten.

The output is written by the same engine that reads it, so a write followed by a
normal file read round-trips: =Set=-injected keys are included as long as they
fall under =prefix=, and the prefix is retained in the written keys. This fits the
"write a managed snapshot of one namespace" pattern (e.g. persist the resolved
=install.*= options to a generated file that is read back at startup).

** Reload

=App.Reload= discards all merged configuration (a fresh viper and metadata) and
re-runs =Configure= against the current environment and files. The registry,
root command, and file-config state persist.

** Status

#+begin_src go
  app.StatusTable(nil)            // text table of all keys, values, sources
  app.Status([]string{"db.host"}) // []*StatusInfo for specific keys
  app.Source("db.host")           // "default" | "env" | "file(name)" | "cli" | "none" | "unknown"
#+end_src

Documentation

Index

Constants

View Source
const StatusNotFound = "not found"

StatusNotFound is the error reported for a requested key that has no value.

Variables

This section is empty.

Functions

func AssertSync added in v0.1.0

func AssertSync(t *testing.T, app *App, cfg any)

AssertSync fails t if SyncErrors reports any drift. Intended for a consumer test that builds the App the same way main does (New + Root/RootGroup/On).

Types

type App added in v0.1.0

type App struct {
	// contains filtered or unexported fields
}

App is a configured application: it owns a cobra root command, its own *viper.Viper, a registry of declared options, and metadata for status reporting. Construct one with New, register option packages (Root/RootGroup/ On/OnGroup), then Run. The same App can be Reloaded to re-read sources.

func New added in v0.1.0

func New(root *cobra.Command) *App

New returns an App owning root with a fresh *viper.Viper.

func (*App) AddConfigDir added in v0.1.4

func (a *App) AddConfigDir(dir string) *App

AddConfigDir adds a directory whose files are read during Configure, in the position added. This is the "package.d" / *.conf.d pattern: every top-level file with a recognized config extension (json, yaml, toml, ...) is merged in ascending filename order (prefix files with 00-, 10- to control that order), each later file overriding the earlier ones. Dotfiles and subdirectories are skipped, and the directory is not searched recursively. A missing or empty directory is not an error.

func (*App) AddConfigFile added in v0.1.4

func (a *App) AddConfigFile(path string) *App

AddConfigFile adds a single config file to be read during Configure. Files are read in the order added; a later file overrides an earlier one at key granularity. A missing file is not an error (it is skipped); an unreadable or malformed file is. The file's format is inferred from its extension.

func (*App) AddConfigPath added in v0.1.0

func (a *App) AddConfigPath(p string) *App

AddConfigPath adds a directory searched for the config file named by SetConfigName/UseConfigFile. Paths are searched in the order added; viper reads the first match.

func (*App) Configure added in v0.1.0

func (a *App) Configure() error

Configure reads configuration from all sources in precedence order: defaults, CLI bindings, file, then environment. Source attribution is recorded as each source merges; CLI is attributed later, during Run's PreRun, once flags parse. Configure is safe to re-run after Reload.

func (*App) Get added in v0.1.3

func (a *App) Get(key string) any

Get returns the merged, resolved value for a config key across all sources (default < file < env < cli). It is the single-value counterpart to Unmarshal, intended for predicates such as CommandOptions.RequiredWhen that must inspect other options' resolved values at validation time.

func (*App) On added in v0.1.0

func (a *App) On(cmd *cobra.Command, opts ...*CommandOptions) *App

On registers options on cmd under a namespace derived from cmd's path in the tree (cmd "server start" => "server.start.addr"). Flags are local to cmd unless an option's Persistent field is set.

func (*App) OnGroup added in v0.1.0

func (a *App) OnGroup(cmd *cobra.Command, name string, opts ...*CommandOptions) *App

OnGroup registers options on cmd under an explicit namespace. Flags are local to cmd unless an option's Persistent field is set.

func (*App) Reload added in v0.1.0

func (a *App) Reload() error

Reload discards all merged configuration (fresh viper + metadata) and re-runs Configure. The registry, root, and file-config state are kept, so declared options and the command tree persist.

func (*App) Root added in v0.1.0

func (a *App) Root(opts ...*CommandOptions) *App

Root registers persistent options on the root command under the flat (empty) namespace, so their keys are un-nested (e.g. "env_prefix", "log_level").

func (*App) RootGroup added in v0.1.0

func (a *App) RootGroup(name string, opts ...*CommandOptions) *App

RootGroup registers a persistent, namespaced package of options on root, so keys nest under name (name "db" => "db.host", "db.port") and inherit to every subcommand. This is the seam for cross-cutting config such as db/mail.

func (*App) Run added in v0.1.0

func (a *App) Run() error

Run configures, installs panfigure's PreRun hooks (CLI source attribution and required validation), and executes the root command.

func (*App) Set added in v0.1.2

func (a *App) Set(key string, value any) *App

Set assigns value to key in the merged configuration, returning the App for chaining. Use it to inject values that no source supplies or that are not declared options — for example a managed "install.installed" marker written to a generated config file. Set values are serialized by WriteSubset like any other. Set does not attribute a source; Status reports such keys as "unknown".

func (*App) SetConfigName added in v0.1.0

func (a *App) SetConfigName(name string) *App

SetConfigName sets the config file name (without directory) and appends a search source to the file list, capturing the paths added so far via AddConfigPath. If name has a recognizable extension, the config type is inferred from it. The file is read during Configure.

func (*App) SetConfigType added in v0.1.0

func (a *App) SetConfigType(t string) *App

SetConfigType sets the config file format (e.g. "json", "yaml"). Needed only when the config name has no recognizable extension. It also serves as a fallback for explicitly added files (AddConfigFile) whose path has no extension.

func (*App) Source added in v0.1.0

func (a *App) Source(key string) string

Source reports where key was configured.

func (*App) Status added in v0.1.0

func (a *App) Status(keys []string) []*StatusInfo

Status returns StatusInfo for the requested keys; an empty slice returns all.

func (*App) StatusTable added in v0.1.0

func (a *App) StatusTable(keys []string) string

StatusTable renders a text table of keys, values, and sources suitable for a terminal "status" command, prefixed with the files parsed.

func (*App) SyncErrors added in v0.1.0

func (a *App) SyncErrors(cfg any) []error

SyncErrors reflects the App's declared options against the struct pointed to by cfg and returns one error per mismatch:

  • a declared option whose key has no compatible struct field;
  • a struct field that resolves to no declared option (catches typos like a field "Net" that should be "Network").

It uses the same key normalization as Unmarshal, so a tag-free struct that round-trips through Unmarshal should pass. Embedding and struct tags are not supported in v0.1.0. Returns nil when declarations and the struct agree. SyncErrors does not require Configure to have run.

func (*App) Unmarshal added in v0.1.0

func (a *App) Unmarshal(dst any) error

Unmarshal populates dst from the merged configuration. Config keys (snake_case, dot-nested) match struct fields case- and separator-insensitively, so "db.host" maps to field DB.Host and "base_url" maps to BaseURL without struct tags. dst must be a pointer to a struct.

func (*App) UseConfigFile added in v0.1.0

func (a *App) UseConfigFile(name string) *App

UseConfigFile is shorthand for SetConfigName. The file is read during Configure (merged into the defaults/env state), not at this call.

func (*App) WriteSubset added in v0.1.2

func (a *App) WriteSubset(prefix, path string) error

WriteSubset writes every currently-merged key whose name begins with prefix to the file at path. The format is inferred from the file's extension (".json" -> JSON) and is re-readable by panfigure's normal file read (AddConfigPath + SetConfigName), so a write followed by a read round-trips. The directory at path must already exist; an existing file is overwritten.

Values are serialized from panfigure's own merged configuration. Keys need not be declared options: values injected with Set are written too, so long as they fall under prefix. An empty prefix writes the entire merged configuration.

type CommandOptions

type CommandOptions struct {
	// LongOpt is the long CLI flag name without "--", e.g. "db-host" or "addr".
	LongOpt string
	// ShortOpt is the optional one-letter flag, e.g. "h".
	ShortOpt string
	// OptName is the config-key leaf; if empty it is derived from LongOpt (with a
	// leading "<namespace>_" prefix stripped when present, then '-' -> '_').
	OptName string
	// Description is shown in --help.
	Description string
	// OptType selects the parser; omit for a string. Validated up front.
	OptType OptType
	// NoCLI hides the option from CLI flags (file/env only).
	NoCLI bool
	// Persistent exposes the flag on subcommands too. Only meaningful for options
	// registered via App.On/App.OnGroup; root options are always persistent.
	Persistent bool
	// Required makes App.Run fail when the resolved value is empty, regardless of
	// which source (flag, env, file) supplies it.
	Required bool
	// RequiredWhen, when non-nil, is evaluated during required validation (after
	// sources merge) and its result takes the place of Required for this run: the
	// option is required only when the predicate returns true. When nil, Required
	// governs as usual, so the zero value preserves existing behavior. The
	// predicate receives the *App so it can inspect other options' resolved values
	// via App.Get (e.g. require a set of LDAP options only when user-management is
	// "ldap"). RequiredWhen, when set, overrides Required.
	RequiredWhen func(*App) bool
	// DefaultValue is applied when no source provides the option.
	DefaultValue any
}

CommandOptions declares a single configuration option: its CLI flag, its config key (leaf), its type, default, and required-ness. Declarations are the source of truth; a plain typed struct populated by App.Unmarshal is the read view. Keep them aligned with App.SyncErrors / AssertSync.

type OptType added in v0.1.0

type OptType string

OptType identifies the Go type used to parse a CommandOptions value from CLI flags, files, and environment variables. The zero value is equivalent to OptString.

const (
	// OptString is the default when OptType is omitted.
	OptString      OptType = "string"
	OptInt         OptType = "int"
	OptBool        OptType = "bool"
	OptCount       OptType = "count"
	OptStringSlice OptType = "[]string"
	OptDuration    OptType = "duration"
)

type Options added in v0.1.0

type Options []*CommandOptions

Options is a named slice of *CommandOptions for readable declarations.

type StatusError

type StatusError struct {
	// contains filtered or unexported fields
}

StatusError reports that a requested key has no value.

func (*StatusError) Error

func (e *StatusError) Error() string

type StatusInfo

type StatusInfo struct {
	Key, Source string
	Value       any
	Err         error
}

StatusInfo describes one config key's value and its source.

func (*StatusInfo) String

func (s *StatusInfo) String() string

Directories

Path Synopsis
Example application showing panfigure's instance + option packages + typed config model, including precedence and source attribution.
Example application showing panfigure's instance + option packages + typed config model, including precedence and source attribution.

Jump to

Keyboard shortcuts

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