Skip to content

Commit 3be8abb

Browse files
authored
feat: allow cli args to be set via env vars (#88)
1 parent 128e40a commit 3be8abb

File tree

4 files changed

+169
-2
lines changed

4 files changed

+169
-2
lines changed

internal/cli/env.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"log/slog"
6+
"os"
7+
"strings"
8+
9+
"github.com/spf13/cobra"
10+
"github.com/spf13/pflag"
11+
)
12+
13+
// bindEnvVars automatically binds environment variables to cobra command flags.
14+
// Environment variable names are generated as KAT_<FLAG_NAME> where the flag name
15+
// is converted to uppercase and dashes are replaced with underscores.
16+
//
17+
// For example:
18+
// - Flag "log-level" becomes environment variable "KAT_LOG_LEVEL"
19+
// - Flag "config" becomes environment variable "KAT_CONFIG"
20+
//
21+
// Arguments take precedence over environment variables, which take precedence
22+
// over default values.
23+
//
24+
// This function also updates flag usage descriptions to include the environment
25+
// variable name, making it visible in help output.
26+
func bindEnvVars(cmd *cobra.Command) {
27+
cmd.Flags().VisitAll(func(flag *pflag.Flag) {
28+
bindFlagToEnv(flag)
29+
})
30+
31+
cmd.PersistentFlags().VisitAll(func(flag *pflag.Flag) {
32+
bindFlagToEnv(flag)
33+
})
34+
}
35+
36+
// bindFlagToEnv binds a single flag to its corresponding environment variable.
37+
func bindFlagToEnv(flag *pflag.Flag) {
38+
envName := flagToEnvName(flag.Name)
39+
40+
// Update the flag usage to include the environment variable name.
41+
if !strings.Contains(flag.Usage, envName) {
42+
flag.Usage = fmt.Sprintf("%s ($%s)", flag.Usage, envName)
43+
}
44+
45+
// Skip if flag was already set via command line arguments.
46+
if flag.Changed {
47+
return
48+
}
49+
50+
envValue, ok := os.LookupEnv(envName)
51+
if ok {
52+
err := flag.Value.Set(envValue)
53+
if err != nil {
54+
// Log error but don't fail - use default value instead.
55+
slog.Error("failed to set flag from environment variable",
56+
slog.String("flag", flag.Name),
57+
slog.String("env", envName),
58+
slog.String("value", envValue),
59+
slog.Any("error", err),
60+
)
61+
}
62+
}
63+
}
64+
65+
// flagToEnvName converts a flag name to its corresponding environment variable name.
66+
// Example: "log-level" -> "KAT_LOG_LEVEL".
67+
func flagToEnvName(flagName string) string {
68+
envName := strings.ReplaceAll(flagName, "-", "_")
69+
return strings.ToUpper(cmdName + "_" + envName)
70+
}

internal/cli/env_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package cli_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/macropower/kat/internal/cli"
10+
)
11+
12+
func TestBindEnvVars(t *testing.T) {
13+
tcs := map[string]struct {
14+
envVars map[string]string
15+
wantLogLevel string
16+
wantLogFormat string
17+
args []string
18+
}{
19+
"environment variables are bound when no args provided": {
20+
envVars: map[string]string{
21+
"KAT_LOG_LEVEL": "debug",
22+
"KAT_LOG_FORMAT": "json",
23+
},
24+
args: []string{},
25+
wantLogLevel: "debug",
26+
wantLogFormat: "json",
27+
},
28+
"command line args take precedence over environment variables": {
29+
envVars: map[string]string{
30+
"KAT_LOG_LEVEL": "debug",
31+
"KAT_LOG_FORMAT": "json",
32+
},
33+
args: []string{"--log-level", "error", "--log-format", "text"},
34+
wantLogLevel: "error",
35+
wantLogFormat: "text",
36+
},
37+
"partial environment variable override": {
38+
envVars: map[string]string{
39+
"KAT_LOG_LEVEL": "warn",
40+
},
41+
args: []string{"--log-format", "json"},
42+
wantLogLevel: "warn",
43+
wantLogFormat: "json",
44+
},
45+
"no environment variables uses defaults": {
46+
envVars: map[string]string{},
47+
args: []string{},
48+
wantLogLevel: "info", // Default value.
49+
wantLogFormat: "text", // Default value.
50+
},
51+
}
52+
53+
for name, tc := range tcs {
54+
t.Run(name, func(t *testing.T) {
55+
for key, val := range tc.envVars {
56+
t.Setenv(key, val)
57+
}
58+
59+
cmd := cli.NewRootCmd()
60+
cmd.SetArgs(tc.args)
61+
62+
// Parse flags (this triggers environment variable binding).
63+
err := cmd.ParseFlags(tc.args)
64+
require.NoError(t, err)
65+
66+
// Check flag values.
67+
logLevel, err := cmd.Flags().GetString("log-level")
68+
require.NoError(t, err)
69+
assert.Equal(t, tc.wantLogLevel, logLevel)
70+
71+
logFormat, err := cmd.Flags().GetString("log-format")
72+
require.NoError(t, err)
73+
assert.Equal(t, tc.wantLogFormat, logFormat)
74+
})
75+
}
76+
}
77+
78+
// Test that flag usage strings are updated to include environment variable names.
79+
func TestEnvironmentVariableUsageUpdate(t *testing.T) {
80+
t.Parallel()
81+
82+
cmd := cli.NewRootCmd()
83+
84+
logLevelFlag := cmd.PersistentFlags().Lookup("log-level")
85+
require.NotNil(t, logLevelFlag)
86+
assert.Contains(t, logLevelFlag.Usage, "$KAT_LOG_LEVEL")
87+
88+
configFlag := cmd.Flags().Lookup("config")
89+
require.NotNil(t, configFlag)
90+
assert.Contains(t, configFlag.Usage, "$KAT_CONFIG")
91+
}

internal/cli/root.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@ func NewRootArgs() *RootArgs {
2424
}
2525

2626
func (ra *RootArgs) AddFlags(cmd *cobra.Command) {
27-
cmd.PersistentFlags().StringVar(&ra.LogLevel, "log-level", "info", fmt.Sprintf("Log level %s", log.AllLevels))
28-
cmd.PersistentFlags().StringVar(&ra.LogFormat, "log-format", "text", fmt.Sprintf("Log format %s", log.AllFormats))
27+
cmd.PersistentFlags().
28+
StringVar(&ra.LogLevel, "log-level", "info", fmt.Sprintf("Log level, one of: %s", log.AllLevels))
29+
cmd.PersistentFlags().
30+
StringVar(&ra.LogFormat, "log-format", "text", fmt.Sprintf("Log format, one of: %s", log.AllFormats))
2931

3032
var err error
3133

@@ -63,6 +65,8 @@ func NewRootCmd() *cobra.Command {
6365
runArgs.AddFlags(cmd)
6466
cmd.AddCommand(runCmd)
6567

68+
bindEnvVars(cmd)
69+
6670
return cmd
6771
}
6872

internal/cli/run.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ func NewRunCmd(ra *RunArgs) *cobra.Command {
124124
}
125125
ra.AddFlags(cmd)
126126

127+
bindEnvVars(cmd)
128+
127129
return cmd
128130
}
129131

0 commit comments

Comments
 (0)