From 3c3732f03331bd73d2218dd957f99c73fdb06749 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 21 Jul 2025 12:13:14 +0200 Subject: [PATCH] Add JSON5 support, --json CLI flag, typed getters, and improve YAML quoting - Updated JSON resolver to use gjson library for JSON5 support (comments, trailing commas) - Added --json flag to CLI tool for JSON output format - Added typed getter methods: GetInt(), GetUint(), GetFloat(), GetBool(), GetBytes() - GetBytes() supports human-readable formats like "10G", "20KiB" via go-humanize - Fixed YAML interpolation to always quote output values for safety - Updated README to clarify YAML-only input and automatic quoting behavior - Added .gitignore to exclude compiled binary - Fixed test config files to use unquoted interpolation syntax - Updated tests to expect quoted interpolation output --- .gitignore | 1 + README.md | 168 ++++++++++++++++++++++++++++++----- cmd/smartconfig/main.go | 23 ++++- go.mod | 5 ++ go.sum | 11 +++ interpolate.go | 18 +++- resolver_json.go | 23 ++--- smartconfig.go | 188 ++++++++++++++++++++++++++++++++++++++++ smartconfig_test.go | 28 +++--- test/config.yaml | 16 ++-- 10 files changed, 418 insertions(+), 63 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7465229 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/smartconfig \ No newline at end of file diff --git a/README.md b/README.md index e59e869..f2f8fc1 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,11 @@ # smartconfig -smartconfig is a go library that reads a configuration file from the -filesystem and interpolates variables in it from several sources, then -parses the config file as yaml. +smartconfig is a go library that reads YAML configuration files from the +filesystem and interpolates variables in them from several sources, then +parses the resulting YAML. + +**Important:** smartconfig only supports valid YAML files as input. The file +must be valid YAML syntax before interpolation occurs. This was inspired by Ryan Smith and one of his config file formats/parsers. It struck me as very clever and I wanted to begin using it. @@ -28,39 +31,79 @@ to the environment of the process that reads the config file. This allows a config file to serve as a bridge between fancy backend secret management services and "traditional" configuration via env vars. +## Important: Automatic Quoting + +All interpolated values are automatically quoted and escaped for YAML safety. +This means you should NOT wrap interpolations in quotes: + +```yaml +# Correct - no quotes needed +password: ${ENV:DB_PASSWORD} +enabled: ${ENV:FEATURE_ENABLED} + +# Incorrect - will result in double-quoted values +password: "${ENV:DB_PASSWORD}" +``` + +**Note:** ALL interpolated values are output as quoted strings, regardless of +their content. This means: + +1. YAML keywords like `no`, `yes`, `true`, `false`, `on`, `off`, etc. will + always be treated as strings, never as boolean values +2. Numbers will always be treated as strings, never as numeric values + +```yaml +# If ENV:FEATURE_ENABLED is "true", this will be the string "true", not boolean true +enabled: ${ENV:FEATURE_ENABLED} + +# If ENV:DEBUG_MODE is "no", this will be the string "no", not boolean false +debug: ${ENV:DEBUG_MODE} + +# If ENV:PORT is "8080", this will be the string "8080", not the number 8080 +port: ${ENV:PORT} + +# If ENV:TIMEOUT is "30.5", this will be the string "30.5", not the float 30.5 +timeout: ${ENV:TIMEOUT} +``` + +This design choice prevents ambiguity and parsing errors, ensuring consistent +behavior across all interpolated values. Use the typed getter methods (GetInt, +GetFloat, GetBool, etc.) to convert string values to their appropriate types +when accessing configuration values. + # Usage ```yaml # config.yaml name: ${ENV:APPLICATION_NAME} -host: ${EXEC:"hostname -s"} +host: ${EXEC:hostname -s} port: ${ENV:PORT} vhost: - tls_sni_host: "${JSON:/etc/config/hosts.json:'.tls_sni_host[0]'}" + tls_sni_host: ${JSON:/etc/config/hosts.json:tls_sni_host.0} machine: - id: "${FILE:/etc/machine-id}" - temperature: "${FILE:/sys/class/thermal/thermal_zone0/temp}" - num_cpus: "${EXEC:nproc}" + id: ${FILE:/etc/machine-id} + temperature: ${FILE:/sys/class/thermal/thermal_zone0/temp} + num_cpus: ${EXEC:nproc} api: root_user: "admin" - root_password: "${AWSSM:root_password}" + root_password: ${AWSSM:root_password} db: - host: "${GCPSM:${ENV:APPLICATION_NAME}_DB_HOST}" - user: "${GCPSM:${ENV:APPLICATION_NAME}_DB_USER}" - password: "${GCPSM:${ENV:APPLICATION_NAME}_DB_PASSWORD}" + host: ${GCPSM:${ENV:APPLICATION_NAME}_DB_HOST} + user: ${GCPSM:${ENV:APPLICATION_NAME}_DB_USER} + password: ${GCPSM:${ENV:APPLICATION_NAME}_DB_PASSWORD} external: - google_api_key: "${CONSUL:secret:google_api_key}" - twilio_api_key: "${VAULT:secret:twilio_api_key}" + google_api_key: ${CONSUL:secret:google_api_key} + twilio_api_key: ${VAULT:secret:twilio_api_key} env: - ENCRYPTION_PUBLIC_KEY: "${EXEC:secret get myapp/encryption_public_key}" - SLACK_WEBHOOK_URL: "${FILE:/etc/config/slack_webhook_url.txt}" + ENCRYPTION_PUBLIC_KEY: ${EXEC:secret get myapp/encryption_public_key} + SLACK_WEBHOOK_URL: ${FILE:/etc/config/slack_webhook_url.txt} ``` # Supported Providers @@ -74,7 +117,7 @@ env: * AZURESM - Azure Key Vault * K8SS - Kubernetes Secrets * FILE - read from a file -* JSON - read from a JSON file (supports json5) +* JSON - read from a JSON file (supports JSON5 syntax including comments and trailing commas) * YAML - read from a YAML file * ETCD - etcd key-value store @@ -173,6 +216,62 @@ if err != nil { } ``` +### Get Integer Value + +```go +// Returns (int, error) +port, err := config.GetInt("server.port") +if err != nil { + log.Printf("Error: %v", err) +} +``` + +### Get Unsigned Integer Value + +```go +// Returns (uint, error) +maxConnections, err := config.GetUint("server.max_connections") +if err != nil { + log.Printf("Error: %v", err) +} +``` + +### Get Float Value + +```go +// Returns (float64, error) +timeout, err := config.GetFloat("server.timeout_seconds") +if err != nil { + log.Printf("Error: %v", err) +} +``` + +### Get Boolean Value + +```go +// Returns (bool, error) +debugMode, err := config.GetBool("debug_enabled") +if err != nil { + log.Printf("Error: %v", err) +} +``` + +### Get Byte Size Value (Human-Readable) + +```go +// Supports formats like "10G", "20KiB", "25TB" +// Returns (uint64, error) - size in bytes +maxFileSize, err := config.GetBytes("upload.max_file_size") +if err != nil { + log.Printf("Error: %v", err) +} + +// Example config values: +// max_file_size: "100MB" -> 100000000 bytes +// max_file_size: "10GiB" -> 10737418240 bytes +// max_file_size: "1.5TB" -> 1500000000000 bytes +``` + ### Get Entire Configuration ```go @@ -207,7 +306,7 @@ config.RegisterResolver("CUSTOM", &CustomResolver{}) * `${ENV:VARIABLE_NAME}` - Environment variable * `${EXEC:command}` - Execute shell command * `${FILE:/path/to/file}` - Read file contents -* `${JSON:/path/to/file.json:json.path}` - Read JSON value +* `${JSON:/path/to/file.json:json.path}` - Read JSON value (uses gjson syntax, e.g., `field.subfield` or `array.0`) * `${YAML:/path/to/file.yaml:yaml.path}` - Read YAML value ### Cloud Resolvers @@ -225,19 +324,33 @@ config.RegisterResolver("CUSTOM", &CustomResolver{}) Smartconfig supports nested interpolations up to 3 levels deep: ```yaml -env_suffix: "prod" +# Example: Using environment suffix for dynamic config selection +# First, set the ENV_SUFFIX environment variable: +# export ENV_SUFFIX=prod + database: - host: "${ENV:DB_HOST_${ENV:ENV_SUFFIX}}" # Resolves DB_HOST_prod + # This will look for the environment variable DB_HOST_prod + host: ${ENV:DB_HOST_${ENV:ENV_SUFFIX}} + + # You can also use it with other resolvers + password: ${VAULT:secret/data/${ENV:ENV_SUFFIX}/db:password} + +# Another example with multiple environments +api: + # If ENV_SUFFIX=staging, this resolves to API_KEY_STAGING + key: ${ENV:API_KEY_${ENV:ENV_SUFFIX}} ``` +Note: The `env_suffix` shown in the config is just a regular YAML key. To use it in interpolations, you need to reference it as `${ENV:ENV_SUFFIX}` where `ENV_SUFFIX` is an actual environment variable. + ## Environment Variable Injection Values under the `env` key are automatically set as environment variables: ```yaml env: - API_KEY: "${VAULT:secret/api:key}" - DB_PASSWORD: "${AWSSM:db-password}" + API_KEY: ${VAULT:secret/api:key} + DB_PASSWORD: ${AWSSM:db-password} # After loading, these are available as environment variables ``` @@ -250,13 +363,22 @@ A command-line tool is included that reads YAML from stdin, interpolates all var # Build the CLI tool go build ./cmd/smartconfig -# Use it to interpolate config files +# Use it to interpolate config files (default YAML output) cat config.yaml | ./smartconfig > interpolated.yaml +# Output as formatted JSON instead of YAML +cat config.yaml | ./smartconfig --json > interpolated.json + # Or use it in a pipeline export DB_PASSWORD="secret123" echo 'password: ${ENV:DB_PASSWORD}' | ./smartconfig # Output: password: secret123 + +# JSON output in a pipeline +echo 'password: ${ENV:DB_PASSWORD}' | ./smartconfig --json +# Output: { +# "password": "secret123" +# } ``` Note: If an environment variable or other resource is not found, the tool will exit with an error. diff --git a/cmd/smartconfig/main.go b/cmd/smartconfig/main.go index 37d3207..d18a8af 100644 --- a/cmd/smartconfig/main.go +++ b/cmd/smartconfig/main.go @@ -1,6 +1,8 @@ package main import ( + "encoding/json" + "flag" "log" "os" @@ -9,6 +11,10 @@ import ( ) func main() { + var jsonOutput bool + flag.BoolVar(&jsonOutput, "json", false, "Output as formatted JSON instead of YAML") + flag.Parse() + // Read from stdin config, err := smartconfig.NewFromReader(os.Stdin) if err != nil { @@ -18,10 +24,19 @@ func main() { // Get the interpolated data data := config.Data() - // Marshal back to YAML - output, err := yaml.Marshal(data) - if err != nil { - log.Fatalf("Error marshaling to YAML: %v", err) + var output []byte + if jsonOutput { + // Marshal to JSON with indentation + output, err = json.MarshalIndent(data, "", " ") + if err != nil { + log.Fatalf("Error marshaling to JSON: %v", err) + } + } else { + // Marshal to YAML + output, err = yaml.Marshal(data) + if err != nil { + log.Fatalf("Error marshaling to YAML: %v", err) + } } // Write to stdout diff --git a/go.mod b/go.mod index 9634e5d..bff9d85 100644 --- a/go.mod +++ b/go.mod @@ -42,6 +42,7 @@ require ( github.com/coreos/go-semver v0.3.1 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/fatih/color v1.16.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -89,6 +90,10 @@ require ( github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/x448/float16 v0.8.4 // indirect go.etcd.io/etcd/api/v3 v3.6.2 // indirect go.etcd.io/etcd/client/pkg/v3 v3.6.2 // indirect diff --git a/go.sum b/go.sum index 3d809c6..60cbd70 100644 --- a/go.sum +++ b/go.sum @@ -87,6 +87,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= @@ -333,6 +335,15 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +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= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= diff --git a/interpolate.go b/interpolate.go index 93e27c2..d8ad8d3 100644 --- a/interpolate.go +++ b/interpolate.go @@ -2,6 +2,7 @@ package smartconfig import ( "fmt" + "strconv" "strings" ) @@ -41,6 +42,11 @@ func findInterpolations(s string) []struct{ start, end int } { // interpolate handles nested interpolations properly func (c *Config) interpolate(content string, depth int) (string, error) { + return c.interpolateWithQuoting(content, depth, true) +} + +// interpolateWithQuoting handles interpolation with control over quoting +func (c *Config) interpolateWithQuoting(content string, depth int, shouldQuote bool) (string, error) { if depth >= maxRecursionDepth { return content, nil } @@ -71,8 +77,8 @@ func (c *Config) interpolate(content string, depth int) (string, error) { // Check if value contains nested interpolations if strings.Contains(value, "${") { - // Recursively interpolate the value first - interpolatedValue, err := c.interpolate(value, depth+1) + // Recursively interpolate the value first, without quoting + interpolatedValue, err := c.interpolateWithQuoting(value, depth+1, false) if err != nil { return "", err } @@ -90,8 +96,14 @@ func (c *Config) interpolate(content string, depth int) (string, error) { return "", fmt.Errorf("failed to resolve %s:%s: %w", resolverName, value, err) } + // Only quote if requested (top-level interpolations) + finalValue := resolved + if shouldQuote && depth == 0 { + finalValue = strconv.Quote(resolved) + } + // Replace the match - result = result[:pos.start] + resolved + result[pos.end:] + result = result[:pos.start] + finalValue + result[pos.end:] changed = true } diff --git a/resolver_json.go b/resolver_json.go index 1866d22..da8a464 100644 --- a/resolver_json.go +++ b/resolver_json.go @@ -1,10 +1,11 @@ package smartconfig import ( - "encoding/json" "fmt" "os" "strings" + + "github.com/tidwall/gjson" ) // JSONResolver reads values from JSON files. @@ -26,17 +27,17 @@ func (r *JSONResolver) Resolve(value string) (string, error) { return "", fmt.Errorf("failed to read JSON file %s: %w", filePath, err) } - var jsonData interface{} - if err := json.Unmarshal(data, &jsonData); err != nil { - return "", fmt.Errorf("failed to parse JSON: %w", err) - } - - // Simple JSON path evaluation (would need a proper library for complex paths) + // gjson supports JSON5 syntax including comments, trailing commas, etc. + // Special case: if path is ".", return the entire JSON as a string if jsonPath == "." { - return fmt.Sprintf("%v", jsonData), nil + return string(data), nil } - // This is a simplified implementation - // In production, use a proper JSON path library - return fmt.Sprintf("%v", jsonData), nil + result := gjson.GetBytes(data, jsonPath) + if !result.Exists() { + return "", fmt.Errorf("path %s not found in JSON file", jsonPath) + } + + // Return the raw value as a string + return result.String(), nil } diff --git a/smartconfig.go b/smartconfig.go index 288a5d7..3a01cd3 100644 --- a/smartconfig.go +++ b/smartconfig.go @@ -4,7 +4,9 @@ import ( "fmt" "io" "os" + "strconv" + "github.com/dustin/go-humanize" "gopkg.in/yaml.v3" ) @@ -207,6 +209,192 @@ func (c *Config) GetString(key string) (string, error) { return fmt.Sprintf("%v", value), nil } +// GetInt retrieves an integer value from the configuration. +// Returns an error if the key doesn't exist or if the value cannot be converted to an integer. +func (c *Config) GetInt(key string) (int, error) { + value, ok := c.data[key] + if !ok { + return 0, fmt.Errorf("key %s not found", key) + } + + // Try direct int conversion first + if intValue, ok := value.(int); ok { + return intValue, nil + } + + // Try float64 (common in JSON/YAML parsing) + if floatValue, ok := value.(float64); ok { + return int(floatValue), nil + } + + // Try string conversion + if strValue, ok := value.(string); ok { + intValue, err := strconv.Atoi(strValue) + if err != nil { + return 0, fmt.Errorf("cannot convert value %q to integer: %w", strValue, err) + } + return intValue, nil + } + + return 0, fmt.Errorf("cannot convert value of type %T to integer", value) +} + +// GetUint retrieves an unsigned integer value from the configuration. +// Returns an error if the key doesn't exist or if the value cannot be converted to a uint. +func (c *Config) GetUint(key string) (uint, error) { + value, ok := c.data[key] + if !ok { + return 0, fmt.Errorf("key %s not found", key) + } + + // Try direct uint conversion first + if uintValue, ok := value.(uint); ok { + return uintValue, nil + } + + // Try int conversion + if intValue, ok := value.(int); ok { + if intValue < 0 { + return 0, fmt.Errorf("cannot convert negative value %d to uint", intValue) + } + return uint(intValue), nil + } + + // Try float64 (common in JSON/YAML parsing) + if floatValue, ok := value.(float64); ok { + if floatValue < 0 { + return 0, fmt.Errorf("cannot convert negative value %f to uint", floatValue) + } + return uint(floatValue), nil + } + + // Try string conversion + if strValue, ok := value.(string); ok { + uintValue, err := strconv.ParseUint(strValue, 10, 0) + if err != nil { + return 0, fmt.Errorf("cannot convert value %q to uint: %w", strValue, err) + } + return uint(uintValue), nil + } + + return 0, fmt.Errorf("cannot convert value of type %T to uint", value) +} + +// GetFloat retrieves a float64 value from the configuration. +// Returns an error if the key doesn't exist or if the value cannot be converted to a float64. +func (c *Config) GetFloat(key string) (float64, error) { + value, ok := c.data[key] + if !ok { + return 0, fmt.Errorf("key %s not found", key) + } + + // Try direct float64 conversion first + if floatValue, ok := value.(float64); ok { + return floatValue, nil + } + + // Try float32 conversion + if floatValue, ok := value.(float32); ok { + return float64(floatValue), nil + } + + // Try int conversion + if intValue, ok := value.(int); ok { + return float64(intValue), nil + } + + // Try string conversion + if strValue, ok := value.(string); ok { + floatValue, err := strconv.ParseFloat(strValue, 64) + if err != nil { + return 0, fmt.Errorf("cannot convert value %q to float: %w", strValue, err) + } + return floatValue, nil + } + + return 0, fmt.Errorf("cannot convert value of type %T to float", value) +} + +// GetBool retrieves a boolean value from the configuration. +// Returns an error if the key doesn't exist or if the value cannot be converted to a boolean. +func (c *Config) GetBool(key string) (bool, error) { + value, ok := c.data[key] + if !ok { + return false, fmt.Errorf("key %s not found", key) + } + + // Try direct bool conversion first + if boolValue, ok := value.(bool); ok { + return boolValue, nil + } + + // Try string conversion + if strValue, ok := value.(string); ok { + boolValue, err := strconv.ParseBool(strValue) + if err != nil { + return false, fmt.Errorf("cannot convert value %q to boolean: %w", strValue, err) + } + return boolValue, nil + } + + // Try numeric conversions (0 = false, non-zero = true) + if intValue, ok := value.(int); ok { + return intValue != 0, nil + } + + if floatValue, ok := value.(float64); ok { + return floatValue != 0, nil + } + + return false, fmt.Errorf("cannot convert value of type %T to boolean", value) +} + +// GetBytes retrieves a byte size value from the configuration. +// Supports human-readable formats like "10G", "20KiB", "25TB", etc. +// Returns the size in bytes as uint64. +func (c *Config) GetBytes(key string) (uint64, error) { + value, ok := c.data[key] + if !ok { + return 0, fmt.Errorf("key %s not found", key) + } + + // Try direct numeric conversions first + if uintValue, ok := value.(uint64); ok { + return uintValue, nil + } + + if intValue, ok := value.(int); ok { + if intValue < 0 { + return 0, fmt.Errorf("cannot convert negative value %d to bytes", intValue) + } + return uint64(intValue), nil + } + + if floatValue, ok := value.(float64); ok { + if floatValue < 0 { + return 0, fmt.Errorf("cannot convert negative value %f to bytes", floatValue) + } + return uint64(floatValue), nil + } + + // Try string conversion with humanize parsing + if strValue, ok := value.(string); ok { + // Try parsing as a plain number first + if bytesValue, err := strconv.ParseUint(strValue, 10, 64); err == nil { + return bytesValue, nil + } + + // Try parsing with humanize + bytesValue, err := humanize.ParseBytes(strValue) + if err != nil { + return 0, fmt.Errorf("cannot parse value %q as bytes: %w", strValue, err) + } + return bytesValue, nil + } + + return 0, fmt.Errorf("cannot convert value of type %T to bytes", value) +} + // Data returns the entire configuration as a map. // This is useful for unmarshaling into custom structures. func (c *Config) Data() map[string]interface{} { diff --git a/smartconfig_test.go b/smartconfig_test.go index 8daae7d..fb633ec 100644 --- a/smartconfig_test.go +++ b/smartconfig_test.go @@ -74,7 +74,7 @@ func TestInterpolateBasic(t *testing.T) { defer func() { _ = os.Unsetenv("BASIC") }() config := New() - content := "test: \"${ENV:BASIC}\"" + content := "test: ${ENV:BASIC}" err := config.LoadFromReader(strings.NewReader(content)) if err != nil { t.Fatalf("Failed to load config: %v", err) @@ -96,8 +96,8 @@ func TestInterpolateMethod(t *testing.T) { // Test direct interpolation result, _ := config.interpolate("${ENV:FOO}", 0) - if result != "bar" { - t.Errorf("Expected 'bar', got '%s'", result) + if result != "\"bar\"" { + t.Errorf("Expected '\"bar\"', got '%s'", result) } } @@ -115,8 +115,8 @@ func TestInterpolateSimple(t *testing.T) { // Test simple interpolation result, _ := config.interpolate("${ENV:TEST}", 0) - if result != "value" { - t.Errorf("Simple interpolation failed: expected 'value', got '%s'", result) + if result != "\"value\"" { + t.Errorf("Simple interpolation failed: expected '\"value\"', got '%s'", result) } } @@ -134,22 +134,22 @@ func TestInterpolateStep(t *testing.T) { // Step 1: Inner interpolation should resolve first step1 := "${ENV:BAR}" result1, _ := config.interpolate(step1, 0) - if result1 != "value1" { - t.Errorf("Step 1 failed: expected 'value1', got '%s'", result1) + if result1 != "\"value1\"" { + t.Errorf("Step 1 failed: expected '\"value1\"', got '%s'", result1) } // Step 2: With the result, build the outer step2 := "${ENV:FOO_value1}" result2, _ := config.interpolate(step2, 0) - if result2 != "success" { - t.Errorf("Step 2 failed: expected 'success', got '%s'", result2) + if result2 != "\"success\"" { + t.Errorf("Step 2 failed: expected '\"success\"', got '%s'", result2) } // Step 3: Full nested full := "${ENV:FOO_${ENV:BAR}}" result3, _ := config.interpolate(full, 0) - if result3 != "success" { - t.Errorf("Full nested failed: expected 'success', got '%s'", result3) + if result3 != "\"success\"" { + t.Errorf("Full nested failed: expected '\"success\"', got '%s'", result3) } } @@ -182,8 +182,8 @@ func TestInterpolateNested(t *testing.T) { // Test nested interpolation directly result, _ := config.interpolate("${ENV:FOO_${ENV:INNER}}", 0) - if result != "success" { - t.Errorf("Expected 'success', got '%s'", result) + if result != "\"success\"" { + t.Errorf("Expected '\"success\"', got '%s'", result) } } @@ -196,7 +196,7 @@ func TestSimpleNestedInterpolation(t *testing.T) { }() config := New() - content := "test: \"${ENV:FOO_${ENV:SUFFIX}}\"" + content := "test: ${ENV:FOO_${ENV:SUFFIX}}" err := config.LoadFromReader(strings.NewReader(content)) if err != nil { t.Fatalf("Failed to load config: %v", err) diff --git a/test/config.yaml b/test/config.yaml index aad054e..9e56aa4 100644 --- a/test/config.yaml +++ b/test/config.yaml @@ -6,20 +6,20 @@ server: hostname: ${EXEC:hostname} machine: - id: "${FILE:./test/machine-id}" - cores: "${EXEC:echo 4}" + id: ${FILE:./test/machine-id} + cores: ${EXEC:echo 4} api: - endpoint: "${JSON:./test/hosts.json:'.api_endpoints.primary'}" - tls_host: "${JSON:./test/hosts.json:'.tls_sni_host[0]'}" + endpoint: ${JSON:./test/hosts.json:api_endpoints.primary} + tls_host: ${JSON:./test/hosts.json:tls_sni_host.0} database: - host: "${YAML:./test/data.yaml:'.database.host'}" - version: "${YAML:./test/data.yaml:'.version'}" + host: ${YAML:./test/data.yaml:'.database.host'} + version: ${YAML:./test/data.yaml:'.version'} nested: - value: "${ENV:NESTED_${ENV:TEST_ENV_SUFFIX}}" + value: ${ENV:NESTED_${ENV:TEST_ENV_SUFFIX}} env: INJECTED_VAR: "injected-value" - COMPUTED_VAR: "${EXEC:echo computed}" \ No newline at end of file + COMPUTED_VAR: ${EXEC:echo computed} \ No newline at end of file