Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 114 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,21 +151,57 @@ export DB_PW= # The db password.

## Struct Tags

Per-field behavior is controlled with struct tags:

| Tag | Description |
| -------- | -------------------------------------------------------------------------------------- |
| `env` | Override the env var name. Use `-` to ignore, or `omitprefix` on a struct (see below). |
| `flag` | Override the flag name. Supports a short alias, e.g. `flag:"username,u"`. Use `-` to ignore. |
| `toml` | Override the TOML key name. |
| `yaml` | Override the YAML key name. |
| `json` | Override the JSON key name. |
| `help` | Help text shown in the `--help` screen and as comments in generated templates. |
| `fmt` | Time format for `time.Time` fields (Go reference layout or names like `RFC3339`). |
| `sep` | Separator used for slice values (default is `,`). |
| `show` | Set to `false` to redact a value when printing loaded config (e.g. secrets). |
| `req` | Set to `true` to mark a field as required. |
| `ignore` | Set to `true` to ignore a field entirely (equivalent to `config:"ignore"`). |
Per-field behavior is controlled with struct tags. The table below lists every supported tag, its
accepted values, and an example.

| Tag | Supported values | Example |
| -------- | ------------------------------------------------------------------------------------------------- | -------------------------------- |
| `env` | A custom env var name; `-` to ignore; `omitprefix` (struct fields only); optional `,string` suffix | `env:"DB_HOST"`, `env:"-"` |
| `flag` | A custom flag name, optionally `name,alias` (alias must be one char); `-` to ignore; `omitprefix`; optional `,string` suffix | `flag:"username,u"`, `flag:"-"` |
| `toml` | A custom TOML key name | `toml:"db_host"` |
| `yaml` | A custom YAML key name; `,inline` to promote an embedded struct | `yaml:"db_host"`, `yaml:",inline"` |
| `json` | A custom JSON key name | `json:"db_host"` |
| `help` | Any text (shown in `--help` and as comments in generated templates) | `help:"The db host:port."` |
| `fmt` | A Go time layout, or a named layout (`RFC3339`, `RFC1123`, `Kitchen`, `UnixDate`, etc.) for `time.Time` fields | `fmt:"RFC3339"`, `fmt:"2006-01-02"` |
| `sep` | Any separator string used for slice values (default `,`) | `sep:";"` |
| `show` | `true` or `false` — set `false` to redact the value when printing config (default `true`) | `show:"false"` |
| `req` | `true` or `false` — annotate the field as required in the help/printed output, e.g. `(required)` (default `false`; not auto-enforced — use the `Validator` interface to enforce) | `req:"true"` |
| `ignore` | `true` or `false` — ignore the field entirely (default `false`) | `ignore:"true"` |
| `config` | `ignore` to ignore the field entirely (alternative to `ignore:"true"`) | `config:"ignore"` |

Notes:

- **`,string` suffix** (env and flag only): treats slice elements as quoted strings, so values keep
surrounding quotes when generated and have them stripped when parsed — e.g. `env:"NAMES,string"`.
- **`-` vs `omitprefix`**: `-` ignores a field for that loader; `omitprefix` keeps the field but drops
the struct's name from the generated prefix (see [Omitting a struct prefix](#omitting-a-struct-prefix)).
- A field with no tag for a given loader uses its struct field name, re-cased to the loader's
convention (e.g. `SCREAMING_SNAKE` for env, `kebab-case` for flags).

### All tags at a glance

```go
type Config struct {
// env/flag/file name overrides + a short flag alias.
Host string `env:"DB_HOST" flag:"host,H" toml:"db_host" yaml:"db_host" json:"db_host" help:"The db host:port." req:"true"`

// Redacted when printing loaded config.
Password string `flag:"pw,p" help:"The db password." show:"false"`

// time.Time with a named layout.
StartAt time.Time `fmt:"RFC3339"`

// Slice with a custom separator.
Tags []string `sep:";"`

// Ignored entirely (both forms are equivalent).
Internal string `ignore:"true"`
Internal2 string `config:"ignore"`

// Ignored only for specific loaders.
OnlyFromFile string `env:"-" flag:"-"`
}
```

### Nested structs and env prefixes

Expand Down Expand Up @@ -245,6 +281,69 @@ export UN= # no prefix
export PW= # no prefix
```

### Embedded (anonymous) structs

Embedding a struct **promotes** its fields, so they are treated as if they were declared directly on the
parent — there is no type-name prefix. This mirrors how Go's own `encoding/json` handles embedded structs.

```go
type DB struct {
Host string
Port int
}

