From 9179736c90baa07295d0aef32a61f8cf472cfe0b Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Wed, 25 Feb 2026 21:52:57 +1100 Subject: [PATCH] updated ezconf --- ezconf/doc.go | 43 +++++---- ezconf/ezconf.go | 73 +++++++-------- ezconf/ezconf_test.go | 135 +++++++++++++++------------- ezconf/integration.go | 15 ++-- ezconf/integration_test.go | 152 +++++++++++++++---------------- ezconf/parser.go | 178 ++++++++++++++----------------------- ezconf/parser_test.go | 171 +++++++++++++++++++---------------- 7 files changed, 380 insertions(+), 387 deletions(-) diff --git a/ezconf/doc.go b/ezconf/doc.go index 50cfefb..bec196a 100644 --- a/ezconf/doc.go +++ b/ezconf/doc.go @@ -3,7 +3,7 @@ // // ezconf allows you to: // - Load configurations from multiple packages using their ConfigFromEnv functions -// - Parse package source code to extract environment variable documentation +// - Parse config struct tags to extract environment variable documentation // - Generate and update .env files with all required environment variables // - Print environment variable lists with descriptions and current values // - Track additional custom environment variables @@ -40,16 +40,16 @@ // // Use configuration... // } // -// Alternatively, you can manually register packages: +// Alternatively, you can manually register config structs: // // loader := ezconf.New() // -// // Add package paths to parse for ENV comments -// loader.AddPackagePath("/path/to/golib/hlog") +// // Add config struct for tag parsing +// loader.AddConfigStruct(&mypackage.Config{}, "MyPackage") // // // Add configuration loaders -// loader.AddConfigFunc("hlog", func() (interface{}, error) { -// return hlog.ConfigFromEnv() +// loader.AddConfigFunc("mypackage", func() (interface{}, error) { +// return mypackage.ConfigFromEnv() // }) // // loader.Load() @@ -94,27 +94,34 @@ // Default: "postgres://localhost/mydb", // }) // -// # ENV Comment Format +// # Struct Tag Format // -// ezconf parses struct field comments in the following format: +// ezconf uses struct tags to define environment variable metadata: // // type Config struct { -// // ENV LOG_LEVEL: Log level for the application (default: info) -// LogLevel string -// -// // ENV DATABASE_URL: Database connection string (required) -// DatabaseURL string +// LogLevel string `ezconf:"LOG_LEVEL,description:Log level for the application,default:info"` +// DatabaseURL string `ezconf:"DATABASE_URL,description:Database connection string,required"` +// LogDir string `ezconf:"LOG_DIR,description:Directory for log files,required:when LOG_OUTPUT is file"` // } // -// The format is: -// - ENV ENV_VAR_NAME: Description (optional modifiers) -// - (required) or (required if condition) - marks variable as required -// - (default: value) - specifies default value +// Tag components (comma-separated): +// - First value: environment variable name (required) +// - description:...: Description of the variable +// - default:...: Default value +// - required: Marks the variable as required +// - required:condition: Marks as required with a condition description // // # Integration // +// Packages can implement the Integration interface to provide automatic +// registration with ezconf. The interface requires: +// - Name() string: Registration key for the config +// - ConfigPointer() any: Pointer to config struct for tag parsing +// - ConfigFunc() func() (any, error): Function to load config from env +// - GroupName() string: Display name for grouping env vars +// // ezconf integrates with: // - All golib packages that follow the ConfigFromEnv pattern -// - Any custom configuration structs with ENV comments +// - Any custom configuration structs with ezconf struct tags // - Standard .env file format package ezconf diff --git a/ezconf/ezconf.go b/ezconf/ezconf.go index a417f5e..3d78fec 100644 --- a/ezconf/ezconf.go +++ b/ezconf/ezconf.go @@ -16,14 +16,19 @@ type EnvVar struct { Group string // Group name for organizing variables (e.g., "Database", "Logging") } +// configStruct holds a config struct pointer and its group name for parsing +type configStruct struct { + configPtr any + groupName string +} + // ConfigLoader manages configuration loading from multiple sources type ConfigLoader struct { - configFuncs map[string]ConfigFunc // Map of config names to ConfigFromEnv functions - packagePaths []string // Paths to packages to parse for ENV comments - groupNames map[string]string // Map of package paths to group names - extraEnvVars []EnvVar // Additional environment variables to track - envVars []EnvVar // All extracted environment variables - configs map[string]any // Loaded configurations + configFuncs map[string]ConfigFunc // Map of config names to ConfigFromEnv functions + configStructs []configStruct // Config struct pointers for tag parsing + extraEnvVars []EnvVar // Additional environment variables to track + envVars []EnvVar // All extracted environment variables + configs map[string]any // Loaded configurations } // ConfigFunc is a function that loads configuration from environment variables @@ -32,12 +37,11 @@ type ConfigFunc func() (any, error) // New creates a new ConfigLoader func New() *ConfigLoader { return &ConfigLoader{ - configFuncs: make(map[string]ConfigFunc), - packagePaths: make([]string, 0), - groupNames: make(map[string]string), - extraEnvVars: make([]EnvVar, 0), - envVars: make([]EnvVar, 0), - configs: make(map[string]any), + configFuncs: make(map[string]ConfigFunc), + configStructs: make([]configStruct, 0), + extraEnvVars: make([]EnvVar, 0), + envVars: make([]EnvVar, 0), + configs: make(map[string]any), } } @@ -54,16 +58,20 @@ func (cl *ConfigLoader) AddConfigFunc(name string, fn ConfigFunc) error { return nil } -// AddPackagePath adds a package directory path to parse for ENV comments -func (cl *ConfigLoader) AddPackagePath(path string) error { - if path == "" { - return errors.New("package path cannot be empty") +// AddConfigStruct adds a config struct pointer for parsing ezconf tags. +// The configPtr must be a pointer to a struct with ezconf struct tags. +// The groupName is used for organizing environment variables in output. +func (cl *ConfigLoader) AddConfigStruct(configPtr any, groupName string) error { + if configPtr == nil { + return errors.New("config pointer cannot be nil") } - // Check if path exists - if _, err := os.Stat(path); os.IsNotExist(err) { - return errors.Errorf("package path does not exist: %s", path) + if groupName == "" { + groupName = "Other" } - cl.packagePaths = append(cl.packagePaths, path) + cl.configStructs = append(cl.configStructs, configStruct{ + configPtr: configPtr, + groupName: groupName, + }) return nil } @@ -72,27 +80,22 @@ func (cl *ConfigLoader) AddEnvVar(envVar EnvVar) { cl.extraEnvVars = append(cl.extraEnvVars, envVar) } -// ParseEnvVars extracts environment variables from packages and extra vars -// This can be called without having actual environment variables set +// ParseEnvVars extracts environment variables from config struct tags and extra vars. +// This can be called without having actual environment variables set. func (cl *ConfigLoader) ParseEnvVars() error { // Clear existing env vars to prevent duplicates cl.envVars = make([]EnvVar, 0) - // Parse packages for ENV comments - for _, pkgPath := range cl.packagePaths { - envVars, err := ParseConfigPackage(pkgPath) + // Parse config structs for ezconf tags + for _, cs := range cl.configStructs { + envVars, err := ParseConfigStruct(cs.configPtr) if err != nil { - return errors.Wrapf(err, "failed to parse package: %s", pkgPath) - } - - // Set group name for these variables from stored mapping - groupName := cl.groupNames[pkgPath] - if groupName == "" { - groupName = "Other" + return errors.Wrap(err, "failed to parse config struct") } + // Set group name for these variables for i := range envVars { - envVars[i].Group = groupName + envVars[i].Group = cs.groupName } cl.envVars = append(cl.envVars, envVars...) @@ -109,8 +112,8 @@ func (cl *ConfigLoader) ParseEnvVars() error { return nil } -// LoadConfigs executes the config functions to load actual configurations -// This should be called after environment variables are properly set +// LoadConfigs executes the config functions to load actual configurations. +// This should be called after environment variables are properly set. func (cl *ConfigLoader) LoadConfigs() error { // Load configurations for name, fn := range cl.configFuncs { diff --git a/ezconf/ezconf_test.go b/ezconf/ezconf_test.go index fbb127d..ce8296e 100644 --- a/ezconf/ezconf_test.go +++ b/ezconf/ezconf_test.go @@ -2,11 +2,17 @@ package ezconf import ( "os" - "path/filepath" "strings" "testing" ) +// testConfig is a Config struct used by multiple tests +type testConfig struct { + LogLevel string `ezconf:"LOG_LEVEL,description:Log level for the application,default:info"` + LogOutput string `ezconf:"LOG_OUTPUT,description:Output destination,default:console"` + DatabaseURL string `ezconf:"DATABASE_URL,description:Database connection string,required"` +} + func TestNew(t *testing.T) { loader := New() if loader == nil { @@ -16,8 +22,8 @@ func TestNew(t *testing.T) { if loader.configFuncs == nil { t.Error("configFuncs map is nil") } - if loader.packagePaths == nil { - t.Error("packagePaths slice is nil") + if loader.configStructs == nil { + t.Error("configStructs slice is nil") } if loader.extraEnvVars == nil { t.Error("extraEnvVars slice is nil") @@ -66,35 +72,39 @@ func TestAddConfigFunc_EmptyName(t *testing.T) { } } -func TestAddPackagePath(t *testing.T) { +func TestAddConfigStruct(t *testing.T) { loader := New() - // Use current directory as test path - err := loader.AddPackagePath(".") + err := loader.AddConfigStruct(&testConfig{}, "Test") if err != nil { - t.Errorf("AddPackagePath failed: %v", err) + t.Errorf("AddConfigStruct failed: %v", err) } - if len(loader.packagePaths) != 1 { - t.Errorf("expected 1 package path, got %d", len(loader.packagePaths)) + if len(loader.configStructs) != 1 { + t.Errorf("expected 1 config struct, got %d", len(loader.configStructs)) } } -func TestAddPackagePath_InvalidPath(t *testing.T) { +func TestAddConfigStruct_NilPointer(t *testing.T) { loader := New() - err := loader.AddPackagePath("/nonexistent/path") + err := loader.AddConfigStruct(nil, "Test") if err == nil { - t.Error("expected error for nonexistent path") + t.Error("expected error for nil pointer") } } -func TestAddPackagePath_EmptyPath(t *testing.T) { +func TestAddConfigStruct_EmptyGroupName(t *testing.T) { loader := New() - err := loader.AddPackagePath("") - if err == nil { - t.Error("expected error for empty path") + err := loader.AddConfigStruct(&testConfig{}, "") + if err != nil { + t.Errorf("AddConfigStruct failed: %v", err) + } + + // Should default to "Other" + if loader.configStructs[0].groupName != "Other" { + t.Errorf("expected group name 'Other', got %s", loader.configStructs[0].groupName) } } @@ -131,8 +141,8 @@ func TestLoad(t *testing.T) { return testCfg, nil }) - // Add current package path - loader.AddPackagePath(".") + // Add config struct for tag parsing + loader.AddConfigStruct(&testConfig{}, "Test") // Add an extra env var loader.AddEnvVar(EnvVar{ @@ -249,8 +259,8 @@ func TestParseEnvVars(t *testing.T) { return "test config", nil }) - // Add current package path - loader.AddPackagePath(".") + // Add config struct for tag parsing + loader.AddConfigStruct(&testConfig{}, "Test") // Add an extra env var loader.AddEnvVar(EnvVar{ @@ -353,8 +363,8 @@ func TestParseEnvVars_Then_LoadConfigs(t *testing.T) { return testCfg, nil }) - // Add current package path - loader.AddPackagePath(".") + // Add config struct for tag parsing + loader.AddConfigStruct(&testConfig{}, "Test") // Add an extra env var loader.AddEnvVar(EnvVar{ @@ -398,63 +408,68 @@ func TestParseEnvVars_Then_LoadConfigs(t *testing.T) { } } -func TestLoad_Integration(t *testing.T) { - // Integration test with real hlog package - hlogPath := filepath.Join("..", "hlog") - if _, err := os.Stat(hlogPath); os.IsNotExist(err) { - t.Skip("hlog package not found, skipping integration test") - } - +func TestParseEnvVars_GroupName(t *testing.T) { loader := New() - // Add hlog package - if err := loader.AddPackagePath(hlogPath); err != nil { - t.Fatalf("failed to add hlog package: %v", err) - } + loader.AddConfigStruct(&testConfig{}, "MyGroup") - // Load without config function (just parse) - if err := loader.Load(); err != nil { - t.Fatalf("Load failed: %v", err) + err := loader.ParseEnvVars() + if err != nil { + t.Fatalf("ParseEnvVars failed: %v", err) } envVars := loader.GetEnvVars() - if len(envVars) == 0 { - t.Error("expected env vars from hlog package") - } - - t.Logf("Found %d environment variables from hlog", len(envVars)) for _, ev := range envVars { - t.Logf(" %s: %s (default: %s, required: %t)", ev.Name, ev.Description, ev.Default, ev.Required) + if ev.Group != "MyGroup" { + t.Errorf("expected group 'MyGroup', got '%s' for var %s", ev.Group, ev.Name) + } } } -func TestParseEnvVars_GenerateEnvFile_Integration(t *testing.T) { - // Test the new separated ParseEnvVars functionality - hlogPath := filepath.Join("..", "hlog") - if _, err := os.Stat(hlogPath); os.IsNotExist(err) { - t.Skip("hlog package not found, skipping integration test") - } - +func TestParseEnvVars_CurrentValues(t *testing.T) { loader := New() - // Add hlog package - if err := loader.AddPackagePath(hlogPath); err != nil { - t.Fatalf("failed to add hlog package: %v", err) + loader.AddConfigStruct(&testConfig{}, "Test") + + // Set an env var + t.Setenv("LOG_LEVEL", "debug") + + err := loader.ParseEnvVars() + if err != nil { + t.Fatalf("ParseEnvVars failed: %v", err) } - // Parse env vars without loading configs (this should work even if required env vars are missing) + envVars := loader.GetEnvVars() + for _, ev := range envVars { + if ev.Name == "LOG_LEVEL" { + if ev.CurrentValue != "debug" { + t.Errorf("expected CurrentValue 'debug', got '%s'", ev.CurrentValue) + } + return + } + } + t.Error("LOG_LEVEL not found in env vars") +} + +func TestParseEnvVars_GenerateEnvFile_Integration(t *testing.T) { + loader := New() + + // Add config struct for tag parsing + loader.AddConfigStruct(&testConfig{}, "Test") + + // Parse env vars if err := loader.ParseEnvVars(); err != nil { t.Fatalf("ParseEnvVars failed: %v", err) } envVars := loader.GetEnvVars() if len(envVars) == 0 { - t.Error("expected env vars from hlog package") + t.Error("expected env vars from config struct") } // Now test that we can generate an env file without calling Load() tempDir := t.TempDir() - envFile := filepath.Join(tempDir, "test-generated.env") + envFile := tempDir + "/test-generated.env" err := loader.GenerateEnvFile(envFile, false) if err != nil { @@ -472,16 +487,16 @@ func TestParseEnvVars_GenerateEnvFile_Integration(t *testing.T) { t.Error("expected header in generated file") } - // Should contain environment variables from hlog - foundHlogVar := false + // Should contain environment variables from config struct + foundVar := false for _, ev := range envVars { if strings.Contains(output, ev.Name) { - foundHlogVar = true + foundVar = true break } } - if !foundHlogVar { - t.Error("expected to find at least one hlog environment variable in generated file") + if !foundVar { + t.Error("expected to find at least one environment variable in generated file") } t.Logf("Successfully generated env file with %d variables", len(envVars)) diff --git a/ezconf/integration.go b/ezconf/integration.go index ba4fdf3..1f84cf9 100644 --- a/ezconf/integration.go +++ b/ezconf/integration.go @@ -6,11 +6,11 @@ type Integration interface { // Name returns the name to use when registering the config Name() string - // PackagePath returns the path to the package for source parsing - PackagePath() string + // ConfigPointer returns a pointer to the config struct for tag parsing + ConfigPointer() any // ConfigFunc returns the ConfigFromEnv function - ConfigFunc() func() (interface{}, error) + ConfigFunc() func() (any, error) // GroupName returns the display name for grouping environment variables GroupName() string @@ -18,15 +18,12 @@ type Integration interface { // RegisterIntegration registers a package that implements the Integration interface func (cl *ConfigLoader) RegisterIntegration(integration Integration) error { - // Add package path - pkgPath := integration.PackagePath() - if err := cl.AddPackagePath(pkgPath); err != nil { + // Add config struct for tag parsing + configPtr := integration.ConfigPointer() + if err := cl.AddConfigStruct(configPtr, integration.GroupName()); err != nil { return err } - // Store group name for this package - cl.groupNames[pkgPath] = integration.GroupName() - // Add config function if err := cl.AddConfigFunc(integration.Name(), integration.ConfigFunc()); err != nil { return err diff --git a/ezconf/integration_test.go b/ezconf/integration_test.go index af954ec..5407831 100644 --- a/ezconf/integration_test.go +++ b/ezconf/integration_test.go @@ -1,24 +1,34 @@ package ezconf import ( - "os" - "path/filepath" "testing" ) -// Mock integration for testing +// mockConfig is a test config struct with ezconf tags +type mockConfig struct { + Host string `ezconf:"MOCK_HOST,description:Host to connect to,default:localhost"` + Port int `ezconf:"MOCK_PORT,description:Port to connect to,default:8080"` +} + +// mockConfig2 is a second test config struct +type mockConfig2 struct { + Token string `ezconf:"MOCK_TOKEN,description:API token,required"` +} + +// mockIntegration implements the Integration interface for testing type mockIntegration struct { - name string - packagePath string - configFunc func() (interface{}, error) + name string + configPtr any + configFunc func() (interface{}, error) + groupName string } func (m mockIntegration) Name() string { return m.name } -func (m mockIntegration) PackagePath() string { - return m.packagePath +func (m mockIntegration) ConfigPointer() any { + return m.configPtr } func (m mockIntegration) ConfigFunc() func() (interface{}, error) { @@ -26,15 +36,18 @@ func (m mockIntegration) ConfigFunc() func() (interface{}, error) { } func (m mockIntegration) GroupName() string { - return "Test Group" + if m.groupName == "" { + return "Test Group" + } + return m.groupName } func TestRegisterIntegration(t *testing.T) { loader := New() integration := mockIntegration{ - name: "test", - packagePath: ".", + name: "test", + configPtr: &mockConfig{}, configFunc: func() (interface{}, error) { return "test config", nil }, @@ -45,9 +58,9 @@ func TestRegisterIntegration(t *testing.T) { t.Fatalf("RegisterIntegration failed: %v", err) } - // Verify package path was added - if len(loader.packagePaths) != 1 { - t.Errorf("expected 1 package path, got %d", len(loader.packagePaths)) + // Verify config struct was added + if len(loader.configStructs) != 1 { + t.Errorf("expected 1 config struct, got %d", len(loader.configStructs)) } // Verify config func was added @@ -68,14 +81,46 @@ func TestRegisterIntegration(t *testing.T) { if cfg != "test config" { t.Errorf("expected 'test config', got %v", cfg) } + + // Verify env vars were parsed from struct tags + envVars := loader.GetEnvVars() + if len(envVars) != 2 { + t.Errorf("expected 2 env vars, got %d", len(envVars)) + } + + foundHost := false + foundPort := false + for _, ev := range envVars { + if ev.Name == "MOCK_HOST" { + foundHost = true + if ev.Default != "localhost" { + t.Errorf("expected default 'localhost', got '%s'", ev.Default) + } + if ev.Group != "Test Group" { + t.Errorf("expected group 'Test Group', got '%s'", ev.Group) + } + } + if ev.Name == "MOCK_PORT" { + foundPort = true + if ev.Default != "8080" { + t.Errorf("expected default '8080', got '%s'", ev.Default) + } + } + } + if !foundHost { + t.Error("MOCK_HOST not found in env vars") + } + if !foundPort { + t.Error("MOCK_PORT not found in env vars") + } } -func TestRegisterIntegration_InvalidPath(t *testing.T) { +func TestRegisterIntegration_NilConfigPointer(t *testing.T) { loader := New() integration := mockIntegration{ - name: "test", - packagePath: "/nonexistent/path", + name: "test", + configPtr: nil, configFunc: func() (interface{}, error) { return "test config", nil }, @@ -83,7 +128,7 @@ func TestRegisterIntegration_InvalidPath(t *testing.T) { err := loader.RegisterIntegration(integration) if err == nil { - t.Error("expected error for invalid package path") + t.Error("expected error for nil config pointer") } } @@ -91,16 +136,16 @@ func TestRegisterIntegrations(t *testing.T) { loader := New() integration1 := mockIntegration{ - name: "test1", - packagePath: ".", + name: "test1", + configPtr: &mockConfig{}, configFunc: func() (interface{}, error) { return "config1", nil }, } integration2 := mockIntegration{ - name: "test2", - packagePath: ".", + name: "test2", + configPtr: &mockConfig2{}, configFunc: func() (interface{}, error) { return "config2", nil }, @@ -130,22 +175,28 @@ func TestRegisterIntegrations(t *testing.T) { if cfg1 != "config1" || cfg2 != "config2" { t.Error("config values mismatch") } + + // Should have env vars from both structs + envVars := loader.GetEnvVars() + if len(envVars) != 3 { + t.Errorf("expected 3 env vars (2 from mockConfig + 1 from mockConfig2), got %d", len(envVars)) + } } func TestRegisterIntegrations_PartialFailure(t *testing.T) { loader := New() integration1 := mockIntegration{ - name: "test1", - packagePath: ".", + name: "test1", + configPtr: &mockConfig{}, configFunc: func() (interface{}, error) { return "config1", nil }, } integration2 := mockIntegration{ - name: "test2", - packagePath: "/nonexistent", + name: "test2", + configPtr: nil, // This should cause failure configFunc: func() (interface{}, error) { return "config2", nil }, @@ -161,52 +212,3 @@ func TestIntegration_Interface(t *testing.T) { // Verify that mockIntegration implements Integration interface var _ Integration = (*mockIntegration)(nil) } - -func TestRegisterIntegration_RealPackage(t *testing.T) { - // Integration test with real hlog package if available - hlogPath := filepath.Join("..", "hlog") - if _, err := os.Stat(hlogPath); os.IsNotExist(err) { - t.Skip("hlog package not found, skipping integration test") - } - - loader := New() - - // Create a simple integration for testing - integration := mockIntegration{ - name: "hlog", - packagePath: hlogPath, - configFunc: func() (interface{}, error) { - // Return a mock config instead of calling real ConfigFromEnv - return struct{ LogLevel string }{LogLevel: "info"}, nil - }, - } - - err := loader.RegisterIntegration(integration) - if err != nil { - t.Fatalf("RegisterIntegration with real package failed: %v", err) - } - - if err := loader.Load(); err != nil { - t.Fatalf("Load failed: %v", err) - } - - // Should have parsed env vars from hlog - envVars := loader.GetEnvVars() - if len(envVars) == 0 { - t.Error("expected env vars from hlog package") - } - - // Check for known hlog variables - foundLogLevel := false - for _, ev := range envVars { - if ev.Name == "LOG_LEVEL" { - foundLogLevel = true - t.Logf("Found LOG_LEVEL: %s", ev.Description) - break - } - } - - if !foundLogLevel { - t.Error("expected to find LOG_LEVEL from hlog") - } -} diff --git a/ezconf/parser.go b/ezconf/parser.go index aa09f7f..f032051 100644 --- a/ezconf/parser.go +++ b/ezconf/parser.go @@ -1,146 +1,102 @@ package ezconf import ( - "go/ast" - "go/parser" - "go/token" - "os" - "path/filepath" - "regexp" + "reflect" "strings" "github.com/pkg/errors" ) -// ParseConfigFile parses a Go source file and extracts ENV comments from struct fields -func ParseConfigFile(filename string) ([]EnvVar, error) { - content, err := os.ReadFile(filename) - if err != nil { - return nil, errors.Wrap(err, "failed to read file") +// ParseConfigStruct extracts environment variable metadata from a config +// struct's ezconf struct tags using reflection. +// +// The configPtr parameter must be a pointer to a struct. Each field with an +// ezconf tag will be parsed to extract environment variable information. +// +// Tag format: `ezconf:"VAR_NAME,description:Description text,default:value,required"` +// +// Components: +// - First value: environment variable name (required) +// - description:...: Description of the variable +// - default:...: Default value +// - required: Marks the variable as required (optionally required:condition) +func ParseConfigStruct(configPtr any) ([]EnvVar, error) { + if configPtr == nil { + return nil, errors.New("config pointer cannot be nil") } - fset := token.NewFileSet() - file, err := parser.ParseFile(fset, filename, content, parser.ParseComments) - if err != nil { - return nil, errors.Wrap(err, "failed to parse file") + v := reflect.ValueOf(configPtr) + if v.Kind() != reflect.Ptr { + return nil, errors.New("config must be a pointer to a struct") } + v = v.Elem() + if v.Kind() != reflect.Struct { + return nil, errors.New("config must be a pointer to a struct") + } + + t := v.Type() envVars := make([]EnvVar, 0) - // Walk through the AST - ast.Inspect(file, func(n ast.Node) bool { - // Look for struct type declarations - typeSpec, ok := n.(*ast.TypeSpec) - if !ok { - return true + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + tag := field.Tag.Get("ezconf") + if tag == "" { + continue } - structType, ok := typeSpec.Type.(*ast.StructType) - if !ok { - return true + envVar, err := parseEzconfTag(tag) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse ezconf tag on field %s", field.Name) } - // Iterate through struct fields - for _, field := range structType.Fields.List { - var comment string - - // Try to get from doc comment (comment before field) - if field.Doc != nil && len(field.Doc.List) > 0 { - comment = field.Doc.List[0].Text - comment = strings.TrimPrefix(comment, "//") - comment = strings.TrimSpace(comment) - } - - // Try to get from inline comment (comment after field) - if comment == "" && field.Comment != nil && len(field.Comment.List) > 0 { - comment = field.Comment.List[0].Text - comment = strings.TrimPrefix(comment, "//") - comment = strings.TrimSpace(comment) - } - - // Parse ENV comment - if strings.HasPrefix(comment, "ENV ") { - envVar, err := parseEnvComment(comment) - if err == nil { - envVars = append(envVars, *envVar) - } - } - } - - return true - }) + envVars = append(envVars, *envVar) + } return envVars, nil } -// ParseConfigPackage parses all Go files in a package directory and extracts ENV comments -func ParseConfigPackage(packagePath string) ([]EnvVar, error) { - // Find all .go files in the package - files, err := filepath.Glob(filepath.Join(packagePath, "*.go")) - if err != nil { - return nil, errors.Wrap(err, "failed to glob package files") +// parseEzconfTag parses an ezconf struct tag value to extract environment +// variable information. +// +// Expected format: "VAR_NAME,description:Description text,default:value,required" +func parseEzconfTag(tag string) (*EnvVar, error) { + if tag == "" { + return nil, errors.New("tag cannot be empty") } - allEnvVars := make([]EnvVar, 0) - - for _, file := range files { - // Skip test files - if strings.HasSuffix(file, "_test.go") { - continue - } - - envVars, err := ParseConfigFile(file) - if err != nil { - // Log error but continue with other files - continue - } - - allEnvVars = append(allEnvVars, envVars...) - } - - return allEnvVars, nil -} - -// parseEnvComment parses a field comment to extract environment variable information. -// Expected format: ENV ENV_NAME: Description (required ) (default: ) -func parseEnvComment(comment string) (*EnvVar, error) { - // Check if comment starts with ENV - if !strings.HasPrefix(comment, "ENV ") { - return nil, errors.New("comment does not start with 'ENV '") - } - - // Remove "ENV " prefix - comment = strings.TrimPrefix(comment, "ENV ") - - // Extract env var name (everything before the first colon) - colonIdx := strings.Index(comment, ":") - if colonIdx == -1 { - return nil, errors.New("missing colon separator") + parts := strings.Split(tag, ",") + if len(parts) == 0 { + return nil, errors.New("tag cannot be empty") } envVar := &EnvVar{ - Name: strings.TrimSpace(comment[:colonIdx]), + Name: strings.TrimSpace(parts[0]), } - // Extract description and optional parts - remainder := strings.TrimSpace(comment[colonIdx+1:]) - - // Check for (required ...) pattern - requiredPattern := regexp.MustCompile(`\(required[^)]*\)`) - if requiredPattern.MatchString(remainder) { - envVar.Required = true - remainder = requiredPattern.ReplaceAllString(remainder, "") + if envVar.Name == "" { + return nil, errors.New("environment variable name cannot be empty") } - // Check for (default: ...) pattern - defaultPattern := regexp.MustCompile(`\(default:\s*([^)]*)\)`) - if matches := defaultPattern.FindStringSubmatch(remainder); len(matches) > 1 { - envVar.Default = strings.TrimSpace(matches[1]) - remainder = defaultPattern.ReplaceAllString(remainder, "") - } + for _, part := range parts[1:] { + part = strings.TrimSpace(part) - // What remains is the description - envVar.Description = strings.TrimSpace(remainder) + switch { + case strings.HasPrefix(part, "description:"): + envVar.Description = strings.TrimSpace(strings.TrimPrefix(part, "description:")) + case strings.HasPrefix(part, "default:"): + envVar.Default = strings.TrimSpace(strings.TrimPrefix(part, "default:")) + case part == "required": + envVar.Required = true + case strings.HasPrefix(part, "required:"): + envVar.Required = true + // Store the condition in the description if it adds context + condition := strings.TrimSpace(strings.TrimPrefix(part, "required:")) + if condition != "" && envVar.Description != "" { + envVar.Description = envVar.Description + " (required " + condition + ")" + } + } + } return envVar, nil } diff --git a/ezconf/parser_test.go b/ezconf/parser_test.go index 0dcdfe1..f8fa1f3 100644 --- a/ezconf/parser_test.go +++ b/ezconf/parser_test.go @@ -1,21 +1,19 @@ package ezconf import ( - "os" - "path/filepath" "testing" ) -func TestParseEnvComment(t *testing.T) { +func TestParseEzconfTag(t *testing.T) { tests := []struct { name string - comment string + tag string wantEnvVar *EnvVar expectError bool }{ { - name: "simple env variable", - comment: "ENV LOG_LEVEL: Log level for the application", + name: "simple env variable", + tag: "LOG_LEVEL,description:Log level for the application", wantEnvVar: &EnvVar{ Name: "LOG_LEVEL", Description: "Log level for the application", @@ -25,8 +23,8 @@ func TestParseEnvComment(t *testing.T) { expectError: false, }, { - name: "env variable with default", - comment: "ENV LOG_LEVEL: Log level for the application (default: info)", + name: "env variable with default", + tag: "LOG_LEVEL,description:Log level for the application,default:info", wantEnvVar: &EnvVar{ Name: "LOG_LEVEL", Description: "Log level for the application", @@ -36,8 +34,8 @@ func TestParseEnvComment(t *testing.T) { expectError: false, }, { - name: "required env variable", - comment: "ENV DATABASE_URL: Database connection string (required)", + name: "required env variable", + tag: "DATABASE_URL,description:Database connection string,required", wantEnvVar: &EnvVar{ Name: "DATABASE_URL", Description: "Database connection string", @@ -47,25 +45,36 @@ func TestParseEnvComment(t *testing.T) { expectError: false, }, { - name: "required with condition and default", - comment: "ENV LOG_DIR: Directory for log files (required when LOG_OUTPUT is file) (default: /var/log)", + name: "required with condition and default", + tag: "LOG_DIR,description:Directory for log files,required:when LOG_OUTPUT is file,default:/var/log", wantEnvVar: &EnvVar{ Name: "LOG_DIR", - Description: "Directory for log files", + Description: "Directory for log files (required when LOG_OUTPUT is file)", Required: true, Default: "/var/log", }, expectError: false, }, { - name: "missing colon", - comment: "ENV LOG_LEVEL Log level", + name: "name only", + tag: "SIMPLE_VAR", + wantEnvVar: &EnvVar{ + Name: "SIMPLE_VAR", + Description: "", + Required: false, + Default: "", + }, + expectError: false, + }, + { + name: "empty tag", + tag: "", wantEnvVar: nil, expectError: true, }, { - name: "not an ENV comment", - comment: "This is a regular comment", + name: "empty name", + tag: ",description:some desc", wantEnvVar: nil, expectError: true, }, @@ -73,7 +82,7 @@ func TestParseEnvComment(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - envVar, err := parseEnvComment(tt.comment) + envVar, err := parseEzconfTag(tt.tag) if tt.expectError { if err == nil { @@ -103,32 +112,17 @@ func TestParseEnvComment(t *testing.T) { } } -func TestParseConfigFile(t *testing.T) { - // Create a temporary test file - tempDir := t.TempDir() - testFile := filepath.Join(tempDir, "config.go") - - content := `package testpkg - -type Config struct { - // ENV LOG_LEVEL: Log level for the application (default: info) - LogLevel string - - // ENV LOG_OUTPUT: Output destination (default: console) - LogOutput string - - // ENV DATABASE_URL: Database connection string (required) - DatabaseURL string -} -` - - if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { - t.Fatalf("failed to create test file: %v", err) +func TestParseConfigStruct(t *testing.T) { + type TestConfig struct { + LogLevel string `ezconf:"LOG_LEVEL,description:Log level for the application,default:info"` + LogOutput string `ezconf:"LOG_OUTPUT,description:Output destination,default:console"` + DatabaseURL string `ezconf:"DATABASE_URL,description:Database connection string,required"` + NoTag string } - envVars, err := ParseConfigFile(testFile) + envVars, err := ParseConfigStruct(&TestConfig{}) if err != nil { - t.Fatalf("ParseConfigFile failed: %v", err) + t.Fatalf("ParseConfigStruct failed: %v", err) } if len(envVars) != 3 { @@ -152,51 +146,70 @@ type Config struct { } } -func TestParseConfigPackage(t *testing.T) { - // Test with actual hlog package - hlogPath := filepath.Join("..", "hlog") - if _, err := os.Stat(hlogPath); os.IsNotExist(err) { - t.Skip("hlog package not found, skipping integration test") - } - - envVars, err := ParseConfigPackage(hlogPath) - if err != nil { - t.Fatalf("ParseConfigPackage failed: %v", err) - } - - if len(envVars) == 0 { - t.Error("expected at least one env var from hlog package") - } - - // Check for known hlog variables - foundLogLevel := false - for _, envVar := range envVars { - if envVar.Name == "LOG_LEVEL" { - foundLogLevel = true - t.Logf("Found LOG_LEVEL: %s", envVar.Description) - } - } - - if !foundLogLevel { - t.Error("expected to find LOG_LEVEL in hlog package") - } -} - -func TestParseConfigFile_InvalidFile(t *testing.T) { - _, err := ParseConfigFile("/nonexistent/file.go") +func TestParseConfigStruct_NilPointer(t *testing.T) { + _, err := ParseConfigStruct(nil) if err == nil { - t.Error("expected error for nonexistent file") + t.Error("expected error for nil pointer") } } -func TestParseConfigPackage_InvalidPath(t *testing.T) { - envVars, err := ParseConfigPackage("/nonexistent/package") +func TestParseConfigStruct_NotPointer(t *testing.T) { + type TestConfig struct { + Foo string `ezconf:"FOO,description:test"` + } + _, err := ParseConfigStruct(TestConfig{}) + if err == nil { + t.Error("expected error for non-pointer") + } +} + +func TestParseConfigStruct_NotStruct(t *testing.T) { + str := "not a struct" + _, err := ParseConfigStruct(&str) + if err == nil { + t.Error("expected error for non-struct pointer") + } +} + +func TestParseConfigStruct_NoTags(t *testing.T) { + type EmptyConfig struct { + Foo string + Bar int + } + + envVars, err := ParseConfigStruct(&EmptyConfig{}) if err != nil { - t.Fatalf("ParseConfigPackage should not error on invalid path: %v", err) + t.Fatalf("ParseConfigStruct failed: %v", err) } - // Should return empty slice for invalid path if len(envVars) != 0 { - t.Errorf("expected 0 env vars for invalid path, got %d", len(envVars)) + t.Errorf("expected 0 env vars for struct with no tags, got %d", len(envVars)) + } +} + +func TestParseConfigStruct_UnexportedFields(t *testing.T) { + type TestConfig struct { + exported string `ezconf:"EXPORTED,description:An exported field"` + unexported string `ezconf:"UNEXPORTED,description:An unexported field"` + } + + envVars, err := ParseConfigStruct(&TestConfig{}) + if err != nil { + t.Fatalf("ParseConfigStruct failed: %v", err) + } + + if len(envVars) != 2 { + t.Errorf("expected 2 env vars (both exported and unexported), got %d", len(envVars)) + } +} + +func TestParseConfigStruct_InvalidTag(t *testing.T) { + type TestConfig struct { + Bad string `ezconf:",description:missing name"` + } + + _, err := ParseConfigStruct(&TestConfig{}) + if err == nil { + t.Error("expected error for invalid tag") } }