diff --git a/Makefile b/Makefile index 5bf96dd..9baf6b4 100644 --- a/Makefile +++ b/Makefile @@ -44,3 +44,7 @@ coverage: go test -coverprofile=coverage.out ./... go tool cover -html=coverage.out -o coverage.html @echo "Coverage report generated: coverage.html" + +# Generate example code +generate-example: + go generate ./example diff --git a/cfgx.go b/cfgx.go index 795392d..59dde49 100644 --- a/cfgx.go +++ b/cfgx.go @@ -75,6 +75,12 @@ type GenerateOptions struct { // MaxFileSize is the maximum size in bytes for files referenced with "file:" prefix. // If zero, defaults to DefaultMaxFileSize (1 MB). MaxFileSize int64 + + // Mode specifies the generation mode: + // "static" - values baked at build time (default) + // "getter" - generate getter methods with runtime env var overrides + // If empty, defaults to "static". + Mode string } // GenerateFromFile generates Go code from a TOML file and writes it to the output file. @@ -131,8 +137,14 @@ func GenerateFromFile(opts *GenerateOptions) error { maxFileSize = DefaultMaxFileSize } + // Set default mode if not specified + mode := opts.Mode + if mode == "" { + mode = "static" + } + // Generate code - generated, err := GenerateWithOptions(data, packageName, opts.EnableEnv, inputDir, maxFileSize) + generated, err := GenerateWithOptions(data, packageName, opts.EnableEnv, inputDir, maxFileSize, mode) if err != nil { return fmt.Errorf("failed to generate code: %w", err) } @@ -166,7 +178,7 @@ func GenerateFromFile(opts *GenerateOptions) error { // Note: This function does not support file: references since no input directory is provided. // Use GenerateWithOptions for full file embedding support. func Generate(tomlData []byte, packageName string, enableEnv bool) ([]byte, error) { - return GenerateWithOptions(tomlData, packageName, enableEnv, "", DefaultMaxFileSize) + return GenerateWithOptions(tomlData, packageName, enableEnv, "", DefaultMaxFileSize, "static") } // GenerateWithOptions generates Go code from TOML data with full options support. @@ -179,9 +191,10 @@ func Generate(tomlData []byte, packageName string, enableEnv bool) ([]byte, erro // - enableEnv: Whether to enable environment variable override markers in generated code // - inputDir: Directory to resolve file: references from (empty string to disable) // - maxFileSize: Maximum file size in bytes for file: references (0 for default 1MB) +// - mode: Generation mode ("static" or "getter") // // Returns the generated Go code as bytes, or an error if generation fails. -func GenerateWithOptions(tomlData []byte, packageName string, enableEnv bool, inputDir string, maxFileSize int64) ([]byte, error) { +func GenerateWithOptions(tomlData []byte, packageName string, enableEnv bool, inputDir string, maxFileSize int64, mode string) ([]byte, error) { if packageName == "" { packageName = "config" } @@ -190,11 +203,16 @@ func GenerateWithOptions(tomlData []byte, packageName string, enableEnv bool, in maxFileSize = DefaultMaxFileSize } + if mode == "" { + mode = "static" + } + gen := generator.New( generator.WithPackageName(packageName), generator.WithEnvOverride(enableEnv), generator.WithInputDir(inputDir), generator.WithMaxFileSize(maxFileSize), + generator.WithMode(mode), ) return gen.Generate(tomlData) diff --git a/cmd/cfgx/main.go b/cmd/cfgx/main.go index 92e514c..baca746 100644 --- a/cmd/cfgx/main.go +++ b/cmd/cfgx/main.go @@ -25,6 +25,7 @@ var ( packageName string noEnv bool maxFileSize string + mode string ) func main() { @@ -104,6 +105,11 @@ var generateCmd = &cobra.Command{ return fmt.Errorf("--out flag is required") } + // Validate mode + if mode != "static" && mode != "getter" { + return fmt.Errorf("invalid --mode value %q: must be 'static' or 'getter'", mode) + } + // Parse max file size maxFileSizeBytes, err := parseFileSize(maxFileSize) if err != nil { @@ -117,6 +123,7 @@ var generateCmd = &cobra.Command{ PackageName: packageName, EnableEnv: !noEnv, MaxFileSize: maxFileSizeBytes, + Mode: mode, } if err := cfgx.GenerateFromFile(opts); err != nil { @@ -155,6 +162,7 @@ func init() { generateCmd.Flags().StringVarP(&packageName, "pkg", "p", "", "package name (default: inferred from output path or 'config')") generateCmd.Flags().BoolVar(&noEnv, "no-env", false, "disable environment variable overrides") generateCmd.Flags().StringVar(&maxFileSize, "max-file-size", "1MB", "maximum file size for file: references (e.g., 10MB, 1GB, 512KB)") + generateCmd.Flags().StringVar(&mode, "mode", "static", "generation mode: 'static' (values baked at build time) or 'getter' (runtime env var overrides)") generateCmd.MarkFlagRequired("out") diff --git a/example/gen.go b/example/gen.go index fd750dd..d5f0a9d 100644 --- a/example/gen.go +++ b/example/gen.go @@ -1,3 +1,4 @@ package main //go:generate go run ../cmd/cfgx/main.go generate --in config/config.toml --out config/config.go --pkg config +//go:generate go run ../cmd/cfgx/main.go generate --in config/config.toml --out getter_config/config.go --pkg getter_config --mode getter diff --git a/example/getter_config/config.go b/example/getter_config/config.go new file mode 100644 index 0000000..00be738 --- /dev/null +++ b/example/getter_config/config.go @@ -0,0 +1,439 @@ +// Code generated by cfgx. DO NOT EDIT. + +package getter_config + +import ( + "os" + "strconv" + "time" +) + +type AppConfig struct{} + +type AppLoggingConfig struct{} + +type AppLoggingRotationConfig struct{} + +type CacheConfig struct{} + +type CacheRedisConfig struct{} + +type DatabaseConfig struct{} + +type DatabasePoolConfig struct{} + +type EndpointsItem struct{} + +type FeaturesItem struct{} + +type ServerConfig struct{} + +type ServiceConfig struct{} + +func (AppConfig) Logging() AppLoggingConfig { + return AppLoggingConfig{} +} + +func (AppLoggingConfig) File() string { + if v := os.Getenv("CONFIG_APP_LOGGING_FILE"); v != "" { + return v + } + return "/var/log/app.log" +} + +func (AppLoggingConfig) Format() string { + if v := os.Getenv("CONFIG_APP_LOGGING_FORMAT"); v != "" { + return v + } + return "json" +} + +func (AppLoggingConfig) Level() string { + if v := os.Getenv("CONFIG_APP_LOGGING_LEVEL"); v != "" { + return v + } + return "info" +} + +func (AppLoggingConfig) Rotation() AppLoggingRotationConfig { + return AppLoggingRotationConfig{} +} + +func (AppLoggingRotationConfig) Compress() bool { + if v := os.Getenv("CONFIG_APP_LOGGING_ROTATION_COMPRESS"); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + return b + } + } + return true +} + +func (AppLoggingRotationConfig) MaxAge() int64 { + if v := os.Getenv("CONFIG_APP_LOGGING_ROTATION_MAX_AGE"); v != "" { + if i, err := strconv.ParseInt(v, 10, 64); err == nil { + return i + } + } + return 30 +} + +func (AppLoggingRotationConfig) MaxSize() int64 { + if v := os.Getenv("CONFIG_APP_LOGGING_ROTATION_MAX_SIZE"); v != "" { + if i, err := strconv.ParseInt(v, 10, 64); err == nil { + return i + } + } + return 100 +} + +func (AppConfig) Name() string { + if v := os.Getenv("CONFIG_APP_NAME"); v != "" { + return v + } + return "myservice" +} + +func (AppConfig) Version() string { + if v := os.Getenv("CONFIG_APP_VERSION"); v != "" { + return v + } + return "1.0.0" +} + +func (CacheConfig) Enabled() bool { + if v := os.Getenv("CONFIG_CACHE_ENABLED"); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + return b + } + } + return true +} + +func (CacheConfig) MaxEntries() int64 { + if v := os.Getenv("CONFIG_CACHE_MAX_ENTRIES"); v != "" { + if i, err := strconv.ParseInt(v, 10, 64); err == nil { + return i + } + } + return 10000 +} + +func (CacheConfig) Outputs() []string { + if v := os.Getenv("CONFIG_CACHE_OUTPUTS"); v != "" { + // Array overrides not supported via env vars + } + return []string{"stdout", "file"} +} + +func (CacheConfig) Redis() CacheRedisConfig { + return CacheRedisConfig{} +} + +func (CacheRedisConfig) Addr() string { + if v := os.Getenv("CONFIG_CACHE_REDIS_ADDR"); v != "" { + return v + } + return "localhost:6379" +} + +func (CacheRedisConfig) Db() int64 { + if v := os.Getenv("CONFIG_CACHE_REDIS_DB"); v != "" { + if i, err := strconv.ParseInt(v, 10, 64); err == nil { + return i + } + } + return 0 +} + +func (CacheRedisConfig) Password() string { + if v := os.Getenv("CONFIG_CACHE_REDIS_PASSWORD"); v != "" { + return v + } + return "" +} + +func (CacheConfig) Ttl() time.Duration { + if v := os.Getenv("CONFIG_CACHE_TTL"); v != "" { + if d, err := time.ParseDuration(v); err == nil { + return d + } + } + return 1 * time.Hour +} + +func (DatabaseConfig) ConnMaxLifetime() time.Duration { + if v := os.Getenv("CONFIG_DATABASE_CONN_MAX_LIFETIME"); v != "" { + if d, err := time.ParseDuration(v); err == nil { + return d + } + } + return 5 * time.Minute +} + +func (DatabaseConfig) Dsn() string { + if v := os.Getenv("CONFIG_DATABASE_DSN"); v != "" { + return v + } + return "postgres://localhost/myapp" +} + +func (DatabaseConfig) MaxIdleConns() int64 { + if v := os.Getenv("CONFIG_DATABASE_MAX_IDLE_CONNS"); v != "" { + if i, err := strconv.ParseInt(v, 10, 64); err == nil { + return i + } + } + return 5 +} + +func (DatabaseConfig) MaxOpenConns() int64 { + if v := os.Getenv("CONFIG_DATABASE_MAX_OPEN_CONNS"); v != "" { + if i, err := strconv.ParseInt(v, 10, 64); err == nil { + return i + } + } + return 25 +} + +func (DatabaseConfig) Pool() DatabasePoolConfig { + return DatabasePoolConfig{} +} + +func (DatabasePoolConfig) Enabled() bool { + if v := os.Getenv("CONFIG_DATABASE_POOL_ENABLED"); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + return b + } + } + return true +} + +func (DatabasePoolConfig) MaxSize() int64 { + if v := os.Getenv("CONFIG_DATABASE_POOL_MAX_SIZE"); v != "" { + if i, err := strconv.ParseInt(v, 10, 64); err == nil { + return i + } + } + return 10 +} + +func (DatabasePoolConfig) MinSize() int64 { + if v := os.Getenv("CONFIG_DATABASE_POOL_MIN_SIZE"); v != "" { + if i, err := strconv.ParseInt(v, 10, 64); err == nil { + return i + } + } + return 2 +} + +func (EndpointsItem) Methods() []string { + if v := os.Getenv("CONFIG_ENDPOINTS_METHODS"); v != "" { + // Array overrides not supported via env vars + } + return []string{"GET", "POST"} +} + +func (EndpointsItem) Path() string { + if v := os.Getenv("CONFIG_ENDPOINTS_PATH"); v != "" { + return v + } + return "/api/v1" +} + +func (EndpointsItem) RateLimit() int64 { + if v := os.Getenv("CONFIG_ENDPOINTS_RATE_LIMIT"); v != "" { + if i, err := strconv.ParseInt(v, 10, 64); err == nil { + return i + } + } + return 100 +} + +func (FeaturesItem) Enabled() bool { + if v := os.Getenv("CONFIG_FEATURES_ENABLED"); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + return b + } + } + return true +} + +func (FeaturesItem) Name() string { + if v := os.Getenv("CONFIG_FEATURES_NAME"); v != "" { + return v + } + return "authentication" +} + +func (FeaturesItem) Priority() int64 { + if v := os.Getenv("CONFIG_FEATURES_PRIORITY"); v != "" { + if i, err := strconv.ParseInt(v, 10, 64); err == nil { + return i + } + } + return 1 +} + +func (ServerConfig) Addr() string { + if v := os.Getenv("CONFIG_SERVER_ADDR"); v != "" { + return v + } + return ":8080" +} + +func (ServerConfig) Cert() []byte { + // Check for file path to load + if path := os.Getenv("CONFIG_SERVER_CERT"); path != "" { + if data, err := os.ReadFile(path); err == nil { + return data + } + } + return []byte{ + 0x2d, 0x2d, 0x2d, 0x2d, 0x2d, 0x42, 0x45, 0x47, 0x49, 0x4e, 0x20, 0x43, + 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x2d, 0x2d, + 0x2d, 0x2d, 0x2d, 0x0a, 0x4d, 0x49, 0x49, 0x44, 0x58, 0x54, 0x43, 0x43, + 0x41, 0x6b, 0x57, 0x67, 0x41, 0x77, 0x49, 0x42, 0x41, 0x67, 0x49, 0x4a, + 0x41, 0x4b, 0x4c, 0x30, 0x55, 0x47, 0x2b, 0x6d, 0x52, 0x4b, 0x53, 0x7a, + 0x4d, 0x41, 0x30, 0x47, 0x43, 0x53, 0x71, 0x47, 0x53, 0x49, 0x62, 0x33, + 0x44, 0x51, 0x45, 0x42, 0x43, 0x77, 0x55, 0x41, 0x4d, 0x45, 0x55, 0x78, + 0x43, 0x7a, 0x41, 0x4a, 0x42, 0x67, 0x4e, 0x56, 0x0a, 0x42, 0x41, 0x59, + 0x54, 0x41, 0x6b, 0x46, 0x56, 0x4d, 0x52, 0x4d, 0x77, 0x45, 0x51, 0x59, + 0x44, 0x56, 0x51, 0x51, 0x49, 0x44, 0x41, 0x70, 0x54, 0x62, 0x32, 0x31, + 0x6c, 0x4c, 0x56, 0x4e, 0x30, 0x59, 0x58, 0x52, 0x6c, 0x4d, 0x53, 0x45, + 0x77, 0x48, 0x77, 0x59, 0x44, 0x56, 0x51, 0x51, 0x4b, 0x44, 0x42, 0x68, + 0x4a, 0x62, 0x6e, 0x52, 0x6c, 0x63, 0x6d, 0x35, 0x6c, 0x64, 0x43, 0x42, + 0x58, 0x0a, 0x61, 0x57, 0x52, 0x6e, 0x61, 0x58, 0x52, 0x7a, 0x49, 0x46, + 0x42, 0x30, 0x65, 0x53, 0x42, 0x4d, 0x64, 0x47, 0x51, 0x77, 0x48, 0x68, + 0x63, 0x4e, 0x4d, 0x54, 0x63, 0x77, 0x4f, 0x44, 0x49, 0x7a, 0x4d, 0x54, + 0x55, 0x78, 0x4e, 0x54, 0x45, 0x79, 0x57, 0x68, 0x63, 0x4e, 0x4d, 0x6a, + 0x63, 0x77, 0x4f, 0x44, 0x49, 0x78, 0x4d, 0x54, 0x55, 0x78, 0x4e, 0x54, + 0x45, 0x79, 0x57, 0x6a, 0x42, 0x46, 0x0a, 0x4d, 0x51, 0x73, 0x77, 0x43, + 0x51, 0x59, 0x44, 0x56, 0x51, 0x51, 0x47, 0x45, 0x77, 0x4a, 0x42, 0x56, + 0x54, 0x45, 0x54, 0x4d, 0x42, 0x45, 0x47, 0x41, 0x31, 0x55, 0x45, 0x43, + 0x41, 0x77, 0x4b, 0x55, 0x32, 0x39, 0x74, 0x5a, 0x53, 0x31, 0x54, 0x64, + 0x47, 0x46, 0x30, 0x5a, 0x54, 0x45, 0x68, 0x4d, 0x42, 0x38, 0x47, 0x41, + 0x31, 0x55, 0x45, 0x43, 0x67, 0x77, 0x59, 0x53, 0x57, 0x35, 0x30, 0x0a, + 0x5a, 0x58, 0x4a, 0x75, 0x5a, 0x58, 0x51, 0x67, 0x56, 0x32, 0x6c, 0x6b, + 0x5a, 0x32, 0x6c, 0x30, 0x63, 0x79, 0x42, 0x51, 0x64, 0x48, 0x6b, 0x67, + 0x54, 0x48, 0x52, 0x6b, 0x4d, 0x49, 0x49, 0x42, 0x49, 0x6a, 0x41, 0x4e, + 0x42, 0x67, 0x6b, 0x71, 0x68, 0x6b, 0x69, 0x47, 0x39, 0x77, 0x30, 0x42, + 0x41, 0x51, 0x45, 0x46, 0x41, 0x41, 0x4f, 0x43, 0x41, 0x51, 0x38, 0x41, + 0x4d, 0x49, 0x49, 0x42, 0x0a, 0x43, 0x67, 0x4b, 0x43, 0x41, 0x51, 0x45, + 0x41, 0x7a, 0x50, 0x4a, 0x6e, 0x36, 0x4e, 0x43, 0x4d, 0x6d, 0x4e, 0x47, + 0x70, 0x52, 0x68, 0x5a, 0x4b, 0x57, 0x58, 0x41, 0x36, 0x64, 0x47, 0x7a, + 0x70, 0x46, 0x33, 0x42, 0x4f, 0x38, 0x63, 0x47, 0x31, 0x59, 0x54, 0x2f, + 0x63, 0x53, 0x4c, 0x55, 0x4a, 0x75, 0x50, 0x4b, 0x69, 0x56, 0x6d, 0x48, + 0x59, 0x78, 0x59, 0x51, 0x7a, 0x38, 0x78, 0x51, 0x57, 0x0a, 0x2d, 0x2d, + 0x2d, 0x2d, 0x2d, 0x45, 0x4e, 0x44, 0x20, 0x43, 0x45, 0x52, 0x54, 0x49, + 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x2d, 0x2d, 0x2d, 0x2d, 0x2d, 0x0a, + } +} + +func (ServerConfig) Debug() bool { + if v := os.Getenv("CONFIG_SERVER_DEBUG"); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + return b + } + } + return true +} + +func (ServerConfig) IdleTimeout() time.Duration { + if v := os.Getenv("CONFIG_SERVER_IDLE_TIMEOUT"); v != "" { + if d, err := time.ParseDuration(v); err == nil { + return d + } + } + return 5 * time.Minute +} + +func (ServerConfig) MaxHeaderBytes() int64 { + if v := os.Getenv("CONFIG_SERVER_MAX_HEADER_BYTES"); v != "" { + if i, err := strconv.ParseInt(v, 10, 64); err == nil { + return i + } + } + return 1048576 +} + +func (ServerConfig) ReadTimeout() time.Duration { + if v := os.Getenv("CONFIG_SERVER_READ_TIMEOUT"); v != "" { + if d, err := time.ParseDuration(v); err == nil { + return d + } + } + return 15 * time.Second +} + +func (ServerConfig) ShutdownTimeout() time.Duration { + if v := os.Getenv("CONFIG_SERVER_SHUTDOWN_TIMEOUT"); v != "" { + if d, err := time.ParseDuration(v); err == nil { + return d + } + } + return 2*time.Hour + 30*time.Minute +} + +func (ServerConfig) Timeout() time.Duration { + if v := os.Getenv("CONFIG_SERVER_TIMEOUT"); v != "" { + if d, err := time.ParseDuration(v); err == nil { + return d + } + } + return 30 * time.Second +} + +func (ServerConfig) WriteTimeout() time.Duration { + if v := os.Getenv("CONFIG_SERVER_WRITE_TIMEOUT"); v != "" { + if d, err := time.ParseDuration(v); err == nil { + return d + } + } + return 15 * time.Second +} + +func (ServiceConfig) AllowedOrigins() []string { + if v := os.Getenv("CONFIG_SERVICE_ALLOWED_ORIGINS"); v != "" { + // Array overrides not supported via env vars + } + return []string{"https://example.com", "https://app.example.com"} +} + +func (ServiceConfig) Features() []string { + if v := os.Getenv("CONFIG_SERVICE_FEATURES"); v != "" { + // Array overrides not supported via env vars + } + return []string{"auth", "cache", "metrics"} +} + +func (ServiceConfig) Name() string { + if v := os.Getenv("CONFIG_SERVICE_NAME"); v != "" { + return v + } + return "api" +} + +func (ServiceConfig) Ports() []int64 { + if v := os.Getenv("CONFIG_SERVICE_PORTS"); v != "" { + // Array overrides not supported via env vars + } + return []int64{8080, 8081, 8082} +} + +func (ServiceConfig) Weights() []float64 { + if v := os.Getenv("CONFIG_SERVICE_WEIGHTS"); v != "" { + // Array overrides not supported via env vars + } + return []float64{1, 2.5, 3.7} +} + +var ( + App AppConfig + Cache CacheConfig + Database DatabaseConfig + Endpoints []EndpointsItem + Features []FeaturesItem + Name string + Server ServerConfig + Service ServiceConfig +) diff --git a/internal/generator/generator.go b/internal/generator/generator.go index 728216d..0bd917c 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -16,6 +16,7 @@ type Generator struct { envOverride bool // Whether to enable environment variable override support inputDir string // Directory of input TOML file for resolving relative file paths maxFileSize int64 // Maximum file size in bytes for file: references + mode string // Generation mode: "static" or "getter" } // Option configures a Generator. @@ -49,12 +50,20 @@ func WithMaxFileSize(size int64) Option { } } +// WithMode sets the generation mode. +func WithMode(mode string) Option { + return func(g *Generator) { + g.mode = mode + } +} + // New creates a new Generator with the given options. func New(opts ...Option) *Generator { g := &Generator{ packageName: "config", envOverride: true, maxFileSize: 1024 * 1024, // 1MB default + mode: "static", // default to static mode } for _, opt := range opts { opt(g) @@ -75,6 +84,66 @@ func stripSuffix(name string) string { return name } +// writeGetterImports writes the necessary imports for getter mode. +func (g *Generator) writeGetterImports(buf *bytes.Buffer, data map[string]any) { + needsTime := g.needsTimeImport(data) + needsStrconv := g.needsStrconvImport(data) + + // Always need os for os.Getenv in getter mode + if needsTime || needsStrconv { + buf.WriteString("import (\n") + buf.WriteString("\t\"os\"\n") + if needsStrconv { + buf.WriteString("\t\"strconv\"\n") + } + if needsTime { + buf.WriteString("\t\"time\"\n") + } + buf.WriteString(")\n\n") + } else { + buf.WriteString("import \"os\"\n\n") + } +} + +// needsStrconvImport checks if the data needs strconv import (for int64, float64, bool). +func (g *Generator) needsStrconvImport(data map[string]any) bool { + for _, v := range data { + if g.checkStrconvNeeded(v) { + return true + } + } + return false +} + +// checkStrconvNeeded recursively checks if a value needs strconv. +func (g *Generator) checkStrconvNeeded(v any) bool { + switch val := v.(type) { + case int64, int, float64, bool: + return true + case map[string]any: + for _, nested := range val { + if g.checkStrconvNeeded(nested) { + return true + } + } + case []map[string]any: + for _, item := range val { + for _, nested := range item { + if g.checkStrconvNeeded(nested) { + return true + } + } + } + case []any: + for _, item := range val { + if g.checkStrconvNeeded(item) { + return true + } + } + } + return false +} + // Generate parses TOML data and generates Go code. func (g *Generator) Generate(tomlData []byte) ([]byte, error) { var data map[string]any @@ -92,13 +161,25 @@ func (g *Generator) Generate(tomlData []byte) ([]byte, error) { buf.WriteString("// Code generated by cfgx. DO NOT EDIT.\n\n") buf.WriteString(fmt.Sprintf("package %s\n\n", g.packageName)) - needsTime := g.needsTimeImport(data) - if needsTime { - buf.WriteString("import \"time\"\n\n") + // Generate imports based on mode + if g.mode == "getter" { + g.writeGetterImports(&buf, data) + } else { + needsTime := g.needsTimeImport(data) + if needsTime { + buf.WriteString("import \"time\"\n\n") + } } - if err := g.generateStructsAndVars(&buf, data); err != nil { - return nil, err + // Generate code based on mode + if g.mode == "getter" { + if err := g.generateStructsAndGetters(&buf, data); err != nil { + return nil, err + } + } else { + if err := g.generateStructsAndVars(&buf, data); err != nil { + return nil, err + } } formatted, err := format.Source(buf.Bytes()) diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go index d82d261..6404bb5 100644 --- a/internal/generator/generator_test.go +++ b/internal/generator/generator_test.go @@ -137,3 +137,210 @@ value = 3 require.True(t, alphaPos < betaPos && betaPos < zuluPos, "variables not sorted alphabetically") } + +func TestGenerator_GetterMode(t *testing.T) { + data := []byte(` +[server] +addr = ":8080" +timeout = "30s" +port = 8080 +debug = true + +[database] +max_conns = 25 +`) + + gen := New(WithPackageName("config"), WithMode("getter")) + output, err := gen.Generate(data) + require.NoError(t, err, "Generate() should not error in getter mode") + + outputStr := string(output) + + // Check package and imports + require.Contains(t, outputStr, "package config", "output missing package declaration") + require.Contains(t, outputStr, `import (`, "output missing imports") + require.Contains(t, outputStr, `"os"`, "output missing os import") + require.Contains(t, outputStr, `"strconv"`, "output missing strconv import") + require.Contains(t, outputStr, `"time"`, "output missing time import") + + // Check empty structs + require.Contains(t, outputStr, "type ServerConfig struct{}", "output missing empty ServerConfig struct") + require.Contains(t, outputStr, "type DatabaseConfig struct{}", "output missing empty DatabaseConfig struct") + + // Check getter methods exist + require.Contains(t, outputStr, "func (ServerConfig) Addr() string", "output missing Addr getter") + require.Contains(t, outputStr, "func (ServerConfig) Timeout() time.Duration", "output missing Timeout getter") + require.Contains(t, outputStr, "func (ServerConfig) Port() int64", "output missing Port getter") + require.Contains(t, outputStr, "func (ServerConfig) Debug() bool", "output missing Debug getter") + require.Contains(t, outputStr, "func (DatabaseConfig) MaxConns() int64", "output missing MaxConns getter") + + // Check env var logic + require.Contains(t, outputStr, `os.Getenv("CONFIG_SERVER_ADDR")`, "output missing env var check for addr") + require.Contains(t, outputStr, `os.Getenv("CONFIG_SERVER_TIMEOUT")`, "output missing env var check for timeout") + require.Contains(t, outputStr, `os.Getenv("CONFIG_DATABASE_MAX_CONNS")`, "output missing env var check for max_conns") + + // Check type conversions + require.Contains(t, outputStr, "strconv.ParseInt", "output missing int parsing") + require.Contains(t, outputStr, "strconv.ParseBool", "output missing bool parsing") + require.Contains(t, outputStr, "time.ParseDuration", "output missing duration parsing") + + // Check default value returns + require.Contains(t, outputStr, `return ":8080"`, "output missing default addr") + require.Contains(t, outputStr, `return 30 * time.Second`, "output missing default timeout") + require.Contains(t, outputStr, `return 8080`, "output missing default port") + require.Contains(t, outputStr, `return true`, "output missing default debug") + require.Contains(t, outputStr, `return 25`, "output missing default max_conns") + + // Check var declarations (allow for variable spacing due to gofmt alignment) + require.Contains(t, outputStr, "Server", "output missing Server var") + require.Contains(t, outputStr, "ServerConfig", "output missing ServerConfig type") + require.Contains(t, outputStr, "Database", "output missing Database var") + require.Contains(t, outputStr, "DatabaseConfig", "output missing DatabaseConfig type") +} + +func TestGenerator_GetterMode_NestedStructs(t *testing.T) { + data := []byte(` +[database] +dsn = "postgres://localhost/db" + +[database.pool] +max_size = 10 +min_size = 2 +`) + + gen := New(WithPackageName("config"), WithMode("getter")) + output, err := gen.Generate(data) + require.NoError(t, err, "Generate() should not error") + + outputStr := string(output) + + // Check nested structs + require.Contains(t, outputStr, "type DatabaseConfig struct{}", "output missing DatabaseConfig") + require.Contains(t, outputStr, "type DatabasePoolConfig struct{}", "output missing DatabasePoolConfig") + + // Check nested getter returns nested struct + require.Contains(t, outputStr, "func (DatabaseConfig) Pool() DatabasePoolConfig", "output missing Pool getter") + require.Contains(t, outputStr, "return DatabasePoolConfig{}", "output missing DatabasePoolConfig return") + + // Check nested struct methods with correct env var names + require.Contains(t, outputStr, `os.Getenv("CONFIG_DATABASE_POOL_MAX_SIZE")`, "output missing nested env var") + require.Contains(t, outputStr, `os.Getenv("CONFIG_DATABASE_POOL_MIN_SIZE")`, "output missing nested env var") + require.Contains(t, outputStr, "func (DatabasePoolConfig) MaxSize() int64", "output missing nested MaxSize getter") + require.Contains(t, outputStr, "func (DatabasePoolConfig) MinSize() int64", "output missing nested MinSize getter") +} + +func TestGenerator_GetterMode_Arrays(t *testing.T) { + data := []byte(` +[service] +hosts = ["localhost", "example.com"] +ports = [8080, 8081] +`) + + gen := New(WithPackageName("config"), WithMode("getter")) + output, err := gen.Generate(data) + require.NoError(t, err, "Generate() should not error") + + outputStr := string(output) + + // Check array getters with limitation comment + require.Contains(t, outputStr, "func (ServiceConfig) Hosts() []string", "output missing Hosts getter") + require.Contains(t, outputStr, "func (ServiceConfig) Ports() []int64", "output missing Ports getter") + require.Contains(t, outputStr, "// Array overrides not supported via env vars", "output missing array limitation comment") + require.Contains(t, outputStr, `return []string{"localhost", "example.com"}`, "output missing hosts default") + require.Contains(t, outputStr, "return []int64{8080, 8081}", "output missing ports default") +} + +func TestGenerator_GetterMode_NoDuplicateMethods(t *testing.T) { + data := []byte(` +[cache] +enabled = true + +[cache.redis] +addr = "localhost:6379" +db = 0 +`) + + gen := New(WithPackageName("config"), WithMode("getter")) + output, err := gen.Generate(data) + require.NoError(t, err, "Generate() should not error") + + outputStr := string(output) + + // Check that Redis methods are only generated once + addrCount := strings.Count(outputStr, "func (CacheRedisConfig) Addr() string") + require.Equal(t, 1, addrCount, "Addr method should be generated exactly once") + + dbCount := strings.Count(outputStr, "func (CacheRedisConfig) Db() int64") + require.Equal(t, 1, dbCount, "Db method should be generated exactly once") +} + +func TestGenerator_EnvVarName(t *testing.T) { + gen := New() + + tests := []struct { + structName string + fieldName string + expected string + }{ + {"ServerConfig", "addr", "CONFIG_SERVER_ADDR"}, + {"DatabaseConfig", "max_conns", "CONFIG_DATABASE_MAX_CONNS"}, + {"AppLoggingConfig", "level", "CONFIG_APP_LOGGING_LEVEL"}, + {"CacheRedisConfig", "addr", "CONFIG_CACHE_REDIS_ADDR"}, + } + + for _, tt := range tests { + result := gen.envVarName(tt.structName, tt.fieldName) + require.Equal(t, tt.expected, result, "envVarName(%s, %s) = %s, want %s", tt.structName, tt.fieldName, result, tt.expected) + } +} + +func TestGenerator_StaticModeDefault(t *testing.T) { + data := []byte(` +[server] +addr = ":8080" +`) + + // Test without WithMode (should default to static) + gen := New(WithPackageName("config")) + output, err := gen.Generate(data) + require.NoError(t, err, "Generate() should not error") + + outputStr := string(output) + + // Should generate fields, not methods + require.Contains(t, outputStr, "type ServerConfig struct {", "output should have struct with fields") + require.Contains(t, outputStr, "Addr string", "output should have Addr field") + require.NotContains(t, outputStr, "func (ServerConfig) Addr() string", "output should not have getter methods") + require.NotContains(t, outputStr, "os.Getenv", "static mode should not use env vars") +} + +func TestGenerator_GetterMode_FileReferences(t *testing.T) { + data := []byte(` +[server] +tls_cert = "file:files/cert.txt" +tls_key = "file:files/small.txt" +`) + + gen := New( + WithPackageName("config"), + WithMode("getter"), + WithInputDir("../../testdata"), + ) + output, err := gen.Generate(data) + require.NoError(t, err, "Generate() should not error") + + outputStr := string(output) + + // Check file loading logic exists + require.Contains(t, outputStr, "func (ServerConfig) TlsCert() []byte", "output missing TlsCert getter") + require.Contains(t, outputStr, `os.Getenv("CONFIG_SERVER_TLS_CERT")`, "output missing file path env var check") + require.Contains(t, outputStr, "os.ReadFile(path)", "output missing file read") + require.Contains(t, outputStr, "return data", "output missing return data") + + // Check it still has embedded fallback + require.Contains(t, outputStr, "return []byte{", "output missing embedded fallback bytes") + + // Check same for key + require.Contains(t, outputStr, "func (ServerConfig) TlsKey() []byte", "output missing TlsKey getter") + require.Contains(t, outputStr, `os.Getenv("CONFIG_SERVER_TLS_KEY")`, "output missing file path env var check for key") +} diff --git a/internal/generator/struct_gen.go b/internal/generator/struct_gen.go index 10cc644..cd24195 100644 --- a/internal/generator/struct_gen.go +++ b/internal/generator/struct_gen.go @@ -368,3 +368,253 @@ func (g *Generator) writeArrayOfStructs(buf *bytes.Buffer, arr any, indent int) buf.WriteString(strings.Repeat("\t", indent)) buf.WriteString("}") } + +// generateStructsAndGetters generates empty struct types and getter methods for getter mode. +// This is an alternative to generateStructsAndVars that creates methods instead of fields. +func (g *Generator) generateStructsAndGetters(buf *bytes.Buffer, data map[string]any) error { + keys := make([]string, 0, len(data)) + for k := range data { + keys = append(keys, k) + } + sort.Strings(keys) // deterministic output + + // Collect all struct names + allStructs := make(map[string]map[string]any) + for _, key := range keys { + if m, ok := data[key].(map[string]any); ok { + structName := sx.PascalCase(key) + "Config" + g.collectNestedStructsForGetters(allStructs, structName, m) + } else if arr, ok := data[key].([]map[string]any); ok { + if len(arr) > 0 { + structName := sx.PascalCase(key) + "Item" + g.collectNestedStructsForGetters(allStructs, structName, arr[0]) + } + } + } + + // Generate empty struct types (no fields, just methods) + structNames := make([]string, 0, len(allStructs)) + for name := range allStructs { + structNames = append(structNames, name) + } + sort.Strings(structNames) + + for _, name := range structNames { + fmt.Fprintf(buf, "type %s struct{}\n\n", name) + } + + // Generate getter methods for each struct + generated := make(map[string]bool) + for _, name := range structNames { + fields := allStructs[name] + if err := g.generateGetterMethods(buf, name, fields, "", generated); err != nil { + return err + } + } + + // Generate var declarations + buf.WriteString("var (\n") + for _, key := range keys { + varName := sx.PascalCase(key) + value := data[key] + + switch value.(type) { + case map[string]any: + structName := sx.PascalCase(key) + "Config" + fmt.Fprintf(buf, "\t%s %s\n", varName, structName) + case []map[string]any: + structName := sx.PascalCase(key) + "Item" + fmt.Fprintf(buf, "\t%s []%s\n", varName, structName) + case []any: + goType := g.toGoType(value) + fmt.Fprintf(buf, "\t%s %s\n", varName, goType) + default: + goType := g.toGoType(value) + fmt.Fprintf(buf, "\t%s %s\n", varName, goType) + } + } + buf.WriteString(")\n") + + return nil +} + +// collectNestedStructsForGetters is similar to collectNestedStructs but for getter mode. +func (g *Generator) collectNestedStructsForGetters(structs map[string]map[string]any, name string, data map[string]any) { + if _, exists := structs[name]; exists { + return + } + + structs[name] = data + + for key, val := range data { + switch v := val.(type) { + case map[string]any: + nestedName := stripSuffix(name) + sx.PascalCase(key) + "Config" + g.collectNestedStructsForGetters(structs, nestedName, v) + case []any: + if len(v) > 0 { + if m, ok := v[0].(map[string]any); ok { + nestedName := stripSuffix(name) + sx.PascalCase(key) + "Item" + g.collectNestedStructsForGetters(structs, nestedName, m) + } + } + case []map[string]any: + if len(v) > 0 { + nestedName := stripSuffix(name) + sx.PascalCase(key) + "Item" + g.collectNestedStructsForGetters(structs, nestedName, v[0]) + } + } + } +} + +// generateGetterMethods generates getter methods for a struct type. +func (g *Generator) generateGetterMethods(buf *bytes.Buffer, structName string, fields map[string]any, envPrefix string, generated map[string]bool) error { + // Skip if already generated + if generated[structName] { + return nil + } + generated[structName] = true + + fieldNames := make([]string, 0, len(fields)) + for k := range fields { + fieldNames = append(fieldNames, k) + } + sort.Strings(fieldNames) + + for _, fieldName := range fieldNames { + value := fields[fieldName] + goFieldName := sx.PascalCase(fieldName) + + // Build env var name + var envVarName string + if envPrefix == "" { + envVarName = g.envVarName(structName, fieldName) + } else { + envVarName = envPrefix + "_" + strings.ToUpper(fieldName) + } + + // Handle nested structs - they need their own getter methods + if nestedMap, ok := value.(map[string]any); ok { + nestedStructName := stripSuffix(structName) + sx.PascalCase(fieldName) + "Config" + // Generate method that returns nested struct + fmt.Fprintf(buf, "func (%s) %s() %s {\n", structName, goFieldName, nestedStructName) + fmt.Fprintf(buf, "\treturn %s{}\n", nestedStructName) + buf.WriteString("}\n\n") + // Generate methods for nested struct (pass along env prefix) + if err := g.generateGetterMethods(buf, nestedStructName, nestedMap, envVarName, generated); err != nil { + return err + } + continue + } + + // Handle arrays of structs - for now, return empty slice (limitation) + if arr, ok := value.([]any); ok && len(arr) > 0 { + if _, isMap := arr[0].(map[string]any); isMap { + nestedStructName := stripSuffix(structName) + sx.PascalCase(fieldName) + "Item" + goType := "[]" + nestedStructName + // For arrays of structs, return default empty value + fmt.Fprintf(buf, "func (%s) %s() %s {\n", structName, goFieldName, goType) + fmt.Fprintf(buf, "\t// Arrays of structs cannot be overridden via env vars\n") + fmt.Fprintf(buf, "\treturn nil\n") + buf.WriteString("}\n\n") + continue + } + } + + if arr, ok := value.([]map[string]any); ok && len(arr) > 0 { + nestedStructName := stripSuffix(structName) + sx.PascalCase(fieldName) + "Item" + goType := "[]" + nestedStructName + fmt.Fprintf(buf, "func (%s) %s() %s {\n", structName, goFieldName, goType) + fmt.Fprintf(buf, "\t// Arrays of structs cannot be overridden via env vars\n") + fmt.Fprintf(buf, "\treturn nil\n") + buf.WriteString("}\n\n") + continue + } + + // Get the Go type + goType := g.toGoType(value) + + // Generate getter method based on type + if err := g.generateGetterMethod(buf, structName, goFieldName, goType, envVarName, value); err != nil { + return err + } + } + + return nil +} + +// generateGetterMethod generates a single getter method with env var override. +func (g *Generator) generateGetterMethod(buf *bytes.Buffer, structName, fieldName, goType, envVarName string, defaultValue any) error { + fmt.Fprintf(buf, "func (%s) %s() %s {\n", structName, fieldName, goType) + + // Special handling for []byte (file references) - check for file path in env var + if goType == "[]byte" { + buf.WriteString("\t// Check for file path to load\n") + fmt.Fprintf(buf, "\tif path := os.Getenv(%q); path != \"\" {\n", envVarName) + buf.WriteString("\t\tif data, err := os.ReadFile(path); err == nil {\n") + buf.WriteString("\t\t\treturn data\n") + buf.WriteString("\t\t}\n") + buf.WriteString("\t}\n") + // Write default value + buf.WriteString("\treturn ") + g.writeValue(buf, defaultValue) + buf.WriteString("\n") + buf.WriteString("}\n\n") + return nil + } + + // For other types, check env var with type conversion + fmt.Fprintf(buf, "\tif v := os.Getenv(%q); v != \"\" {\n", envVarName) + + // Generate type-specific parsing + switch goType { + case "string": + buf.WriteString("\t\treturn v\n") + case "int64": + buf.WriteString("\t\tif i, err := strconv.ParseInt(v, 10, 64); err == nil {\n") + buf.WriteString("\t\t\treturn i\n") + buf.WriteString("\t\t}\n") + case "float64": + buf.WriteString("\t\tif f, err := strconv.ParseFloat(v, 64); err == nil {\n") + buf.WriteString("\t\t\treturn f\n") + buf.WriteString("\t\t}\n") + case "bool": + buf.WriteString("\t\tif b, err := strconv.ParseBool(v); err == nil {\n") + buf.WriteString("\t\t\treturn b\n") + buf.WriteString("\t\t}\n") + case "time.Duration": + buf.WriteString("\t\tif d, err := time.ParseDuration(v); err == nil {\n") + buf.WriteString("\t\t\treturn d\n") + buf.WriteString("\t\t}\n") + default: + // Handle arrays of primitives (for now, don't support env override) + if strings.HasPrefix(goType, "[]") { + buf.WriteString("\t\t// Array overrides not supported via env vars\n") + } + } + + buf.WriteString("\t}\n") + + // Write default value + buf.WriteString("\treturn ") + g.writeValue(buf, defaultValue) + buf.WriteString("\n") + + buf.WriteString("}\n\n") + return nil +} + +// envVarName generates an environment variable name from a struct name and field name. +// Format: CONFIG_SECTION_KEY +func (g *Generator) envVarName(structName, fieldName string) string { + // Remove "Config" or "Item" suffix from struct name + section := stripSuffix(structName) + section = strings.TrimSuffix(section, "Config") + section = strings.TrimSuffix(section, "Item") + + // Convert to uppercase snake case + sectionUpper := strings.ToUpper(sx.SnakeCase(section)) + fieldUpper := strings.ToUpper(fieldName) + + return "CONFIG_" + sectionUpper + "_" + fieldUpper +} diff --git a/readme.md b/readme.md index c1164c6..b6e01d0 100644 --- a/readme.md +++ b/readme.md @@ -93,11 +93,140 @@ Flags: -i, --in string Input TOML file (default "config.toml") -o, --out string Output Go file (required) -p, --pkg string Package name (inferred from output path) - --no-env Disable environment variable overrides + --mode string Generation mode: 'static' or 'getter' (default "static") + --no-env Disable environment variable overrides (static mode only) --max-file-size Maximum file size for file: references (default "1MB") Supports: KB, MB, GB (e.g., "5MB", "1GB", "512KB") ``` +## Modes + +`cfgx` supports two generation modes, chosen via the `--mode` flag: + +### Static Mode (default) + +Values are baked into the binary at build time. Best for: + +- Internal tools +- Single-environment deployments +- Maximum performance (zero runtime overhead) + +```bash +cfgx generate --in config.toml --out config/config.go --mode static +# or just omit --mode (static is default) +cfgx generate --in config.toml --out config/config.go +``` + +### Getter Mode + +Generates getter methods that check environment variables at runtime, falling back to defaults from TOML. Best for: + +- Open source projects +- Docker/container deployments +- Multi-environment apps +- 12-factor apps + +```bash +cfgx generate --in config.toml --out config/config.go --mode getter +``` + +**Input:** + +```toml +[server] +addr = ":8080" +timeout = "30s" +debug = true +``` + +**Generated (getter mode):** + +```go +type ServerConfig struct{} + +func (ServerConfig) Addr() string { + if v := os.Getenv("CONFIG_SERVER_ADDR"); v != "" { + return v + } + return ":8080" +} + +func (ServerConfig) Timeout() time.Duration { + if v := os.Getenv("CONFIG_SERVER_TIMEOUT"); v != "" { + if d, err := time.ParseDuration(v); err == nil { + return d + } + } + return 30 * time.Second +} + +func (ServerConfig) Debug() bool { + if v := os.Getenv("CONFIG_SERVER_DEBUG"); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + return b + } + } + return true +} + +var Server ServerConfig +``` + +**Usage:** + +```go +// In your application +http.ListenAndServe(config.Server.Addr(), handler) + +// Override at runtime +// $ CONFIG_SERVER_ADDR=":3000" ./myapp +``` + +**Environment variable format:** `CONFIG_SECTION_KEY` + +- Nested: `CONFIG_DATABASE_POOL_MAX_SIZE` +- Type-safe parsing with silent fallback to defaults + +**File overrides:** + +File references can be overridden at runtime by passing file paths via env vars: + +```bash +# Use embedded file (from build time) +./myapp + +# Override with production certificate +CONFIG_SERVER_TLS_CERT=/etc/ssl/certs/prod.crt ./myapp +``` + +**Kubernetes example:** + +```yaml +env: + - name: CONFIG_SERVER_TLS_CERT + value: /etc/tls/tls.crt + - name: CONFIG_SERVER_TLS_KEY + value: /etc/tls/tls.key +volumeMounts: + - name: tls-secret + mountPath: /etc/tls + readOnly: true +volumes: + - name: tls-secret + secret: + secretName: my-tls-secret +``` + +**Behavior:** + +- If env var is set to a file path and file is readable, use that file +- If file doesn't exist or can't be read, silently fall back to embedded bytes +- Embedded bytes from build time are always available as fallback + +**Limitations in getter mode:** + +- Arrays cannot be overridden via env vars (always use defaults) + ## Features ### Type Detection @@ -237,24 +366,53 @@ cfgx generate --in config.toml --out config/config.go --max-file-size 5MB ## Multi-Environment Config -### Approach 1: Separate files per environment +### Approach 1: Getter Mode (Recommended for Docker/OSS) + +Use `--mode getter` to generate runtime-configurable code: + +```dockerfile +FROM golang:1.25.1 as builder +WORKDIR /app +COPY . . +RUN go install github.com/gomantics/cfgx/cmd/cfgx@latest +RUN cfgx generate --in config.toml --out config/config.go --mode getter +RUN go build -o app + +FROM alpine +COPY --from=builder /app/app /app +CMD ["/app"] +``` + +Users can now configure via environment variables: + +```bash +docker run -e CONFIG_SERVER_ADDR=":3000" \ + -e CONFIG_DATABASE_DSN="postgres://mydb/app" \ + -e CONFIG_SERVER_TIMEOUT="60s" \ + yourapp:latest +``` + +### Approach 2: Static Mode with Separate Files + +For locked-down production deployments: ```bash # Development -cfgx generate --in config.dev.toml --out config/config.go +cfgx generate --in config.dev.toml --out config/config.go --mode static # Production -cfgx generate --in config.prod.toml --out config/config.go +cfgx generate --in config.prod.toml --out config/config.go --mode static ``` -### Approach 2: CI/CD with environment variables +### Approach 3: Static Mode with Build-Time Env Vars ```dockerfile FROM golang:1.25.1 as builder WORKDIR /app COPY . . RUN go install github.com/gomantics/cfgx/cmd/cfgx@latest -RUN cfgx generate --in config.toml --out config/config.go +# Inject secrets at build time via --no-env flag is disabled (enabled by default) +RUN cfgx generate --in config.toml --out config/config.go --mode static RUN go build -o app ``` @@ -265,7 +423,7 @@ CONFIG_DATABASE_DSN="postgres://prod.example.com/db" CONFIG_SERVER_ADDR=":443" ``` -### Approach 3: Build matrix +### Approach 4: Build Matrix ```bash cfgx generate --in config.prod.toml --out config/config.go && go build -o app-prod @@ -274,12 +432,65 @@ cfgx generate --in config.dev.toml --out config/config.go && go build -o app-dev ## FAQ +### When should I use static vs getter mode? + +**Use static mode when:** + +- Building internal tools or services +- You control the deployment environment +- Performance is critical (zero runtime overhead) +- Config rarely changes between environments +- You want maximum security (no runtime overrides possible) + +**Use getter mode when:** + +- Building open source applications +- Distributing via Docker/containers +- Users need to configure without rebuilding +- Following 12-factor app principles +- You want sensible defaults with easy overrides + +**Rule of thumb:** If you're shipping to users who can't rebuild from source, use getter mode. + ### Should I commit generated code? **Yes.** Like `sqlc` and `protoc`, commit the generated code. It's part of your source tree and should be versioned. **However:** Don't commit TOML files with production secrets. Keep those in your secrets manager and inject via environment variables during build. +### How do I use different TLS certs in production? + +**Getter mode (recommended):** + +```bash +# Development: uses embedded certs from config.toml +./myapp + +# Production: override via env vars pointing to file paths +CONFIG_SERVER_TLS_CERT=/etc/ssl/prod.crt \ +CONFIG_SERVER_TLS_KEY=/etc/ssl/prod.key \ +./myapp +``` + +In Kubernetes, mount secrets as files and point to them: + +```yaml +env: + - name: CONFIG_SERVER_TLS_CERT + value: /etc/tls/tls.crt +volumeMounts: + - name: tls-secret + mountPath: /etc/tls +volumes: + - name: tls-secret + secret: + secretName: prod-tls-secret +``` + +**Static mode:** + +Use different TOML files for each environment and generate at build time. + ### Do I need to distribute config files? **No.** The whole point is that config is baked into your binary. No runtime file loading needed.