diff --git a/cmd/kubectx/history.go b/cmd/kubectx/history.go new file mode 100644 index 0000000..e2b4ba4 --- /dev/null +++ b/cmd/kubectx/history.go @@ -0,0 +1,149 @@ +// Copyright 2021 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" +) + +const ( + maxHistoryLength = 10 +) + +// ContextHistory stores the history of used contexts +type ContextHistory struct { + Contexts []string `json:"contexts"` +} + +// Add adds a context to history, moving it to the front if it already exists +func (h *ContextHistory) Add(ctx string) { + // Remove if exists + for i, c := range h.Contexts { + if c == ctx { + h.Contexts = append(h.Contexts[:i], h.Contexts[i+1:]...) + break + } + } + + // Add to front + h.Contexts = append([]string{ctx}, h.Contexts...) + + // Trim if needed + if len(h.Contexts) > maxHistoryLength { + h.Contexts = h.Contexts[:maxHistoryLength] + } +} + +// getHistoryFilePath returns the path to the history file +var getHistoryFilePath = func() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".kube", "kubectx_history.json"), nil +} + +// loadHistory loads the context history from disk +func loadHistory() (*ContextHistory, error) { + path, err := getHistoryFilePath() + if err != nil { + return nil, err + } + + // If file doesn't exist, return empty history + if _, err := os.Stat(path); os.IsNotExist(err) { + return &ContextHistory{Contexts: []string{}}, nil + } + + // Read file + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + // Parse JSON + var history ContextHistory + if len(data) > 0 { + if err := json.Unmarshal(data, &history); err != nil { + return nil, err + } + } else { + history.Contexts = []string{} + } + + return &history, nil +} + +// saveHistory saves the context history to disk +func saveHistory(history *ContextHistory) error { + path, err := getHistoryFilePath() + if err != nil { + return err + } + + // Create directory if needed + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + // Serialize to JSON + data, err := json.Marshal(history) + if err != nil { + return err + } + + // Write to file + return os.WriteFile(path, data, 0644) +} + +// prioritizeContexts sorts contexts with recently used ones first +func prioritizeContexts(allContexts []string, historyContexts []string) []string { + // Create a map for O(1) lookup + seen := make(map[string]bool) + result := []string{} + + // First add contexts from history that exist in allContexts + for _, ctx := range historyContexts { + if contains(allContexts, ctx) { + result = append(result, ctx) + seen[ctx] = true + } + } + + // Then add remaining contexts in alphabetical order + remaining := []string{} + for _, ctx := range allContexts { + if !seen[ctx] { + remaining = append(remaining, ctx) + } + } + sort.Strings(remaining) + + return append(result, remaining...) +} + +// contains checks if a string is in a slice +func contains(slice []string, str string) bool { + for _, s := range slice { + if s == str { + return true + } + } + return false +} diff --git a/cmd/kubectx/main_test.go b/cmd/kubectx/main_test.go new file mode 100644 index 0000000..ddbf005 --- /dev/null +++ b/cmd/kubectx/main_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestContextHistory(t *testing.T) { + // Create a temporary directory for testing + tmpDir, err := ioutil.TempDir("", "kubectx-test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Override history file path for testing + origHistoryFilePath := getHistoryFilePath + defer func() { getHistoryFilePath = origHistoryFilePath }() + + historyPath := filepath.Join(tmpDir, "history.json") + getHistoryFilePath = func() (string, error) { + return historyPath, nil + } + + t.Run("New history is empty", func(t *testing.T) { + history, err := loadHistory() + assert.NoError(t, err) + assert.NotNil(t, history) + assert.Empty(t, history.Contexts) + }) + + t.Run("Add context to history", func(t *testing.T) { + history := &ContextHistory{Contexts: []string{}} + history.Add("context1") + assert.Equal(t, []string{"context1"}, history.Contexts) + + // Add the same context again + history.Add("context1") + assert.Equal(t, []string{"context1"}, history.Contexts) + + // Add another context + history.Add("context2") + assert.Equal(t, []string{"context2", "context1"}, history.Contexts) + }) + + t.Run("Save and load history", func(t *testing.T) { + history := &ContextHistory{Contexts: []string{"context1", "context2"}} + err := saveHistory(history) + assert.NoError(t, err) + + loaded, err := loadHistory() + assert.NoError(t, err) + assert.Equal(t, history.Contexts, loaded.Contexts) + }) + + t.Run("History respects max length", func(t *testing.T) { + history := &ContextHistory{Contexts: []string{}} + + // Add more than maxHistoryLength contexts + for i := 0; i < maxHistoryLength+5; i++ { + history.Add(fmt.Sprintf("context%d", i)) + } + + assert.Equal(t, maxHistoryLength, len(history.Contexts)) + assert.Equal(t, "context9", history.Contexts[0]) + }) +} + +func TestPrioritizeContexts(t *testing.T) { + allContexts := []string{"a", "b", "c", "d", "e"} + historyContexts := []string{"c", "a", "f"} // Note: "f" is not in allContexts + + result := prioritizeContexts(allContexts, historyContexts) + + // Expected: c, a (from history, in order), then b, d, e alphabetically + expected := []string{"c", "a", "b", "d", "e"} + assert.Equal(t, expected, result) +} diff --git a/internal/flags/flags.go b/internal/flags/flags.go new file mode 100644 index 0000000..1f59022 --- /dev/null +++ b/internal/flags/flags.go @@ -0,0 +1,95 @@ +package flags + +import ( + "fmt" + "strings" +) + +// Flags contains parsed flags. +type Flags struct { + Delete string + Current bool + ShowHelp bool + Version bool + Unset bool + History bool + SelectedContext []string + NewContext []string +} + +// parseArgs parses given command line arguments to flags. +func parseArgs(args []string) (Flags, error) { + var f Flags + + // filter out first argument (program name) + if len(args) > 0 { + args = args[1:] + } + + // parse flags + for i := 0; i < len(args); i++ { + if args[i] == "--help" || args[i] == "-h" { + f.ShowHelp = true + continue + } + + if args[i] == "--current" || args[i] == "-c" { + f.Current = true + continue + } + + if args[i] == "--unset" || args[i] == "-u" { + f.Unset = true + continue + } + + if args[i] == "--version" || args[i] == "-V" { + f.Version = true + continue + } + + if args[i] == "--history" { + f.History = true + continue + } + + if (args[i] == "--delete" || args[i] == "-d") && i+1 < len(args) { + f.Delete = args[i+1] + i++ + continue + } + + // = + if strings.Contains(args[i], "=") { + a := strings.SplitN(args[i], "=", 2) + if len(a) != 2 { + return f, fmt.Errorf("invalid argument: %s", args[i]) + } + new, old := a[0], a[1] + if new == "" || old == "" { + return f, fmt.Errorf("invalid argument: %s", args[i]) + } + f.NewContext = []string{new, old} + continue + } + + // + f.SelectedContext = append(f.SelectedContext, args[i]) + } + return f, nil +} + +// New returns empty flags. +func New() *Flags { + return &Flags{} +} + +// Parse command line flags. +func (f *Flags) Parse(args []string) error { + fl, err := parseArgs(args) + if err != nil { + return err + } + *f = fl + return nil +}