diff --git a/tools/fxconfig/internal/cli/v1/info.go b/tools/fxconfig/internal/cli/v1/info.go index 86d8e6f..194d247 100644 --- a/tools/fxconfig/internal/cli/v1/info.go +++ b/tools/fxconfig/internal/cli/v1/info.go @@ -8,6 +8,7 @@ package v1 import ( "fmt" + "reflect" "slices" "strings" @@ -46,10 +47,7 @@ Examples: RunE: func(_ *cobra.Command, _ []string) error { switch format { case "env": - env, err := toEnv("FXCONFIG", ctx.Config) - if err != nil { - return err - } + env := structToEnv("FXCONFIG", ctx.Config) slices.Sort(env) ctx.Printer.Print(strings.Join(env, "\n") + "\n") case "yaml": @@ -70,42 +68,120 @@ Examples: return cmd } -func toEnv(prefix string, cfg any) ([]string, error) { - // we use yaml marshaling as a shortcut to get a map representation of the config - // that respects all yaml tags and omitempty. - out, err := yaml.Marshal(cfg) - if err != nil { - return nil, err +// structToEnv converts a struct to a flat list of KEY=value environment variable +// strings by walking all exported fields via reflection. Field names are derived +// from mapstructure tags. Unlike the previous YAML round-trip approach, this +// preserves zero-value fields (false, 0s, empty strings) so the full effective +// configuration is always visible. +func structToEnv(prefix string, cfg any) []string { + v := reflect.ValueOf(cfg) + if v.Kind() == reflect.Ptr { + if v.IsNil() { + return nil + } + v = v.Elem() } - var m map[string]any - if err := yaml.Unmarshal(out, &m); err != nil { - return nil, err + if v.Kind() != reflect.Struct { + return nil } - return flatten(prefix, m), nil + return flattenStruct(prefix, v) } -func flatten(prefix string, m map[string]any) []string { +// flattenStruct recursively walks the struct fields and builds env var entries. +func flattenStruct(prefix string, v reflect.Value) []string { var result []string - for k, v := range m { - key := strings.ToUpper(strings.ReplaceAll(k, "-", "_")) - if prefix != "" { - key = prefix + "_" + key + t := v.Type() + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if !field.IsExported() { + continue + } + + tag := field.Tag.Get("mapstructure") + if tag == "-" { + continue } - switch val := v.(type) { - case map[string]any: - result = append(result, flatten(key, val)...) - case []any: - strVals := make([]string, 0, len(val)) - for _, item := range val { - strVals = append(strVals, fmt.Sprintf("%v", item)) + name, squash := parseMapstructureTag(tag) + if name == "" { + name = field.Name + } + + fieldVal := v.Field(i) + + // squash means the embedded struct's fields are promoted into the parent + if squash { + inner := reflect.Indirect(fieldVal) + if inner.IsValid() && inner.Kind() == reflect.Struct { + result = append(result, flattenStruct(prefix, inner)...) } - result = append(result, fmt.Sprintf("%s=%s", key, strings.Join(strVals, ","))) - default: - result = append(result, fmt.Sprintf("%s=%v", key, val)) + continue } + + key := envKey(prefix, name) + result = append(result, flattenValue(key, fieldVal)...) } + return result } + +// flattenValue converts a single reflected value into env var entries. +func flattenValue(key string, v reflect.Value) []string { + // dereference pointers — nil pointers are skipped + if v.Kind() == reflect.Ptr { + if v.IsNil() { + return nil + } + v = v.Elem() + } + + switch v.Kind() { + case reflect.Struct: + // types like time.Duration implement Stringer; print them as values + if stringer, ok := v.Interface().(fmt.Stringer); ok { + return []string{fmt.Sprintf("%s=%s", key, stringer.String())} + } + return flattenStruct(key, v) + + case reflect.Slice: + items := make([]string, v.Len()) + for i := 0; i < v.Len(); i++ { + items[i] = fmt.Sprintf("%v", v.Index(i).Interface()) + } + return []string{fmt.Sprintf("%s=%s", key, strings.Join(items, ","))} + + default: + return []string{fmt.Sprintf("%s=%v", key, v.Interface())} + } +} + +// envKey builds an environment variable key from a prefix and field name. +func envKey(prefix, name string) string { + key := strings.ToUpper(strings.ReplaceAll(name, "-", "_")) + if prefix != "" { + return prefix + "_" + key + } + return key +} + +// parseMapstructureTag extracts the field name and squash flag from a +// mapstructure struct tag. Examples: +// +// "address" → ("address", false) +// ",squash" → ("", true) +// "" → ("", false) +func parseMapstructureTag(tag string) (name string, squash bool) { + parts := strings.Split(tag, ",") + if len(parts) > 0 { + name = parts[0] + } + for _, p := range parts[1:] { + if p == "squash" { + squash = true + } + } + return name, squash +} diff --git a/tools/fxconfig/internal/cli/v1/info_test.go b/tools/fxconfig/internal/cli/v1/info_test.go index 68d9c97..9ab989a 100644 --- a/tools/fxconfig/internal/cli/v1/info_test.go +++ b/tools/fxconfig/internal/cli/v1/info_test.go @@ -185,3 +185,65 @@ func TestInfoCommand_InvalidFormat(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "invalid --format: json (want yaml|env)") } + +func TestInfoCommand_EnvPreservesZeroValues(t *testing.T) { + t.Parallel() + + boolPtr := func(b bool) *bool { return &b } + + var outBuf bytes.Buffer + ctx := &CLIContext{ + Config: &config.Config{ + Logging: config.LoggingConfig{ + Level: "", + Format: "", + }, + TLS: config.TLSConfig{ + Enabled: boolPtr(false), + ClientKeyPath: "", + }, + Orderer: config.OrdererConfig{ + EndpointServiceConfig: config.EndpointServiceConfig{ + Address: "", + ConnectionTimeout: 0, + }, + Channel: "", + }, + }, + Printer: cliio.NewCLIPrinter(&outBuf, &outBuf, cliio.FormatTable), + } + + cmd := NewInfoCommand(ctx) + err := cmd.Flags().Set("format", "env") + require.NoError(t, err) + + err = cmd.RunE(cmd, nil) + require.NoError(t, err) + + output := outBuf.String() + + // These zero-value fields were previously dropped by the YAML omitempty round-trip. + // The reflection-based approach must preserve them all. + require.Contains(t, output, "FXCONFIG_LOGGING_LEVEL=") + require.Contains(t, output, "FXCONFIG_LOGGING_FORMAT=") + require.Contains(t, output, "FXCONFIG_TLS_ENABLED=false") + require.Contains(t, output, "FXCONFIG_TLS_CLIENTKEY=") + require.Contains(t, output, "FXCONFIG_ORDERER_ADDRESS=") + require.Contains(t, output, "FXCONFIG_ORDERER_CONNECTIONTIMEOUT=0s") + require.Contains(t, output, "FXCONFIG_ORDERER_CHANNEL=") +} + +func TestStructToEnv_NilConfig(t *testing.T) { + t.Parallel() + + result := structToEnv("FXCONFIG", (*config.Config)(nil)) + require.Empty(t, result) +} + +func TestStructToEnv_NonStruct(t *testing.T) { + t.Parallel() + + result := structToEnv("FXCONFIG", "not a struct") + require.Empty(t, result) +} +