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
This commit is contained in:
Jeffrey Paul 2025-07-21 12:13:14 +02:00
parent 8a38afba5e
commit 3c3732f033
10 changed files with 418 additions and 63 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/smartconfig

168
README.md
View File

@ -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.

View File

@ -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

5
go.mod
View File

@ -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

11
go.sum
View File

@ -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=

View File

@ -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
}

View File

@ -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
}

View File

@ -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{} {

View File

@ -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)

View File

@ -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}"
COMPUTED_VAR: ${EXEC:echo computed}