Compare commits

...

3 Commits

Author SHA1 Message Date
1745458a95 updated hlog to use new ezconf 2026-02-25 22:06:27 +11:00
f3d6a01105 added new way to integrate with ezconf 2026-02-25 22:01:25 +11:00
9179736c90 updated ezconf 2026-02-25 21:52:57 +11:00
11 changed files with 441 additions and 428 deletions

View File

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

View File

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

View File

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

View File

@@ -1,31 +1,70 @@
package ezconf
// Integration is an interface that packages can implement to provide
type Integration struct {
Name string
ConfigPointer any
ConfigFunc func() (any, error)
GroupName string
}
func NewIntegration(name, groupname string, cfgptr any, cfgfunc func() (any, error)) *Integration {
return &Integration{
name,
cfgptr,
cfgfunc,
groupname,
}
}
// IntegrationDepr is an interface that packages can implement to provide
// easy integration with ezconf
type Integration interface {
type IntegrationDepr 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
}
// 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 {
// AddIntegration registers a package using an Integration object returned by another package
func (cl *ConfigLoader) AddIntegration(integration *Integration) error {
// 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
}
return nil
}
// AddIntegrations registers multiple integrations at once
func (cl *ConfigLoader) AddIntegrations(integrations ...*Integration) error {
for _, integration := range integrations {
if err := cl.AddIntegration(integration); err != nil {
return err
}
}
return nil
}
// RegisterIntegration registers a package that implements the Integration interface
func (cl *ConfigLoader) RegisterIntegration(integration IntegrationDepr) error {
// Add config struct for tag parsing
configPtr := integration.ConfigPointer()
if err := cl.AddConfigStruct(configPtr, integration.GroupName()); err != nil {
return err
}
// Add config function
if err := cl.AddConfigFunc(integration.Name(), integration.ConfigFunc()); err != nil {
@@ -36,7 +75,7 @@ func (cl *ConfigLoader) RegisterIntegration(integration Integration) error {
}
// RegisterIntegrations registers multiple integrations at once
func (cl *ConfigLoader) RegisterIntegrations(integrations ...Integration) error {
func (cl *ConfigLoader) RegisterIntegrations(integrations ...IntegrationDepr) error {
for _, integration := range integrations {
if err := cl.RegisterIntegration(integration); err != nil {
return err

View File

@@ -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
},
@@ -159,54 +210,5 @@ func TestRegisterIntegrations_PartialFailure(t *testing.T) {
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")
}
var _ IntegrationDepr = (*mockIntegration)(nil)
}

View File

@@ -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 <condition>) (default: <value>)
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
}

View File

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

View File

@@ -9,11 +9,11 @@ import (
// It can be populated from environment variables using ConfigFromEnv
// or created programmatically.
type Config struct {
LogLevel Level // ENV LOG_LEVEL: Log level for the logger - trace, debug, info, warn, error, fatal, panic (default: info)
LogOutput string // ENV LOG_OUTPUT: Output destination for logs - console, file, or both (default: console)
LogDir string // ENV LOG_DIR: Directory path for log files (required when LOG_OUTPUT is "file" or "both")
LogFileName string // ENV LOG_FILE_NAME: Name of the log file (required when LOG_OUTPUT is "file" or "both")
LogAppend bool // ENV LOG_APPEND: Append to existing log file or overwrite (default: true)
LogLevel Level `ezconf:"LOG_LEVEL,description:Log level for the logger - trace debug info warn error fatal panic,default:info"`
LogOutput string `ezconf:"LOG_OUTPUT,description:Output destination for logs - console file or both,default:console"`
LogDir string `ezconf:"LOG_DIR,description:Directory path for log files,required:when LOG_OUTPUT is file or both"`
LogFileName string `ezconf:"LOG_FILE_NAME,description:Name of the log file,required:when LOG_OUTPUT is file or both"`
LogAppend bool `ezconf:"LOG_APPEND,description:Append to existing log file or overwrite,default:true"`
}
// ConfigFromEnv loads logger configuration from environment variables.

View File

@@ -1,35 +1,9 @@
package hlog
import "runtime"
import "git.haelnorr.com/h/golib/ezconf"
// EZConfIntegration provides integration with ezconf for automatic configuration
type EZConfIntegration struct{}
// PackagePath returns the path to the hlog package for source parsing
func (e EZConfIntegration) PackagePath() string {
_, filename, _, _ := runtime.Caller(0)
// Return directory of this file
return filename[:len(filename)-len("/ezconf.go")]
}
// ConfigFunc returns the ConfigFromEnv function for ezconf
func (e EZConfIntegration) ConfigFunc() func() (interface{}, error) {
return func() (interface{}, error) {
return ConfigFromEnv()
}
}
// Name returns the name to use when registering with ezconf
func (e EZConfIntegration) Name() string {
return "hlog"
}
// GroupName returns the display name for grouping environment variables
func (e EZConfIntegration) GroupName() string {
return "HLog"
}
// NewEZConfIntegration creates a new EZConf integration helper
func NewEZConfIntegration() EZConfIntegration {
return EZConfIntegration{}
// NewEZConfIntegration creates a new EZConf integration
func NewEZConfIntegration() *ezconf.Integration {
return ezconf.NewIntegration("hlog", "HLog",
&Config{}, func() (any, error) { return ConfigFromEnv() })
}

View File

@@ -7,6 +7,8 @@ require (
github.com/rs/zerolog v1.34.0
)
require git.haelnorr.com/h/golib/ezconf v0.2.1
require (
git.haelnorr.com/h/golib/env v0.9.1
github.com/mattn/go-colorable v0.1.13 // indirect

View File

@@ -1,5 +1,7 @@
git.haelnorr.com/h/golib/env v0.9.1 h1:2Vsj+mJKnO5f1Md1GO5v9ggLN5zWa0baCewcSHTjoNY=
git.haelnorr.com/h/golib/env v0.9.1/go.mod h1:glUQVdA1HMKX1avTDyTyuhcr36SSxZtlJxKDT5KTztg=
git.haelnorr.com/h/golib/ezconf v0.2.1 h1:axMyKtgO9Zk6E8CrYrLpMzifvpjz73yxCQq0lOtuhck=
git.haelnorr.com/h/golib/ezconf v0.2.1/go.mod h1:rETDcjpcEyyeBgCiZSU617wc0XycwZSC5+IAOtXmwP8=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=