diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..b98091f --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,48 @@ +// Package config loads the user-specific, non-versioned kctl-tui +// configuration (context pairs, team label key) from a YAML file. +package config + +import ( + "os" + "path/filepath" + + "gopkg.in/yaml.v3" + + "github.com/skoelle/kctl-tui/internal/kctl" +) + +// Config is the root structure of ~/.kctl-tui/config.yaml +type Config struct { + ContextPairs []kctl.ContextPair `yaml:"context_pairs"` + TeamLabelKey string `yaml:"team_label_key"` +} + +// DefaultPath returns the default config file location: ~/.kctl-tui/config.yaml +func DefaultPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".kctl-tui", "config.yaml"), nil +} + +// Load reads and parses the config file at path. If the file does not +// exist, it returns a zero-value Config (no pairs, empty label key) and no +// error, so the tool can run with sane defaults before the user has set up +// a config file. +func Load(path string) (Config, error) { + cfg := Config{} + + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return cfg, nil + } + if err != nil { + return cfg, err + } + + if err := yaml.Unmarshal(data, &cfg); err != nil { + return cfg, err + } + return cfg, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..af72074 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,44 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoad_MissingFileReturnsDefaults(t *testing.T) { + cfg, err := Load(filepath.Join(t.TempDir(), "does-not-exist.yaml")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(cfg.ContextPairs) != 0 { + t.Fatalf("expected no context pairs, got %v", cfg.ContextPairs) + } + if cfg.TeamLabelKey != "" { + t.Fatalf("expected empty team label key, got %q", cfg.TeamLabelKey) + } +} + +func TestLoad_ValidFile(t *testing.T) { + content := []byte(` +context_pairs: + - name: "env-pair-1" + contexts: ["ctx-a", "ctx-b"] +team_label_key: "example.org/team" +`) + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatalf("failed to write test config: %v", err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.TeamLabelKey != "example.org/team" { + t.Fatalf("unexpected team label key: %q", cfg.TeamLabelKey) + } + if len(cfg.ContextPairs) != 1 || cfg.ContextPairs[0].Name != "env-pair-1" { + t.Fatalf("unexpected context pairs: %v", cfg.ContextPairs) + } +} diff --git a/internal/kctl/labels.go b/internal/kctl/labels.go new file mode 100644 index 0000000..54fd0b3 --- /dev/null +++ b/internal/kctl/labels.go @@ -0,0 +1,33 @@ +package kctl + +import "sort" + +// DistinctLabelValues returns the sorted, unique, non-empty values of the +// given label key across a set of namespaces (name -> labels map). +func DistinctLabelValues(namespaces map[string]map[string]string, labelKey string) []string { + seen := map[string]bool{} + for _, labels := range namespaces { + if v, ok := labels[labelKey]; ok && v != "" { + seen[v] = true + } + } + result := make([]string, 0, len(seen)) + for v := range seen { + result = append(result, v) + } + sort.Strings(result) + return result +} + +// NamespacesForLabelValue returns the sorted namespace names whose labelKey +// matches the given value exactly. +func NamespacesForLabelValue(namespaces map[string]map[string]string, labelKey, value string) []string { + result := make([]string, 0) + for ns, labels := range namespaces { + if labels[labelKey] == value { + result = append(result, ns) + } + } + sort.Strings(result) + return result +} diff --git a/internal/kctl/labels_test.go b/internal/kctl/labels_test.go new file mode 100644 index 0000000..9e73ebe --- /dev/null +++ b/internal/kctl/labels_test.go @@ -0,0 +1,45 @@ +package kctl + +import ( + "reflect" + "testing" +) + +func testNamespaces() map[string]map[string]string { + return map[string]map[string]string{ + "ns-one": {"team-label": "team-a"}, + "ns-two": {"team-label": "team-b"}, + "ns-three": {"team-label": "team-a"}, + "ns-four": {}, // no label at all + } +} + +func TestDistinctLabelValues(t *testing.T) { + got := DistinctLabelValues(testNamespaces(), "team-label") + want := []string{"team-a", "team-b"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestDistinctLabelValues_UnknownKey(t *testing.T) { + got := DistinctLabelValues(testNamespaces(), "does-not-exist") + if len(got) != 0 { + t.Fatalf("expected empty result, got %v", got) + } +} + +func TestNamespacesForLabelValue(t *testing.T) { + got := NamespacesForLabelValue(testNamespaces(), "team-label", "team-a") + want := []string{"ns-one", "ns-three"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestNamespacesForLabelValue_NoMatch(t *testing.T) { + got := NamespacesForLabelValue(testNamespaces(), "team-label", "team-z") + if len(got) != 0 { + t.Fatalf("expected empty result, got %v", got) + } +} diff --git a/internal/kctl/pairs.go b/internal/kctl/pairs.go new file mode 100644 index 0000000..26e5656 --- /dev/null +++ b/internal/kctl/pairs.go @@ -0,0 +1,35 @@ +// Package kctl contains the core, non-interactive logic of kctl-tui. +// Functions here are pure (no kubectl/tmux side effects) so they can be +// unit tested without a live cluster. +package kctl + +// ContextPair groups a set of related kubectl contexts that should be +// switchable via TAB while keeping the same namespace (e.g. staging/prod). +type ContextPair struct { + Name string `yaml:"name"` + Contexts []string `yaml:"contexts"` +} + +// FindNextContext returns the next context in the same pair/group as +// current, cycling through the group. Returns "", false if current is not +// part of any configured pair. +func FindNextContext(current string, pairs []ContextPair) (string, bool) { + for _, pair := range pairs { + idx := indexOf(pair.Contexts, current) + if idx == -1 { + continue + } + next := pair.Contexts[(idx+1)%len(pair.Contexts)] + return next, true + } + return "", false +} + +func indexOf(items []string, target string) int { + for i, v := range items { + if v == target { + return i + } + } + return -1 +} diff --git a/internal/kctl/pairs_test.go b/internal/kctl/pairs_test.go new file mode 100644 index 0000000..80d2144 --- /dev/null +++ b/internal/kctl/pairs_test.go @@ -0,0 +1,48 @@ +package kctl + +import "testing" + +func TestFindNextContext_TwoWayToggle(t *testing.T) { + pairs := []ContextPair{ + {Name: "env-pair-1", Contexts: []string{"ctx-a", "ctx-b"}}, + } + + next, ok := FindNextContext("ctx-a", pairs) + if !ok || next != "ctx-b" { + t.Fatalf("expected ctx-b, got %q (ok=%v)", next, ok) + } + + next, ok = FindNextContext("ctx-b", pairs) + if !ok || next != "ctx-a" { + t.Fatalf("expected ctx-a, got %q (ok=%v)", next, ok) + } +} + +func TestFindNextContext_Rotation(t *testing.T) { + pairs := []ContextPair{ + {Name: "rotation", Contexts: []string{"a", "b", "c"}}, + } + + next, ok := FindNextContext("c", pairs) + if !ok || next != "a" { + t.Fatalf("expected wraparound to a, got %q (ok=%v)", next, ok) + } +} + +func TestFindNextContext_NotConfigured(t *testing.T) { + pairs := []ContextPair{ + {Name: "env-pair-1", Contexts: []string{"ctx-a", "ctx-b"}}, + } + + _, ok := FindNextContext("unrelated-context", pairs) + if ok { + t.Fatalf("expected ok=false for a context with no configured pair") + } +} + +func TestFindNextContext_NoPairsConfigured(t *testing.T) { + _, ok := FindNextContext("ctx-a", nil) + if ok { + t.Fatalf("expected ok=false when no pairs are configured") + } +}