Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 59 additions & 2 deletions cfgx.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Package cfgx generates type-safe Go code from TOML configuration files.
//
// This package provides a clean API for generating strongly-typed configuration code
// from TOML files, with optional environment variable override support.
// from TOML files, with optional environment variable override support and file embedding.
//
// Example usage:
//
Expand All @@ -16,6 +16,22 @@
// log.Fatal(err)
// }
//
// // With file embedding
// opts := &cfgx.GenerateOptions{
// InputFile: "config.toml",
// OutputFile: "config/config.go",
// PackageName: "config",
// MaxFileSize: 5 * cfgx.DefaultMaxFileSize, // 5MB limit
// }
// if err := cfgx.GenerateFromFile(opts); err != nil {
// log.Fatal(err)
// }
//
// // TOML with file references:
// // [server]
// // tls_cert = "file:certs/server.crt"
// // This generates a []byte field with embedded file contents
//
// // Programmatic usage
// tomlData := []byte(`[server]
// addr = ":8080"`)
Expand All @@ -38,6 +54,9 @@ import (
"github.com/gomantics/cfgx/internal/pkgutil"
)

// DefaultMaxFileSize is the default maximum file size (1 MB) for files referenced with "file:" prefix.
const DefaultMaxFileSize = 1024 * 1024 // 1 MB

// GenerateOptions contains all options for generating configuration code.
type GenerateOptions struct {
// InputFile is the path to the input TOML file
Expand All @@ -52,6 +71,10 @@ type GenerateOptions struct {

// EnableEnv enables environment variable override support
EnableEnv bool

// MaxFileSize is the maximum size in bytes for files referenced with "file:" prefix.
// If zero, defaults to DefaultMaxFileSize (1 MB).
MaxFileSize int64
}

// GenerateFromFile generates Go code from a TOML file and writes it to the output file.
Expand Down Expand Up @@ -99,8 +122,17 @@ func GenerateFromFile(opts *GenerateOptions) error {
packageName = pkgutil.InferName(opts.OutputFile)
}

// Extract input directory for resolving file: references
inputDir := filepath.Dir(opts.InputFile)

// Set default max file size if not specified
maxFileSize := opts.MaxFileSize
if maxFileSize == 0 {
maxFileSize = DefaultMaxFileSize
}

// Generate code
generated, err := Generate(data, packageName, opts.EnableEnv)
generated, err := GenerateWithOptions(data, packageName, opts.EnableEnv, inputDir, maxFileSize)
if err != nil {
return fmt.Errorf("failed to generate code: %w", err)
}
Expand Down Expand Up @@ -130,14 +162,39 @@ func GenerateFromFile(opts *GenerateOptions) error {
// - enableEnv: Whether to enable environment variable override markers in generated code
//
// Returns the generated Go code as bytes, or an error if generation fails.
//
// 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)
}

// GenerateWithOptions generates Go code from TOML data with full options support.
// This is useful for programmatic usage where you have the TOML data in memory
// and need to control file embedding behavior.
//
// Parameters:
// - tomlData: The TOML configuration data as bytes
// - packageName: The Go package name for the generated code
// - 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)
//
// 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) {
if packageName == "" {
packageName = "config"
}

if maxFileSize == 0 {
maxFileSize = DefaultMaxFileSize
}

gen := generator.New(
generator.WithPackageName(packageName),
generator.WithEnvOverride(enableEnv),
generator.WithInputDir(inputDir),
generator.WithMaxFileSize(maxFileSize),
)

return gen.Generate(tomlData)
Expand Down
106 changes: 106 additions & 0 deletions cfgx_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,109 @@ metrics_enabled = true
require.Contains(t, outputStr, expected, "expected variable declaration not found: %s", expected)
}
}

func TestGenerateFromFile_WithFileEmbedding(t *testing.T) {
tmpDir := t.TempDir()
inputFile := filepath.Join(tmpDir, "config.toml")
outputFile := filepath.Join(tmpDir, "generated/config.go")

filesDir := filepath.Join(tmpDir, "data")
err := os.MkdirAll(filesDir, 0755)
require.NoError(t, err)

testContent := []byte("Hello from embedded file!\nLine 2")
testFile := filepath.Join(filesDir, "test.txt")
err = os.WriteFile(testFile, testContent, 0644)
require.NoError(t, err)

tomlData := []byte(`
[app]
name = "test"
content = "file:data/test.txt"

[server]
addr = ":8080"
`)

err = os.WriteFile(inputFile, tomlData, 0644)
require.NoError(t, err)

opts := &GenerateOptions{
InputFile: inputFile,
OutputFile: outputFile,
PackageName: "config",
EnableEnv: false,
MaxFileSize: 10 * 1024 * 1024,
}

err = GenerateFromFile(opts)
require.NoError(t, err, "GenerateFromFile() should not error")

output, err := os.ReadFile(outputFile)
require.NoError(t, err)

outputStr := string(output)

require.Contains(t, outputStr, "Content []byte", "should have []byte field")
require.Contains(t, outputStr, "0x48", "should contain 'H' (0x48)")
require.Contains(t, outputStr, "0x65", "should contain 'e' (0x65)")
require.Contains(t, outputStr, "[]byte{", "should have byte array literal")

cmd := exec.Command("go", "build", outputFile)
cmd.Dir = tmpDir
cmdOutput, err := cmd.CombinedOutput()
require.NoError(t, err, "generated code does not compile: %s", cmdOutput)
}

