package config import ( "os" "path/filepath" "strings" "testing" "time" "github.com/spf13/pflag" ) // clearEnv removes every GITFLOW_ variable so tests start from a known // state regardless of the developer's shell. func clearEnv(t *testing.T) { t.Helper() for _, kv := range os.Environ() { if strings.HasPrefix(kv, "GITFLOW_") { key := strings.SplitN(kv, "=", 2)[0] os.Unsetenv(key) } } } func loadWithFlags(t *testing.T, set func(f *pflag.FlagSet)) (*Config, error) { t.Helper() fs := NewFlagSet() if set != nil { set(fs) } return Load(fs) } func TestDefaults(t *testing.T) { clearEnv(t) home := t.TempDir() t.Setenv("HOME", home) // no config file present cfg, err := loadWithFlags(t, nil) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Dir != "." { t.Errorf("Dir = %q, want %q", cfg.Dir, ".") } if cfg.Interval != 0 || cfg.MaxDepth != 0 { t.Errorf("Interval/MaxDepth = %v/%d, want 0/0", cfg.Interval, cfg.MaxDepth) } if cfg.Format != "table" || cfg.Workers != 8 { t.Errorf("Format/Workers = %q/%d, want table/8", cfg.Format, cfg.Workers) } if cfg.AI.Provider != "openai" || cfg.AI.Model != "gpt-4o" || cfg.AI.APIKeyEnv != "OPENAI_API_KEY" { t.Errorf("AI defaults wrong: %+v", cfg.AI) } } func TestFlagOverrides(t *testing.T) { clearEnv(t) home := t.TempDir() t.Setenv("HOME", home) cfg, err := loadWithFlags(t, func(f *pflag.FlagSet) { if err := f.Set("format", "compact"); err != nil { t.Fatal(err) } if err := f.Set("dir", "/tmp/x"); err != nil { t.Fatal(err) } if err := f.Set("workers", "4"); err != nil { t.Fatal(err) } }) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Format != "compact" || cfg.Dir != "/tmp/x" || cfg.Workers != 4 { t.Errorf("flag overrides not applied: %+v", cfg) } } func TestEnvOverrides(t *testing.T) { clearEnv(t) home := t.TempDir() t.Setenv("HOME", home) t.Setenv("GITFLOW_FORMAT", "json") t.Setenv("GITFLOW_MAX_DEPTH", "3") t.Setenv("GITFLOW_AI_PROVIDER", "ollama") cfg, err := loadWithFlags(t, nil) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Format != "json" || cfg.MaxDepth != 3 || cfg.AI.Provider != "ollama" { t.Errorf("env overrides not applied: %+v", cfg) } } func TestFlagBeatsEnv(t *testing.T) { clearEnv(t) home := t.TempDir() t.Setenv("HOME", home) t.Setenv("GITFLOW_FORMAT", "json") cfg, err := loadWithFlags(t, func(f *pflag.FlagSet) { if err := f.Set("format", "compact"); err != nil { t.Fatal(err) } }) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Format != "compact" { t.Errorf("Format = %q, want compact (flag must beat env)", cfg.Format) } } func TestConfigFile(t *testing.T) { clearEnv(t) home := t.TempDir() t.Setenv("HOME", home) content := "dir: /home/user/projects\nformat: json\nmax_depth: 2\ninterval: 5m\nexclude:\n - node_modules\n - vendor\n" if err := os.WriteFile(filepath.Join(home, ".gitflow.yaml"), []byte(content), 0o644); err != nil { t.Fatal(err) } cfg, err := loadWithFlags(t, nil) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Dir != "/home/user/projects" || cfg.Format != "json" || cfg.MaxDepth != 2 { t.Errorf("file values not applied: %+v", cfg) } if cfg.Interval != 5*time.Minute { t.Errorf("Interval = %v, want 5m", cfg.Interval) } if len(cfg.Exclude) != 2 || cfg.Exclude[0] != "node_modules" || cfg.Exclude[1] != "vendor" { t.Errorf("Exclude = %v, want [node_modules vendor]", cfg.Exclude) } if cfg.ConfigFile != filepath.Join(home, ".gitflow.yaml") { t.Errorf("ConfigFile = %q, want %q", cfg.ConfigFile, filepath.Join(home, ".gitflow.yaml")) } } func TestConfigPathEnvOverride(t *testing.T) { clearEnv(t) home := t.TempDir() t.Setenv("HOME", home) alt := filepath.Join(home, "custom.yaml") if err := os.WriteFile(alt, []byte("format: compact\n"), 0o644); err != nil { t.Fatal(err) } t.Setenv("GITFLOW_CONFIG", alt) cfg, err := loadWithFlags(t, nil) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Format != "compact" { t.Errorf("Format = %q, want compact from GITFLOW_CONFIG file", cfg.Format) } } func TestValidateRejectsBadFormat(t *testing.T) { clearEnv(t) home := t.TempDir() t.Setenv("HOME", home) if _, err := loadWithFlags(t, func(f *pflag.FlagSet) { if err := f.Set("format", "xml"); err != nil { t.Fatal(err) } }); err == nil { t.Error("Load(bad format) succeeded, want error") } } func TestValidateRejectsBadWorkers(t *testing.T) { clearEnv(t) home := t.TempDir() t.Setenv("HOME", home) if _, err := loadWithFlags(t, func(f *pflag.FlagSet) { if err := f.Set("workers", "0"); err != nil { t.Fatal(err) } }); err == nil { t.Error("Load(workers=0) succeeded, want error") } } func TestConfigRules(t *testing.T) { clearEnv(t) home := t.TempDir() t.Setenv("HOME", home) content := "rules:\n - name: stale\n field: behind\n op: '>='\n value: 10\n label: stale\n - name: dirty\n field: changes\n op: '>='\n value: 1\n label: dirty\n" if err := os.WriteFile(filepath.Join(home, ".gitflow.yaml"), []byte(content), 0o644); err != nil { t.Fatal(err) } cfg, err := loadWithFlags(t, nil) if err != nil { t.Fatalf("Load: %v", err) } if len(cfg.Rules) != 2 { t.Fatalf("Rules = %+v, want 2 rules", cfg.Rules) } if cfg.Rules[0].Field != "behind" || cfg.Rules[0].Op != ">=" || cfg.Rules[0].Value != 10 || cfg.Rules[0].Label != "stale" { t.Errorf("rule[0] = %+v", cfg.Rules[0]) } } func TestConfigRejectsInvalidRules(t *testing.T) { clearEnv(t) home := t.TempDir() t.Setenv("HOME", home) content := "rules:\n - name: bad\n field: nope\n op: '>='\n value: 1\n label: x\n" if err := os.WriteFile(filepath.Join(home, ".gitflow.yaml"), []byte(content), 0o644); err != nil { t.Fatal(err) } if _, err := loadWithFlags(t, nil); err == nil { t.Error("Load(invalid rule) succeeded, want error") } } func TestValidateRejectsBadTheme(t *testing.T) { clearEnv(t) home := t.TempDir() t.Setenv("HOME", home) if _, err := loadWithFlags(t, func(f *pflag.FlagSet) { if err := f.Set("theme", "neon"); err != nil { t.Fatal(err) } }); err == nil { t.Error("Load(bad theme) succeeded, want error") } } func TestValidateRejectsBadProvider(t *testing.T) { clearEnv(t) home := t.TempDir() t.Setenv("HOME", home) if _, err := loadWithFlags(t, func(f *pflag.FlagSet) { if err := f.Set("ai", "true"); err != nil { t.Fatal(err) } if err := f.Set("ai-provider", "magic"); err != nil { t.Fatal(err) } }); err == nil { t.Error("Load(bad ai provider) succeeded, want error") } } func TestConfigWebhooks(t *testing.T) { clearEnv(t) home := t.TempDir() t.Setenv("HOME", home) content := "webhooks:\n - name: slack\n type: slack\n url: https://hooks.slack.com/services/x\n - name: custom\n type: generic\n url: https://example.com/api\n on_change_only: true\n rate_limit: 30s\n retry:\n max_attempts: 3\n backoff: 2s\n" if err := os.WriteFile(filepath.Join(home, ".gitflow.yaml"), []byte(content), 0o644); err != nil { t.Fatal(err) } cfg, err := loadWithFlags(t, nil) if err != nil { t.Fatalf("Load: %v", err) } if len(cfg.Webhooks) != 2 { t.Fatalf("Webhooks = %+v, want 2", cfg.Webhooks) } if cfg.Webhooks[0].Name != "slack" || cfg.Webhooks[0].Type != "slack" { t.Errorf("webhook[0] = %+v", cfg.Webhooks[0]) } if cfg.Webhooks[1].OnChangeOnly != true || cfg.Webhooks[1].RateLimit != 30*time.Second { t.Errorf("webhook[1] = %+v", cfg.Webhooks[1]) } } func TestConfigRejectsBadWebhook(t *testing.T) { clearEnv(t) home := t.TempDir() t.Setenv("HOME", home) bad := []string{ "webhooks:\n - name: x\n type: nope\n url: https://x", "webhooks:\n - name: x\n type: generic\n url: ''", "webhooks:\n - name: x\n type: generic\n url: ftp://x", "webhooks:\n - name: x\n type: generic\n url: https://x\n rate_limit: 1ms", } for _, content := range bad { os.WriteFile(filepath.Join(home, ".gitflow.yaml"), []byte(content), 0o644) if _, err := loadWithFlags(t, nil); err == nil { t.Errorf("Load(bad webhook) succeeded, want error\n%s", content) } } } func TestDump(t *testing.T) { clearEnv(t) home := t.TempDir() t.Setenv("HOME", home) cfg, err := loadWithFlags(t, nil) if err != nil { t.Fatalf("Load: %v", err) } out, err := cfg.Dump() if err != nil { t.Fatalf("Dump: %v", err) } for _, want := range []string{"dir:", "interval:", "format:", "workers:", "ai:", "provider:"} { if !strings.Contains(out, want) { t.Errorf("Dump() missing %q:\n%s", want, out) } } }