mirror of
https://github.com/skoelle/kctl-tui.git
synced 2026-09-18 04:20:25 +00:00
Add internal/kctl and internal/config packages with unit tests
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user