diff --git a/viper.go b/viper.go index 34a94798b..6e637cfbc 100644 --- a/viper.go +++ b/viper.go @@ -1660,6 +1660,196 @@ func (v *Viper) SafeWriteConfigAs(filename string) error { return v.writeConfig(filename, false) } +// WriteConfigAsTyped writes the current configuration to a file at the specified +// path, with automatic type coercion (e.g. "true" → true, "42" → 42). +// +// The configuration is marshaled in the format specified by the file's extension +// (e.g. .yaml, .json, .toml). If the file cannot be created or written, an error is returned. +// +// This function is similar to WriteConfigAs(), but ensures that string values +// (e.g. from environment variables or flags) are converted to their proper types +// before writing. +func WriteConfigAsTyped(filename string) error { return v.WriteConfigAsTyped(filename) } + +// WriteConfigAsTyped writes the current configuration to the file at the given path. +// The file is created or truncated if it already exists. +// The config format is determined by the file extension. +func (v *Viper) WriteConfigAsTyped(filename string) error { + return v.writeConfigAsTyped(filename, true) +} + +// SafeWriteConfigAsTyped writes the current configuration to the file at the given path +// only if the file does not already exist. +// +// If the file exists, SafeWriteConfigAsTyped returns an error of type ConfigFileAlreadyExistsError. +func SafeWriteConfigAsTyped(filename string) error { return v.SafeWriteConfigAsTyped(filename) } + +// SafeWriteConfigAsTyped is the method version of SafeWriteConfigAsTyped. +// It ensures no existing file is overwritten. +func (v *Viper) SafeWriteConfigAsTyped(filename string) error { + alreadyExists, err := afero.Exists(v.fs, filename) + if err != nil { + return err + } + if alreadyExists { + return ConfigFileAlreadyExistsError(filename) + } + return v.writeConfigAsTyped(filename, false) +} + +// writeConfigAsTyped writes the current configuration to the given filename. +// If force is false, it returns an error if the file exists. +// The configuration is first processed to convert string representations +// of booleans, integers, and floats into their native types. +func (v *Viper) writeConfigAsTyped(filename string, force bool) error { + if v.logger != nil { + v.logger.Debug("Starting typed config write", "filename", filename, "force", force) + } + + exists, err := afero.Exists(v.fs, filename) + if err != nil { + if v.logger != nil { + v.logger.Error("Error checking if config file exists", "filename", filename, "error", err) + } + return err + } + if exists && !force { + if v.logger != nil { + v.logger.Warn("Config file already exists, skipping write", "filename", filename) + } + return ConfigFileAlreadyExistsError(filename) + } + + rawSettings := v.AllSettings() + if v.logger != nil { + v.logger.Debug("Raw settings before type coercion", "settings", rawSettings) + } + + coerced := convertStringTypes(rawSettings) + settings, ok := coerced.(map[string]interface{}) + if !ok { + if v.logger != nil { + v.logger.Error("Coerced settings are not a map", "type", fmt.Sprintf("%T", coerced), "error", err) + } + return ConfigMarshalError{fmt.Errorf("expected map[string]interface{}, got %T", coerced)} + } + + if v.logger != nil { + v.logger.Debug("Coerced settings after type conversion", "settings", settings) + } + + configType := v.getConfigType() + if configType == "" { + ext := filepath.Ext(filename) + if ext != "" { + configType = ext[1:] // remove leading dot + } + } + configType = strings.ToLower(configType) + + if !slices.Contains(SupportedExts, configType) { + if v.logger != nil { + v.logger.Error("Unsupported config type", "type", configType) + } + return UnsupportedConfigError(configType) + } + + encoder, err := v.encoderRegistry.Encoder(configType) + if err != nil { + if v.logger != nil { + v.logger.Error("Failed to get encoder for config type", "type", configType, "error", err) + } + return ConfigMarshalError{err} + } + + data, err := encoder.Encode(settings) + if err != nil { + if v.logger != nil { + v.logger.Error("Failed to marshal config", "type", configType, "error", err) + } + return ConfigMarshalError{err} + } + + if err := afero.WriteFile(v.fs, filename, data, 0644); err != nil { + if v.logger != nil { + v.logger.Error("Failed to write config file", "filename", filename, "error", err) + } + return err + } + + if v.logger != nil { + v.logger.Info("Successfully wrote typed config file", "filename", filename, "size", len(data)) + } + + return nil +} + +// convertStringTypes recursively converts string values in a map[string]interface{} to their +// proper types: boolean, float64 (for both integers and floats). +// +// This is used when writing configuration to ensure that values set from sources +// that only provide strings (e.g. environment variables, CLI flags) are written +// with correct types (e.g. true instead of "true", 42 instead of "42"). +// +// Supported conversions: +// - "true" → true (bool) +// - "false" → false (bool) +// - "42" → 42.0 (float64) +// - "3.14" → 3.14 (float64) +// - "1e5" → 100000.0 (float64) +// +// Strings that do not match these patterns are left unchanged. +// Nested maps are processed recursively. +// +// Note: Integers are converted to float64 to match the behavior of json.Unmarshal, +// which treats all JSON numbers as float64. This ensures consistency across formats. +func convertStringTypes(in interface{}) interface{} { + switch x := in.(type) { + case map[string]interface{}: + out := make(map[string]interface{}, len(x)) + for k, v := range x { + out[k] = convertStringTypes(v) + } + return out + + case []interface{}: + out := make([]interface{}, len(x)) + for i, v := range x { + out[i] = convertStringTypes(v) + } + return out + + case string: + s := strings.TrimSpace(x) + if s == "" { + return x + } + + // Булевы значения + switch strings.ToLower(s) { + case "true": + return true + case "false": + return false + } + + // Сначала попробуем как int64 + if i, err := strconv.ParseInt(s, 10, 64); err == nil { + return i // вернём int64, а не float64 + } + + // Потом как float64 + if f, err := strconv.ParseFloat(s, 64); err == nil { + return f + } + + return x + + default: + return x + } +} + func (v *Viper) writeConfig(filename string, force bool) error { v.logger.Info("attempting to write configuration to file") diff --git a/viper_test.go b/viper_test.go index e7e262b62..64608cf00 100644 --- a/viper_test.go +++ b/viper_test.go @@ -22,6 +22,7 @@ import ( "github.com/fsnotify/fsnotify" "github.com/go-viper/mapstructure/v2" + "github.com/pelletier/go-toml/v2" "github.com/sagikazarmark/locafero" "github.com/spf13/afero" "github.com/spf13/cast" @@ -2000,6 +2001,264 @@ func TestSafeWriteConfigAsWithExistingFile(t *testing.T) { assert.True(t, ok, "Expected ConfigFileAlreadyExistsError") } +func TestWriteConfigAsTyped(t *testing.T) { + fs := afero.NewMemMapFs() + + inputJSON := []byte(`{ + "enabled": "true", + "debug": "false", + "port": "8080", + "timeout": "3.14", + "features": { + "new_ui": "true", + "beta": "false" + }, + "nested": { + "value": "42", + "flag": "True" + }, + "tags": ["1", "2.5", "enabled"] +}`) + + inputYAML := []byte(`enabled: "true" +debug: "false" +port: "8080" +timeout: "3.14" +features: + new_ui: "true" + beta: "false" +nested: + value: "42" + flag: "True" +tags: + - "1" + - "2.5" + - "enabled" +`) + + inputTOML := []byte(` +enabled = "true" +debug = "false" +port = "8080" +timeout = "3.14" +tags = ["1", "2.5", "enabled"] + +[features] +new_ui = "true" +beta = "false" + +[nested] +value = "42" +flag = "True" +`) + + expectedJSON := []byte(`{ + "debug": false, + "enabled": true, + "features": { + "beta": false, + "new_ui": true + }, + "nested": { + "flag": true, + "value": 42 + }, + "port": 8080, + "tags": [ + 1, + 2.5, + "enabled" + ], + "timeout": 3.14 +}`) + + expectedYAML := []byte(`debug: false +enabled: true +features: + beta: false + new_ui: true +nested: + flag: true + value: 42 +port: 8080 +tags: + - 1 + - 2.5 + - enabled +timeout: 3.14 +`) + + expectedTOML := []byte(`debug = false +enabled = true +port = 8080 +timeout = 3.14 + +tags = [1, 2.5, "enabled"] + +[features] +beta = false +new_ui = true + +[nested] +flag = true +value = 42 +`) + + testCases := map[string]struct { + configName string + inConfigType string + outConfigType string + fileName string + input []byte + expectedContent []byte + }{ + "json with file extension": { + configName: "c", + inConfigType: "json", + outConfigType: "json", + fileName: "c.json", + input: inputJSON, + expectedContent: expectedJSON, + }, + "json without file extension": { + configName: "c", + inConfigType: "json", + outConfigType: "json", + fileName: "c", + input: inputJSON, + expectedContent: expectedJSON, + }, + "yaml with file extension": { + configName: "c", + inConfigType: "yaml", + outConfigType: "yaml", + fileName: "c.yaml", + input: inputYAML, + expectedContent: expectedYAML, + }, + "yaml without file extension": { + configName: "c", + inConfigType: "yaml", + outConfigType: "yaml", + fileName: "c", + input: inputYAML, + expectedContent: expectedYAML, + }, + "toml with file extension": { + configName: "c", + inConfigType: "toml", + outConfigType: "toml", + fileName: "c.toml", + input: inputTOML, + expectedContent: expectedTOML, + }, + "toml without file extension": { + configName: "c", + inConfigType: "toml", + outConfigType: "toml", + fileName: "c", + input: inputTOML, + expectedContent: expectedTOML, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + fs = afero.NewMemMapFs() + v := New() + v.SetFs(fs) + v.SetConfigName(tc.configName) + v.SetConfigType(tc.inConfigType) + + err := v.ReadConfig(bytes.NewBuffer(tc.input)) + require.NoError(t, err) + + v.SetConfigType(tc.outConfigType) + + err = v.WriteConfigAsTyped(tc.fileName) + require.NoError(t, err) + + read, err := afero.ReadFile(fs, tc.fileName) + require.NoError(t, err) + + switch tc.outConfigType { + case "json": + assert.JSONEq(t, string(tc.expectedContent), string(read)) + case "yaml": + assert.YAMLEq(t, string(tc.expectedContent), string(read)) + case "toml": + var expected, actual interface{} + err1 := toml.Unmarshal(tc.expectedContent, &expected) + err2 := toml.Unmarshal(read, &actual) + require.NoError(t, err1) + require.NoError(t, err2) + assert.Equal(t, expected, actual) + default: + assert.Equal(t, tc.expectedContent, read) + } + }) + } +} + +func TestSafeWriteConfigAsTyped(t *testing.T) { + fs := afero.NewMemMapFs() + + testCases := map[string]struct { + setup func() + expectError bool + }{ + "should succeed if file does not exist": { + setup: func() {}, + expectError: false, + }, + "should fail if file already exists": { + setup: func() { + err := afero.WriteFile(fs, "config.yaml", []byte("dummy: value"), 0644) + require.NoError(t, err, "Failed to create existing config file") + }, + expectError: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + fs = afero.NewMemMapFs() + + v := New() + v.SetFs(fs) + v.SetConfigType("yaml") + + v.Set("enabled", "true") + v.Set("port", "8080") + v.Set("features.new_ui", "true") + + tc.setup() + + err := v.SafeWriteConfigAsTyped("config.yaml") + + if tc.expectError { + require.Error(t, err) + _, ok := err.(ConfigFileAlreadyExistsError) + assert.True(t, ok, "Error should be ConfigFileAlreadyExistsError") + } else { + require.NoError(t, err) + + exists, err := afero.Exists(fs, "config.yaml") + require.NoError(t, err) + assert.True(t, exists) + + data, err := afero.ReadFile(fs, "config.yaml") + require.NoError(t, err) + content := string(data) + + assert.Contains(t, content, "enabled: true") + assert.Contains(t, content, "port: 8080") + assert.Contains(t, content, "new_ui: true") + } + }) + } +} + func TestWriteHiddenFile(t *testing.T) { v := New() fs := afero.NewMemMapFs()