diff --git a/cfgx.go b/cfgx.go index 7de9ee8..795392d 100644 --- a/cfgx.go +++ b/cfgx.go @@ -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: // @@ -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"`) @@ -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 @@ -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. @@ -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) } @@ -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) diff --git a/cfgx_test.go b/cfgx_test.go index 2d3cbbf..e0fd884 100644 --- a/cfgx_test.go +++ b/cfgx_test.go @@ -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") +} diff --git a/cmd/cfgx/main.go b/cmd/cfgx/main.go index fa00e33..92e514c 100644 --- a/cmd/cfgx/main.go +++ b/cmd/cfgx/main.go @@ -6,6 +6,8 @@ import ( "os" "runtime" "runtime/debug" + "strconv" + "strings" "github.com/spf13/cobra" @@ -22,6 +24,7 @@ var ( outputFile string packageName string noEnv bool + maxFileSize string ) func main() { @@ -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", @@ -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 { @@ -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") diff --git a/example/config/config.go b/example/config/config.go index c373a43..334ccc1 100644 --- a/example/config/config.go +++ b/example/config/config.go @@ -65,6 +65,7 @@ type FeaturesItem struct { type ServerConfig struct { Addr string + Cert []byte Debug bool IdleTimeout time.Duration MaxHeaderBytes int64 @@ -150,7 +151,46 @@ var ( } Name string = "cfgx" Server = ServerConfig{ - Addr: ":8080", + Addr: ":8080", + Cert: []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, + }, Debug: true, IdleTimeout: 5 * time.Minute, MaxHeaderBytes: 1048576, diff --git a/example/config/config.toml b/example/config/config.toml index ceaa15d..a572fc7 100644 --- a/example/config/config.toml +++ b/example/config/config.toml @@ -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] diff --git a/example/config/files/cert.txt b/example/config/files/cert.txt new file mode 100644 index 0000000..e71672f --- /dev/null +++ b/example/config/files/cert.txt @@ -0,0 +1,8 @@ +-----BEGIN CERTIFICATE----- +MIIDXTCCAkWgAwIBAgIJAKL0UG+mRKSzMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTcwODIzMTUxNTEyWhcNMjcwODIxMTUxNTEyWjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEAzPJn6NCMmNGpRhZKWXA6dGzpF3BO8cG1YT/cSLUJuPKiVmHYxYQz8xQW +-----END CERTIFICATE----- diff --git a/internal/generator/file_handler.go b/internal/generator/file_handler.go new file mode 100644 index 0000000..ed34efd --- /dev/null +++ b/internal/generator/file_handler.go @@ -0,0 +1,52 @@ +package generator + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// isFileReference checks if a string value is a file reference (starts with "file:"). +func (g *Generator) isFileReference(s string) bool { + return strings.HasPrefix(s, "file:") +} + +// loadFileContent reads a file and returns its contents as bytes. +// The file path is resolved relative to the inputDir. +// Returns an error if the file doesn't exist, can't be read, or exceeds maxFileSize. +func (g *Generator) loadFileContent(filePath string) ([]byte, error) { + // Strip "file:" prefix + relativePath := strings.TrimPrefix(filePath, "file:") + + // Resolve path relative to input directory + var resolvedPath string + if g.inputDir != "" { + resolvedPath = filepath.Join(g.inputDir, relativePath) + } else { + resolvedPath = relativePath + } + + // Check file exists and get size + fileInfo, err := os.Stat(resolvedPath) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("file not found: %s (referenced in config)", resolvedPath) + } + return nil, fmt.Errorf("failed to stat file %s: %w", resolvedPath, err) + } + + // Check file size + if g.maxFileSize > 0 && fileInfo.Size() > g.maxFileSize { + return nil, fmt.Errorf("file %s exceeds max size %d bytes (actual: %d bytes)", + resolvedPath, g.maxFileSize, fileInfo.Size()) + } + + // Read file + content, err := os.ReadFile(resolvedPath) + if err != nil { + return nil, fmt.Errorf("failed to read file %s: %w", resolvedPath, err) + } + + return content, nil +} diff --git a/internal/generator/file_handler_test.go b/internal/generator/file_handler_test.go new file mode 100644 index 0000000..36080a3 --- /dev/null +++ b/internal/generator/file_handler_test.go @@ -0,0 +1,218 @@ +package generator + +import ( + "os" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGenerator_FileEmbedding(t *testing.T) { + tests := []struct { + name string + toml string + inputDir string + maxFileSize int64 + wantType string + wantError bool + checkBytes bool + }{ + { + name: "simple text file", + toml: `[config] +content = "file:files/small.txt"`, + inputDir: "../../testdata", + maxFileSize: 10 * 1024 * 1024, + wantType: "[]byte", + wantError: false, + checkBytes: true, + }, + { + name: "certificate file", + toml: `[tls] +cert = "file:files/cert.txt"`, + inputDir: "../../testdata", + maxFileSize: 10 * 1024 * 1024, + wantType: "[]byte", + wantError: false, + checkBytes: true, + }, + { + name: "binary file", + toml: `[data] +binary = "file:files/binary.dat"`, + inputDir: "../../testdata", + maxFileSize: 10 * 1024 * 1024, + wantType: "[]byte", + wantError: false, + checkBytes: true, + }, + { + name: "file not found", + toml: `[config] +content = "file:files/nonexistent.txt"`, + inputDir: "../../testdata", + maxFileSize: 10 * 1024 * 1024, + wantError: true, + }, + { + name: "file exceeds size limit", + toml: `[config] +content = "file:files/small.txt"`, + inputDir: "../../testdata", + maxFileSize: 10, // Very small limit + wantError: true, + }, + { + name: "multiple files in struct", + toml: `[files] +file1 = "file:files/small.txt" +file2 = "file:files/binary.dat"`, + inputDir: "../../testdata", + maxFileSize: 10 * 1024 * 1024, + wantType: "[]byte", + wantError: false, + checkBytes: true, + }, + { + name: "file in nested struct", + toml: `[app.config] +content = "file:files/small.txt"`, + inputDir: "../../testdata", + maxFileSize: 10 * 1024 * 1024, + wantType: "[]byte", + wantError: false, + checkBytes: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gen := New( + WithInputDir(tt.inputDir), + WithMaxFileSize(tt.maxFileSize), + ) + output, err := gen.Generate([]byte(tt.toml)) + + if tt.wantError { + require.Error(t, err, "Generate() should error") + return + } + + require.NoError(t, err, "Generate() should not error") + outputStr := string(output) + + if tt.wantType != "" { + require.Contains(t, outputStr, tt.wantType, "output missing type") + } + + if tt.checkBytes { + // Verify byte array format + require.Contains(t, outputStr, "[]byte{", "output missing byte array") + require.Contains(t, outputStr, "0x", "output missing hex format") + } + }) + } +} + +func TestGenerator_FileEmbeddingByteFormat(t *testing.T) { + // Test that byte arrays are formatted correctly + toml := `[config] +content = "file:files/binary.dat"` + + gen := New( + WithInputDir("../../testdata"), + WithMaxFileSize(10*1024*1024), + ) + output, err := gen.Generate([]byte(toml)) + require.NoError(t, err, "Generate() should not error") + + outputStr := string(output) + + // Check for proper hex format + require.Contains(t, outputStr, "0x00", "should contain first byte (0x00)") + require.Contains(t, outputStr, "0xff", "should contain byte 0xff") + require.Contains(t, outputStr, "0x0f", "should contain byte 0x0f") + + // Verify proper formatting (12 bytes per line) + require.Contains(t, outputStr, "[]byte{", "should have byte array opening") + require.Contains(t, outputStr, "Content []byte", "should have []byte field type") + + // Read the actual file to verify byte count + expectedContent, err := os.ReadFile("../../testdata/files/binary.dat") + require.NoError(t, err, "should read test file") + + // Count hex patterns in output - should match file size + hexCount := strings.Count(outputStr, "0x") + require.Equal(t, len(expectedContent), hexCount, "should have correct number of bytes") +} + +func TestGenerator_FileEmbeddingInArrayOfTables(t *testing.T) { + toml := `[[endpoints]] +path = "/api/v1" +cert = "file:files/small.txt" + +[[endpoints]] +path = "/api/v2" +cert = "file:files/binary.dat"` + + gen := New( + WithInputDir("../../testdata"), + WithMaxFileSize(10*1024*1024), + ) + output, err := gen.Generate([]byte(toml)) + require.NoError(t, err, "Generate() should not error") + + outputStr := string(output) + + // Verify structure + require.Contains(t, outputStr, "type EndpointsItem struct", "should have struct") + require.Contains(t, outputStr, "Cert []byte", "should have []byte field") + require.Contains(t, outputStr, "[]EndpointsItem{", "should have array") + + // Verify both files are embedded + require.Contains(t, outputStr, "[]byte{", "should have byte arrays") +} + +func TestGenerator_FileSizeLimit(t *testing.T) { + // Test the file size limit enforcement + tests := []struct { + name string + fileSize int64 + toml string + wantErr bool + }{ + { + name: "within 1KB limit", + fileSize: 1024, + toml: `[config] +content = "file:files/small.txt"`, + wantErr: false, + }, + { + name: "exceeds 10 byte limit", + fileSize: 10, + toml: `[config] +content = "file:files/small.txt"`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gen := New( + WithInputDir("../../testdata"), + WithMaxFileSize(tt.fileSize), + ) + _, err := gen.Generate([]byte(tt.toml)) + + if tt.wantErr { + require.Error(t, err, "should error due to size limit") + require.Contains(t, err.Error(), "exceeds max size", "error should mention size limit") + } else { + require.NoError(t, err, "should not error") + } + }) + } +} diff --git a/internal/generator/generator.go b/internal/generator/generator.go index ef15bce..728216d 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -5,19 +5,17 @@ import ( "bytes" "fmt" "go/format" - "slices" - "sort" "strings" - "time" "github.com/BurntSushi/toml" - "github.com/gomantics/sx" ) // Generator handles the conversion of TOML config to Go code. type Generator struct { - packageName string - envOverride bool + packageName string // The package name for the generated code + 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 } // Option configures a Generator. @@ -37,11 +35,26 @@ func WithEnvOverride(enable bool) Option { } } +// WithInputDir sets the input directory for resolving relative file paths. +func WithInputDir(dir string) Option { + return func(g *Generator) { + g.inputDir = dir + } +} + +// WithMaxFileSize sets the maximum file size for file: references. +func WithMaxFileSize(size int64) Option { + return func(g *Generator) { + g.maxFileSize = size + } +} + // 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 } for _, opt := range opts { opt(g) @@ -69,6 +82,11 @@ func (g *Generator) Generate(tomlData []byte) ([]byte, error) { return nil, fmt.Errorf("failed to parse TOML: %w", err) } + // Validate all file references before generating code + if err := g.validateFileReferences(data); err != nil { + return nil, err + } + var buf bytes.Buffer buf.WriteString("// Code generated by cfgx. DO NOT EDIT.\n\n") @@ -90,551 +108,3 @@ func (g *Generator) Generate(tomlData []byte) ([]byte, error) { return formatted, nil } - -// needsTimeImport checks if any value in the data map is a duration string, -// recursively traversing nested maps and arrays to determine if the generated -// code needs to import the "time" package. -func (g *Generator) needsTimeImport(data map[string]any) bool { - for _, v := range data { - if g.needsTimeImportValue(v) { - return true - } - } - return false -} - -func (g *Generator) needsTimeImportValue(v any) bool { - switch val := v.(type) { - case string: - // Check if string is a valid duration - if g.isDurationString(val) { - return true - } - case map[string]any: - return g.needsTimeImport(val) - case []any: - if slices.ContainsFunc(val, g.needsTimeImportValue) { - return true - } - case []map[string]any: - if slices.ContainsFunc(val, g.needsTimeImport) { - return true - } - } - return false -} - -// isDurationString checks if a string can be parsed as a time.Duration. -func (g *Generator) isDurationString(s string) bool { - _, err := time.ParseDuration(s) - return err == nil -} - -// generateStructsAndVars orchestrates the generation of all struct type definitions -// and variable declarations from the parsed TOML data. It processes the data in two -// phases: -// -// 1. Collects all struct definitions (including nested ones) by traversing the data -// and building a complete map of struct types needed. -// 2. Generates the Go code for structs first (sorted alphabetically for deterministic -// output), then generates variable declarations with their initializations. -// -// This function handles top-level tables, arrays of tables, and nested structures, -// ensuring proper naming conventions (e.g., "DatabaseConfig", "ServersItem") and -// correct type references. -func (g *Generator) generateStructsAndVars(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 - - 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.collectNestedStructs(allStructs, structName, m) - } else if arr, ok := data[key].([]map[string]any); ok { - if len(arr) > 0 { - structName := sx.PascalCase(key) + "Item" - g.collectNestedStructs(allStructs, structName, arr[0]) - } - } - } - - structNames := make([]string, 0, len(allStructs)) - for name := range allStructs { - structNames = append(structNames, name) - } - sort.Strings(structNames) - - for _, name := range structNames { - fields := allStructs[name] - if err := g.generateStruct(buf, name, fields); err != nil { - return err - } - buf.WriteString("\n\n") - } - - buf.WriteString("var (\n") - - for _, key := range keys { - varName := sx.PascalCase(key) - value := data[key] - - switch val := value.(type) { - case map[string]any: - structName := sx.PascalCase(key) + "Config" - fmt.Fprintf(buf, "\t%s = %s", varName, structName) - if err := g.generateStructInit(buf, structName, val, 0); err != nil { - return err - } - buf.WriteString("\n") - case []map[string]any: - if len(val) > 0 { - structName := sx.PascalCase(key) + "Item" - fmt.Fprintf(buf, "\t%s = []%s", varName, structName) - if err := g.writeArrayOfTablesInit(buf, structName, val, 0); err != nil { - return err - } - buf.WriteString("\n") - } else { - fmt.Fprintf(buf, "\t%s []%sItem\n", varName, sx.PascalCase(key)) - } - case []any: - if len(val) > 0 { - if _, ok := val[0].(map[string]any); ok { - structName := sx.PascalCase(key) + "Item" - fmt.Fprintf(buf, "\t%s = []%s", varName, structName) - if err := g.writeArrayOfTablesInit(buf, structName, val, 0); err != nil { - return err - } - buf.WriteString("\n") - } else { - goType := g.toGoType(value) - fmt.Fprintf(buf, "\t%s %s = ", varName, goType) - g.writeValue(buf, value) - buf.WriteString("\n") - } - } else { - goType := g.toGoType(value) - fmt.Fprintf(buf, "\t%s %s\n", varName, goType) - } - default: - // Generate simple variable - goType := g.toGoType(value) - fmt.Fprintf(buf, "\t%s %s = ", varName, goType) - g.writeValue(buf, value) - buf.WriteString("\n") - } - } - - buf.WriteString(")\n") - - return nil -} - -// collectNestedStructs recursively collects all struct definitions needed for the -// generated code. It traverses nested maps and arrays to discover all struct types -// that must be defined. -// -// The function builds unique struct names by concatenating parent and child names -// (e.g., "DatabaseConfig" -> "DatabaseCredentialsConfig" for nested credentials). -// It handles: -// - Nested maps (inline tables) - suffixed with "Config" -// - Arrays of maps (array of tables) - suffixed with "Item" -// -// The structs map is populated with name->fields mapping, ensuring each struct type -// is only processed once (deduplication via existence check). -func (g *Generator) collectNestedStructs(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.collectNestedStructs(structs, nestedName, v) - case []any: - // Check if it's an array of maps - if len(v) > 0 { - if m, ok := v[0].(map[string]any); ok { - nestedName := stripSuffix(name) + sx.PascalCase(key) + "Item" - g.collectNestedStructs(structs, nestedName, m) - } - } - case []map[string]any: - if len(v) > 0 { - nestedName := stripSuffix(name) + sx.PascalCase(key) + "Item" - g.collectNestedStructs(structs, nestedName, v[0]) - } - } - } -} - -// generateStruct generates a struct type definition with properly typed fields. -// Field names are converted to Go-idiomatic CamelCase, and field types are determined -// based on the value types in the TOML data. -// -// For nested structures, the function constructs type names by prefixing the parent -// struct name to maintain uniqueness (e.g., "DatabaseConfig" with a "server" field -// becomes "DatabaseConfigServerConfig" type). -// -// Fields are sorted alphabetically for deterministic output. -func (g *Generator) generateStruct(buf *bytes.Buffer, name string, fields map[string]any) error { - fmt.Fprintf(buf, "type %s struct {\n", name) - - 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) - goType := g.toGoType(value) - - // Handle nested structs - prefix with parent struct name - if _, ok := value.(map[string]any); ok { - goType = stripSuffix(name) + sx.PascalCase(fieldName) + "Config" - } else if arr, ok := value.([]any); ok && len(arr) > 0 { - if _, isMap := arr[0].(map[string]any); isMap { - goType = "[]" + stripSuffix(name) + sx.PascalCase(fieldName) + "Item" - } - } else if arr, ok := value.([]map[string]any); ok && len(arr) > 0 { - goType = "[]" + stripSuffix(name) + sx.PascalCase(fieldName) + "Item" - } - - fmt.Fprintf(buf, "\t%s %s\n", goFieldName, goType) - } - - buf.WriteString("}") - return nil -} - -// generateStructInit generates struct initialization code with proper indentation -// and nested struct literals. This function recursively creates the initialization -// syntax for complex nested structures. -// -// For nested maps, it generates inline struct literals with the appropriate type name. -// For arrays of structs, it delegates to writeArrayOfStructs or handles simple arrays. -// Simple values are written as literals using writeValue. -// -// The indent parameter controls the indentation level for proper formatting of nested -// structures. Fields are sorted alphabetically for deterministic output. -func (g *Generator) generateStructInit(buf *bytes.Buffer, parentStructName string, data map[string]any, indent int) error { - buf.WriteString("{\n") - - keys := make([]string, 0, len(data)) - for k := range data { - keys = append(keys, k) - } - sort.Strings(keys) // deterministic output - - indentStr := strings.Repeat("\t", indent+1) - for _, key := range keys { - value := data[key] - fieldName := sx.PascalCase(key) - - buf.WriteString(indentStr) - fmt.Fprintf(buf, "%s: ", fieldName) - - switch val := value.(type) { - case map[string]any: - structType := stripSuffix(parentStructName) + sx.PascalCase(key) + "Config" - buf.WriteString(structType) - if err := g.generateStructInit(buf, structType, val, indent+1); err != nil { - return err - } - case []any: - if len(val) > 0 { - if _, ok := val[0].(map[string]any); ok { - g.writeArrayOfStructs(buf, val, indent+1) - } else { - g.writeValue(buf, value) - } - } else { - g.writeValue(buf, value) - } - case []map[string]any: - g.writeArrayOfStructs(buf, val, indent+1) - default: - g.writeValue(buf, value) - } - - buf.WriteString(",\n") - } - - buf.WriteString(strings.Repeat("\t", indent)) - buf.WriteString("}") - return nil -} - -// writeArrayOfTablesInit writes an array initialization for top-level tables, -// specifically handling TOML's [[array.of.tables]] syntax. This generates code -// for slices of structs where each element is initialized with its fields. -// -// The function handles both []any and []map[string]any types from the TOML parser, -// generating struct literals with proper indentation. Each element is initialized -// by calling generateStructInit for the nested structure. -// -// The type name is omitted from each element to comply with gofmt -s simplification rules. -// -// Example output: -// -// []ServerItem{ -// {Host: "localhost", Port: 8080}, -// {Host: "example.com", Port: 443}, -// } -func (g *Generator) writeArrayOfTablesInit(buf *bytes.Buffer, structName string, arr any, indent int) error { - buf.WriteString("{\n") - indentStr := strings.Repeat("\t", indent+1) - - switch val := arr.(type) { - case []any: - for _, item := range val { - if m, ok := item.(map[string]any); ok { - buf.WriteString(indentStr) - // Omit type name for gofmt -s compliance - if err := g.generateStructInit(buf, structName, m, indent+1); err != nil { - return err - } - buf.WriteString(",\n") - } - } - case []map[string]any: - for _, m := range val { - buf.WriteString(indentStr) - // Omit type name for gofmt -s compliance - if err := g.generateStructInit(buf, structName, m, indent+1); err != nil { - return err - } - buf.WriteString(",\n") - } - } - - buf.WriteString(strings.Repeat("\t", indent)) - buf.WriteString("}") - return nil -} - -// writeArrayOfStructs writes an array of struct initializations using compact inline -// syntax. Unlike writeArrayOfTablesInit, this generates inline struct literals without -// the type name prefix, making it more suitable for deeply nested structures. -// -// Example output: -// -// { -// {Host: "localhost", Port: 8080}, -// {Host: "example.com", Port: 443}, -// } -// -// Fields within each struct are written in sorted order and separated by commas on a -// single line. This function handles both []any and []map[string]any input types. -func (g *Generator) writeArrayOfStructs(buf *bytes.Buffer, arr any, indent int) { - buf.WriteString("{\n") - indentStr := strings.Repeat("\t", indent+1) - - switch val := arr.(type) { - case []any: - for _, item := range val { - if m, ok := item.(map[string]any); ok { - buf.WriteString(indentStr) - buf.WriteString("{") - // Inline struct fields - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - - for i, k := range keys { - if i > 0 { - buf.WriteString(", ") - } - buf.WriteString(sx.PascalCase(k)) - buf.WriteString(": ") - g.writeValue(buf, m[k]) - } - buf.WriteString("},\n") - } - } - case []map[string]any: - for _, m := range val { - buf.WriteString(indentStr) - buf.WriteString("{") - // Inline struct fields - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - - for i, k := range keys { - if i > 0 { - buf.WriteString(", ") - } - buf.WriteString(sx.PascalCase(k)) - buf.WriteString(": ") - g.writeValue(buf, m[k]) - } - buf.WriteString("},\n") - } - } - - buf.WriteString(strings.Repeat("\t", indent)) - buf.WriteString("}") -} - -// toGoType converts a value to its Go type string representation. This function -// inspects the runtime type of a value and returns the corresponding Go type as a string. -// -// For primitive types (string, int64, float64, bool), it returns the standard type name. -// For slices, it recursively determines the element type. For maps and []map[string]any, -// it returns placeholder strings ("struct", "[]struct") that will be replaced with actual -// struct type names in context by the calling code. -func (g *Generator) toGoType(v any) string { - switch val := v.(type) { - case string: - // Check if this is a duration string - if g.isDurationString(val) { - return "time.Duration" - } - return "string" - case int64: - return "int64" - case int: - return "int64" - case float64: - return "float64" - case bool: - return "bool" - case []any: - if len(val) > 0 { - elemType := g.toGoType(val[0]) - return "[]" + elemType - } - return "[]any" - case []map[string]any: - // This will be replaced with the actual struct type name in context - return "[]struct" - case map[string]any: - // This will be replaced with the actual struct type name in context - return "struct" - default: - return "any" - } -} - -// writeValue writes a Go value literal to the buffer. This function handles the -// serialization of various Go types into their source code representation. -// -// Strings are quoted, numbers are formatted appropriately, duration strings are -// parsed and written as duration literals, and arrays are handled recursively. -// This ensures the generated code is valid Go syntax that can be compiled directly. -func (g *Generator) writeValue(buf *bytes.Buffer, v any) { - switch val := v.(type) { - case string: - // Check if this is a duration string - if g.isDurationString(val) { - g.writeDurationLiteral(buf, val) - } else { - fmt.Fprintf(buf, "%q", val) - } - case int64: - fmt.Fprintf(buf, "%d", val) - case int: - fmt.Fprintf(buf, "%d", val) - case float64: - fmt.Fprintf(buf, "%g", val) - case bool: - fmt.Fprintf(buf, "%t", val) - case []any: - g.writeArray(buf, val) - default: - buf.WriteString("nil") - } -} - -// writeDurationLiteral parses a duration string at generation time and writes -// it as a duration literal in a human-readable format using time constants. -// Complex durations like '2h30m' are decomposed into multiple time constants -// (e.g., 2*time.Hour + 30*time.Minute) for better readability. -func (g *Generator) writeDurationLiteral(buf *bytes.Buffer, s string) { - d, err := time.ParseDuration(s) - if err != nil { - // This should never happen since isDurationString already validated it - fmt.Fprintf(buf, "time.Duration(0) /* invalid: %s */", s) - return - } - - if d == 0 { - buf.WriteString("0") - return - } - - // Decompose duration into components from largest to smallest - components := []struct { - unit time.Duration - name string - }{ - {time.Hour, "time.Hour"}, - {time.Minute, "time.Minute"}, - {time.Second, "time.Second"}, - {time.Millisecond, "time.Millisecond"}, - {time.Microsecond, "time.Microsecond"}, - {time.Nanosecond, "time.Nanosecond"}, - } - - remaining := d - parts := []string{} - - for _, comp := range components { - if remaining >= comp.unit { - count := remaining / comp.unit - if count > 0 { - parts = append(parts, fmt.Sprintf("%d*%s", count, comp.name)) - remaining = remaining % comp.unit - } - } - } - - if len(parts) == 0 { - // Should not happen for non-zero durations, but handle it - buf.WriteString("0") - return - } - - // Join parts with " + " - // Note: gofmt will add spaces around * for simple expressions (e.g., "30 * time.Second") - // but keep them compact in complex expressions (e.g., "2*time.Hour + 30*time.Minute") - buf.WriteString(strings.Join(parts, " + ")) -} - -// writeArray writes an array literal in Go slice syntax. The function infers the -// element type from the first element and generates a typed slice literal. -// -// Empty arrays are written as "nil". Non-empty arrays are written in the format: -// []Type{elem1, elem2, ...} with elements separated by commas and spaces. -func (g *Generator) writeArray(buf *bytes.Buffer, arr []any) { - if len(arr) == 0 { - buf.WriteString("nil") - return - } - - elemType := g.toGoType(arr[0]) - fmt.Fprintf(buf, "[]%s{", elemType) - - for i, item := range arr { - if i > 0 { - fmt.Fprintf(buf, ", ") - } - g.writeValue(buf, item) - } - - buf.WriteString("}") -} diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go index 96b9e97..d82d261 100644 --- a/internal/generator/generator_test.go +++ b/internal/generator/generator_test.go @@ -137,159 +137,3 @@ value = 3 require.True(t, alphaPos < betaPos && betaPos < zuluPos, "variables not sorted alphabetically") } - -func TestGenerator_Types(t *testing.T) { - tests := []struct { - name string - toml string - want []string - }{ - { - name: "string type", - toml: `[config] -value = "hello"`, - want: []string{"Value", "string", `Value: "hello"`}, - }, - { - name: "int type", - toml: `[config] -value = 42`, - want: []string{"Value", "int64", "Value: 42"}, - }, - { - name: "float type", - toml: `[config] -value = 3.14`, - want: []string{"Value", "float64", "Value: 3.14"}, - }, - { - name: "bool type", - toml: `[config] -value = true`, - want: []string{"Value", "bool", "Value: true"}, - }, - { - name: "string array", - toml: `[config] -values = ["a", "b", "c"]`, - want: []string{"Values", "[]string", `[]string{"a", "b", "c"}`}, - }, - { - name: "int array", - toml: `[config] -values = [1, 2, 3]`, - want: []string{"Values", "[]int64", "[]int64{1, 2, 3}"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gen := New() - output, err := gen.Generate([]byte(tt.toml)) - require.NoError(t, err, "Generate() should not error") - - outputStr := string(output) - for _, want := range tt.want { - require.Contains(t, outputStr, want, "output missing expected string") - } - }) - } -} - -func TestGenerator_DurationTypes(t *testing.T) { - tests := []struct { - name string - toml string - want []string - }{ - { - name: "simple duration - seconds", - toml: `[config] -timeout = "30s"`, - want: []string{"Timeout", "time.Duration", "30 * time.Second", "import \"time\""}, - }, - { - name: "simple duration - milliseconds", - toml: `[config] -timeout = "500ms"`, - want: []string{"Timeout", "time.Duration", "500 * time.Millisecond", "import \"time\""}, - }, - { - name: "simple duration - minutes", - toml: `[config] -timeout = "5m"`, - want: []string{"Timeout", "time.Duration", "5 * time.Minute", "import \"time\""}, - }, - { - name: "simple duration - hours", - toml: `[config] -timeout = "2h"`, - want: []string{"Timeout", "time.Duration", "2 * time.Hour", "import \"time\""}, - }, - { - name: "zero duration", - toml: `[config] -timeout = "0s"`, - want: []string{"Timeout", "time.Duration", "Timeout: 0", "import \"time\""}, - }, - { - name: "complex duration - hours and minutes", - toml: `[config] -timeout = "2h30m"`, - want: []string{"Timeout", "time.Duration", "2*time.Hour + 30*time.Minute", "import \"time\""}, - }, - { - name: "complex duration - minutes and seconds", - toml: `[config] -timeout = "5m30s"`, - want: []string{"Timeout", "time.Duration", "5*time.Minute + 30*time.Second", "import \"time\""}, - }, - { - name: "complex duration - hours, minutes and seconds", - toml: `[config] -timeout = "1h30m45s"`, - want: []string{"Timeout", "time.Duration", "1*time.Hour + 30*time.Minute + 45*time.Second", "import \"time\""}, - }, - { - name: "complex duration - seconds and milliseconds", - toml: `[config] -timeout = "3s500ms"`, - want: []string{"Timeout", "time.Duration", "3*time.Second + 500*time.Millisecond", "import \"time\""}, - }, - { - name: "complex duration - full decomposition", - toml: `[config] -timeout = "1h2m3s4ms5us6ns"`, - want: []string{"Timeout", "time.Duration", "1*time.Hour + 2*time.Minute + 3*time.Second + 4*time.Millisecond + 5*time.Microsecond + 6*time.Nanosecond", "import \"time\""}, - }, - { - name: "multiple durations with different formats", - toml: `[config] -short = "500ms" -medium = "5m" -long = "2h" -complex = "1h30m"`, - want: []string{ - "Short", "Medium", "Long", "Complex", - "time.Duration", - "500 * time.Millisecond", - "5 * time.Minute", - "2 * time.Hour", - "1*time.Hour + 30*time.Minute", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gen := New() - output, err := gen.Generate([]byte(tt.toml)) - require.NoError(t, err, "Generate() should not error") - - outputStr := string(output) - for _, want := range tt.want { - require.Contains(t, outputStr, want, "output missing expected string: %s", want) - } - }) - } -} diff --git a/internal/generator/struct_gen.go b/internal/generator/struct_gen.go new file mode 100644 index 0000000..10cc644 --- /dev/null +++ b/internal/generator/struct_gen.go @@ -0,0 +1,370 @@ +package generator + +import ( + "bytes" + "fmt" + "sort" + "strings" + + "github.com/gomantics/sx" +) + +// generateStructsAndVars orchestrates the generation of all struct type definitions +// and variable declarations from the parsed TOML data. It processes the data in two +// phases: +// +// 1. Collects all struct definitions (including nested ones) by traversing the data +// and building a complete map of struct types needed. +// 2. Generates the Go code for structs first (sorted alphabetically for deterministic +// output), then generates variable declarations with their initializations. +// +// This function handles top-level tables, arrays of tables, and nested structures, +// ensuring proper naming conventions (e.g., "DatabaseConfig", "ServersItem") and +// correct type references. +func (g *Generator) generateStructsAndVars(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 + + 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.collectNestedStructs(allStructs, structName, m) + } else if arr, ok := data[key].([]map[string]any); ok { + if len(arr) > 0 { + structName := sx.PascalCase(key) + "Item" + g.collectNestedStructs(allStructs, structName, arr[0]) + } + } + } + + structNames := make([]string, 0, len(allStructs)) + for name := range allStructs { + structNames = append(structNames, name) + } + sort.Strings(structNames) + + for _, name := range structNames { + fields := allStructs[name] + if err := g.generateStruct(buf, name, fields); err != nil { + return err + } + buf.WriteString("\n\n") + } + + buf.WriteString("var (\n") + + for _, key := range keys { + varName := sx.PascalCase(key) + value := data[key] + + switch val := value.(type) { + case map[string]any: + structName := sx.PascalCase(key) + "Config" + fmt.Fprintf(buf, "\t%s = %s", varName, structName) + if err := g.generateStructInit(buf, structName, val, 0); err != nil { + return err + } + buf.WriteString("\n") + case []map[string]any: + if len(val) > 0 { + structName := sx.PascalCase(key) + "Item" + fmt.Fprintf(buf, "\t%s = []%s", varName, structName) + if err := g.writeArrayOfTablesInit(buf, structName, val, 0); err != nil { + return err + } + buf.WriteString("\n") + } else { + fmt.Fprintf(buf, "\t%s []%sItem\n", varName, sx.PascalCase(key)) + } + case []any: + if len(val) > 0 { + if _, ok := val[0].(map[string]any); ok { + structName := sx.PascalCase(key) + "Item" + fmt.Fprintf(buf, "\t%s = []%s", varName, structName) + if err := g.writeArrayOfTablesInit(buf, structName, val, 0); err != nil { + return err + } + buf.WriteString("\n") + } else { + goType := g.toGoType(value) + fmt.Fprintf(buf, "\t%s %s = ", varName, goType) + g.writeValue(buf, value) + buf.WriteString("\n") + } + } else { + goType := g.toGoType(value) + fmt.Fprintf(buf, "\t%s %s\n", varName, goType) + } + default: + // Generate simple variable + goType := g.toGoType(value) + fmt.Fprintf(buf, "\t%s %s = ", varName, goType) + g.writeValue(buf, value) + buf.WriteString("\n") + } + } + + buf.WriteString(")\n") + + return nil +} + +// collectNestedStructs recursively collects all struct definitions needed for the +// generated code. It traverses nested maps and arrays to discover all struct types +// that must be defined. +// +// The function builds unique struct names by concatenating parent and child names +// (e.g., "DatabaseConfig" -> "DatabaseCredentialsConfig" for nested credentials). +// It handles: +// - Nested maps (inline tables) - suffixed with "Config" +// - Arrays of maps (array of tables) - suffixed with "Item" +// +// The structs map is populated with name->fields mapping, ensuring each struct type +// is only processed once (deduplication via existence check). +func (g *Generator) collectNestedStructs(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.collectNestedStructs(structs, nestedName, v) + case []any: + // Check if it's an array of maps + if len(v) > 0 { + if m, ok := v[0].(map[string]any); ok { + nestedName := stripSuffix(name) + sx.PascalCase(key) + "Item" + g.collectNestedStructs(structs, nestedName, m) + } + } + case []map[string]any: + if len(v) > 0 { + nestedName := stripSuffix(name) + sx.PascalCase(key) + "Item" + g.collectNestedStructs(structs, nestedName, v[0]) + } + } + } +} + +// generateStruct generates a struct type definition with properly typed fields. +// Field names are converted to Go-idiomatic CamelCase, and field types are determined +// based on the value types in the TOML data. +// +// For nested structures, the function constructs type names by prefixing the parent +// struct name to maintain uniqueness (e.g., "DatabaseConfig" with a "server" field +// becomes "DatabaseConfigServerConfig" type). +// +// Fields are sorted alphabetically for deterministic output. +func (g *Generator) generateStruct(buf *bytes.Buffer, name string, fields map[string]any) error { + fmt.Fprintf(buf, "type %s struct {\n", name) + + 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) + goType := g.toGoType(value) + + // Handle nested structs - prefix with parent struct name + if _, ok := value.(map[string]any); ok { + goType = stripSuffix(name) + sx.PascalCase(fieldName) + "Config" + } else if arr, ok := value.([]any); ok && len(arr) > 0 { + if _, isMap := arr[0].(map[string]any); isMap { + goType = "[]" + stripSuffix(name) + sx.PascalCase(fieldName) + "Item" + } + } else if arr, ok := value.([]map[string]any); ok && len(arr) > 0 { + goType = "[]" + stripSuffix(name) + sx.PascalCase(fieldName) + "Item" + } + + fmt.Fprintf(buf, "\t%s %s\n", goFieldName, goType) + } + + buf.WriteString("}") + return nil +} + +// generateStructInit generates struct initialization code with proper indentation +// and nested struct literals. This function recursively creates the initialization +// syntax for complex nested structures. +// +// For nested maps, it generates inline struct literals with the appropriate type name. +// For arrays of structs, it delegates to writeArrayOfStructs or handles simple arrays. +// Simple values are written as literals using writeValue. +// +// The indent parameter controls the indentation level for proper formatting of nested +// structures. Fields are sorted alphabetically for deterministic output. +func (g *Generator) generateStructInit(buf *bytes.Buffer, parentStructName string, data map[string]any, indent int) error { + buf.WriteString("{\n") + + keys := make([]string, 0, len(data)) + for k := range data { + keys = append(keys, k) + } + sort.Strings(keys) // deterministic output + + indentStr := strings.Repeat("\t", indent+1) + for _, key := range keys { + value := data[key] + fieldName := sx.PascalCase(key) + + buf.WriteString(indentStr) + fmt.Fprintf(buf, "%s: ", fieldName) + + switch val := value.(type) { + case map[string]any: + structType := stripSuffix(parentStructName) + sx.PascalCase(key) + "Config" + buf.WriteString(structType) + if err := g.generateStructInit(buf, structType, val, indent+1); err != nil { + return err + } + case []any: + if len(val) > 0 { + if _, ok := val[0].(map[string]any); ok { + g.writeArrayOfStructs(buf, val, indent+1) + } else { + g.writeValueWithIndent(buf, value, indent+1) + } + } else { + g.writeValueWithIndent(buf, value, indent+1) + } + case []map[string]any: + g.writeArrayOfStructs(buf, val, indent+1) + default: + g.writeValueWithIndent(buf, value, indent+1) + } + + buf.WriteString(",\n") + } + + buf.WriteString(strings.Repeat("\t", indent)) + buf.WriteString("}") + return nil +} + +// writeArrayOfTablesInit writes an array initialization for top-level tables, +// specifically handling TOML's [[array.of.tables]] syntax. This generates code +// for slices of structs where each element is initialized with its fields. +// +// The function handles both []any and []map[string]any types from the TOML parser, +// generating struct literals with proper indentation. Each element is initialized +// by calling generateStructInit for the nested structure. +// +// The type name is omitted from each element to comply with gofmt -s simplification rules. +// +// Example output: +// +// []ServerItem{ +// {Host: "localhost", Port: 8080}, +// {Host: "example.com", Port: 443}, +// } +func (g *Generator) writeArrayOfTablesInit(buf *bytes.Buffer, structName string, arr any, indent int) error { + buf.WriteString("{\n") + indentStr := strings.Repeat("\t", indent+1) + + switch val := arr.(type) { + case []any: + for _, item := range val { + if m, ok := item.(map[string]any); ok { + buf.WriteString(indentStr) + // Omit type name for gofmt -s compliance + if err := g.generateStructInit(buf, structName, m, indent+1); err != nil { + return err + } + buf.WriteString(",\n") + } + } + case []map[string]any: + for _, m := range val { + buf.WriteString(indentStr) + // Omit type name for gofmt -s compliance + if err := g.generateStructInit(buf, structName, m, indent+1); err != nil { + return err + } + buf.WriteString(",\n") + } + } + + buf.WriteString(strings.Repeat("\t", indent)) + buf.WriteString("}") + return nil +} + +// writeArrayOfStructs writes an array of struct initializations using compact inline +// syntax. Unlike writeArrayOfTablesInit, this generates inline struct literals without +// the type name prefix, making it more suitable for deeply nested structures. +// +// Example output: +// +// { +// {Host: "localhost", Port: 8080}, +// {Host: "example.com", Port: 443}, +// } +// +// Fields within each struct are written in sorted order and separated by commas on a +// single line. This function handles both []any and []map[string]any input types. +func (g *Generator) writeArrayOfStructs(buf *bytes.Buffer, arr any, indent int) { + buf.WriteString("{\n") + indentStr := strings.Repeat("\t", indent+1) + + switch val := arr.(type) { + case []any: + for _, item := range val { + if m, ok := item.(map[string]any); ok { + buf.WriteString(indentStr) + buf.WriteString("{") + // Inline struct fields + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + + for i, k := range keys { + if i > 0 { + buf.WriteString(", ") + } + buf.WriteString(sx.PascalCase(k)) + buf.WriteString(": ") + g.writeValue(buf, m[k]) + } + buf.WriteString("},\n") + } + } + case []map[string]any: + for _, m := range val { + buf.WriteString(indentStr) + buf.WriteString("{") + // Inline struct fields + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + + for i, k := range keys { + if i > 0 { + buf.WriteString(", ") + } + buf.WriteString(sx.PascalCase(k)) + buf.WriteString(": ") + g.writeValue(buf, m[k]) + } + buf.WriteString("},\n") + } + } + + buf.WriteString(strings.Repeat("\t", indent)) + buf.WriteString("}") +} diff --git a/internal/generator/struct_gen_test.go b/internal/generator/struct_gen_test.go new file mode 100644 index 0000000..c58ec59 --- /dev/null +++ b/internal/generator/struct_gen_test.go @@ -0,0 +1,121 @@ +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGenerator_Types(t *testing.T) { + tests := []struct { + name string + toml string + want []string + }{ + { + name: "string type", + toml: `[config] +value = "hello"`, + want: []string{"Value", "string", `Value: "hello"`}, + }, + { + name: "int type", + toml: `[config] +value = 42`, + want: []string{"Value", "int64", "Value: 42"}, + }, + { + name: "float type", + toml: `[config] +value = 3.14`, + want: []string{"Value", "float64", "Value: 3.14"}, + }, + { + name: "bool type", + toml: `[config] +value = true`, + want: []string{"Value", "bool", "Value: true"}, + }, + { + name: "string array", + toml: `[config] +values = ["a", "b", "c"]`, + want: []string{"Values", "[]string", `[]string{"a", "b", "c"}`}, + }, + { + name: "int array", + toml: `[config] +values = [1, 2, 3]`, + want: []string{"Values", "[]int64", "[]int64{1, 2, 3}"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gen := New() + output, err := gen.Generate([]byte(tt.toml)) + require.NoError(t, err, "Generate() should not error") + + outputStr := string(output) + for _, want := range tt.want { + require.Contains(t, outputStr, want, "output missing expected string") + } + }) + } +} + +func TestGenerator_NestedStructs(t *testing.T) { + toml := `[database.pool] +max_connections = 10 +min_connections = 5` + + gen := New() + output, err := gen.Generate([]byte(toml)) + require.NoError(t, err, "Generate() should not error") + + outputStr := string(output) + + // Check for nested struct types + require.Contains(t, outputStr, "type DatabaseConfig struct", "missing parent struct") + require.Contains(t, outputStr, "type DatabasePoolConfig struct", "missing nested struct") + require.Contains(t, outputStr, "Pool DatabasePoolConfig", "missing field reference") +} + +func TestGenerator_ArrayOfTables(t *testing.T) { + toml := `[[servers]] +name = "web1" +port = 8080 + +[[servers]] +name = "web2" +port = 8081` + + gen := New() + output, err := gen.Generate([]byte(toml)) + require.NoError(t, err, "Generate() should not error") + + outputStr := string(output) + + // Check for array of tables struct + require.Contains(t, outputStr, "type ServersItem struct", "missing array item struct") + require.Contains(t, outputStr, "Servers = []ServersItem", "missing array variable") + require.Contains(t, outputStr, `Name: "web1"`, "missing first item") + require.Contains(t, outputStr, `Name: "web2"`, "missing second item") +} + +func TestGenerator_DeeplyNestedStructs(t *testing.T) { + toml := `[app.logging.rotation] +enabled = true +max_size = 100` + + gen := New() + output, err := gen.Generate([]byte(toml)) + require.NoError(t, err, "Generate() should not error") + + outputStr := string(output) + + // Check for deeply nested structs + require.Contains(t, outputStr, "type AppConfig struct", "missing top-level struct") + require.Contains(t, outputStr, "type AppLoggingConfig struct", "missing mid-level struct") + require.Contains(t, outputStr, "type AppLoggingRotationConfig struct", "missing deep struct") +} diff --git a/internal/generator/validation.go b/internal/generator/validation.go new file mode 100644 index 0000000..2d1cf8f --- /dev/null +++ b/internal/generator/validation.go @@ -0,0 +1,85 @@ +package generator + +import ( + "slices" + "time" +) + +// validateFileReferences recursively validates all file: references in the data. +// This ensures all referenced files exist and don't exceed size limits before generation. +func (g *Generator) validateFileReferences(data map[string]any) error { + for _, v := range data { + if err := g.validateFileReferencesValue(v); err != nil { + return err + } + } + return nil +} + +// validateFileReferencesValue validates file references in a single value. +func (g *Generator) validateFileReferencesValue(v any) error { + switch val := v.(type) { + case string: + if g.isFileReference(val) { + // Try to load the file to validate it exists and size is OK + _, err := g.loadFileContent(val) + if err != nil { + return err + } + } + case map[string]any: + return g.validateFileReferences(val) + case []any: + for _, item := range val { + if err := g.validateFileReferencesValue(item); err != nil { + return err + } + } + case []map[string]any: + for _, m := range val { + if err := g.validateFileReferences(m); err != nil { + return err + } + } + } + return nil +} + +// needsTimeImport checks if any value in the data map is a duration string, +// recursively traversing nested maps and arrays to determine if the generated +// code needs to import the "time" package. +func (g *Generator) needsTimeImport(data map[string]any) bool { + for _, v := range data { + if g.needsTimeImportValue(v) { + return true + } + } + return false +} + +func (g *Generator) needsTimeImportValue(v any) bool { + switch val := v.(type) { + case string: + // Check if string is a valid duration + if g.isDurationString(val) { + return true + } + case map[string]any: + return g.needsTimeImport(val) + case []any: + if slices.ContainsFunc(val, g.needsTimeImportValue) { + return true + } + case []map[string]any: + if slices.ContainsFunc(val, g.needsTimeImport) { + return true + } + } + return false +} + +// isDurationString checks if a string can be parsed as a time.Duration. +func (g *Generator) isDurationString(s string) bool { + _, err := time.ParseDuration(s) + return err == nil +} diff --git a/internal/generator/validation_test.go b/internal/generator/validation_test.go new file mode 100644 index 0000000..13fe6fa --- /dev/null +++ b/internal/generator/validation_test.go @@ -0,0 +1,122 @@ +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGenerator_needsTimeImport(t *testing.T) { + tests := []struct { + name string + data map[string]any + want bool + }{ + { + name: "simple duration string", + data: map[string]any{"timeout": "30s"}, + want: true, + }, + { + name: "nested duration string", + data: map[string]any{ + "config": map[string]any{ + "timeout": "5m", + }, + }, + want: true, + }, + { + name: "no duration", + data: map[string]any{"value": "hello"}, + want: false, + }, + { + name: "duration in array", + data: map[string]any{"timeouts": []any{"30s", "1m"}}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := New() + got := g.needsTimeImport(tt.data) + require.Equal(t, tt.want, got) + }) + } +} + +func TestGenerator_isDurationString(t *testing.T) { + tests := []struct { + name string + s string + want bool + }{ + {"valid seconds", "30s", true}, + {"valid minutes", "5m", true}, + {"valid hours", "2h", true}, + {"valid complex", "2h30m", true}, + {"invalid string", "hello", false}, + {"invalid format", "30", false}, + {"empty string", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := New() + got := g.isDurationString(tt.s) + require.Equal(t, tt.want, got) + }) + } +} + +func TestGenerator_validateFileReferences(t *testing.T) { + tests := []struct { + name string + data map[string]any + inputDir string + wantError bool + }{ + { + name: "valid file reference", + data: map[string]any{"content": "file:files/small.txt"}, + inputDir: "../../testdata", + wantError: false, + }, + { + name: "missing file", + data: map[string]any{"content": "file:files/nonexistent.txt"}, + inputDir: "../../testdata", + wantError: true, + }, + { + name: "no file references", + data: map[string]any{"value": "hello"}, + inputDir: "", + wantError: false, + }, + { + name: "nested file reference", + data: map[string]any{ + "config": map[string]any{ + "cert": "file:files/cert.txt", + }, + }, + inputDir: "../../testdata", + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := New(WithInputDir(tt.inputDir)) + err := g.validateFileReferences(tt.data) + if tt.wantError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/internal/generator/value_writer.go b/internal/generator/value_writer.go new file mode 100644 index 0000000..143ed37 --- /dev/null +++ b/internal/generator/value_writer.go @@ -0,0 +1,214 @@ +package generator + +import ( + "bytes" + "fmt" + "strings" + "time" +) + +// toGoType converts a value to its Go type string representation. This function +// inspects the runtime type of a value and returns the corresponding Go type as a string. +// +// For primitive types (string, int64, float64, bool), it returns the standard type name. +// For slices, it recursively determines the element type. For maps and []map[string]any, +// it returns placeholder strings ("struct", "[]struct") that will be replaced with actual +// struct type names in context by the calling code. +func (g *Generator) toGoType(v any) string { + switch val := v.(type) { + case string: + // Check if this is a file reference + if g.isFileReference(val) { + return "[]byte" + } + // Check if this is a duration string + if g.isDurationString(val) { + return "time.Duration" + } + return "string" + case int64: + return "int64" + case int: + return "int64" + case float64: + return "float64" + case bool: + return "bool" + case []any: + if len(val) > 0 { + elemType := g.toGoType(val[0]) + return "[]" + elemType + } + return "[]any" + case []map[string]any: + // This will be replaced with the actual struct type name in context + return "[]struct" + case map[string]any: + // This will be replaced with the actual struct type name in context + return "struct" + default: + return "any" + } +} + +// writeValue writes a Go value literal to the buffer. This function handles the +// serialization of various Go types into their source code representation. +// +// Strings are quoted, numbers are formatted appropriately, duration strings are +// parsed and written as duration literals, and arrays are handled recursively. +// This ensures the generated code is valid Go syntax that can be compiled directly. +// The indent parameter is used for proper formatting of multi-line values like byte arrays. +func (g *Generator) writeValue(buf *bytes.Buffer, v any) { + g.writeValueWithIndent(buf, v, 0) +} + +// writeValueWithIndent is the internal implementation of writeValue with indent support. +func (g *Generator) writeValueWithIndent(buf *bytes.Buffer, v any, indent int) { + switch val := v.(type) { + case string: + // Check if this is a file reference + if g.isFileReference(val) { + // File was already validated in validateFileReferences, so this should not fail + content, err := g.loadFileContent(val) + if err != nil { + // This should never happen if validation passed + fmt.Fprintf(buf, "[]byte{} /* unexpected error: %s */", err) + return + } + g.writeByteArrayLiteral(buf, content, indent) + return + } + // Check if this is a duration string + if g.isDurationString(val) { + g.writeDurationLiteral(buf, val) + } else { + fmt.Fprintf(buf, "%q", val) + } + case int64: + fmt.Fprintf(buf, "%d", val) + case int: + fmt.Fprintf(buf, "%d", val) + case float64: + fmt.Fprintf(buf, "%g", val) + case bool: + fmt.Fprintf(buf, "%t", val) + case []any: + g.writeArray(buf, val) + default: + buf.WriteString("nil") + } +} + +// writeByteArrayLiteral writes a byte array in idiomatic Go hex format. +// Format: []byte{0x2d, 0x2d, ...} with 12 bytes per line for readability. +// The indent parameter controls indentation level for proper formatting in nested contexts. +func (g *Generator) writeByteArrayLiteral(buf *bytes.Buffer, data []byte, indent int) { + if len(data) == 0 { + buf.WriteString("[]byte{}") + return + } + + buf.WriteString("[]byte{\n") + indentStr := strings.Repeat("\t", indent+1) + + // Write 12 bytes per line (each byte is "0xXX, " = 6 chars, 12*6 = 72 chars) + const bytesPerLine = 12 + for i := 0; i < len(data); i++ { + if i%bytesPerLine == 0 { + buf.WriteString(indentStr) + } + + fmt.Fprintf(buf, "0x%02x", data[i]) + + if i < len(data)-1 { + buf.WriteString(", ") + } + + if i%bytesPerLine == bytesPerLine-1 && i < len(data)-1 { + buf.WriteString("\n") + } + } + + buf.WriteString(",\n") + buf.WriteString(strings.Repeat("\t", indent)) + buf.WriteString("}") +} + +// writeDurationLiteral parses a duration string at generation time and writes +// it as a duration literal in a human-readable format using time constants. +// Complex durations like '2h30m' are decomposed into multiple time constants +// (e.g., 2*time.Hour + 30*time.Minute) for better readability. +func (g *Generator) writeDurationLiteral(buf *bytes.Buffer, s string) { + d, err := time.ParseDuration(s) + if err != nil { + // This should never happen since isDurationString already validated it + fmt.Fprintf(buf, "time.Duration(0) /* invalid: %s */", s) + return + } + + if d == 0 { + buf.WriteString("0") + return + } + + // Decompose duration into components from largest to smallest + components := []struct { + unit time.Duration + name string + }{ + {time.Hour, "time.Hour"}, + {time.Minute, "time.Minute"}, + {time.Second, "time.Second"}, + {time.Millisecond, "time.Millisecond"}, + {time.Microsecond, "time.Microsecond"}, + {time.Nanosecond, "time.Nanosecond"}, + } + + remaining := d + parts := []string{} + + for _, comp := range components { + if remaining >= comp.unit { + count := remaining / comp.unit + if count > 0 { + parts = append(parts, fmt.Sprintf("%d*%s", count, comp.name)) + remaining = remaining % comp.unit + } + } + } + + if len(parts) == 0 { + // Should not happen for non-zero durations, but handle it + buf.WriteString("0") + return + } + + // Join parts with " + " + // Note: gofmt will add spaces around * for simple expressions (e.g., "30 * time.Second") + // but keep them compact in complex expressions (e.g., "2*time.Hour + 30*time.Minute") + buf.WriteString(strings.Join(parts, " + ")) +} + +// writeArray writes an array literal in Go slice syntax. The function infers the +// element type from the first element and generates a typed slice literal. +// +// Empty arrays are written as "nil". Non-empty arrays are written in the format: +// []Type{elem1, elem2, ...} with elements separated by commas and spaces. +func (g *Generator) writeArray(buf *bytes.Buffer, arr []any) { + if len(arr) == 0 { + buf.WriteString("nil") + return + } + + elemType := g.toGoType(arr[0]) + fmt.Fprintf(buf, "[]%s{", elemType) + + for i, item := range arr { + if i > 0 { + fmt.Fprintf(buf, ", ") + } + g.writeValue(buf, item) + } + + buf.WriteString("}") +} diff --git a/internal/generator/value_writer_test.go b/internal/generator/value_writer_test.go new file mode 100644 index 0000000..5e537fa --- /dev/null +++ b/internal/generator/value_writer_test.go @@ -0,0 +1,144 @@ +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGenerator_DurationTypes(t *testing.T) { + tests := []struct { + name string + toml string + want []string + }{ + { + name: "simple duration - seconds", + toml: `[config] +timeout = "30s"`, + want: []string{"Timeout", "time.Duration", "30 * time.Second", "import \"time\""}, + }, + { + name: "simple duration - milliseconds", + toml: `[config] +timeout = "500ms"`, + want: []string{"Timeout", "time.Duration", "500 * time.Millisecond", "import \"time\""}, + }, + { + name: "simple duration - minutes", + toml: `[config] +timeout = "5m"`, + want: []string{"Timeout", "time.Duration", "5 * time.Minute", "import \"time\""}, + }, + { + name: "simple duration - hours", + toml: `[config] +timeout = "2h"`, + want: []string{"Timeout", "time.Duration", "2 * time.Hour", "import \"time\""}, + }, + { + name: "zero duration", + toml: `[config] +timeout = "0s"`, + want: []string{"Timeout", "time.Duration", "Timeout: 0", "import \"time\""}, + }, + { + name: "complex duration - hours and minutes", + toml: `[config] +timeout = "2h30m"`, + want: []string{"Timeout", "time.Duration", "2*time.Hour + 30*time.Minute", "import \"time\""}, + }, + { + name: "complex duration - minutes and seconds", + toml: `[config] +timeout = "5m30s"`, + want: []string{"Timeout", "time.Duration", "5*time.Minute + 30*time.Second", "import \"time\""}, + }, + { + name: "complex duration - hours, minutes and seconds", + toml: `[config] +timeout = "1h30m45s"`, + want: []string{"Timeout", "time.Duration", "1*time.Hour + 30*time.Minute + 45*time.Second", "import \"time\""}, + }, + { + name: "complex duration - seconds and milliseconds", + toml: `[config] +timeout = "3s500ms"`, + want: []string{"Timeout", "time.Duration", "3*time.Second + 500*time.Millisecond", "import \"time\""}, + }, + { + name: "complex duration - full decomposition", + toml: `[config] +timeout = "1h2m3s4ms5us6ns"`, + want: []string{"Timeout", "time.Duration", "1*time.Hour + 2*time.Minute + 3*time.Second + 4*time.Millisecond + 5*time.Microsecond + 6*time.Nanosecond", "import \"time\""}, + }, + { + name: "multiple durations with different formats", + toml: `[config] +short = "500ms" +medium = "5m" +long = "2h" +complex = "1h30m"`, + want: []string{ + "Short", "Medium", "Long", "Complex", + "time.Duration", + "500 * time.Millisecond", + "5 * time.Minute", + "2 * time.Hour", + "1*time.Hour + 30*time.Minute", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gen := New() + output, err := gen.Generate([]byte(tt.toml)) + require.NoError(t, err, "Generate() should not error") + + outputStr := string(output) + for _, want := range tt.want { + require.Contains(t, outputStr, want, "output missing expected string: %s", want) + } + }) + } +} + +func TestGenerator_toGoType(t *testing.T) { + tests := []struct { + name string + value any + want string + }{ + {"string type", "hello", "string"}, + {"int64 type", int64(42), "int64"}, + {"int type", 42, "int64"}, + {"float64 type", 3.14, "float64"}, + {"bool type", true, "bool"}, + {"string array", []any{"a", "b"}, "[]string"}, + {"int array", []any{int64(1), int64(2)}, "[]int64"}, + {"empty array", []any{}, "[]any"}, + {"map type", map[string]any{"key": "value"}, "struct"}, + {"map array", []map[string]any{{"key": "value"}}, "[]struct"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := New() + got := g.toGoType(tt.value) + require.Equal(t, tt.want, got) + }) + } +} + +func TestGenerator_toGoType_Duration(t *testing.T) { + g := New() + got := g.toGoType("30s") + require.Equal(t, "time.Duration", got) +} + +func TestGenerator_toGoType_FileReference(t *testing.T) { + g := New() + got := g.toGoType("file:test.txt") + require.Equal(t, "[]byte", got) +} diff --git a/readme.md b/readme.md index aadb7df..c1164c6 100644 --- a/readme.md +++ b/readme.md @@ -90,10 +90,12 @@ cfgx generate --in worker.toml --out config/worker.go cfgx generate [flags] 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 + -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 + --max-file-size Maximum file size for file: references (default "1MB") + Supports: KB, MB, GB (e.g., "5MB", "1GB", "512KB") ``` ## Features @@ -167,10 +169,68 @@ The generated code will contain the overridden values. Useful for CI/CD pipeline Use `--no-env` to disable this feature. +### File Embedding + +Embed file contents directly into your generated code using the `file:` prefix: + +```toml +[server] +addr = ":8080" +tls_cert = "file:certs/server.crt" +tls_key = "file:certs/server.key" + +[app] +logo = "file:assets/logo.png" +sql_schema = "file:migrations/schema.sql" +``` + +Generates: + +```go +type ServerConfig struct { + Addr string + TlsCert []byte // Embedded certificate bytes + TlsKey []byte // Embedded key bytes +} + +var Server = ServerConfig{ + Addr: ":8080", + TlsCert: []byte{ + 0x2d, 0x2d, 0x2d, 0x2d, 0x2d, 0x42, 0x45, 0x47, 0x49, 0x4e, 0x20, 0x43, + // ... actual cert bytes ... + }, + TlsKey: []byte{ /* ... key bytes ... */ }, +} +``` + +**Key features:** + +- **Paths are relative** to the TOML file location +- **Files are read at generation time** - no runtime I/O +- **Self-contained binaries** - no need to distribute separate files +- **Size limits** - defaults to 1MB, configurable via `--max-file-size` +- **Any file type** - text, json, binary, certificates, images, etc. + +**Example usage:** + +```bash +cfgx generate --in config.toml --out config/config.go --max-file-size 5MB +``` + +**Use cases:** + +- TLS certificates and keys +- SQL migration schemas +- Template files +- Small assets (logos, icons) +- Configuration snippets +- Test fixtures + ## Supported Types - **Primitives:** `string`, `int64`, `float64`, `bool` - **Duration:** `time.Duration` (auto-detected from Go duration strings: `"30s"`, `"5m"`, `"2h30m"`) +- **File content:** `[]byte` (use `"file:path/to/file"` prefix) - **Arrays:** Arrays of any supported type - **Nested tables:** Becomes nested structs - **Array of tables:** `[]StructType` diff --git a/testdata/files/binary.dat b/testdata/files/binary.dat new file mode 100644 index 0000000..bcc2200 Binary files /dev/null and b/testdata/files/binary.dat differ diff --git a/testdata/files/cert.txt b/testdata/files/cert.txt new file mode 100644 index 0000000..e71672f --- /dev/null +++ b/testdata/files/cert.txt @@ -0,0 +1,8 @@ +-----BEGIN CERTIFICATE----- +MIIDXTCCAkWgAwIBAgIJAKL0UG+mRKSzMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTcwODIzMTUxNTEyWhcNMjcwODIxMTUxNTEyWjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEAzPJn6NCMmNGpRhZKWXA6dGzpF3BO8cG1YT/cSLUJuPKiVmHYxYQz8xQW +-----END CERTIFICATE----- diff --git a/testdata/files/small.txt b/testdata/files/small.txt new file mode 100644 index 0000000..7775b60 --- /dev/null +++ b/testdata/files/small.txt @@ -0,0 +1,5 @@ +Hello, World! +This is a small test file. +It contains just a few lines of text. +Perfect for testing file embedding. +