func TestGenerateFromFile_FileNotFound(t *testing.T) {
tmpDir := t.TempDir()
inputFile := filepath.Join(tmpDir, "config.toml")
outputFile := filepath.Join(tmpDir, "config.go")

// Create TOML with reference to non-existent file
tomlData := []byte(`
[app]
content = "file:nonexistent.txt"
`)

err := os.WriteFile(inputFile, tomlData, 0644)
require.NoError(t, err)

opts := &GenerateOptions{
InputFile: inputFile,
OutputFile: outputFile,
}

err = GenerateFromFile(opts)
require.Error(t, err, "should error on non-existent file")
require.Contains(t, err.Error(), "file not found", "error should mention file not found")
}

func TestGenerateFromFile_FileSizeExceeded(t *testing.T) {
tmpDir := t.TempDir()
inputFile := filepath.Join(tmpDir, "config.toml")
outputFile := filepath.Join(tmpDir, "config.go")

// Create a test file
largeFile := filepath.Join(tmpDir, "large.txt")
err := os.WriteFile(largeFile, []byte("This file is too large for the limit"), 0644)
require.NoError(t, err)

tomlData := []byte(`
[app]
content = "file:large.txt"
`)

err = os.WriteFile(inputFile, tomlData, 0644)
require.NoError(t, err)

opts := &GenerateOptions{
InputFile: inputFile,
OutputFile: outputFile,
MaxFileSize: 10, // Very small limit
}

err = GenerateFromFile(opts)
require.Error(t, err, "should error on file size exceeded")
require.Contains(t, err.Error(), "exceeds max size", "error should mention size limit")
}
56 changes: 56 additions & 0 deletions cmd/cfgx/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"os"
"runtime"
"runtime/debug"
"strconv"
"strings"

"github.com/spf13/cobra"

Expand All @@ -22,6 +24,7 @@ var (
outputFile string
packageName string
noEnv bool
maxFileSize string
)

func main() {
Expand All @@ -30,6 +33,51 @@ func main() {
}
}

// parseFileSize parses a human-readable file size string like "10MB", "1GB", "512KB"
// into bytes. Returns 0 and error if parsing fails.
func parseFileSize(sizeStr string) (int64, error) {
if sizeStr == "" {
return 0, nil
}

sizeStr = strings.TrimSpace(strings.ToUpper(sizeStr))

// Define multipliers in order from longest to shortest to avoid prefix issues
multipliers := []struct {
suffix string
multiplier int64
}{
{"TB", 1024 * 1024 * 1024 * 1024},
{"GB", 1024 * 1024 * 1024},
{"MB", 1024 * 1024},
{"KB", 1024},
{"B", 1},
}

// Try to parse with suffix (check longest first)
for _, m := range multipliers {
if strings.HasSuffix(sizeStr, m.suffix) {
numStr := strings.TrimSuffix(sizeStr, m.suffix)
numStr = strings.TrimSpace(numStr)

num, err := strconv.ParseInt(numStr, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid size format: %s", sizeStr)
}

return num * m.multiplier, nil
}
}

// Try to parse as plain number (bytes)
num, err := strconv.ParseInt(sizeStr, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid size format: %s", sizeStr)
}

return num, nil
}

var rootCmd = &cobra.Command{
Use: "cfgx",
Short: "Type-safe config generation for Go",
Expand All @@ -56,12 +104,19 @@ var generateCmd = &cobra.Command{
return fmt.Errorf("--out flag is required")
}

// Parse max file size
maxFileSizeBytes, err := parseFileSize(maxFileSize)
if err != nil {
return fmt.Errorf("invalid --max-file-size: %w", err)
}

// Use the public API
opts := &cfgx.GenerateOptions{
InputFile: inputFile,
OutputFile: outputFile,
PackageName: packageName,
EnableEnv: !noEnv,
MaxFileSize: maxFileSizeBytes,
}

if err := cfgx.GenerateFromFile(opts); err != nil {
Expand Down Expand Up @@ -99,6 +154,7 @@ func init() {
generateCmd.Flags().StringVarP(&outputFile, "out", "o", "", "output Go file (required)")
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.MarkFlagRequired("out")

Expand Down
42 changes: 41 additions & 1 deletion example/config/config.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions example/config/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ idle_timeout = "5m"
shutdown_timeout = "2h30m"
max_header_bytes = 1048576
debug = true
cert = "file:files/cert.txt"

# Nested structures (multiple levels)
[database]
Expand Down
8 changes: 8 additions & 0 deletions example/config/files/cert.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBAgIJAKL0UG+mRKSzMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
aWRnaXRzIFB0eSBMdGQwHhcNMTcwODIzMTUxNTEyWhcNMjcwODIxMTUxNTEyWjBF
MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50
ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
CgKCAQEAzPJn6NCMmNGpRhZKWXA6dGzpF3BO8cG1YT/cSLUJuPKiVmHYxYQz8xQW
-----END CERTIFICATE-----
Loading
Loading