type Config struct {
DB // embedded (anonymous)
Name string
}
```

With the struct above, the fields are promoted across every loader:

```sh
# env (no "DB_" prefix)
export HOST=localhost
export PORT=5432
export NAME=myapp
```

```sh
# flags (no "db-" prefix)
./myapp --host=localhost --port=5432 --name=myapp
```

```yaml
# yaml / json / toml (top-level, no "db" nesting)
host: localhost
port: 5432
name: myapp
```

Two things to keep in mind:

- **YAML requires an inline tag.** `gopkg.in/yaml.v2` does not inline anonymous structs automatically, so
add `yaml:",inline"` to the embedded field if you load YAML:

```go
type Config struct {
DB `yaml:",inline"`
Name string
}
```

- **The embedded type must be exported.** Fields of an unexported embedded type (e.g. `db`) are not
settable via reflection and are skipped by the env and flag loaders.

If you want the type name to act as a prefix instead of promoting the fields, use a **named** field rather
than embedding:

```go
type Config struct {
DB DB // named field -> DB_HOST, --db-host, nested "db" in files
Name string
}
```

## Long Help Descriptions

For longer help descriptions, call `FieldHelp`. Nested struct fields are addressed with a `.` between
Expand Down
95 changes: 95 additions & 0 deletions config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,98 @@ func TestLoadFromFileExtensions(t *testing.T) {
})
}
}

// EmbeddedDB is used to exercise embedded (anonymous) struct promotion. It must
// be exported so its fields are settable when embedded.
type EmbeddedDB struct {
Host string `yaml:"host" toml:"host" json:"host"`
Port int `yaml:"port" toml:"port" json:"port"`
}

// embeddedConfig embeds EmbeddedDB. The yaml:",inline" tag is required for the
// yaml loader to promote the embedded fields (gopkg.in/yaml.v2 does not inline
// anonymous structs by default).
type embeddedConfig struct {
EmbeddedDB `yaml:",inline"`
Name string `yaml:"name" toml:"name" json:"name"`
}

// TestEmbeddedStructPromotionEnv verifies that fields of an embedded
// (anonymous) struct are promoted in the env loader: the embedded type name is
// NOT used as a prefix.
func TestEmbeddedStructPromotionEnv(t *testing.T) {
os.Clearenv()
os.Setenv("HOST", "promoted-host")
os.Setenv("PORT", "5432")
os.Setenv("NAME", "myapp")

c := &embeddedConfig{}
if err := New().DisableStdFlags().With("env").Load(c); err != nil {
t.Fatalf("unexpected error: %v", err)
}

if c.Host != "promoted-host" {
t.Errorf("expected Host=%q from HOST env var, got %q", "promoted-host", c.Host)
}
if c.Port != 5432 {
t.Errorf("expected Port=5432 from PORT env var, got %d", c.Port)
}
if c.Name != "myapp" {
t.Errorf("expected Name=%q, got %q", "myapp", c.Name)
}
}

// TestEmbeddedStructPromotionFiles verifies that embedded struct fields are
// promoted (flattened) consistently across the yaml, toml, and json loaders.
func TestEmbeddedStructPromotionFiles(t *testing.T) {
cases := []struct {
name string
file string
contents string
loader string
}{
{
name: "yaml",
file: "config.yaml",
contents: "host: yaml-host\nport: 1\nname: yaml-name\n",
loader: "yaml",
},
{
name: "toml",
file: "config.toml",
contents: "host = \"toml-host\"\nport = 2\nname = \"toml-name\"\n",
loader: "toml",
},
{
name: "json",
file: "config.json",
contents: `{"host":"json-host","port":3,"name":"json-name"}`,
loader: "json",
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
fpath := filepath.Join(dir, tc.file)
if err := os.WriteFile(fpath, []byte(tc.contents), 0o600); err != nil {
t.Fatalf("writing temp config: %v", err)
}

c := &embeddedConfig{}
if err := New().DisableStdFlags().With(tc.loader).SetConfigPath(fpath).Load(c); err != nil {
t.Fatalf("unexpected error loading %q: %v", tc.file, err)
}

if c.Host == "" {
t.Errorf("expected promoted Host to be populated from %q, got empty", tc.file)
}
if c.Port == 0 {
t.Errorf("expected promoted Port to be populated from %q, got 0", tc.file)
}
if c.Name == "" {
t.Errorf("expected Name to be populated from %q, got empty", tc.file)
}
})
}
}
7 changes: 7 additions & 0 deletions load/env/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ func nodeEnvName(n *node.Node) string {
case "omitprefix":
return ""
case "":
// Embedded (anonymous) struct fields are promoted: their fields are
// treated as if declared on the parent, so they contribute no prefix.
// This matches how json/toml handle embedded structs and Go's own
// encoding/json promotion. An explicit env tag overrides this.
if n.IsAnonymous() && n.IsStruct() && !n.IsTime() {
return ""
}
return util.ToScreamingSnake(n.FieldName())
default:
return ev
Expand Down
7 changes: 7 additions & 0 deletions load/flag/flag.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,13 @@ func nodeFlagName(n *node.Node) (name, alias string) {
case "omitprefix":
return "", ""
case "":
// Embedded (anonymous) struct fields are promoted: their fields are
// treated as if declared on the parent, so they contribute no prefix.
// This matches how json/toml handle embedded structs and Go's own
// encoding/json promotion. An explicit flag tag overrides this.
if n.IsAnonymous() && n.IsStruct() && !n.IsTime() {
return "", ""
}
return util.ToKebab(n.FieldName()), ""
default:
return name, alias
Expand Down
5 changes: 5 additions & 0 deletions render/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,11 @@ func nodeName(n *node.Node, nameFrom, formatAs string) string {
}

if name == "" {
// Embedded (anonymous) struct fields are promoted: they contribute no
// name segment, consistent with how the env/flag loaders treat them.
if n.IsAnonymous() && n.IsStruct() && !n.IsTime() {
return ""
}
name = n.FieldName()
}

Expand Down
6 changes: 6 additions & 0 deletions util/node/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,12 @@ func (n *Node) IsString() bool {
return n.Kind() == reflect.String
}

// IsAnonymous reports whether the node represents an embedded (anonymous)
// struct field.
func (n *Node) IsAnonymous() bool {
return n.Field.Anonymous
}

func (n *Node) IsDuration() bool {
return n.ValueType() == "time.Duration"
}
Expand Down
Loading