diff --git a/ezconf/ezconf.go b/ezconf/ezconf.go index 84c2654..a417f5e 100644 --- a/ezconf/ezconf.go +++ b/ezconf/ezconf.go @@ -18,16 +18,16 @@ type EnvVar struct { // 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 + 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 } // ConfigFunc is a function that loads configuration from environment variables -type ConfigFunc func() (interface{}, error) +type ConfigFunc func() (any, error) // New creates a new ConfigLoader func New() *ConfigLoader { @@ -37,7 +37,7 @@ func New() *ConfigLoader { groupNames: make(map[string]string), extraEnvVars: make([]EnvVar, 0), envVars: make([]EnvVar, 0), - configs: make(map[string]interface{}), + configs: make(map[string]any), } } @@ -72,8 +72,12 @@ 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 { +// ParseEnvVars extracts environment variables from packages 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) @@ -102,6 +106,12 @@ func (cl *ConfigLoader) Load() error { cl.envVars[i].CurrentValue = os.Getenv(cl.envVars[i].Name) } + return nil +} + +// 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 { cfg, err := fn() @@ -114,14 +124,22 @@ func (cl *ConfigLoader) Load() error { return nil } +// Load loads all configurations and extracts environment variables +func (cl *ConfigLoader) Load() error { + if err := cl.ParseEnvVars(); err != nil { + return err + } + return cl.LoadConfigs() +} + // GetConfig returns a loaded configuration by name -func (cl *ConfigLoader) GetConfig(name string) (interface{}, bool) { +func (cl *ConfigLoader) GetConfig(name string) (any, bool) { cfg, ok := cl.configs[name] return cfg, ok } // GetAllConfigs returns all loaded configurations -func (cl *ConfigLoader) GetAllConfigs() map[string]interface{} { +func (cl *ConfigLoader) GetAllConfigs() map[string]any { return cl.configs } diff --git a/ezconf/ezconf_test.go b/ezconf/ezconf_test.go index 2a92a82..fbb127d 100644 --- a/ezconf/ezconf_test.go +++ b/ezconf/ezconf_test.go @@ -3,6 +3,7 @@ package ezconf import ( "os" "path/filepath" + "strings" "testing" ) @@ -240,6 +241,163 @@ func TestGetEnvVars(t *testing.T) { } } +func TestParseEnvVars(t *testing.T) { + loader := New() + + // Add a test config function + loader.AddConfigFunc("test", func() (interface{}, error) { + return "test config", 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.ParseEnvVars() + if err != nil { + t.Fatalf("ParseEnvVars failed: %v", err) + } + + // 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") + } + + // Check that configs are NOT loaded (should be empty) + configs := loader.GetAllConfigs() + if len(configs) != 0 { + t.Errorf("expected no configs loaded after ParseEnvVars, got %d", len(configs)) + } +} + +func TestLoadConfigs(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 + }) + + // Manually set some env vars (simulating ParseEnvVars already called) + loader.envVars = []EnvVar{ + {Name: "TEST_VAR", Description: "Test variable"}, + } + + err := loader.LoadConfigs() + if err != nil { + t.Fatalf("LoadConfigs 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") + } + _ = cfg // Use the variable to avoid unused variable error + + // Check that env vars are NOT modified (should remain as set) + envVars := loader.GetEnvVars() + if len(envVars) != 1 { + t.Errorf("expected 1 env var, got %d", len(envVars)) + } +} + +func TestLoadConfigs_Error(t *testing.T) { + loader := New() + + loader.AddConfigFunc("error", func() (interface{}, error) { + return nil, os.ErrNotExist + }) + + err := loader.LoadConfigs() + if err == nil { + t.Error("expected error from failing config func") + } +} + +func TestParseEnvVars_Then_LoadConfigs(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", + }) + + // First parse env vars + err := loader.ParseEnvVars() + if err != nil { + t.Fatalf("ParseEnvVars failed: %v", err) + } + + // Check env vars are extracted but configs are not loaded + envVars := loader.GetEnvVars() + if len(envVars) == 0 { + t.Error("expected env vars to be extracted") + } + + configs := loader.GetAllConfigs() + if len(configs) != 0 { + t.Error("expected no configs loaded yet") + } + + // Then load configs + err = loader.LoadConfigs() + if err != nil { + t.Fatalf("LoadConfigs failed: %v", err) + } + + // Check both env vars and configs are loaded + _, ok := loader.GetConfig("test") + if !ok { + t.Error("test config not loaded after LoadConfigs") + } + + configs = loader.GetAllConfigs() + if len(configs) != 1 { + t.Errorf("expected 1 config loaded, got %d", len(configs)) + } +} + func TestLoad_Integration(t *testing.T) { // Integration test with real hlog package hlogPath := filepath.Join("..", "hlog") @@ -269,3 +427,62 @@ func TestLoad_Integration(t *testing.T) { t.Logf(" %s: %s (default: %s, required: %t)", ev.Name, ev.Description, ev.Default, ev.Required) } } + +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") + } + + loader := New() + + // Add hlog package + if err := loader.AddPackagePath(hlogPath); err != nil { + t.Fatalf("failed to add hlog package: %v", err) + } + + // Parse env vars without loading configs (this should work even if required env vars are missing) + 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") + } + + // Now test that we can generate an env file without calling Load() + tempDir := t.TempDir() + envFile := filepath.Join(tempDir, "test-generated.env") + + err := loader.GenerateEnvFile(envFile, false) + if err != nil { + t.Fatalf("GenerateEnvFile failed: %v", err) + } + + // Verify the file was created and contains expected content + content, err := os.ReadFile(envFile) + if err != nil { + t.Fatalf("failed to read generated file: %v", err) + } + + output := string(content) + if !strings.Contains(output, "# Environment Configuration") { + t.Error("expected header in generated file") + } + + // Should contain environment variables from hlog + foundHlogVar := false + for _, ev := range envVars { + if strings.Contains(output, ev.Name) { + foundHlogVar = true + break + } + } + if !foundHlogVar { + t.Error("expected to find at least one hlog environment variable in generated file") + } + + t.Logf("Successfully generated env file with %d variables", len(envVars)) +} diff --git a/ezconf/output.go b/ezconf/output.go index eebffc6..471c317 100644 --- a/ezconf/output.go +++ b/ezconf/output.go @@ -12,20 +12,20 @@ import ( // 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 { + if 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) } @@ -35,7 +35,7 @@ func (cl *ConfigLoader) PrintEnvVars(w io.Writer, showValues bool) error { // 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 { @@ -51,12 +51,12 @@ func (cl *ConfigLoader) PrintEnvVars(w io.Writer, showValues bool) error { 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 @@ -69,10 +69,10 @@ func (cl *ConfigLoader) PrintEnvVars(w io.Writer, showValues bool) error { } 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) @@ -85,7 +85,7 @@ func (cl *ConfigLoader) PrintEnvVars(w io.Writer, showValues bool) error { fmt.Fprintln(w) } } - + fmt.Fprintln(w) return nil @@ -109,7 +109,7 @@ func (cl *ConfigLoader) GenerateEnvFile(filename string, useCurrentValues bool) for _, envVar := range cl.envVars { managedVars[envVar.Name] = true } - + // Collect untracked variables for _, line := range existingVars { if line.IsVar && !managedVars[line.Key] { @@ -118,7 +118,7 @@ func (cl *ConfigLoader) GenerateEnvFile(filename string, useCurrentValues bool) } } } - + file, err := os.Create(filename) if err != nil { return errors.Wrap(err, "failed to create env file") @@ -138,13 +138,13 @@ func (cl *ConfigLoader) GenerateEnvFile(filename string, useCurrentValues bool) // 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) } @@ -154,12 +154,12 @@ func (cl *ConfigLoader) GenerateEnvFile(filename string, useCurrentValues bool) // 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) @@ -185,7 +185,7 @@ func (cl *ConfigLoader) GenerateEnvFile(filename string, useCurrentValues bool) } else { fmt.Fprintf(writer, "%s=%s\n", envVar.Name, value) } - + fmt.Fprintln(writer) } } @@ -197,7 +197,7 @@ func (cl *ConfigLoader) GenerateEnvFile(filename string, useCurrentValues bool) 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) } diff --git a/ezconf/output_test.go b/ezconf/output_test.go index 13e3161..6e4949c 100644 --- a/ezconf/output_test.go +++ b/ezconf/output_test.go @@ -360,3 +360,46 @@ func TestPrintEnvVarsStdout_NoEnvVars(t *testing.T) { t.Error("expected error when no env vars are loaded") } } + +func TestPrintEnvVars_AfterParseEnvVars(t *testing.T) { + loader := New() + + // Add some env vars manually to simulate ParseEnvVars + loader.envVars = []EnvVar{ + { + Name: "LOG_LEVEL", + Description: "Log level for the application", + Required: false, + Default: "info", + CurrentValue: "", + }, + { + Name: "DATABASE_URL", + Description: "Database connection string", + Required: true, + Default: "", + CurrentValue: "", + }, + } + + // Test that PrintEnvVars works after ParseEnvVars (without Load) + 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, "DATABASE_URL") { + t.Error("output should contain DATABASE_URL") + } + if !strings.Contains(output, "(required)") { + t.Error("output should indicate required variables") + } + if !strings.Contains(output, "(default: info)") { + t.Error("output should contain default value") + } +}