diff --git a/README.md b/README.md index 83c0119..f98ace4 100644 --- a/README.md +++ b/README.md @@ -206,9 +206,17 @@ bool_string: "prefix-${ENV:ENABLED}" # Forces string output For standalone interpolations, smartconfig automatically converts: - `"true"` → `true` (boolean) - `"false"` → `false` (boolean) -- Numeric strings → numbers (int or float64) +- Numeric strings → numbers (int or float64) with safety checks - Everything else → string +**Safety Check**: Numbers are only converted if the conversion is lossless. This means: +- `"123"` → `123` (converts to int) +- `"0123"` → `"0123"` (stays string - leading zeros) +- `"123.45"` → `123.45` (converts to float) +- `"1.50"` → `"1.50"` (stays string - trailing zeros would be lost) +- `"+123"` → `"+123"` (stays string - plus sign would be lost) +- `"1e10"` → `"1e10"` (stays string - notation would change) + ## Supported Resolvers ### Local Resolvers diff --git a/smartconfig.go b/smartconfig.go index ccb1737..13d1fe7 100644 --- a/smartconfig.go +++ b/smartconfig.go @@ -605,16 +605,26 @@ func (c *Config) convertToType(s string) interface{} { // Try integer if i, err := strconv.Atoi(s); err == nil { - return i + // Verify the conversion is lossless + if strconv.Itoa(i) == s { + return i + } } // Try float if f, err := strconv.ParseFloat(s, 64); err == nil { // Check if it's actually an integer if float64(int(f)) == f { - return int(f) + // Verify integer conversion is lossless + if strconv.Itoa(int(f)) == s { + return int(f) + } + } + // For floats, use FormatFloat to check if conversion is lossless + // Using -1 precision means use the smallest number of digits necessary + if strconv.FormatFloat(f, 'f', -1, 64) == s { + return f } - return f } // Default to string diff --git a/typed_interpolation_test.go b/typed_interpolation_test.go index 050246c..e95a369 100644 --- a/typed_interpolation_test.go +++ b/typed_interpolation_test.go @@ -1,6 +1,7 @@ package smartconfig import ( + "fmt" "os" "strings" "testing" @@ -142,3 +143,115 @@ func TestTypedInterpolation(t *testing.T) { }) } } + +func TestNumericConversionSafety(t *testing.T) { + tests := []struct { + name string + input string + expected interface{} + desc string + }{ + { + name: "integer with leading zeros stays string", + input: "0123", + expected: "0123", + desc: "Leading zeros should prevent integer conversion", + }, + { + name: "float with trailing zeros", + input: "1.50", + expected: "1.50", + desc: "Trailing zeros should prevent float conversion", + }, + { + name: "scientific notation stays string", + input: "1e10", + expected: "1e10", + desc: "Scientific notation should stay as string", + }, + { + name: "integer within range converts", + input: "12345", + expected: 12345, + desc: "Normal integers should convert", + }, + { + name: "float with no trailing zeros converts", + input: "123.45", + expected: 123.45, + desc: "Normal floats should convert", + }, + { + name: "negative integer converts", + input: "-123", + expected: -123, + desc: "Negative integers should convert", + }, + { + name: "negative float converts", + input: "-123.45", + expected: -123.45, + desc: "Negative floats should convert", + }, + { + name: "very large number stays string", + input: "999999999999999999999999999999", + expected: "999999999999999999999999999999", + desc: "Numbers beyond int range should stay as strings", + }, + { + name: "float that looks like int converts to int", + input: "123.0", + expected: "123.0", + desc: "Float notation should be preserved even for whole numbers", + }, + { + name: "plus sign prevents conversion", + input: "+123", + expected: "+123", + desc: "Plus sign should prevent conversion", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set up environment variable + varName := "TEST_NUMERIC_" + strings.ToUpper(strings.ReplaceAll(tt.name, " ", "_")) + _ = os.Setenv(varName, tt.input) + defer func() { _ = os.Unsetenv(varName) }() + + yaml := fmt.Sprintf("value: ${ENV:%s}", varName) + config, err := NewFromReader(strings.NewReader(yaml)) + if err != nil { + t.Fatalf("Failed to parse YAML: %v", err) + } + + val, exists := config.Get("value") + if !exists { + t.Fatal("Expected value to exist") + } + + // Check type and value + switch expected := tt.expected.(type) { + case int: + if i, ok := val.(int); !ok { + t.Errorf("%s: Expected int, got %T: %v", tt.desc, val, val) + } else if i != expected { + t.Errorf("%s: Expected %d, got %d", tt.desc, expected, i) + } + case float64: + if f, ok := val.(float64); !ok { + t.Errorf("%s: Expected float64, got %T: %v", tt.desc, val, val) + } else if f != expected { + t.Errorf("%s: Expected %f, got %f", tt.desc, expected, f) + } + case string: + if s, ok := val.(string); !ok { + t.Errorf("%s: Expected string, got %T: %v", tt.desc, val, val) + } else if s != expected { + t.Errorf("%s: Expected %q, got %q", tt.desc, expected, s) + } + } + }) + } +}