diff --git a/cmd/rootCmd.go b/cmd/rootCmd.go index bfcf871..8702853 100644 --- a/cmd/rootCmd.go +++ b/cmd/rootCmd.go @@ -158,6 +158,11 @@ func bindFlags(cmd *cobra.Command, v *viper.Viper) { configName = strings.ReplaceAll(f.Name, "-", "") } + // Bind the flag to its environment variables. In addition to the legacy + // concatenated form (e.g. MPF_SUBSCRIPTIONID), also accept a snake_case form + // (e.g. MPF_SUBSCRIPTION_ID) so environment variables are easier to read. + bindEnvVars(v, configName) + // Apply the viper config value to the flag when the flag is not set and viper has a value if !f.Changed && v.IsSet(configName) { val := v.Get(configName) @@ -169,6 +174,47 @@ func bindFlags(cmd *cobra.Command, v *viper.Viper) { }) } +// bindEnvVars binds a viper config key to its supported environment variables. Both the +// legacy concatenated form (e.g. MPF_SUBSCRIPTIONID) and a snake_case form +// (e.g. MPF_SUBSCRIPTION_ID) are accepted so that existing setups keep working while +// newer, more readable variable names are also supported. +func bindEnvVars(v *viper.Viper, configName string) { + legacyEnv := envPrefix + "_" + strings.ToUpper(strings.ReplaceAll(configName, "-", "")) + snakeEnv := envPrefix + "_" + camelCaseToSnakeUpper(configName) + + var err error + if snakeEnv != legacyEnv { + err = v.BindEnv(configName, legacyEnv, snakeEnv) + } else { + err = v.BindEnv(configName, legacyEnv) + } + if err != nil { + log.Errorf("Error binding environment variables for %s: %v\n", configName, err) + } +} + +// camelCaseToSnakeUpper converts a camelCase identifier to an UPPER_SNAKE_CASE string. +// For example, "subscriptionID" becomes "SUBSCRIPTION_ID" and "spClientID" becomes "SP_CLIENT_ID". +func camelCaseToSnakeUpper(s string) string { + var b strings.Builder + runes := []rune(strings.ReplaceAll(s, "-", "_")) + for i, r := range runes { + if i > 0 && r >= 'A' && r <= 'Z' { + prev := runes[i-1] + prevIsLowerOrDigit := (prev >= 'a' && prev <= 'z') || (prev >= '0' && prev <= '9') + nextIsLower := i+1 < len(runes) && runes[i+1] >= 'a' && runes[i+1] <= 'z' + // Insert an underscore at a lowercase->uppercase boundary (e.g. "subscriptionID" -> "subscription_ID") + // or at an acronym->word boundary (e.g. "APIVersion" -> "API_Version"), but not between + // consecutive acronym letters (e.g. the "ID" in "subscriptionID" stays together). + if prevIsLowerOrDigit || (nextIsLower && prev != '_') { + b.WriteRune('_') + } + } + b.WriteRune(r) + } + return strings.ToUpper(b.String()) +} + func setLogLevel() { if flgVerbose { log.SetLevel(log.InfoLevel) diff --git a/cmd/rootCmd_test.go b/cmd/rootCmd_test.go index 533d9a2..78d7e33 100644 --- a/cmd/rootCmd_test.go +++ b/cmd/rootCmd_test.go @@ -27,6 +27,8 @@ import ( "path/filepath" "reflect" "testing" + + "github.com/spf13/viper" ) func TestParseInitialPermissions(t *testing.T) { @@ -141,3 +143,99 @@ func TestParseInitialPermissions(t *testing.T) { }) } } + +func TestCamelCaseToSnakeUpper(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"subscriptionID", "SUBSCRIPTION_ID"}, + {"tenantID", "TENANT_ID"}, + {"spClientID", "SP_CLIENT_ID"}, + {"spObjectID", "SP_OBJECT_ID"}, + {"spClientSecret", "SP_CLIENT_SECRET"}, + {"initialPermissions", "INITIAL_PERMISSIONS"}, + {"showDetailedOutput", "SHOW_DETAILED_OUTPUT"}, + {"jsonOutput", "JSON_OUTPUT"}, + {"verbose", "VERBOSE"}, + {"debug", "DEBUG"}, + } + + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + if got := camelCaseToSnakeUpper(tt.in); got != tt.want { + t.Errorf("camelCaseToSnakeUpper(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +// setOnlyEnv unsets every env var in relatedKeys (restoring the originals when the test +// finishes) and then sets setKey to setVal. This keeps each subtest isolated from any +// MPF_* variables that may already exist in the developer's shell. +func setOnlyEnv(t *testing.T, setKey, setVal string, relatedKeys ...string) { + t.Helper() + for _, k := range relatedKeys { + if orig, ok := os.LookupEnv(k); ok { + key, val := k, orig + t.Cleanup(func() { _ = os.Setenv(key, val) }) + } else { + key := k + t.Cleanup(func() { _ = os.Unsetenv(key) }) + } + _ = os.Unsetenv(k) + } + t.Setenv(setKey, setVal) +} + +func TestBindEnvVars(t *testing.T) { + tests := []struct { + name string + configName string + envKey string + envValue string + relatedKeys []string + }{ + { + name: "legacy concatenated subscription id", + configName: "subscriptionID", + envKey: "MPF_SUBSCRIPTIONID", + envValue: "sub-legacy", + relatedKeys: []string{"MPF_SUBSCRIPTIONID", "MPF_SUBSCRIPTION_ID"}, + }, + { + name: "snake_case subscription id", + configName: "subscriptionID", + envKey: "MPF_SUBSCRIPTION_ID", + envValue: "sub-snake", + relatedKeys: []string{"MPF_SUBSCRIPTIONID", "MPF_SUBSCRIPTION_ID"}, + }, + { + name: "legacy concatenated client secret", + configName: "spClientSecret", + envKey: "MPF_SPCLIENTSECRET", + envValue: "secret-legacy", + relatedKeys: []string{"MPF_SPCLIENTSECRET", "MPF_SP_CLIENT_SECRET"}, + }, + { + name: "snake_case client secret", + configName: "spClientSecret", + envKey: "MPF_SP_CLIENT_SECRET", + envValue: "secret-snake", + relatedKeys: []string{"MPF_SPCLIENTSECRET", "MPF_SP_CLIENT_SECRET"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setOnlyEnv(t, tt.envKey, tt.envValue, tt.relatedKeys...) + + v := viper.New() + bindEnvVars(v, tt.configName) + + if got := v.GetString(tt.configName); got != tt.envValue { + t.Errorf("v.GetString(%q) = %q, want %q", tt.configName, got, tt.envValue) + } + }) + } +} diff --git a/docs/commandline-flags-and-env-variables.md b/docs/commandline-flags-and-env-variables.md index eb8a4e7..5e992a9 100644 --- a/docs/commandline-flags-and-env-variables.md +++ b/docs/commandline-flags-and-env-variables.md @@ -2,6 +2,8 @@ **Note**: Environment variables can be set using bash/shell syntax (e.g., `export MPF_SUBSCRIPTIONID=value`) on Linux/macOS, or using PowerShell syntax (e.g., `$env:MPF_SUBSCRIPTIONID = "value"`) on Windows. +**Note**: Each environment variable can also be provided in `snake_case` form for readability. For every variable listed below, MPF additionally accepts a variant where the flag name is split on word boundaries with underscores. For example, `MPF_SUBSCRIPTIONID` can also be supplied as `MPF_SUBSCRIPTION_ID`, and `MPF_SPCLIENTSECRET` as `MPF_SP_CLIENT_SECRET`. The original concatenated form keeps working, so existing configurations are unaffected. If both forms are set, the concatenated form takes precedence. + ## Global Flags (Common to all providers) | Flag | Environment Variable | Required / Optional | Description |