From 0ceeb3705856e4c25520e31f4490f37208f49b68 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Tue, 13 Jan 2026 21:18:35 +1100 Subject: [PATCH] added ezconf and updated modules with integration --- ezconf/LICENSE | 21 +++ ezconf/README.md | 161 ++++++++++++++++ ezconf/doc.go | 120 ++++++++++++ ezconf/ezconf.go | 131 +++++++++++++ ezconf/ezconf_test.go | 271 +++++++++++++++++++++++++++ ezconf/go.mod | 5 + ezconf/go.sum | 2 + ezconf/integration.go | 46 +++++ ezconf/integration_test.go | 212 +++++++++++++++++++++ ezconf/output.go | 365 +++++++++++++++++++++++++++++++++++++ ezconf/output_test.go | 362 ++++++++++++++++++++++++++++++++++++ ezconf/parser.go | 146 +++++++++++++++ ezconf/parser_test.go | 202 ++++++++++++++++++++ hlog/README.md | 2 +- hlog/ezconf.go | 35 ++++ hws/README.md | 2 +- hws/ezconf.go | 35 ++++ hwsauth/README.md | 2 +- hwsauth/ezconf.go | 35 ++++ tmdb/LICENSE | 21 +++ tmdb/README.md | 239 ++++++++++++++++++++++++ tmdb/ezconf.go | 36 ++++ 22 files changed, 2448 insertions(+), 3 deletions(-) create mode 100644 ezconf/LICENSE create mode 100644 ezconf/README.md create mode 100644 ezconf/doc.go create mode 100644 ezconf/ezconf.go create mode 100644 ezconf/ezconf_test.go create mode 100644 ezconf/go.mod create mode 100644 ezconf/go.sum create mode 100644 ezconf/integration.go create mode 100644 ezconf/integration_test.go create mode 100644 ezconf/output.go create mode 100644 ezconf/output_test.go create mode 100644 ezconf/parser.go create mode 100644 ezconf/parser_test.go create mode 100644 hlog/ezconf.go create mode 100644 hws/ezconf.go create mode 100644 hwsauth/ezconf.go create mode 100644 tmdb/LICENSE create mode 100644 tmdb/README.md create mode 100644 tmdb/ezconf.go diff --git a/ezconf/LICENSE b/ezconf/LICENSE new file mode 100644 index 0000000..fbf1733 --- /dev/null +++ b/ezconf/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 haelnorr + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ezconf/README.md b/ezconf/README.md new file mode 100644 index 0000000..d6513e4 --- /dev/null +++ b/ezconf/README.md @@ -0,0 +1,161 @@ +# EZConf - v0.1.0 + +A unified configuration management system for loading and managing environment-based configurations across multiple packages in Go. + +## Features + +- Load configurations from multiple packages using their ConfigFromEnv functions +- Parse package source code to extract environment variable documentation from struct comments +- Generate and update .env files with all required environment variables +- Print environment variable lists with descriptions and current values +- Track additional custom environment variables +- Support for both inline and doc comments in ENV format +- Automatic environment variable value population +- Preserve existing values when updating .env files + +## Installation + +```bash +go get git.haelnorr.com/h/golib/ezconf +``` + +## Quick Start + +### Easy Integration (Recommended) + +```go +package main + +import ( + "log" + "os" + + "git.haelnorr.com/h/golib/ezconf" + "git.haelnorr.com/h/golib/hlog" + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/golib/hwsauth" +) + +func main() { + // Create a new configuration loader + loader := ezconf.New() + + // Register packages using built-in integrations + loader.RegisterIntegrations( + hlog.NewEZConfIntegration(), + hws.NewEZConfIntegration(), + hwsauth.NewEZConfIntegration(), + ) + + // Load all configurations + if err := loader.Load(); err != nil { + log.Fatal(err) + } + + // Get configurations + hlogCfg, _ := loader.GetConfig("hlog") + cfg := hlogCfg.(*hlog.Config) + + // Use configuration + logger, _ := hlog.NewLogger(cfg, os.Stdout) + logger.Info().Msg("Application started") +} +``` + +### Manual Integration + +```go +package main + +import ( + "log" + "os" + + "git.haelnorr.com/h/golib/ezconf" + "git.haelnorr.com/h/golib/hlog" + "git.haelnorr.com/h/golib/hws" +) + +func main() { + // Create a new configuration loader + loader := ezconf.New() + + // Add package paths to parse for ENV comments + loader.AddPackagePath("vendor/git.haelnorr.com/h/golib/hlog") + loader.AddPackagePath("vendor/git.haelnorr.com/h/golib/hws") + + // Add configuration loaders + loader.AddConfigFunc("hlog", func() (interface{}, error) { + return hlog.ConfigFromEnv() + }) + loader.AddConfigFunc("hws", func() (interface{}, error) { + return hws.ConfigFromEnv() + }) + + // Load all configurations + if err := loader.Load(); err != nil { + log.Fatal(err) + } + + // Get a specific configuration + hlogCfg, ok := loader.GetConfig("hlog") + if ok { + cfg := hlogCfg.(*hlog.Config) + // Use configuration... + } + + // Print all environment variables + if err := loader.PrintEnvVarsStdout(false); err != nil { + log.Fatal(err) + } + + // Generate a .env file + if err := loader.GenerateEnvFile(".env", false); err != nil { + log.Fatal(err) + } +} +``` + +## Documentation + +For detailed documentation, see the [EZConf Wiki](../golib-wiki/EZConf.md). + +Additional API documentation is available at [GoDoc](https://pkg.go.dev/git.haelnorr.com/h/golib/ezconf). + +## ENV Comment Format + +EZConf parses struct field comments in the following format: + +```go +type Config struct { + // ENV LOG_LEVEL: Log level for the application (default: info) + LogLevel string + + // ENV DATABASE_URL: Database connection string (required) + DatabaseURL string + + // Inline comments also work + Port int // ENV PORT: Server port (default: 8080) +} +``` + +The format is: +- `ENV ENV_VAR_NAME: Description (optional modifiers)` +- `(required)` or `(required if condition)` - marks variable as required +- `(default: value)` - specifies default value + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## Related Projects + +- [hlog](https://git.haelnorr.com/h/golib/hlog) - Structured logging package with ConfigFromEnv +- [hws](https://git.haelnorr.com/h/golib/hws) - HTTP web server with ConfigFromEnv +- [hwsauth](https://git.haelnorr.com/h/golib/hwsauth) - Authentication middleware with ConfigFromEnv +- [env](https://git.haelnorr.com/h/golib/env) - Environment variable helpers + diff --git a/ezconf/doc.go b/ezconf/doc.go new file mode 100644 index 0000000..50cfefb --- /dev/null +++ b/ezconf/doc.go @@ -0,0 +1,120 @@ +// Package ezconf provides a unified configuration management system for loading +// and managing environment-based configurations across multiple packages. +// +// ezconf allows you to: +// - Load configurations from multiple packages using their ConfigFromEnv functions +// - Parse package source code 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 +// +// # Basic Usage +// +// Create a configuration loader and register packages using built-in integrations (recommended): +// +// import ( +// "git.haelnorr.com/h/golib/ezconf" +// "git.haelnorr.com/h/golib/hlog" +// "git.haelnorr.com/h/golib/hws" +// "git.haelnorr.com/h/golib/hwsauth" +// ) +// +// loader := ezconf.New() +// +// // Register packages using built-in integrations +// loader.RegisterIntegrations( +// hlog.NewEZConfIntegration(), +// hws.NewEZConfIntegration(), +// hwsauth.NewEZConfIntegration(), +// ) +// +// // Load all configurations +// if err := loader.Load(); err != nil { +// log.Fatal(err) +// } +// +// // Get a specific configuration +// hlogCfg, ok := loader.GetConfig("hlog") +// if ok { +// cfg := hlogCfg.(*hlog.Config) +// // Use configuration... +// } +// +// Alternatively, you can manually register packages: +// +// loader := ezconf.New() +// +// // Add package paths to parse for ENV comments +// loader.AddPackagePath("/path/to/golib/hlog") +// +// // Add configuration loaders +// loader.AddConfigFunc("hlog", func() (interface{}, error) { +// return hlog.ConfigFromEnv() +// }) +// +// loader.Load() +// +// # Printing Environment Variables +// +// Print all environment variables with their descriptions: +// +// // Print without values (useful for documentation) +// if err := loader.PrintEnvVarsStdout(false); err != nil { +// log.Fatal(err) +// } +// +// // Print with current values +// if err := loader.PrintEnvVarsStdout(true); err != nil { +// log.Fatal(err) +// } +// +// # Generating .env Files +// +// Generate a new .env file with all environment variables: +// +// // Generate with default values +// err := loader.GenerateEnvFile(".env", false) +// +// // Generate with current environment values +// err := loader.GenerateEnvFile(".env", true) +// +// Update an existing .env file: +// +// // Update existing file, preserving existing values +// err := loader.UpdateEnvFile(".env", true) +// +// # Adding Custom Environment Variables +// +// You can add additional environment variables that aren't in package configs: +// +// loader.AddEnvVar(ezconf.EnvVar{ +// Name: "DATABASE_URL", +// Description: "PostgreSQL connection string", +// Required: true, +// Default: "postgres://localhost/mydb", +// }) +// +// # ENV Comment Format +// +// ezconf parses struct field comments in the following format: +// +// type Config struct { +// // ENV LOG_LEVEL: Log level for the application (default: info) +// LogLevel string +// +// // ENV DATABASE_URL: Database connection string (required) +// DatabaseURL string +// } +// +// The format is: +// - ENV ENV_VAR_NAME: Description (optional modifiers) +// - (required) or (required if condition) - marks variable as required +// - (default: value) - specifies default value +// +// # Integration +// +// ezconf integrates with: +// - All golib packages that follow the ConfigFromEnv pattern +// - Any custom configuration structs with ENV comments +// - Standard .env file format +package ezconf diff --git a/ezconf/ezconf.go b/ezconf/ezconf.go new file mode 100644 index 0000000..84c2654 --- /dev/null +++ b/ezconf/ezconf.go @@ -0,0 +1,131 @@ +package ezconf + +import ( + "os" + + "github.com/pkg/errors" +) + +// EnvVar represents a single environment variable with its metadata +type EnvVar struct { + Name string // The environment variable name (e.g., "LOG_LEVEL") + Description string // Description of what this variable does + Required bool // Whether this variable is required + Default string // Default value if not set + CurrentValue string // Current value from environment (empty if not set) + Group string // Group name for organizing variables (e.g., "Database", "Logging") +} + +// 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]interface{} // Loaded configurations +} + +// ConfigFunc is a function that loads configuration from environment variables +type ConfigFunc func() (interface{}, 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]interface{}), + } +} + +// AddConfigFunc adds a ConfigFromEnv function to be called during loading. +// The name parameter is used as a key to retrieve the loaded config later. +func (cl *ConfigLoader) AddConfigFunc(name string, fn ConfigFunc) error { + if fn == nil { + return errors.New("config function cannot be nil") + } + if name == "" { + return errors.New("config name cannot be empty") + } + cl.configFuncs[name] = fn + 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") + } + // Check if path exists + if _, err := os.Stat(path); os.IsNotExist(err) { + return errors.Errorf("package path does not exist: %s", path) + } + cl.packagePaths = append(cl.packagePaths, path) + return nil +} + +// AddEnvVar adds an additional environment variable to track +func (cl *ConfigLoader) AddEnvVar(envVar EnvVar) { + cl.extraEnvVars = append(cl.extraEnvVars, envVar) +} + +// Load loads all configurations and extracts environment variables +func (cl *ConfigLoader) Load() error { + // Parse packages for ENV comments + for _, pkgPath := range cl.packagePaths { + envVars, err := ParseConfigPackage(pkgPath) + 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" + } + + for i := range envVars { + envVars[i].Group = groupName + } + + cl.envVars = append(cl.envVars, envVars...) + } + + // Add extra env vars + cl.envVars = append(cl.envVars, cl.extraEnvVars...) + + // Populate current values from environment + for i := range cl.envVars { + cl.envVars[i].CurrentValue = os.Getenv(cl.envVars[i].Name) + } + + // Load configurations + for name, fn := range cl.configFuncs { + cfg, err := fn() + if err != nil { + return errors.Wrapf(err, "failed to load config: %s", name) + } + cl.configs[name] = cfg + } + + return nil +} + +// GetConfig returns a loaded configuration by name +func (cl *ConfigLoader) GetConfig(name string) (interface{}, bool) { + cfg, ok := cl.configs[name] + return cfg, ok +} + +// GetAllConfigs returns all loaded configurations +func (cl *ConfigLoader) GetAllConfigs() map[string]interface{} { + return cl.configs +} + +// GetEnvVars returns all extracted environment variables +func (cl *ConfigLoader) GetEnvVars() []EnvVar { + return cl.envVars +} diff --git a/ezconf/ezconf_test.go b/ezconf/ezconf_test.go new file mode 100644 index 0000000..2a92a82 --- /dev/null +++ b/ezconf/ezconf_test.go @@ -0,0 +1,271 @@ +package ezconf + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNew(t *testing.T) { + loader := New() + if loader == nil { + t.Fatal("New() returned nil") + } + + if loader.configFuncs == nil { + t.Error("configFuncs map is nil") + } + if loader.packagePaths == nil { + t.Error("packagePaths slice is nil") + } + if loader.extraEnvVars == nil { + t.Error("extraEnvVars slice is nil") + } + if loader.configs == nil { + t.Error("configs map is nil") + } +} + +func TestAddConfigFunc(t *testing.T) { + loader := New() + + testFunc := func() (interface{}, error) { + return "test config", nil + } + + err := loader.AddConfigFunc("test", testFunc) + if err != nil { + t.Errorf("AddConfigFunc failed: %v", err) + } + + if len(loader.configFuncs) != 1 { + t.Errorf("expected 1 config func, got %d", len(loader.configFuncs)) + } +} + +func TestAddConfigFunc_NilFunction(t *testing.T) { + loader := New() + + err := loader.AddConfigFunc("test", nil) + if err == nil { + t.Error("expected error for nil function") + } +} + +func TestAddConfigFunc_EmptyName(t *testing.T) { + loader := New() + + testFunc := func() (interface{}, error) { + return "test config", nil + } + + err := loader.AddConfigFunc("", testFunc) + if err == nil { + t.Error("expected error for empty name") + } +} + +func TestAddPackagePath(t *testing.T) { + loader := New() + + // Use current directory as test path + err := loader.AddPackagePath(".") + if err != nil { + t.Errorf("AddPackagePath failed: %v", err) + } + + if len(loader.packagePaths) != 1 { + t.Errorf("expected 1 package path, got %d", len(loader.packagePaths)) + } +} + +func TestAddPackagePath_InvalidPath(t *testing.T) { + loader := New() + + err := loader.AddPackagePath("/nonexistent/path") + if err == nil { + t.Error("expected error for nonexistent path") + } +} + +func TestAddPackagePath_EmptyPath(t *testing.T) { + loader := New() + + err := loader.AddPackagePath("") + if err == nil { + t.Error("expected error for empty path") + } +} + +func TestAddEnvVar(t *testing.T) { + loader := New() + + envVar := EnvVar{ + Name: "TEST_VAR", + Description: "Test variable", + Required: true, + Default: "default_value", + } + + loader.AddEnvVar(envVar) + + if len(loader.extraEnvVars) != 1 { + t.Errorf("expected 1 extra env var, got %d", len(loader.extraEnvVars)) + } + + if loader.extraEnvVars[0].Name != "TEST_VAR" { + t.Errorf("expected TEST_VAR, got %s", loader.extraEnvVars[0].Name) + } +} + +func TestLoad(t *testing.T) { + loader := New() + + // Add a test config function + testCfg := struct { + Value string + }{Value: "test"} + + loader.AddConfigFunc("test", func() (interface{}, error) { + return testCfg, nil + }) + + // Add current package path + loader.AddPackagePath(".") + + // Add an extra env var + loader.AddEnvVar(EnvVar{ + Name: "EXTRA_VAR", + Description: "Extra test variable", + Default: "extra", + }) + + err := loader.Load() + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + // Check that config was loaded + cfg, ok := loader.GetConfig("test") + if !ok { + t.Error("test config not loaded") + } + if cfg == nil { + t.Error("test config is nil") + } + + // Check that env vars were extracted + envVars := loader.GetEnvVars() + if len(envVars) == 0 { + t.Error("expected at least one env var") + } + + // Check for extra var + foundExtra := false + for _, ev := range envVars { + if ev.Name == "EXTRA_VAR" { + foundExtra = true + break + } + } + if !foundExtra { + t.Error("extra env var not found") + } +} + +func TestLoad_ConfigFuncError(t *testing.T) { + loader := New() + + loader.AddConfigFunc("error", func() (interface{}, error) { + return nil, os.ErrNotExist + }) + + err := loader.Load() + if err == nil { + t.Error("expected error from failing config func") + } +} + +func TestGetConfig(t *testing.T) { + loader := New() + + testCfg := "test config" + loader.configs["test"] = testCfg + + cfg, ok := loader.GetConfig("test") + if !ok { + t.Error("expected to find test config") + } + if cfg != testCfg { + t.Error("config value mismatch") + } + + // Test non-existent config + _, ok = loader.GetConfig("nonexistent") + if ok { + t.Error("expected not to find nonexistent config") + } +} + +func TestGetAllConfigs(t *testing.T) { + loader := New() + + loader.configs["test1"] = "config1" + loader.configs["test2"] = "config2" + + allConfigs := loader.GetAllConfigs() + if len(allConfigs) != 2 { + t.Errorf("expected 2 configs, got %d", len(allConfigs)) + } + + if allConfigs["test1"] != "config1" { + t.Error("test1 config mismatch") + } + if allConfigs["test2"] != "config2" { + t.Error("test2 config mismatch") + } +} + +func TestGetEnvVars(t *testing.T) { + loader := New() + + loader.envVars = []EnvVar{ + {Name: "VAR1", Description: "Variable 1"}, + {Name: "VAR2", Description: "Variable 2"}, + } + + envVars := loader.GetEnvVars() + if len(envVars) != 2 { + t.Errorf("expected 2 env vars, got %d", len(envVars)) + } +} + +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") + } + + loader := New() + + // Add hlog package + if err := loader.AddPackagePath(hlogPath); err != nil { + t.Fatalf("failed to add hlog package: %v", err) + } + + // Load without config function (just parse) + if err := loader.Load(); err != nil { + t.Fatalf("Load 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) + } +} diff --git a/ezconf/go.mod b/ezconf/go.mod new file mode 100644 index 0000000..472f489 --- /dev/null +++ b/ezconf/go.mod @@ -0,0 +1,5 @@ +module git.haelnorr.com/h/golib/ezconf + +go 1.23.4 + +require github.com/pkg/errors v0.9.1 diff --git a/ezconf/go.sum b/ezconf/go.sum new file mode 100644 index 0000000..7c401c3 --- /dev/null +++ b/ezconf/go.sum @@ -0,0 +1,2 @@ +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/ezconf/integration.go b/ezconf/integration.go new file mode 100644 index 0000000..ba4fdf3 --- /dev/null +++ b/ezconf/integration.go @@ -0,0 +1,46 @@ +package ezconf + +// Integration is an interface that packages can implement to provide +// easy integration with ezconf +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 + + // ConfigFunc returns the ConfigFromEnv function + ConfigFunc() func() (interface{}, 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 { + 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 +} + +// RegisterIntegrations registers multiple integrations at once +func (cl *ConfigLoader) RegisterIntegrations(integrations ...Integration) error { + for _, integration := range integrations { + if err := cl.RegisterIntegration(integration); err != nil { + return err + } + } + return nil +} diff --git a/ezconf/integration_test.go b/ezconf/integration_test.go new file mode 100644 index 0000000..af954ec --- /dev/null +++ b/ezconf/integration_test.go @@ -0,0 +1,212 @@ +package ezconf + +import ( + "os" + "path/filepath" + "testing" +) + +// Mock integration for testing +type mockIntegration struct { + name string + packagePath string + configFunc func() (interface{}, error) +} + +func (m mockIntegration) Name() string { + return m.name +} + +func (m mockIntegration) PackagePath() string { + return m.packagePath +} + +func (m mockIntegration) ConfigFunc() func() (interface{}, error) { + return m.configFunc +} + +func (m mockIntegration) GroupName() string { + return "Test Group" +} + +func TestRegisterIntegration(t *testing.T) { + loader := New() + + integration := mockIntegration{ + name: "test", + packagePath: ".", + configFunc: func() (interface{}, error) { + return "test config", nil + }, + } + + err := loader.RegisterIntegration(integration) + if err != nil { + 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 func was added + if len(loader.configFuncs) != 1 { + t.Errorf("expected 1 config func, got %d", len(loader.configFuncs)) + } + + // Load and verify config + if err := loader.Load(); err != nil { + t.Fatalf("Load failed: %v", err) + } + + cfg, ok := loader.GetConfig("test") + if !ok { + t.Error("test config not found") + } + + if cfg != "test config" { + t.Errorf("expected 'test config', got %v", cfg) + } +} + +func TestRegisterIntegration_InvalidPath(t *testing.T) { + loader := New() + + integration := mockIntegration{ + name: "test", + packagePath: "/nonexistent/path", + configFunc: func() (interface{}, error) { + return "test config", nil + }, + } + + err := loader.RegisterIntegration(integration) + if err == nil { + t.Error("expected error for invalid package path") + } +} + +func TestRegisterIntegrations(t *testing.T) { + loader := New() + + integration1 := mockIntegration{ + name: "test1", + packagePath: ".", + configFunc: func() (interface{}, error) { + return "config1", nil + }, + } + + integration2 := mockIntegration{ + name: "test2", + packagePath: ".", + configFunc: func() (interface{}, error) { + return "config2", nil + }, + } + + err := loader.RegisterIntegrations(integration1, integration2) + if err != nil { + t.Fatalf("RegisterIntegrations failed: %v", err) + } + + if len(loader.configFuncs) != 2 { + t.Errorf("expected 2 config funcs, got %d", len(loader.configFuncs)) + } + + // Load and verify configs + if err := loader.Load(); err != nil { + t.Fatalf("Load failed: %v", err) + } + + cfg1, ok1 := loader.GetConfig("test1") + cfg2, ok2 := loader.GetConfig("test2") + + if !ok1 || !ok2 { + t.Error("configs not found") + } + + if cfg1 != "config1" || cfg2 != "config2" { + t.Error("config values mismatch") + } +} + +func TestRegisterIntegrations_PartialFailure(t *testing.T) { + loader := New() + + integration1 := mockIntegration{ + name: "test1", + packagePath: ".", + configFunc: func() (interface{}, error) { + return "config1", nil + }, + } + + integration2 := mockIntegration{ + name: "test2", + packagePath: "/nonexistent", + configFunc: func() (interface{}, error) { + return "config2", nil + }, + } + + err := loader.RegisterIntegrations(integration1, integration2) + if err == nil { + t.Error("expected error when one integration fails") + } +} + +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/output.go b/ezconf/output.go new file mode 100644 index 0000000..eebffc6 --- /dev/null +++ b/ezconf/output.go @@ -0,0 +1,365 @@ +package ezconf + +import ( + "bufio" + "fmt" + "io" + "os" + "strings" + + "github.com/pkg/errors" +) + +// PrintEnvVars prints all environment variables to the provided writer +func (cl *ConfigLoader) PrintEnvVars(w io.Writer, showValues bool) error { + if cl.envVars == nil || len(cl.envVars) == 0 { + return errors.New("no environment variables loaded (did you call Load()?)") + } + + // Group variables by their Group field + groups := make(map[string][]EnvVar) + groupOrder := make([]string, 0) + + for _, envVar := range cl.envVars { + group := envVar.Group + if group == "" { + group = "Other" + } + + if _, exists := groups[group]; !exists { + groupOrder = append(groupOrder, group) + } + groups[group] = append(groups[group], envVar) + } + + // Print variables grouped by section + for _, group := range groupOrder { + vars := groups[group] + + // Calculate max name length for alignment within this group + maxNameLen := 0 + for _, envVar := range vars { + nameLen := len(envVar.Name) + if showValues { + value := envVar.CurrentValue + if value == "" && envVar.Default != "" { + value = envVar.Default + } + nameLen += len(value) + 1 // +1 for the '=' sign + } + if nameLen > maxNameLen { + maxNameLen = nameLen + } + } + + // Print group header + fmt.Fprintf(w, "\n%s Configuration\n", group) + fmt.Fprintln(w, strings.Repeat("=", len(group)+14)) + fmt.Fprintln(w) + + for _, envVar := range vars { + // Build the variable line + var varLine string + if showValues { + value := envVar.CurrentValue + if value == "" && envVar.Default != "" { + value = envVar.Default + } + varLine = fmt.Sprintf("%s=%s", envVar.Name, value) + } else { + varLine = envVar.Name + } + + // Calculate padding for alignment + padding := maxNameLen - len(varLine) + 2 + + // Print with indentation and alignment + fmt.Fprintf(w, " %s%s# %s", varLine, strings.Repeat(" ", padding), envVar.Description) + + if envVar.Required { + fmt.Fprint(w, " (required)") + } + if envVar.Default != "" { + fmt.Fprintf(w, " (default: %s)", envVar.Default) + } + fmt.Fprintln(w) + } + } + + fmt.Fprintln(w) + + return nil +} + +// PrintEnvVarsStdout prints all environment variables to stdout +func (cl *ConfigLoader) PrintEnvVarsStdout(showValues bool) error { + return cl.PrintEnvVars(os.Stdout, showValues) +} + +// GenerateEnvFile creates a new .env file with all environment variables +// If the file already exists, it will preserve any untracked variables +func (cl *ConfigLoader) GenerateEnvFile(filename string, useCurrentValues bool) error { + // Check if file exists and parse it to preserve untracked variables + var existingUntracked []envFileLine + if _, err := os.Stat(filename); err == nil { + existingVars, err := parseEnvFile(filename) + if err == nil { + // Track which variables are managed by ezconf + managedVars := make(map[string]bool) + for _, envVar := range cl.envVars { + managedVars[envVar.Name] = true + } + + // Collect untracked variables + for _, line := range existingVars { + if line.IsVar && !managedVars[line.Key] { + existingUntracked = append(existingUntracked, line) + } + } + } + } + + file, err := os.Create(filename) + if err != nil { + return errors.Wrap(err, "failed to create env file") + } + defer file.Close() + + writer := bufio.NewWriter(file) + defer writer.Flush() + + // Write header + fmt.Fprintln(writer, "# Environment Configuration") + fmt.Fprintln(writer, "# Generated by ezconf") + fmt.Fprintln(writer, "#") + fmt.Fprintln(writer, "# Variables marked as (required) must be set") + fmt.Fprintln(writer, "# Variables with defaults can be left commented out to use the default value") + + // Group variables by their Group field + groups := make(map[string][]EnvVar) + groupOrder := make([]string, 0) + + for _, envVar := range cl.envVars { + group := envVar.Group + if group == "" { + group = "Other" + } + + if _, exists := groups[group]; !exists { + groupOrder = append(groupOrder, group) + } + groups[group] = append(groups[group], envVar) + } + + // Write variables grouped by section + for _, group := range groupOrder { + vars := groups[group] + + // Print group header + fmt.Fprintln(writer) + fmt.Fprintf(writer, "# %s Configuration\n", group) + fmt.Fprintln(writer, strings.Repeat("#", len(group)+15)) + + for _, envVar := range vars { + // Write comment with description + fmt.Fprintf(writer, "# %s", envVar.Description) + if envVar.Required { + fmt.Fprint(writer, " (required)") + } + if envVar.Default != "" { + fmt.Fprintf(writer, " (default: %s)", envVar.Default) + } + fmt.Fprintln(writer) + + // Get value to write + value := "" + if useCurrentValues && envVar.CurrentValue != "" { + value = envVar.CurrentValue + } else if envVar.Default != "" { + value = envVar.Default + } + + // Comment out optional variables with defaults + if !envVar.Required && envVar.Default != "" && (!useCurrentValues || envVar.CurrentValue == "") { + fmt.Fprintf(writer, "# %s=%s\n", envVar.Name, value) + } else { + fmt.Fprintf(writer, "%s=%s\n", envVar.Name, value) + } + + fmt.Fprintln(writer) + } + } + + // Write untracked variables from existing file + if len(existingUntracked) > 0 { + fmt.Fprintln(writer) + fmt.Fprintln(writer, "# Untracked Variables") + fmt.Fprintln(writer, "# These variables were in the original file but are not managed by ezconf") + fmt.Fprintln(writer, strings.Repeat("#", 72)) + fmt.Fprintln(writer) + + for _, line := range existingUntracked { + fmt.Fprintf(writer, "%s=%s\n", line.Key, line.Value) + } + } + + return nil +} + +// UpdateEnvFile updates an existing .env file with new variables or updates existing ones +func (cl *ConfigLoader) UpdateEnvFile(filename string, createIfNotExist bool) error { + // Check if file exists + _, err := os.Stat(filename) + if os.IsNotExist(err) { + if createIfNotExist { + return cl.GenerateEnvFile(filename, false) + } + return errors.Errorf("env file does not exist: %s", filename) + } + + // Read existing file + existingVars, err := parseEnvFile(filename) + if err != nil { + return errors.Wrap(err, "failed to parse existing env file") + } + + // Create a map for quick lookup + existingMap := make(map[string]string) + for _, line := range existingVars { + if line.IsVar { + existingMap[line.Key] = line.Value + } + } + + // Create new file with updates + tempFile := filename + ".tmp" + file, err := os.Create(tempFile) + if err != nil { + return errors.Wrap(err, "failed to create temp file") + } + defer file.Close() + + writer := bufio.NewWriter(file) + defer writer.Flush() + + // Track which variables we've written + writtenVars := make(map[string]bool) + + // Copy existing file, updating values as needed + for _, line := range existingVars { + if line.IsVar { + // Check if we have this variable in our config + found := false + for _, envVar := range cl.envVars { + if envVar.Name == line.Key { + found = true + // Keep existing value if it's set + if line.Value != "" { + fmt.Fprintf(writer, "%s=%s\n", line.Key, line.Value) + } else { + // Use default if available + value := envVar.Default + fmt.Fprintf(writer, "%s=%s\n", line.Key, value) + } + writtenVars[envVar.Name] = true + break + } + } + if !found { + // Variable not in our config, keep it anyway + fmt.Fprintf(writer, "%s=%s\n", line.Key, line.Value) + } + } else { + // Comment or empty line, keep as-is + fmt.Fprintln(writer, line.Line) + } + } + + // Add new variables that weren't in the file + addedNew := false + for _, envVar := range cl.envVars { + if !writtenVars[envVar.Name] { + if !addedNew { + fmt.Fprintln(writer) + fmt.Fprintln(writer, "# New variables added by ezconf") + addedNew = true + } + + // Write comment with description + fmt.Fprintf(writer, "# %s", envVar.Description) + if envVar.Required { + fmt.Fprint(writer, " (required)") + } + if envVar.Default != "" { + fmt.Fprintf(writer, " (default: %s)", envVar.Default) + } + fmt.Fprintln(writer) + + // Write variable with default value + value := envVar.Default + fmt.Fprintf(writer, "%s=%s\n", envVar.Name, value) + fmt.Fprintln(writer) + } + } + + writer.Flush() + file.Close() + + // Replace original file with updated one + if err := os.Rename(tempFile, filename); err != nil { + return errors.Wrap(err, "failed to replace env file") + } + + return nil +} + +// envFileLine represents a line in an .env file +type envFileLine struct { + Line string // The full line + IsVar bool // Whether this is a variable assignment + Key string // Variable name (if IsVar is true) + Value string // Variable value (if IsVar is true) +} + +// parseEnvFile parses an .env file and returns all lines +func parseEnvFile(filename string) ([]envFileLine, error) { + file, err := os.Open(filename) + if err != nil { + return nil, errors.Wrap(err, "failed to open file") + } + defer file.Close() + + lines := make([]envFileLine, 0) + scanner := bufio.NewScanner(file) + + for scanner.Scan() { + line := scanner.Text() + trimmed := strings.TrimSpace(line) + + // Check if this is a variable assignment + if trimmed != "" && !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "=") { + parts := strings.SplitN(trimmed, "=", 2) + if len(parts) == 2 { + lines = append(lines, envFileLine{ + Line: line, + IsVar: true, + Key: strings.TrimSpace(parts[0]), + Value: strings.TrimSpace(parts[1]), + }) + continue + } + } + + // Comment or empty line + lines = append(lines, envFileLine{ + Line: line, + IsVar: false, + }) + } + + if err := scanner.Err(); err != nil { + return nil, errors.Wrap(err, "failed to scan file") + } + + return lines, nil +} diff --git a/ezconf/output_test.go b/ezconf/output_test.go new file mode 100644 index 0000000..13e3161 --- /dev/null +++ b/ezconf/output_test.go @@ -0,0 +1,362 @@ +package ezconf + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPrintEnvVars(t *testing.T) { + loader := New() + loader.envVars = []EnvVar{ + { + Name: "LOG_LEVEL", + Description: "Log level", + Required: false, + Default: "info", + CurrentValue: "debug", + }, + { + Name: "DATABASE_URL", + Description: "Database connection", + Required: true, + Default: "", + CurrentValue: "postgres://localhost/db", + }, + } + + // Test without values + t.Run("without values", func(t *testing.T) { + buf := &bytes.Buffer{} + err := loader.PrintEnvVars(buf, false) + if err != nil { + t.Fatalf("PrintEnvVars failed: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "LOG_LEVEL") { + t.Error("output should contain LOG_LEVEL") + } + if !strings.Contains(output, "Log level") { + t.Error("output should contain description") + } + if !strings.Contains(output, "(default: info)") { + t.Error("output should contain default value") + } + if strings.Contains(output, "debug") { + t.Error("output should not contain current value when showValues is false") + } + }) + + // Test with values + t.Run("with values", func(t *testing.T) { + buf := &bytes.Buffer{} + err := loader.PrintEnvVars(buf, true) + if err != nil { + t.Fatalf("PrintEnvVars failed: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "LOG_LEVEL=debug") { + t.Error("output should contain LOG_LEVEL=debug") + } + if !strings.Contains(output, "DATABASE_URL=postgres://localhost/db") { + t.Error("output should contain DATABASE_URL value") + } + if !strings.Contains(output, "(required)") { + t.Error("output should indicate required variables") + } + }) +} + +func TestGenerateEnvFile(t *testing.T) { + loader := New() + loader.envVars = []EnvVar{ + { + Name: "LOG_LEVEL", + Description: "Log level", + Required: false, + Default: "info", + CurrentValue: "debug", + }, + { + Name: "DATABASE_URL", + Description: "Database connection", + Required: true, + Default: "postgres://localhost/db", + CurrentValue: "", + }, + } + + tempDir := t.TempDir() + + t.Run("generate with defaults", func(t *testing.T) { + envFile := filepath.Join(tempDir, "test1.env") + + err := loader.GenerateEnvFile(envFile, false) + if err != nil { + t.Fatalf("GenerateEnvFile failed: %v", err) + } + + content, err := os.ReadFile(envFile) + if err != nil { + t.Fatalf("failed to read generated file: %v", err) + } + + output := string(content) + if !strings.Contains(output, "LOG_LEVEL=info") { + t.Error("expected default value for LOG_LEVEL") + } + if !strings.Contains(output, "# Log level") { + t.Error("expected description comment") + } + if !strings.Contains(output, "# Database connection") { + t.Error("expected DATABASE_URL description") + } + }) + + t.Run("generate with current values", func(t *testing.T) { + envFile := filepath.Join(tempDir, "test2.env") + + err := loader.GenerateEnvFile(envFile, true) + if err != nil { + t.Fatalf("GenerateEnvFile failed: %v", err) + } + + content, err := os.ReadFile(envFile) + if err != nil { + t.Fatalf("failed to read generated file: %v", err) + } + + output := string(content) + if !strings.Contains(output, "LOG_LEVEL=debug") { + t.Error("expected current value for LOG_LEVEL") + } + // DATABASE_URL has no current value, should use default + if !strings.Contains(output, "DATABASE_URL=postgres://localhost/db") { + t.Error("expected default value for DATABASE_URL when current is empty") + } + }) + + t.Run("preserve untracked variables", func(t *testing.T) { + envFile := filepath.Join(tempDir, "test3.env") + + // Create existing file with untracked variable + existing := `# Existing file +LOG_LEVEL=warn +CUSTOM_VAR=custom_value +ANOTHER_VAR=another_value +` + if err := os.WriteFile(envFile, []byte(existing), 0644); err != nil { + t.Fatalf("failed to create existing file: %v", err) + } + + // Generate new file - should preserve untracked variables + err := loader.GenerateEnvFile(envFile, false) + if err != nil { + t.Fatalf("GenerateEnvFile failed: %v", err) + } + + content, err := os.ReadFile(envFile) + if err != nil { + t.Fatalf("failed to read generated file: %v", err) + } + + output := string(content) + // Should have tracked variables with new format + if !strings.Contains(output, "LOG_LEVEL") { + t.Error("expected LOG_LEVEL to be present") + } + if !strings.Contains(output, "DATABASE_URL") { + t.Error("expected DATABASE_URL to be present") + } + // Should preserve untracked variables + if !strings.Contains(output, "CUSTOM_VAR=custom_value") { + t.Error("expected to preserve CUSTOM_VAR") + } + if !strings.Contains(output, "ANOTHER_VAR=another_value") { + t.Error("expected to preserve ANOTHER_VAR") + } + // Should have untracked section header + if !strings.Contains(output, "Untracked Variables") { + t.Error("expected untracked variables section header") + } + }) +} + +func TestUpdateEnvFile(t *testing.T) { + loader := New() + loader.envVars = []EnvVar{ + { + Name: "LOG_LEVEL", + Description: "Log level", + Default: "info", + }, + { + Name: "NEW_VAR", + Description: "New variable", + Default: "new_default", + }, + } + + tempDir := t.TempDir() + + t.Run("update existing file", func(t *testing.T) { + envFile := filepath.Join(tempDir, "existing.env") + + // Create existing file + existing := `# Existing file +LOG_LEVEL=debug +OLD_VAR=old_value +` + if err := os.WriteFile(envFile, []byte(existing), 0644); err != nil { + t.Fatalf("failed to create existing file: %v", err) + } + + err := loader.UpdateEnvFile(envFile, false) + if err != nil { + t.Fatalf("UpdateEnvFile failed: %v", err) + } + + content, err := os.ReadFile(envFile) + if err != nil { + t.Fatalf("failed to read updated file: %v", err) + } + + output := string(content) + // Should preserve existing value + if !strings.Contains(output, "LOG_LEVEL=debug") { + t.Error("expected to preserve existing LOG_LEVEL value") + } + // Should keep old variable + if !strings.Contains(output, "OLD_VAR=old_value") { + t.Error("expected to preserve OLD_VAR") + } + // Should add new variable + if !strings.Contains(output, "NEW_VAR=new_default") { + t.Error("expected to add NEW_VAR") + } + }) + + t.Run("create if not exist", func(t *testing.T) { + envFile := filepath.Join(tempDir, "new.env") + + err := loader.UpdateEnvFile(envFile, true) + if err != nil { + t.Fatalf("UpdateEnvFile failed: %v", err) + } + + if _, err := os.Stat(envFile); os.IsNotExist(err) { + t.Error("expected file to be created") + } + }) + + t.Run("error if not exist and no create", func(t *testing.T) { + envFile := filepath.Join(tempDir, "nonexistent.env") + + err := loader.UpdateEnvFile(envFile, false) + if err == nil { + t.Error("expected error for nonexistent file") + } + }) +} + +func TestParseEnvFile(t *testing.T) { + tempDir := t.TempDir() + envFile := filepath.Join(tempDir, "test.env") + + content := `# Comment line +VAR1=value1 +VAR2=value2 + +# Another comment +VAR3=value3 +EMPTY_VAR= +` + + if err := os.WriteFile(envFile, []byte(content), 0644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + lines, err := parseEnvFile(envFile) + if err != nil { + t.Fatalf("parseEnvFile failed: %v", err) + } + + varCount := 0 + for _, line := range lines { + if line.IsVar { + varCount++ + } + } + + if varCount != 4 { + t.Errorf("expected 4 variables, got %d", varCount) + } + + // Check specific variables + found := false + for _, line := range lines { + if line.IsVar && line.Key == "VAR1" && line.Value == "value1" { + found = true + break + } + } + if !found { + t.Error("expected to find VAR1=value1") + } +} + +func TestParseEnvFile_InvalidFile(t *testing.T) { + _, err := parseEnvFile("/nonexistent/file.env") + if err == nil { + t.Error("expected error for nonexistent file") + } +} + +func TestPrintEnvVars_NoEnvVars(t *testing.T) { + loader := New() + + buf := &bytes.Buffer{} + err := loader.PrintEnvVars(buf, false) + if err == nil { + t.Error("expected error when no env vars are loaded") + } + if !strings.Contains(err.Error(), "did you call Load()") { + t.Errorf("expected helpful error message, got: %v", err) + } +} + +func TestPrintEnvVarsStdout(t *testing.T) { + loader := New() + loader.envVars = []EnvVar{ + { + Name: "TEST_VAR", + Description: "Test variable", + Default: "test", + }, + } + + // This test just ensures it doesn't panic + // We can't easily capture stdout in a unit test without redirecting it + err := loader.PrintEnvVarsStdout(false) + if err != nil { + t.Errorf("PrintEnvVarsStdout(false) failed: %v", err) + } + + err = loader.PrintEnvVarsStdout(true) + if err != nil { + t.Errorf("PrintEnvVarsStdout(true) failed: %v", err) + } +} + +func TestPrintEnvVarsStdout_NoEnvVars(t *testing.T) { + loader := New() + + err := loader.PrintEnvVarsStdout(false) + if err == nil { + t.Error("expected error when no env vars are loaded") + } +} diff --git a/ezconf/parser.go b/ezconf/parser.go new file mode 100644 index 0000000..aa09f7f --- /dev/null +++ b/ezconf/parser.go @@ -0,0 +1,146 @@ +package ezconf + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "regexp" + "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") + } + + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, filename, content, parser.ParseComments) + if err != nil { + return nil, errors.Wrap(err, "failed to parse file") + } + + 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 + } + + structType, ok := typeSpec.Type.(*ast.StructType) + if !ok { + return true + } + + // 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 + }) + + 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") + } + + 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") + } + + envVar := &EnvVar{ + Name: strings.TrimSpace(comment[:colonIdx]), + } + + // 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, "") + } + + // 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, "") + } + + // What remains is the description + envVar.Description = strings.TrimSpace(remainder) + + return envVar, nil +} diff --git a/ezconf/parser_test.go b/ezconf/parser_test.go new file mode 100644 index 0000000..0dcdfe1 --- /dev/null +++ b/ezconf/parser_test.go @@ -0,0 +1,202 @@ +package ezconf + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseEnvComment(t *testing.T) { + tests := []struct { + name string + comment string + wantEnvVar *EnvVar + expectError bool + }{ + { + name: "simple env variable", + comment: "ENV LOG_LEVEL: Log level for the application", + wantEnvVar: &EnvVar{ + Name: "LOG_LEVEL", + Description: "Log level for the application", + Required: false, + Default: "", + }, + expectError: false, + }, + { + name: "env variable with default", + comment: "ENV LOG_LEVEL: Log level for the application (default: info)", + wantEnvVar: &EnvVar{ + Name: "LOG_LEVEL", + Description: "Log level for the application", + Required: false, + Default: "info", + }, + expectError: false, + }, + { + name: "required env variable", + comment: "ENV DATABASE_URL: Database connection string (required)", + wantEnvVar: &EnvVar{ + Name: "DATABASE_URL", + Description: "Database connection string", + Required: true, + Default: "", + }, + 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)", + wantEnvVar: &EnvVar{ + Name: "LOG_DIR", + Description: "Directory for log files", + Required: true, + Default: "/var/log", + }, + expectError: false, + }, + { + name: "missing colon", + comment: "ENV LOG_LEVEL Log level", + wantEnvVar: nil, + expectError: true, + }, + { + name: "not an ENV comment", + comment: "This is a regular comment", + wantEnvVar: nil, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + envVar, err := parseEnvComment(tt.comment) + + if tt.expectError { + if err == nil { + t.Errorf("expected error but got none") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if envVar.Name != tt.wantEnvVar.Name { + t.Errorf("Name = %v, want %v", envVar.Name, tt.wantEnvVar.Name) + } + if envVar.Description != tt.wantEnvVar.Description { + t.Errorf("Description = %v, want %v", envVar.Description, tt.wantEnvVar.Description) + } + if envVar.Required != tt.wantEnvVar.Required { + t.Errorf("Required = %v, want %v", envVar.Required, tt.wantEnvVar.Required) + } + if envVar.Default != tt.wantEnvVar.Default { + t.Errorf("Default = %v, want %v", envVar.Default, tt.wantEnvVar.Default) + } + }) + } +} + +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) + } + + envVars, err := ParseConfigFile(testFile) + if err != nil { + t.Fatalf("ParseConfigFile failed: %v", err) + } + + if len(envVars) != 3 { + t.Errorf("expected 3 env vars, got %d", len(envVars)) + } + + // Check first variable + if envVars[0].Name != "LOG_LEVEL" { + t.Errorf("expected LOG_LEVEL, got %s", envVars[0].Name) + } + if envVars[0].Default != "info" { + t.Errorf("expected default 'info', got %s", envVars[0].Default) + } + + // Check required variable + if envVars[2].Name != "DATABASE_URL" { + t.Errorf("expected DATABASE_URL, got %s", envVars[2].Name) + } + if !envVars[2].Required { + t.Error("expected DATABASE_URL to be required") + } +} + +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") + if err == nil { + t.Error("expected error for nonexistent file") + } +} + +func TestParseConfigPackage_InvalidPath(t *testing.T) { + envVars, err := ParseConfigPackage("/nonexistent/package") + if err != nil { + t.Fatalf("ParseConfigPackage should not error on invalid path: %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)) + } +} diff --git a/hlog/README.md b/hlog/README.md index 44c8ebf..75093db 100644 --- a/hlog/README.md +++ b/hlog/README.md @@ -1,4 +1,4 @@ -# HLog - v0.10.3 +# HLog - v0.10.4 A structured logging package for Go built on top of [zerolog](https://github.com/rs/zerolog). HLog provides simple configuration via environment variables, flexible output options, and automatic log file management. diff --git a/hlog/ezconf.go b/hlog/ezconf.go new file mode 100644 index 0000000..dcb9584 --- /dev/null +++ b/hlog/ezconf.go @@ -0,0 +1,35 @@ +package hlog + +import "runtime" + +// 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{} +} diff --git a/hws/README.md b/hws/README.md index c87e779..31fdc39 100644 --- a/hws/README.md +++ b/hws/README.md @@ -1,4 +1,4 @@ -# HWS (H Web Server) - v0.2.2 +# HWS (H Web Server) - v0.2.3 A lightweight, opinionated HTTP web server framework for Go built on top of the standard library's net/http. diff --git a/hws/ezconf.go b/hws/ezconf.go new file mode 100644 index 0000000..4c25c16 --- /dev/null +++ b/hws/ezconf.go @@ -0,0 +1,35 @@ +package hws + +import "runtime" + +// EZConfIntegration provides integration with ezconf for automatic configuration +type EZConfIntegration struct{} + +// PackagePath returns the path to the hws 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 "hws" +} + +// GroupName returns the display name for grouping environment variables +func (e EZConfIntegration) GroupName() string { + return "HWS" +} + +// NewEZConfIntegration creates a new EZConf integration helper +func NewEZConfIntegration() EZConfIntegration { + return EZConfIntegration{} +} diff --git a/hwsauth/README.md b/hwsauth/README.md index 9a6a166..c4f763f 100644 --- a/hwsauth/README.md +++ b/hwsauth/README.md @@ -1,4 +1,4 @@ -# HWSAuth - v0.3.3 +# HWSAuth - v0.3.4 JWT-based authentication middleware for the HWS web framework. diff --git a/hwsauth/ezconf.go b/hwsauth/ezconf.go new file mode 100644 index 0000000..39ca8ff --- /dev/null +++ b/hwsauth/ezconf.go @@ -0,0 +1,35 @@ +package hwsauth + +import "runtime" + +// EZConfIntegration provides integration with ezconf for automatic configuration +type EZConfIntegration struct{} + +// PackagePath returns the path to the hwsauth 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 "hwsauth" +} + +// GroupName returns the display name for grouping environment variables +func (e EZConfIntegration) GroupName() string { + return "HWSAuth" +} + +// NewEZConfIntegration creates a new EZConf integration helper +func NewEZConfIntegration() EZConfIntegration { + return EZConfIntegration{} +} diff --git a/tmdb/LICENSE b/tmdb/LICENSE new file mode 100644 index 0000000..fbf1733 --- /dev/null +++ b/tmdb/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 haelnorr + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tmdb/README.md b/tmdb/README.md new file mode 100644 index 0000000..0d8e4d5 --- /dev/null +++ b/tmdb/README.md @@ -0,0 +1,239 @@ +# TMDB - v0.9.2 + +A Go client library for The Movie Database (TMDB) API with automatic rate limiting, retry logic, and convenient helper functions. + +## Features + +- Clean interface for TMDB's REST API +- Automatic rate limiting with exponential backoff +- Retry logic for rate limit errors (respects Retry-After header) +- Movie search functionality +- Movie details retrieval +- Cast and crew information +- Image URL helpers +- Environment variable configuration with ConfigFromEnv +- EZConf integration for unified configuration +- Comprehensive test coverage (94.1%) + +## Installation + +```bash +go get git.haelnorr.com/h/golib/tmdb +``` + +## Quick Start + +### Basic Usage + +```go +package main + +import ( + "fmt" + "log" + "git.haelnorr.com/h/golib/tmdb" +) + +func main() { + // Create API connection + api, err := tmdb.NewAPIConnection() + if err != nil { + log.Fatal(err) + } + + // Search for a movie + results, err := api.SearchMovies("Fight Club", false, 1) + if err != nil { + log.Fatal(err) + } + + for _, movie := range results.Results { + fmt.Printf("%s (%s)\n", movie.Title, movie.ReleaseYear()) + fmt.Printf("Poster: %s\n", movie.GetPoster(&api.Image, "w500")) + } +} +``` + +### Getting Movie Details + +```go +// Get detailed information about a movie +movie, err := api.GetMovie(550) // Fight Club +if err != nil { + log.Fatal(err) +} + +fmt.Printf("Title: %s\n", movie.Title) +fmt.Printf("Overview: %s\n", movie.Overview) +fmt.Printf("Release Date: %s\n", movie.ReleaseDate) +fmt.Printf("IMDb ID: %s\n", movie.IMDbID) +fmt.Printf("Rating: %.1f/10\n", movie.VoteAverage) +``` + +### Getting Cast and Crew + +```go +// Get credits for a movie +credits, err := api.GetCredits(550) +if err != nil { + log.Fatal(err) +} + +fmt.Println("Cast:") +for _, actor := range credits.Cast { + fmt.Printf(" %s as %s\n", actor.Name, actor.Character) +} + +fmt.Println("\nDirector:") +for _, member := range credits.Crew { + if member.Job == "Director" { + fmt.Printf(" %s\n", member.Name) + } +} +``` + +## Configuration + +### Environment Variables + +The package requires the following environment variable: + +```bash +# TMDB API access token (required) +TMDB_TOKEN=your_api_token_here +``` + +Get your API token from: https://www.themoviedb.org/settings/api + +### Using EZConf Integration + +```go +import ( + "git.haelnorr.com/h/golib/ezconf" + "git.haelnorr.com/h/golib/tmdb" +) + +loader := ezconf.New() +loader.RegisterIntegration(tmdb.NewEZConfIntegration()) +loader.Load() + +// Get the configured API connection +api, ok := loader.GetConfig("tmdb") +if !ok { + log.Fatal("tmdb config not found") +} +``` + +## Rate Limiting + +TMDB has rate limits around 40 requests per second. This package implements automatic retry logic with exponential backoff: + +- **Initial backoff**: 1 second +- **Exponential growth**: 1s → 2s → 4s → 8s → 16s → 32s (max) +- **Maximum retries**: 3 attempts +- **Respects** Retry-After header when provided by the API + +All API calls automatically handle rate limiting, so you don't need to worry about it. + +## Image URLs + +The TMDB API provides base URLs for images. Use helper methods to construct full image URLs: + +```go +// Available poster sizes: "w92", "w154", "w185", "w342", "w500", "w780", "original" +posterURL := movie.GetPoster(&api.Image, "w500") + +// Available backdrop sizes: "w300", "w780", "w1280", "original" +backdropURL := movie.GetBackdrop(&api.Image, "w1280") + +// Available profile sizes: "w45", "w185", "h632", "original" +profileURL := actor.GetProfile(&api.Image, "w185") +``` + +## API Reference + +### Main Functions + +- `NewAPIConnection() (*APIConnection, error)` - Create a new API connection +- `SearchMovies(query string, includeAdult bool, page int) (*SearchResponse, error)` - Search for movies +- `GetMovie(movieID int) (*Movie, error)` - Get detailed movie information +- `GetCredits(movieID int) (*Credits, error)` - Get cast and crew information + +### Helper Methods + +**Movie Methods:** +- `ReleaseYear() string` - Extract year from release date +- `GetPoster(imgConfig *ImageConfig, size string) string` - Get full poster URL +- `GetBackdrop(imgConfig *ImageConfig, size string) string` - Get full backdrop URL + +**Cast/Crew Methods:** +- `GetProfile(imgConfig *ImageConfig, size string) string` - Get full profile image URL + +## Error Handling + +The package returns wrapped errors for easy debugging: + +```go +data, err := api.SearchMovies("Inception", false, 1) +if err != nil { + if strings.Contains(err.Error(), "rate limit exceeded") { + // Handle rate limiting + } else if strings.Contains(err.Error(), "unexpected status code: 401") { + // Invalid API token + } else if strings.Contains(err.Error(), "unexpected status code: 404") { + // Resource not found + } else { + // Network or other errors + } +} +``` + +## Documentation + +For detailed documentation, see the [TMDB Wiki](https://git.haelnorr.com/h/golib/wiki/TMDB.md). + +Additional API documentation is available at [GoDoc](https://pkg.go.dev/git.haelnorr.com/h/golib/tmdb). + +## Testing + +Run the test suite (requires a valid TMDB_TOKEN environment variable): + +```bash +export TMDB_TOKEN=your_api_token_here +go test -v ./... +``` + +Current test coverage: 94.1% + +## Best Practices + +1. **Reuse API connections** - Create one connection and reuse it for multiple requests +2. **Cache responses** - Cache API responses when appropriate to reduce API calls +3. **Use specific image sizes** - Use appropriate image sizes instead of "original" to save bandwidth +4. **Handle rate limits gracefully** - The library handles this automatically, but be aware it may introduce delays +5. **Set a timeout** - Consider using context with timeout for long-running operations + +## Example Projects + +Check out these projects using the TMDB library: + +- [Project ReShoot](https://git.haelnorr.com/h/reshoot) - Movie database application + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## Related Projects + +- [ezconf](https://git.haelnorr.com/h/golib/ezconf) - Unified configuration management +- [hlog](https://git.haelnorr.com/h/golib/hlog) - Structured logging with zerolog + +## External Resources + +- [TMDB API Documentation](https://developer.themoviedb.org/docs) +- [Get API Token](https://www.themoviedb.org/settings/api) +- [TMDB Website](https://www.themoviedb.org/) diff --git a/tmdb/ezconf.go b/tmdb/ezconf.go new file mode 100644 index 0000000..ee39ccb --- /dev/null +++ b/tmdb/ezconf.go @@ -0,0 +1,36 @@ +package tmdb + +import "runtime" + +// EZConfIntegration provides integration with ezconf for automatic configuration +type EZConfIntegration struct{} + +// PackagePath returns the path to the tmdb 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 NewAPIConnection function for ezconf +// Note: tmdb uses NewAPIConnection instead of ConfigFromEnv +func (e EZConfIntegration) ConfigFunc() func() (interface{}, error) { + return func() (interface{}, error) { + return NewAPIConnection() + } +} + +// Name returns the name to use when registering with ezconf +func (e EZConfIntegration) Name() string { + return "tmdb" +} + +// GroupName returns the display name for grouping environment variables +func (e EZConfIntegration) GroupName() string { + return "TMDB" +} + +// NewEZConfIntegration creates a new EZConf integration helper +func NewEZConfIntegration() EZConfIntegration { + return EZConfIntegration{} +}