package config import ( "os" "path/filepath" "testing" "time" ) func TestInitialize(t *testing.T) { // Test that initialization doesn't error err := Initialize() if err != nil { t.Fatalf("Initialize() returned error: %v", err) } if v == nil { t.Fatal("viper instance is nil after Initialize()") } } func TestDefaults(t *testing.T) { // Reset viper for test isolation err := Initialize() if err != nil { t.Fatalf("Initialize() returned error: %v", err) } tests := []struct { key string expected interface{} getter func(string) interface{} }{ {"json", false, func(k string) interface{} { return GetBool(k) }}, {"no-daemon", false, func(k string) interface{} { return GetBool(k) }}, {"no-auto-flush", false, func(k string) interface{} { return GetBool(k) }}, {"no-auto-import", false, func(k string) interface{} { return GetBool(k) }}, {"db", "", func(k string) interface{} { return GetString(k) }}, {"actor", "", func(k string) interface{} { return GetString(k) }}, {"flush-debounce", 30 * time.Second, func(k string) interface{} { return GetDuration(k) }}, {"auto-start-daemon", true, func(k string) interface{} { return GetBool(k) }}, } for _, tt := range tests { t.Run(tt.key, func(t *testing.T) { got := tt.getter(tt.key) if got != tt.expected { t.Errorf("GetXXX(%q) = %v, want %v", tt.key, got, tt.expected) } }) } } func TestEnvironmentBinding(t *testing.T) { // Test environment variable binding tests := []struct { envVar string key string value string expected interface{} getter func(string) interface{} }{ {"BD_JSON", "json", "true", true, func(k string) interface{} { return GetBool(k) }}, {"BD_NO_DAEMON", "no-daemon", "true", true, func(k string) interface{} { return GetBool(k) }}, {"BD_ACTOR", "actor", "testuser", "testuser", func(k string) interface{} { return GetString(k) }}, {"BD_DB", "db", "/tmp/test.db", "/tmp/test.db", func(k string) interface{} { return GetString(k) }}, {"BEADS_FLUSH_DEBOUNCE", "flush-debounce", "10s", 10 * time.Second, func(k string) interface{} { return GetDuration(k) }}, {"BEADS_AUTO_START_DAEMON", "auto-start-daemon", "false", false, func(k string) interface{} { return GetBool(k) }}, } for _, tt := range tests { t.Run(tt.envVar, func(t *testing.T) { // Set environment variable oldValue := os.Getenv(tt.envVar) _ = os.Setenv(tt.envVar, tt.value) defer os.Setenv(tt.envVar, oldValue) // Re-initialize viper to pick up env var err := Initialize() if err != nil { t.Fatalf("Initialize() returned error: %v", err) } got := tt.getter(tt.key) if got != tt.expected { t.Errorf("GetXXX(%q) with %s=%s = %v, want %v", tt.key, tt.envVar, tt.value, got, tt.expected) } }) } } func TestConfigFile(t *testing.T) { // Create a temporary directory for config file tmpDir := t.TempDir() // Create a config file configContent := ` json: true no-daemon: true actor: configuser flush-debounce: 15s ` configPath := filepath.Join(tmpDir, "config.yaml") if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil { t.Fatalf("failed to write config file: %v", err) } // Create .beads directory beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0750); err != nil { t.Fatalf("failed to create .beads directory: %v", err) } // Move config to .beads directory beadsConfigPath := filepath.Join(beadsDir, "config.yaml") if err := os.Rename(configPath, beadsConfigPath); err != nil { t.Fatalf("failed to move config file: %v", err) } // Change to tmp directory so config file is discovered t.Chdir(tmpDir) // Initialize viper var err error err = Initialize() if err != nil { t.Fatalf("Initialize() returned error: %v", err) } // Test that config file values are loaded if got := GetBool("json"); got != true { t.Errorf("GetBool(json) = %v, want true", got) } if got := GetBool("no-daemon"); got != true { t.Errorf("GetBool(no-daemon) = %v, want true", got) } if got := GetString("actor"); got != "configuser" { t.Errorf("GetString(actor) = %q, want \"configuser\"", got) } if got := GetDuration("flush-debounce"); got != 15*time.Second { t.Errorf("GetDuration(flush-debounce) = %v, want 15s", got) } } func TestConfigPrecedence(t *testing.T) { // Create a temporary directory for config file tmpDir := t.TempDir() // Create a config file with json: false configContent := `json: false` beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0750); err != nil { t.Fatalf("failed to create .beads directory: %v", err) } configPath := filepath.Join(beadsDir, "config.yaml") if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil { t.Fatalf("failed to write config file: %v", err) } // Change to tmp directory t.Chdir(tmpDir) // Test 1: Config file value (json: false) var err error err = Initialize() if err != nil { t.Fatalf("Initialize() returned error: %v", err) } if got := GetBool("json"); got != false { t.Errorf("GetBool(json) from config file = %v, want false", got) } // Test 2: Environment variable overrides config file _ = os.Setenv("BD_JSON", "true") defer func() { _ = os.Unsetenv("BD_JSON") }() err = Initialize() if err != nil { t.Fatalf("Initialize() returned error: %v", err) } if got := GetBool("json"); got != true { t.Errorf("GetBool(json) with env var = %v, want true (env should override config)", got) } } func TestSetAndGet(t *testing.T) { err := Initialize() if err != nil { t.Fatalf("Initialize() returned error: %v", err) } // Test Set and Get Set("test-key", "test-value") if got := GetString("test-key"); got != "test-value" { t.Errorf("GetString(test-key) = %q, want \"test-value\"", got) } Set("test-bool", true) if got := GetBool("test-bool"); got != true { t.Errorf("GetBool(test-bool) = %v, want true", got) } Set("test-int", 42) if got := GetInt("test-int"); got != 42 { t.Errorf("GetInt(test-int) = %d, want 42", got) } } func TestAllSettings(t *testing.T) { err := Initialize() if err != nil { t.Fatalf("Initialize() returned error: %v", err) } Set("custom-key", "custom-value") settings := AllSettings() if settings == nil { t.Fatal("AllSettings() returned nil") } // Check that our custom key is in the settings if val, ok := settings["custom-key"]; !ok || val != "custom-value" { t.Errorf("AllSettings() missing or incorrect custom-key: got %v", val) } } func TestGetStringSlice(t *testing.T) { err := Initialize() if err != nil { t.Fatalf("Initialize() returned error: %v", err) } // Test with Set Set("test-slice", []string{"a", "b", "c"}) got := GetStringSlice("test-slice") if len(got) != 3 || got[0] != "a" || got[1] != "b" || got[2] != "c" { t.Errorf("GetStringSlice(test-slice) = %v, want [a b c]", got) } // Test with non-existent key - should return empty/nil slice got = GetStringSlice("nonexistent-key") if len(got) != 0 { t.Errorf("GetStringSlice(nonexistent-key) = %v, want empty slice", got) } } func TestGetStringSliceFromConfig(t *testing.T) { // Create a temporary directory for config file tmpDir := t.TempDir() // Create a config file with string slice configContent := ` repos: primary: /path/to/primary additional: - /path/to/repo1 - /path/to/repo2 ` beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0750); err != nil { t.Fatalf("failed to create .beads directory: %v", err) } configPath := filepath.Join(beadsDir, "config.yaml") if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil { t.Fatalf("failed to write config file: %v", err) } // Change to tmp directory t.Chdir(tmpDir) // Initialize viper var err error err = Initialize() if err != nil { t.Fatalf("Initialize() returned error: %v", err) } // Test that string slice is loaded correctly got := GetStringSlice("repos.additional") if len(got) != 2 || got[0] != "/path/to/repo1" || got[1] != "/path/to/repo2" { t.Errorf("GetStringSlice(repos.additional) = %v, want [/path/to/repo1 /path/to/repo2]", got) } } func TestGetMultiRepoConfig(t *testing.T) { err := Initialize() if err != nil { t.Fatalf("Initialize() returned error: %v", err) } // Test when repos.primary is not set (single-repo mode) config := GetMultiRepoConfig() if config != nil { t.Errorf("GetMultiRepoConfig() with no repos.primary = %+v, want nil", config) } // Test when repos.primary is set (multi-repo mode) Set("repos.primary", "/path/to/primary") Set("repos.additional", []string{"/path/to/repo1", "/path/to/repo2"}) config = GetMultiRepoConfig() if config == nil { t.Fatal("GetMultiRepoConfig() returned nil when repos.primary is set") } if config.Primary != "/path/to/primary" { t.Errorf("GetMultiRepoConfig().Primary = %q, want \"/path/to/primary\"", config.Primary) } if len(config.Additional) != 2 || config.Additional[0] != "/path/to/repo1" || config.Additional[1] != "/path/to/repo2" { t.Errorf("GetMultiRepoConfig().Additional = %v, want [/path/to/repo1 /path/to/repo2]", config.Additional) } } func TestGetMultiRepoConfigFromFile(t *testing.T) { // Create a temporary directory for config file tmpDir := t.TempDir() // Create a config file with multi-repo config configContent := ` repos: primary: /main/repo additional: - /extra/repo1 - /extra/repo2 - /extra/repo3 ` beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0750); err != nil { t.Fatalf("failed to create .beads directory: %v", err) } configPath := filepath.Join(beadsDir, "config.yaml") if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil { t.Fatalf("failed to write config file: %v", err) } // Change to tmp directory t.Chdir(tmpDir) // Initialize viper var err error err = Initialize() if err != nil { t.Fatalf("Initialize() returned error: %v", err) } // Test that multi-repo config is loaded correctly config := GetMultiRepoConfig() if config == nil { t.Fatal("GetMultiRepoConfig() returned nil") } if config.Primary != "/main/repo" { t.Errorf("GetMultiRepoConfig().Primary = %q, want \"/main/repo\"", config.Primary) } if len(config.Additional) != 3 { t.Errorf("GetMultiRepoConfig().Additional has %d items, want 3", len(config.Additional)) } } func TestNilViperBehavior(t *testing.T) { // Save the current viper instance savedV := v // Set viper to nil to test nil-safety v = nil defer func() { v = savedV }() // All getters should return zero values without panicking if got := GetString("any-key"); got != "" { t.Errorf("GetString with nil viper = %q, want \"\"", got) } if got := GetBool("any-key"); got != false { t.Errorf("GetBool with nil viper = %v, want false", got) } if got := GetInt("any-key"); got != 0 { t.Errorf("GetInt with nil viper = %d, want 0", got) } if got := GetDuration("any-key"); got != 0 { t.Errorf("GetDuration with nil viper = %v, want 0", got) } if got := GetStringSlice("any-key"); got == nil || len(got) != 0 { t.Errorf("GetStringSlice with nil viper = %v, want empty slice", got) } if got := AllSettings(); got == nil || len(got) != 0 { t.Errorf("AllSettings with nil viper = %v, want empty map", got) } if got := GetMultiRepoConfig(); got != nil { t.Errorf("GetMultiRepoConfig with nil viper = %+v, want nil", got) } // Set should not panic Set("any-key", "any-value") // Should be a no-op } func TestGetIdentity(t *testing.T) { // Initialize viper err := Initialize() if err != nil { t.Fatalf("Initialize() returned error: %v", err) } // Test 1: Flag value takes precedence over everything got := GetIdentity("flag-identity") if got != "flag-identity" { t.Errorf("GetIdentity(flag-identity) = %q, want \"flag-identity\"", got) } // Test 2: Empty flag falls back to BEADS_IDENTITY env oldEnv := os.Getenv("BEADS_IDENTITY") _ = os.Setenv("BEADS_IDENTITY", "env-identity") defer func() { if oldEnv == "" { _ = os.Unsetenv("BEADS_IDENTITY") } else { _ = os.Setenv("BEADS_IDENTITY", oldEnv) } }() // Re-initialize to pick up env var _ = Initialize() got = GetIdentity("") if got != "env-identity" { t.Errorf("GetIdentity(\"\") with BEADS_IDENTITY = %q, want \"env-identity\"", got) } // Test 3: Without flag or env, should fall back to git user.name or hostname _ = os.Unsetenv("BEADS_IDENTITY") _ = Initialize() got = GetIdentity("") // We can't predict the exact value (depends on git config and hostname) // but it should not be empty or "unknown" on most systems if got == "" { t.Error("GetIdentity(\"\") without flag or env returned empty string") } } func TestGetExternalProjects(t *testing.T) { err := Initialize() if err != nil { t.Fatalf("Initialize() returned error: %v", err) } // Test default (empty map) got := GetExternalProjects() if got == nil { t.Error("GetExternalProjects() returned nil, want empty map") } if len(got) != 0 { t.Errorf("GetExternalProjects() = %v, want empty map", got) } // Test with Set Set("external_projects", map[string]string{ "beads": "../beads", "gastown": "/absolute/path/to/gastown", }) got = GetExternalProjects() if len(got) != 2 { t.Errorf("GetExternalProjects() has %d items, want 2", len(got)) } if got["beads"] != "../beads" { t.Errorf("GetExternalProjects()[beads] = %q, want \"../beads\"", got["beads"]) } if got["gastown"] != "/absolute/path/to/gastown" { t.Errorf("GetExternalProjects()[gastown] = %q, want \"/absolute/path/to/gastown\"", got["gastown"]) } } func TestGetExternalProjectsFromConfig(t *testing.T) { // Create a temporary directory for config file tmpDir := t.TempDir() // Create a config file with external_projects configContent := ` external_projects: beads: ../beads gastown: /path/to/gastown other: ./relative/path ` beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0750); err != nil { t.Fatalf("failed to create .beads directory: %v", err) } configPath := filepath.Join(beadsDir, "config.yaml") if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil { t.Fatalf("failed to write config file: %v", err) } // Change to tmp directory t.Chdir(tmpDir) // Initialize viper err := Initialize() if err != nil { t.Fatalf("Initialize() returned error: %v", err) } // Test that external_projects is loaded correctly got := GetExternalProjects() if len(got) != 3 { t.Errorf("GetExternalProjects() has %d items, want 3", len(got)) } if got["beads"] != "../beads" { t.Errorf("GetExternalProjects()[beads] = %q, want \"../beads\"", got["beads"]) } if got["gastown"] != "/path/to/gastown" { t.Errorf("GetExternalProjects()[gastown] = %q, want \"/path/to/gastown\"", got["gastown"]) } if got["other"] != "./relative/path" { t.Errorf("GetExternalProjects()[other] = %q, want \"./relative/path\"", got["other"]) } } func TestResolveExternalProjectPath(t *testing.T) { // Create a temporary directory structure tmpDir := t.TempDir() // Create a project directory to resolve to projectDir := filepath.Join(tmpDir, "beads-project") if err := os.MkdirAll(projectDir, 0750); err != nil { t.Fatalf("failed to create project directory: %v", err) } // Create config file beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0750); err != nil { t.Fatalf("failed to create .beads directory: %v", err) } configContent := ` external_projects: beads: beads-project missing: nonexistent-path absolute: ` + projectDir + ` ` configPath := filepath.Join(beadsDir, "config.yaml") if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil { t.Fatalf("failed to write config file: %v", err) } // Change to tmp directory t.Chdir(tmpDir) // Initialize viper err := Initialize() if err != nil { t.Fatalf("Initialize() returned error: %v", err) } // Test resolving a relative path that exists got := ResolveExternalProjectPath("beads") if got != projectDir { t.Errorf("ResolveExternalProjectPath(beads) = %q, want %q", got, projectDir) } // Test resolving a path that doesn't exist got = ResolveExternalProjectPath("missing") if got != "" { t.Errorf("ResolveExternalProjectPath(missing) = %q, want empty string", got) } // Test resolving a project that isn't configured got = ResolveExternalProjectPath("unknown") if got != "" { t.Errorf("ResolveExternalProjectPath(unknown) = %q, want empty string", got) } // Test resolving an absolute path got = ResolveExternalProjectPath("absolute") if got != projectDir { t.Errorf("ResolveExternalProjectPath(absolute) = %q, want %q", got, projectDir) } } func TestGetIdentityFromConfig(t *testing.T) { // Create a temporary directory for config file tmpDir := t.TempDir() // Create a config file with identity configContent := `identity: config-identity` beadsDir := filepath.Join(tmpDir, ".beads") if err := os.MkdirAll(beadsDir, 0750); err != nil { t.Fatalf("failed to create .beads directory: %v", err) } configPath := filepath.Join(beadsDir, "config.yaml") if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil { t.Fatalf("failed to write config file: %v", err) } // Clear BEADS_IDENTITY env var oldEnv := os.Getenv("BEADS_IDENTITY") _ = os.Unsetenv("BEADS_IDENTITY") defer func() { if oldEnv != "" { _ = os.Setenv("BEADS_IDENTITY", oldEnv) } }() // Change to tmp directory t.Chdir(tmpDir) // Initialize viper err := Initialize() if err != nil { t.Fatalf("Initialize() returned error: %v", err) } // Test that identity from config file is used got := GetIdentity("") if got != "config-identity" { t.Errorf("GetIdentity(\"\") with config file = %q, want \"config-identity\"", got) } // Test that flag still takes precedence got = GetIdentity("flag-override") if got != "flag-override" { t.Errorf("GetIdentity(flag-override) = %q, want \"flag-override\"", got) } } func TestGetValueSource(t *testing.T) { // Initialize config if err := Initialize(); err != nil { t.Fatalf("Initialize() returned error: %v", err) } tests := []struct { name string key string setup func() cleanup func() expected ConfigSource }{ { name: "default value returns SourceDefault", key: "json", setup: func() {}, cleanup: func() {}, expected: SourceDefault, }, { name: "env var returns SourceEnvVar", key: "json", setup: func() { os.Setenv("BD_JSON", "true") }, cleanup: func() { os.Unsetenv("BD_JSON") }, expected: SourceEnvVar, }, { name: "BEADS_ prefixed env var returns SourceEnvVar", key: "identity", setup: func() { os.Setenv("BEADS_IDENTITY", "test-identity") }, cleanup: func() { os.Unsetenv("BEADS_IDENTITY") }, expected: SourceEnvVar, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Reinitialize to clear state if err := Initialize(); err != nil { t.Fatalf("Initialize() returned error: %v", err) } tt.setup() defer tt.cleanup() got := GetValueSource(tt.key) if got != tt.expected { t.Errorf("GetValueSource(%q) = %v, want %v", tt.key, got, tt.expected) } }) } } func TestCheckOverrides_FlagOverridesEnvVar(t *testing.T) { // Initialize config if err := Initialize(); err != nil { t.Fatalf("Initialize() returned error: %v", err) } // Set an env var os.Setenv("BD_JSON", "true") defer os.Unsetenv("BD_JSON") // Simulate flag override flagOverrides := map[string]struct { Value interface{} WasSet bool }{ "json": {Value: false, WasSet: true}, } overrides := CheckOverrides(flagOverrides) // Should detect that flag overrides env var found := false for _, o := range overrides { if o.Key == "json" && o.OverriddenBy == SourceFlag { found = true break } } if !found { t.Error("Expected to find flag override for 'json' key") } } func TestConfigSourceConstants(t *testing.T) { // Verify source constants have expected string values if SourceDefault != "default" { t.Errorf("SourceDefault = %q, want \"default\"", SourceDefault) } if SourceConfigFile != "config_file" { t.Errorf("SourceConfigFile = %q, want \"config_file\"", SourceConfigFile) } if SourceEnvVar != "env_var" { t.Errorf("SourceEnvVar = %q, want \"env_var\"", SourceEnvVar) } if SourceFlag != "flag" { t.Errorf("SourceFlag = %q, want \"flag\"", SourceFlag) } }