diff --git a/.ai/checkpoints/topic-2-configuration.md b/.ai/checkpoints/topic-2-configuration.md new file mode 100644 index 0000000..d941913 --- /dev/null +++ b/.ai/checkpoints/topic-2-configuration.md @@ -0,0 +1,78 @@ +# Checkpoint: Topic 2 — Configuration Management + +- **Branch:** `topic-2-configuration` +- **Base:** `topic-1-cli-framework` (commit `5a59418`) +- **Date:** 2026-03-12 +- **Status:** Complete (uncommitted) + +--- + +## Scope + +Topic 2 implements configuration management per spec section 4.2. It provides Viper-based configuration loading with file persistence, environment variable overrides, and CLI flag overrides following the required precedence order. + +### Requirements Addressed + +| ID | Description | Status | +|----|-------------|--------| +| REQ-CFG-010 | Load config from `~/.dcm/config.yaml` by default | Done | +| REQ-CFG-020 | Config path overridable via `--config` flag or `DCM_CONFIG` env var | Done | +| REQ-CFG-030 | Support all `DCM_*` environment variables | Done | +| REQ-CFG-040 | Precedence: CLI flags > env vars > config file > defaults | Done | +| REQ-CFG-050 | Built-in defaults for all config keys | Done | +| REQ-CFG-060 | Uses Viper for configuration management | Done | +| REQ-CFG-070 | Missing config file does not cause failure | Done | + +### Tests Implemented (14 specs) + +| TC ID | Description | Status | +|-------|-------------|--------| +| TC-U001 | Config file loading (`api-gateway-url` from YAML) | Pass | +| TC-U002 | Env var overrides config file value | Pass | +| TC-U003 | CLI flag overrides env var and config file | Pass | +| TC-U004 | Built-in defaults for all 7 config fields | Pass | +| TC-U005 | Missing config file does not cause failure | Pass | +| TC-U006 | Custom config file path via `--config` flag | Pass | +| TC-U007 | Custom config file path via `DCM_CONFIG` env var | Pass | +| TC-U008 | All 7 environment variables supported (table-driven) | Pass | + +--- + +## Files Created + +| File | Purpose | +|------|---------| +| `internal/config/config.go` | `Config` struct, `Load()` with Viper, context helpers (`WithConfig`, `FromContext`, `FromCommand`) | +| `internal/config/config_suite_test.go` | Ginkgo test suite bootstrap | +| `internal/config/config_test.go` | Tests for TC-U001 through TC-U008 | + +## Files Modified + +| File | Change | +|------|--------| +| `internal/commands/root.go` | Added `PersistentPreRunE` to load config and store in command context | +| `go.mod` / `go.sum` | Added `github.com/spf13/viper` dependency | + +--- + +## Key Design Decisions + +1. **`config.Load(cmd *cobra.Command)`** — Takes the cobra command to inspect parsed flags. Only flags marked as `Changed` (explicitly set by user) are bound to Viper, preventing flag defaults from overriding env vars or config file values. + +2. **Context-based config propagation** — The root command's `PersistentPreRunE` calls `config.Load`, then stores the result in the command context via `config.WithConfig`. Subcommands retrieve it with `config.FromCommand(cmd)`. + +3. **Config file path resolution** — Checks `--config` flag first (if changed), then `DCM_CONFIG` env var, then falls back to `~/.dcm/config.yaml`. + +4. **Flag-to-config key mapping** — The `--output` flag maps to config key `output-format` via an explicit `flagToKey` map in `bindFlags`. + +5. **Missing config file tolerance** — `viper.ReadInConfig` errors are suppressed for `ConfigFileNotFoundError` and `os.IsNotExist`, satisfying REQ-CFG-070. + +6. **Test isolation** — Tests clear all `DCM_*` env vars in `BeforeEach`, use `GinkgoT().Setenv()` for auto-restore, and point `--config` to temp files to avoid loading the developer's `~/.dcm/config.yaml`. + +--- + +## What's Next + +- **Topic 3: Output Formatting** — Formatter interface, table/JSON/YAML rendering (independent of Topic 2) +- **Topic 8: Version Command** — Depends only on Topic 1 (already stubbed) +- **Topics 4–7** — Command implementations (depend on Topics 1, 2, 3) diff --git a/go.mod b/go.mod index 47a8ce4..5148564 100644 --- a/go.mod +++ b/go.mod @@ -6,17 +6,26 @@ require ( github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 go.yaml.in/yaml/v3 v3.0.4 ) require ( github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/spf13/pflag v1.0.9 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/sync v0.19.0 // indirect diff --git a/go.sum b/go.sum index 9c9adfd..943c8d0 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,10 @@ github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lpr github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= @@ -13,6 +17,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -35,17 +41,32 @@ github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -71,7 +92,7 @@ golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/commands/root.go b/internal/commands/root.go index 4a12782..a190f70 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -6,6 +6,7 @@ import ( "fmt" "os" + "github.com/dcm-project/cli/internal/config" "github.com/spf13/cobra" ) @@ -25,6 +26,14 @@ func NewRootCommand() *cobra.Command { CompletionOptions: cobra.CompletionOptions{ DisableDefaultCmd: true, }, + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := config.Load(cmd) + if err != nil { + return err + } + cmd.SetContext(config.WithConfig(cmd.Context(), cfg)) + return nil + }, } // Wrap flag parsing errors as usage errors (exit code 2). diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..9f6c63b --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,141 @@ +// Package config manages CLI configuration with file persistence, +// environment variable overrides, and command-line flag overrides. +package config + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +type contextKey struct{} + +// WithConfig stores a Config in the given context. +func WithConfig(ctx context.Context, cfg *Config) context.Context { + return context.WithValue(ctx, contextKey{}, cfg) +} + +// FromContext retrieves the Config from a context. Returns nil if not present. +func FromContext(ctx context.Context) *Config { + cfg, _ := ctx.Value(contextKey{}).(*Config) + return cfg +} + +// FromCommand retrieves the Config from a cobra.Command's context. +func FromCommand(cmd *cobra.Command) *Config { + return FromContext(cmd.Context()) +} + +// Config holds the resolved CLI configuration. +type Config struct { + APIGatewayURL string `yaml:"api-gateway-url" mapstructure:"api-gateway-url"` + OutputFormat string `yaml:"output-format" mapstructure:"output-format"` + Timeout int `yaml:"timeout" mapstructure:"timeout"` + TLSCACert string `yaml:"tls-ca-cert" mapstructure:"tls-ca-cert"` + TLSClientCert string `yaml:"tls-client-cert" mapstructure:"tls-client-cert"` + TLSClientKey string `yaml:"tls-client-key" mapstructure:"tls-client-key"` + TLSSkipVerify bool `yaml:"tls-skip-verify" mapstructure:"tls-skip-verify"` +} + +// Load reads configuration from file, environment variables, and command-line +// flags in the precedence order: flags > env vars > config file > defaults. +func Load(cmd *cobra.Command) (*Config, error) { + v := viper.New() + + // Built-in defaults (REQ-CFG-050) + v.SetDefault("api-gateway-url", "http://localhost:9080") + v.SetDefault("output-format", "table") + v.SetDefault("timeout", 30) + v.SetDefault("tls-ca-cert", "") + v.SetDefault("tls-client-cert", "") + v.SetDefault("tls-client-key", "") + v.SetDefault("tls-skip-verify", false) + + // Environment variable binding (REQ-CFG-030) + v.SetEnvPrefix("DCM") + v.MustBindEnv("api-gateway-url", "DCM_API_GATEWAY_URL") + v.MustBindEnv("output-format", "DCM_OUTPUT_FORMAT") + v.MustBindEnv("timeout", "DCM_TIMEOUT") + v.MustBindEnv("tls-ca-cert", "DCM_TLS_CA_CERT") + v.MustBindEnv("tls-client-cert", "DCM_TLS_CLIENT_CERT") + v.MustBindEnv("tls-client-key", "DCM_TLS_CLIENT_KEY") + v.MustBindEnv("tls-skip-verify", "DCM_TLS_SKIP_VERIFY") + + // Config file path (REQ-CFG-010, REQ-CFG-020) + configPath := configFilePath(cmd) + if configPath != "" { + v.SetConfigFile(configPath) + } else { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("unable to determine home directory: %w", err) + } + v.SetConfigFile(filepath.Join(home, ".dcm", "config.yaml")) + } + + // Read config file — ignore "not found" errors (REQ-CFG-070) + if err := v.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + if !os.IsNotExist(err) { + return nil, fmt.Errorf("reading config file: %w", err) + } + } + } + + // Bind CLI flags so they override env vars and config file (REQ-CFG-040) + if cmd != nil { + if err := bindFlags(v, cmd); err != nil { + return nil, err + } + } + + var cfg Config + if err := v.Unmarshal(&cfg); err != nil { + return nil, fmt.Errorf("unmarshalling config: %w", err) + } + + return &cfg, nil +} + +// configFilePath resolves the config file path from the --config flag +// or the DCM_CONFIG environment variable. +func configFilePath(cmd *cobra.Command) string { + if cmd != nil { + f := cmd.Root().PersistentFlags().Lookup("config") + if f != nil && f.Changed { + return f.Value.String() + } + } + if v := os.Getenv("DCM_CONFIG"); v != "" { + return v + } + return "" +} + +// bindFlags binds only flags that were explicitly set by the user, so that +// unset flags don't override environment variables or config file values. +func bindFlags(v *viper.Viper, cmd *cobra.Command) error { + flagToKey := map[string]string{ + "api-gateway-url": "api-gateway-url", + "output": "output-format", + "timeout": "timeout", + "tls-ca-cert": "tls-ca-cert", + "tls-client-cert": "tls-client-cert", + "tls-client-key": "tls-client-key", + "tls-skip-verify": "tls-skip-verify", + } + + for flagName, configKey := range flagToKey { + f := cmd.Root().PersistentFlags().Lookup(flagName) + if f != nil && f.Changed { + if err := v.BindPFlag(configKey, f); err != nil { + return fmt.Errorf("binding flag %s: %w", flagName, err) + } + } + } + return nil +} diff --git a/internal/config/config_suite_test.go b/internal/config/config_suite_test.go new file mode 100644 index 0000000..c6e29ba --- /dev/null +++ b/internal/config/config_suite_test.go @@ -0,0 +1,13 @@ +package config_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestConfig(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Config Suite") +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..2aae857 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,215 @@ +package config_test + +import ( + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/dcm-project/cli/internal/commands" + "github.com/dcm-project/cli/internal/config" +) + +// clearDCMEnvVars removes all DCM_* environment variables to isolate tests. +func clearDCMEnvVars() { + envVars := []string{ + "DCM_API_GATEWAY_URL", + "DCM_OUTPUT_FORMAT", + "DCM_TIMEOUT", + "DCM_CONFIG", + "DCM_TLS_CA_CERT", + "DCM_TLS_CLIENT_CERT", + "DCM_TLS_CLIENT_KEY", + "DCM_TLS_SKIP_VERIFY", + } + for _, env := range envVars { + Expect(os.Unsetenv(env)).To(Succeed()) + } +} + +// writeConfigFile creates a temporary config file with the given YAML content. +func writeConfigFile(content string) string { + dir := GinkgoT().TempDir() + path := filepath.Join(dir, "config.yaml") + err := os.WriteFile(path, []byte(content), 0o600) + Expect(err).NotTo(HaveOccurred()) + return path +} + +var _ = Describe("Configuration", func() { + BeforeEach(func() { + clearDCMEnvVars() + }) + + // TC-U001: Load configuration from config file + Describe("TC-U001: Config file loading", func() { + It("should load api-gateway-url from config file", func() { + cfgPath := writeConfigFile("api-gateway-url: http://custom:9080\n") + cmd := commands.NewRootCommand() + cmd.SetArgs([]string{"--config", cfgPath, "version"}) + cmd.SetOut(GinkgoWriter) + cmd.SetErr(GinkgoWriter) + _ = cmd.Execute() + + cfg, err := config.Load(cmd) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.APIGatewayURL).To(Equal("http://custom:9080")) + }) + }) + + // TC-U002: Environment variable overrides config file + Describe("TC-U002: Env var overrides config file", func() { + It("should use environment variable over config file value", func() { + cfgPath := writeConfigFile("api-gateway-url: http://file:9080\n") + GinkgoT().Setenv("DCM_API_GATEWAY_URL", "http://env:9080") + + cmd := commands.NewRootCommand() + cmd.SetArgs([]string{"--config", cfgPath, "version"}) + cmd.SetOut(GinkgoWriter) + cmd.SetErr(GinkgoWriter) + _ = cmd.Execute() + + cfg, err := config.Load(cmd) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.APIGatewayURL).To(Equal("http://env:9080")) + }) + }) + + // TC-U003: CLI flag overrides environment variable and config file + Describe("TC-U003: CLI flag overrides env var and config file", func() { + It("should use CLI flag over environment variable and config file", func() { + cfgPath := writeConfigFile("api-gateway-url: http://file:9080\n") + GinkgoT().Setenv("DCM_API_GATEWAY_URL", "http://env:9080") + + cmd := commands.NewRootCommand() + cmd.SetArgs([]string{ + "--config", cfgPath, + "--api-gateway-url", "http://flag:9080", + "version", + }) + cmd.SetOut(GinkgoWriter) + cmd.SetErr(GinkgoWriter) + _ = cmd.Execute() + + cfg, err := config.Load(cmd) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.APIGatewayURL).To(Equal("http://flag:9080")) + }) + }) + + // TC-U004: Default values applied when no config specified + Describe("TC-U004: Built-in defaults", func() { + It("should apply default values when no config file, env vars, or flags are set", func() { + cfgPath := filepath.Join(GinkgoT().TempDir(), "nonexistent.yaml") + cmd := commands.NewRootCommand() + cmd.SetArgs([]string{"--config", cfgPath, "version"}) + cmd.SetOut(GinkgoWriter) + cmd.SetErr(GinkgoWriter) + _ = cmd.Execute() + + cfg, err := config.Load(cmd) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.APIGatewayURL).To(Equal("http://localhost:9080")) + Expect(cfg.OutputFormat).To(Equal("table")) + Expect(cfg.Timeout).To(Equal(30)) + Expect(cfg.TLSCACert).To(BeEmpty()) + Expect(cfg.TLSClientCert).To(BeEmpty()) + Expect(cfg.TLSClientKey).To(BeEmpty()) + Expect(cfg.TLSSkipVerify).To(BeFalse()) + }) + }) + + // TC-U005: Missing config file does not cause failure + Describe("TC-U005: Missing config file", func() { + It("should not fail when the config file does not exist", func() { + cfgPath := filepath.Join(GinkgoT().TempDir(), "does-not-exist.yaml") + cmd := commands.NewRootCommand() + cmd.SetArgs([]string{"--config", cfgPath, "version"}) + cmd.SetOut(GinkgoWriter) + cmd.SetErr(GinkgoWriter) + _ = cmd.Execute() + + cfg, err := config.Load(cmd) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.APIGatewayURL).To(Equal("http://localhost:9080")) + }) + }) + + // TC-U006: Custom config file path via --config flag + Describe("TC-U006: Custom config file via --config", func() { + It("should load configuration from a custom file path", func() { + cfgPath := writeConfigFile("timeout: 60\n") + cmd := commands.NewRootCommand() + cmd.SetArgs([]string{"--config", cfgPath, "version"}) + cmd.SetOut(GinkgoWriter) + cmd.SetErr(GinkgoWriter) + _ = cmd.Execute() + + cfg, err := config.Load(cmd) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Timeout).To(Equal(60)) + }) + }) + + // TC-U007: Custom config file path via DCM_CONFIG environment variable + Describe("TC-U007: Custom config file via DCM_CONFIG", func() { + It("should load configuration from DCM_CONFIG path", func() { + cfgPath := writeConfigFile("timeout: 45\n") + GinkgoT().Setenv("DCM_CONFIG", cfgPath) + + cmd := commands.NewRootCommand() + cmd.SetArgs([]string{"version"}) + cmd.SetOut(GinkgoWriter) + cmd.SetErr(GinkgoWriter) + _ = cmd.Execute() + + cfg, err := config.Load(cmd) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Timeout).To(Equal(45)) + }) + }) + + // TC-U008: All environment variables are supported + Describe("TC-U008: All environment variables", func() { + DescribeTable("should load configuration from each environment variable", + func(envVar, envValue, configField string, expected any) { + cfgPath := filepath.Join(GinkgoT().TempDir(), "nonexistent.yaml") + GinkgoT().Setenv(envVar, envValue) + + cmd := commands.NewRootCommand() + cmd.SetArgs([]string{"--config", cfgPath, "version"}) + cmd.SetOut(GinkgoWriter) + cmd.SetErr(GinkgoWriter) + _ = cmd.Execute() + + cfg, err := config.Load(cmd) + Expect(err).NotTo(HaveOccurred()) + + switch configField { + case "APIGatewayURL": + Expect(cfg.APIGatewayURL).To(Equal(expected)) + case "OutputFormat": + Expect(cfg.OutputFormat).To(Equal(expected)) + case "Timeout": + Expect(cfg.Timeout).To(Equal(expected)) + case "TLSCACert": + Expect(cfg.TLSCACert).To(Equal(expected)) + case "TLSClientCert": + Expect(cfg.TLSClientCert).To(Equal(expected)) + case "TLSClientKey": + Expect(cfg.TLSClientKey).To(Equal(expected)) + case "TLSSkipVerify": + Expect(cfg.TLSSkipVerify).To(Equal(expected)) + } + }, + Entry("DCM_API_GATEWAY_URL", "DCM_API_GATEWAY_URL", "http://e:9080", "APIGatewayURL", "http://e:9080"), + Entry("DCM_OUTPUT_FORMAT", "DCM_OUTPUT_FORMAT", "json", "OutputFormat", "json"), + Entry("DCM_TIMEOUT", "DCM_TIMEOUT", "60", "Timeout", 60), + Entry("DCM_TLS_CA_CERT", "DCM_TLS_CA_CERT", "/path/ca.pem", "TLSCACert", "/path/ca.pem"), + Entry("DCM_TLS_CLIENT_CERT", "DCM_TLS_CLIENT_CERT", "/path/cert.pem", "TLSClientCert", "/path/cert.pem"), + Entry("DCM_TLS_CLIENT_KEY", "DCM_TLS_CLIENT_KEY", "/path/key.pem", "TLSClientKey", "/path/key.pem"), + Entry("DCM_TLS_SKIP_VERIFY", "DCM_TLS_SKIP_VERIFY", "true", "TLSSkipVerify", true), + ) + }) +})