diff --git a/go.mod b/go.mod index 2c46eee..86fb521 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.25.5 require ( github.com/go-git/go-git/v6 v6.0.0-20260206150416-f623c7555599 github.com/shirou/gopsutil/v4 v4.26.1 - golang.org/x/term v0.39.0 ) require ( diff --git a/main.go b/main.go index 2b08946..57f5e8d 100644 --- a/main.go +++ b/main.go @@ -3,7 +3,6 @@ package main import ( "bufio" "encoding/json" - "errors" "fmt" "os" "path/filepath" @@ -12,11 +11,8 @@ import ( "github.com/go-git/go-git/v6" "github.com/shirou/gopsutil/v4/process" - "golang.org/x/term" ) -const defaultStatuslineWidth = 80 - // TokenUsage tracks context window token consumption. type TokenUsage struct { InputTokens int `json:"input_tokens"` @@ -72,36 +68,26 @@ func parseStatusInput(jsonStr string) (*StatusInput, error) { return &data, nil } -// buildStatusLine constructs the left and right parts of the status line. -func buildStatusLine(data *StatusInput) (left, right string) { +// buildStatusLine constructs the formatted status line. +func buildStatusLine(data *StatusInput) string { contextInfo := formatContextInfo(data.ContextWindow.ContextWindowSize, data.ContextWindow.CurrentUsage) dirName := filepath.Base(data.Workspace.CurrentDir) giteaStatus := getGiteaStatus() gitInfo := getGitInfo(data.Workspace.CurrentDir) - left = fmt.Sprintf("%s %s%s%s %s➜%s %s%s%s%s", + left := fmt.Sprintf("%s %s%s%s %s➜%s %s%s%s%s", giteaStatus, magenta, data.Model.DisplayName, reset, boldGreen, reset, cyan, dirName, reset, gitInfo) - right = fmt.Sprintf("%s%s%s", yellow, contextInfo, reset) - return left, right -} + right := fmt.Sprintf("%s%s%s", yellow, contextInfo, reset) -// calculatePadding returns the number of spaces needed for padding. -func calculatePadding(leftVisible, rightVisible string, termWidth int) int { - return max(termWidth-len(leftVisible)-len(rightVisible), 1) -} - -// formatOutput combines left, right, and padding into final output. -func formatOutput(left, right string, padding int) string { - return fmt.Sprintf("%s%s%s", left, strings.Repeat(" ", padding), right) + return left + " " + right } // run reads JSON from r, builds the statusline, and writes it to w. -// Returns an error if the input cannot be parsed. func run(r *bufio.Reader, w *strings.Builder) error { jsonStr := readInputFromStdin(r) @@ -110,20 +96,7 @@ func run(r *bufio.Reader, w *strings.Builder) error { return fmt.Errorf("error parsing JSON: %w", err) } - left, right := buildStatusLine(data) - - // Calculate visible lengths (strip ANSI) - leftVisible := stripANSI(left) - rightVisible := stripANSI(right) - - // Get terminal width - termWidth := getTerminalWidth() - - // Calculate and apply padding - padding := calculatePadding(leftVisible, rightVisible, termWidth) - output := formatOutput(left, right, padding) - - w.WriteString(output) + w.WriteString(buildStatusLine(data)) return nil } @@ -212,34 +185,6 @@ func getGitInfo(cwd string) string { return fmt.Sprintf(" git:(%s)", branch) } -// termWidthFunc returns the usable status line width. -// Checks STATUSLINE_WIDTH env var first (set in Claude Code settings), -// then tries terminal detection on stderr/stdin/stdout, then falls back -// to defaultStatuslineWidth. -// Replaced in tests to avoid depending on a real TTY. -var termWidthFunc = func() (int, error) { - if sw := os.Getenv("STATUSLINE_WIDTH"); sw != "" { - var w int - if _, err := fmt.Sscanf(sw, "%d", &w); err == nil && w > 0 { - return w, nil - } - } - for _, fd := range []uintptr{os.Stderr.Fd(), os.Stdin.Fd(), os.Stdout.Fd()} { - if width, _, err := term.GetSize(int(fd)); err == nil { - return width, nil - } - } - return 0, errors.New("no terminal detected") -} - -func getTerminalWidth() int { - width, err := termWidthFunc() - if err != nil { - return defaultStatuslineWidth - } - return width -} - var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`) func stripANSI(s string) string { diff --git a/main_test.go b/main_test.go index 6970c9a..b14d8e7 100644 --- a/main_test.go +++ b/main_test.go @@ -47,195 +47,6 @@ func TestFormatContextInfo_SmallValues(t *testing.T) { } } -func TestStripANSI(t *testing.T) { - tests := []struct { - name string - input string - expected string - }{ - { - name: "no ansi", - input: "hello world", - expected: "hello world", - }, - { - name: "single color", - input: "\033[32mgreen\033[0m", - expected: "green", - }, - { - name: "multiple colors", - input: "\033[31mred\033[0m \033[32mgreen\033[0m", - expected: "red green", - }, - { - name: "bold", - input: "\033[1mbold\033[0m", - expected: "bold", - }, - { - name: "complex", - input: "\033[32m●\033[0m \033[35mOpus\033[0m \033[1;32m➜\033[0m", - expected: "● Opus ➜", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := stripANSI(tt.input) - if result != tt.expected { - t.Errorf("stripANSI(%q) = %q, want %q", tt.input, result, tt.expected) - } - }) - } -} - -func TestStatusInputParsing(t *testing.T) { - jsonData := `{ - "model": {"display_name": "Opus 4.5"}, - "workspace": {"current_dir": "/root/projects/statusline"}, - "context_window": { - "context_window_size": 200000, - "current_usage": { - "input_tokens": 5000, - "cache_creation_input_tokens": 1000, - "cache_read_input_tokens": 500 - } - } - }` - - var data StatusInput - err := json.Unmarshal([]byte(jsonData), &data) - if err != nil { - t.Fatalf("Failed to parse JSON: %v", err) - } - - if data.Model.DisplayName != "Opus 4.5" { - t.Errorf("Model.DisplayName = %q, want %q", data.Model.DisplayName, "Opus 4.5") - } - - if data.Workspace.CurrentDir != "/root/projects/statusline" { - t.Errorf("Workspace.CurrentDir = %q, want %q", data.Workspace.CurrentDir, "/root/projects/statusline") - } - - if data.ContextWindow.ContextWindowSize != 200000 { - t.Errorf("ContextWindow.ContextWindowSize = %d, want %d", data.ContextWindow.ContextWindowSize, 200000) - } - - if data.ContextWindow.CurrentUsage == nil { - t.Fatal("ContextWindow.CurrentUsage is nil") - } - - if data.ContextWindow.CurrentUsage.InputTokens != 5000 { - t.Errorf("CurrentUsage.InputTokens = %d, want %d", data.ContextWindow.CurrentUsage.InputTokens, 5000) - } -} - -func TestStatusInputParsing_NilUsage(t *testing.T) { - jsonData := `{ - "model": {"display_name": "Sonnet"}, - "workspace": {"current_dir": "/tmp"}, - "context_window": { - "context_window_size": 100000 - } - }` - - var data StatusInput - err := json.Unmarshal([]byte(jsonData), &data) - if err != nil { - t.Fatalf("Failed to parse JSON: %v", err) - } - - if data.ContextWindow.CurrentUsage != nil { - t.Errorf("ContextWindow.CurrentUsage should be nil, got %+v", data.ContextWindow.CurrentUsage) - } -} - -func TestGetGitInfo_CurrentRepo(t *testing.T) { - cwd, err := os.Getwd() - if err != nil { - t.Skipf("Could not get working directory: %v", err) - } - - result := getGitInfo(cwd) - - // Should return something like " git:(master)" or " git:(master) ✗" - if result == "" { - t.Skip("Not in a git repository") - } - - if !contains(result, "git:(") { - t.Errorf("getGitInfo(%q) = %q, expected to contain 'git:('", cwd, result) - } -} - -func TestGetGitInfo_NonRepo(t *testing.T) { - result := getGitInfo("/tmp") - - // /tmp is unlikely to be a git repo - if result != "" && !contains(result, "git:(") { - t.Errorf("getGitInfo(/tmp) = %q, expected empty or valid git info", result) - } -} - -func TestGetGiteaStatus(t *testing.T) { - result := getGiteaStatus() - - // Should return either green or red dot - greenDot := green + "●" + reset - redDot := red + "●" + reset - - if result != greenDot && result != redDot { - t.Errorf("getGiteaStatus() = %q, expected green or red dot", result) - } -} - -func contains(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr)) -} - -func containsHelper(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} - -func TestGetTerminalWidth(t *testing.T) { - // getTerminalWidth should return a positive integer - // In test environment (no TTY), it should fall back to 80 - width := getTerminalWidth() - if width <= 0 { - t.Errorf("getTerminalWidth() = %d, expected positive value", width) - } -} - -func TestGetTerminalWidth_DefaultFallback(t *testing.T) { - // When not connected to a terminal, should return 80 - width := getTerminalWidth() - // In CI/test environments, this typically returns 80 - if width != 80 && width < 40 { - t.Errorf("getTerminalWidth() = %d, expected 80 or reasonable terminal width", width) - } -} - -func TestGetGitInfo_InvalidPath(t *testing.T) { - result := getGitInfo("/nonexistent/path/that/does/not/exist") - if result != "" { - t.Errorf("getGitInfo(invalid) = %q, expected empty string", result) - } -} - -func TestGetGitInfo_RootDir(t *testing.T) { - // Root directory is unlikely to be a git repo - result := getGitInfo("/") - if result != "" && !contains(result, "git:(") { - t.Errorf("getGitInfo(/) = %q, expected empty or valid git info", result) - } -} - func TestFormatContextInfo_ZeroContextSize(t *testing.T) { result := formatContextInfo(0, nil) expected := "0/0k" @@ -270,240 +81,7 @@ func TestFormatContextInfo_ExactThousand(t *testing.T) { } } -func TestStripANSI_Empty(t *testing.T) { - result := stripANSI("") - if result != "" { - t.Errorf("stripANSI(\"\") = %q, want empty", result) - } -} - -func TestStripANSI_OnlyANSI(t *testing.T) { - result := stripANSI("\033[31m\033[0m") - if result != "" { - t.Errorf("stripANSI(only codes) = %q, want empty", result) - } -} - -func TestStripANSI_NestedCodes(t *testing.T) { - input := "\033[1m\033[31mbold red\033[0m\033[0m" - result := stripANSI(input) - expected := "bold red" - if result != expected { - t.Errorf("stripANSI(%q) = %q, want %q", input, result, expected) - } -} - -func TestStatusInputParsing_EmptyJSON(t *testing.T) { - jsonData := `{}` - var data StatusInput - err := json.Unmarshal([]byte(jsonData), &data) - if err != nil { - t.Fatalf("Failed to parse empty JSON: %v", err) - } - if data.Model.DisplayName != "" { - t.Errorf("Expected empty DisplayName, got %q", data.Model.DisplayName) - } -} - -func TestStatusInputParsing_PartialJSON(t *testing.T) { - jsonData := `{"model": {"display_name": "Test"}}` - var data StatusInput - err := json.Unmarshal([]byte(jsonData), &data) - if err != nil { - t.Fatalf("Failed to parse partial JSON: %v", err) - } - if data.Model.DisplayName != "Test" { - t.Errorf("DisplayName = %q, want %q", data.Model.DisplayName, "Test") - } - if data.Workspace.CurrentDir != "" { - t.Errorf("Expected empty CurrentDir, got %q", data.Workspace.CurrentDir) - } -} - -func TestStatusInputParsing_InvalidJSON(t *testing.T) { - jsonData := `{invalid json}` - var data StatusInput - err := json.Unmarshal([]byte(jsonData), &data) - if err == nil { - t.Error("Expected error for invalid JSON, got nil") - } -} - -func TestANSIConstants(t *testing.T) { - // Verify ANSI constants are properly defined - tests := []struct { - name string - constant string - prefix string - }{ - {"reset", reset, "\033["}, - {"red", red, "\033["}, - {"green", green, "\033["}, - {"yellow", yellow, "\033["}, - {"magenta", magenta, "\033["}, - {"cyan", cyan, "\033["}, - {"boldGreen", boldGreen, "\033["}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if !contains(tt.constant, tt.prefix) { - t.Errorf("%s constant doesn't start with ANSI escape", tt.name) - } - }) - } -} - -func TestGetGiteaStatus_ReturnsValidColor(t *testing.T) { - result := getGiteaStatus() - - // Must contain the dot character - if !contains(result, "●") { - t.Errorf("getGiteaStatus() = %q, expected to contain dot", result) - } - - // Must contain ANSI codes - stripped := stripANSI(result) - if stripped != "●" { - t.Errorf("stripped getGiteaStatus() = %q, expected just dot", stripped) - } -} - -func TestReadInputFromStdin(t *testing.T) { - input := "line1\nline2\nline3" - reader := bufio.NewReader(strings.NewReader(input)) - result := readInputFromStdin(reader) - expected := "line1\nline2\nline3" - if result != expected { - t.Errorf("readInputFromStdin = %q, want %q", result, expected) - } -} - -func TestReadInputFromStdin_Empty(t *testing.T) { - reader := bufio.NewReader(strings.NewReader("")) - result := readInputFromStdin(reader) - if result != "" { - t.Errorf("readInputFromStdin (empty) = %q, want empty", result) - } -} - -func TestParseStatusInput_Valid(t *testing.T) { - jsonStr := `{"model": {"display_name": "Test"}, "workspace": {"current_dir": "/test"}}` - data, err := parseStatusInput(jsonStr) - if err != nil { - t.Fatalf("parseStatusInput failed: %v", err) - } - if data.Model.DisplayName != "Test" { - t.Errorf("DisplayName = %q, want Test", data.Model.DisplayName) - } - if data.Workspace.CurrentDir != "/test" { - t.Errorf("CurrentDir = %q, want /test", data.Workspace.CurrentDir) - } -} - -func TestParseStatusInput_Invalid(t *testing.T) { - _, err := parseStatusInput("invalid json") - if err == nil { - t.Error("parseStatusInput should fail on invalid JSON") - } -} - -func TestBuildStatusLine_ContainsComponents(t *testing.T) { - data := &StatusInput{} - data.Model.DisplayName = "TestModel" - data.Workspace.CurrentDir = "/home/user/project" - data.ContextWindow.ContextWindowSize = 100000 - data.ContextWindow.CurrentUsage = &TokenUsage{ - InputTokens: 5000, - CacheCreationTokens: 1000, - CacheReadInputTokens: 500, - } - - left, right := buildStatusLine(data) - - // Check left contains model name and directory - if !contains(left, "TestModel") { - t.Errorf("left statusline missing model: %q", left) - } - if !contains(left, "project") { - t.Errorf("left statusline missing directory: %q", left) - } - - // Check right contains context info - if !contains(right, "6k/100k") { - t.Errorf("right statusline missing context info: %q", right) - } -} - -func TestBuildStatusLine_HasGiteaStatus(t *testing.T) { - data := &StatusInput{} - data.Model.DisplayName = "Model" - data.Workspace.CurrentDir = "/tmp" - data.ContextWindow.ContextWindowSize = 100000 - - left, _ := buildStatusLine(data) - - // Check for gitea status (dot) - if !contains(left, "●") { - t.Errorf("left statusline missing gitea status: %q", left) - } -} - -func TestCalculatePadding_ZeroWidth(t *testing.T) { - result := calculatePadding("left", "right", 0) - expected := 1 - if result != expected { - t.Errorf("calculatePadding(\"left\", \"right\", 0) = %d, want %d", result, expected) - } -} - -func TestCalculatePadding_NegativeResult(t *testing.T) { - result := calculatePadding("left", "right", 5) - expected := 1 - if result != expected { - t.Errorf("calculatePadding with overflow = %d, want minimum of %d", result, expected) - } -} - -func TestCalculatePadding_Normal(t *testing.T) { - result := calculatePadding("left", "right", 50) - expected := 50 - len("left") - len("right") - if result != expected { - t.Errorf("calculatePadding(\"left\", \"right\", 50) = %d, want %d", result, expected) - } -} - -func TestFormatOutput_Composition(t *testing.T) { - result := formatOutput("LEFT", "RIGHT", 5) - expected := "LEFT RIGHT" - if result != expected { - t.Errorf("formatOutput = %q, want %q", result, expected) - } -} - -func TestFormatOutput_Empty(t *testing.T) { - result := formatOutput("", "", 0) - expected := "" - if result != expected { - t.Errorf("formatOutput (empty) = %q, want %q", result, expected) - } -} - -func TestFormatOutput_WithANSI(t *testing.T) { - left := red + "text" + reset - right := green + "info" + reset - result := formatOutput(left, right, 3) - - stripped := stripANSI(result) - if !contains(stripped, "text") || !contains(stripped, "info") { - t.Errorf("formatOutput with ANSI = %q, expected both parts visible", stripped) - } -} - -// Additional edge case tests - func TestFormatContextInfo_MaxTokens(t *testing.T) { - // Test when usage equals context size usage := &TokenUsage{ InputTokens: 200000, CacheCreationTokens: 0, @@ -517,7 +95,6 @@ func TestFormatContextInfo_MaxTokens(t *testing.T) { } func TestFormatContextInfo_OverflowTokens(t *testing.T) { - // Test when usage exceeds context size usage := &TokenUsage{ InputTokens: 250000, CacheCreationTokens: 0, @@ -531,7 +108,6 @@ func TestFormatContextInfo_OverflowTokens(t *testing.T) { } func TestFormatContextInfo_AllCacheTypes(t *testing.T) { - // Test all cache token types contributing usage := &TokenUsage{ InputTokens: 10000, CacheCreationTokens: 20000, @@ -570,136 +146,194 @@ func TestFormatContextInfo_OnlyCacheRead(t *testing.T) { } } -func TestBuildStatusLine_EmptyDir(t *testing.T) { - data := &StatusInput{} - data.Model.DisplayName = "Model" - data.Workspace.CurrentDir = "" - data.ContextWindow.ContextWindowSize = 100000 - - left, right := buildStatusLine(data) - - // Should not panic, should produce valid output - if left == "" { - t.Error("buildStatusLine with empty dir should produce left output") +func TestTokenUsage_ZeroValues(t *testing.T) { + usage := &TokenUsage{ + InputTokens: 0, + CacheCreationTokens: 0, + CacheReadInputTokens: 0, } - if right == "" { - t.Error("buildStatusLine with empty dir should produce right output") - } -} - -func TestBuildStatusLine_LongModelName(t *testing.T) { - data := &StatusInput{} - data.Model.DisplayName = "Claude 3.5 Sonnet with Extended Context" - data.Workspace.CurrentDir = "/home/user/my-very-long-project-name-here" - data.ContextWindow.ContextWindowSize = 200000 - data.ContextWindow.CurrentUsage = &TokenUsage{ - InputTokens: 50000, - } - - left, right := buildStatusLine(data) - - if !contains(left, "Claude 3.5 Sonnet with Extended Context") { - t.Errorf("long model name not in left: %q", left) - } - if !contains(left, "my-very-long-project-name-here") { - t.Errorf("long dir name not in left: %q", left) - } - if !contains(right, "50k/200k") { - t.Errorf("context info not in right: %q", right) - } -} - -func TestBuildStatusLine_NilUsage(t *testing.T) { - data := &StatusInput{} - data.Model.DisplayName = "Model" - data.Workspace.CurrentDir = "/test" - data.ContextWindow.ContextWindowSize = 100000 - data.ContextWindow.CurrentUsage = nil - - left, right := buildStatusLine(data) - - if !contains(right, "0/100k") { - t.Errorf("nil usage should show 0: %q", right) - } - if left == "" { - t.Error("left should not be empty") - } -} - -func TestBuildStatusLine_RootDir(t *testing.T) { - data := &StatusInput{} - data.Model.DisplayName = "Model" - data.Workspace.CurrentDir = "/" - data.ContextWindow.ContextWindowSize = 100000 - - left, _ := buildStatusLine(data) - - // filepath.Base("/") returns "/" - if !contains(left, "/") || !contains(left, "Model") { - t.Errorf("root dir handling failed: %q", left) - } -} - -func TestCalculatePadding_ExactFit(t *testing.T) { - // When content exactly fills the width - result := calculatePadding("12345", "67890", 10) - expected := 1 // max(10-5-5, 1) = max(0, 1) = 1 + result := formatContextInfo(100000, usage) + expected := "0k/100k" if result != expected { - t.Errorf("calculatePadding(exact fit) = %d, want %d", result, expected) + t.Errorf("formatContextInfo(zero usage) = %q, want %q", result, expected) } } -func TestCalculatePadding_LargeWidth(t *testing.T) { - result := calculatePadding("left", "right", 200) - expected := 200 - 4 - 5 // 191 - if result != expected { - t.Errorf("calculatePadding(large) = %d, want %d", result, expected) +func TestStripANSI(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"no ansi", "hello world", "hello world"}, + {"single color", "\033[32mgreen\033[0m", "green"}, + {"multiple colors", "\033[31mred\033[0m \033[32mgreen\033[0m", "red green"}, + {"bold", "\033[1mbold\033[0m", "bold"}, + {"complex", "\033[32m●\033[0m \033[35mOpus\033[0m \033[1;32m➜\033[0m", "● Opus ➜"}, + {"empty", "", ""}, + {"only ansi", "\033[31m\033[0m", ""}, + {"nested", "\033[1m\033[31mbold red\033[0m\033[0m", "bold red"}, + {"256 color", "\033[38;5;196mred\033[0m", "red"}, + {"true color", "\033[38;2;255;0;0mred\033[0m", "red"}, + {"multiline", "\033[31mline1\033[0m\n\033[32mline2\033[0m", "line1\nline2"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := stripANSI(tt.input) + if result != tt.expected { + t.Errorf("stripANSI(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) } } -func TestStripANSI_256Color(t *testing.T) { - // 256-color escape sequences - input := "\033[38;5;196mred\033[0m" - result := stripANSI(input) - expected := "red" - if result != expected { - t.Errorf("stripANSI(256color) = %q, want %q", result, expected) +func TestStatusInputParsing(t *testing.T) { + jsonData := `{ + "model": {"display_name": "Opus 4.5"}, + "workspace": {"current_dir": "/root/projects/statusline"}, + "context_window": { + "context_window_size": 200000, + "current_usage": { + "input_tokens": 5000, + "cache_creation_input_tokens": 1000, + "cache_read_input_tokens": 500 + } + } + }` + + var data StatusInput + err := json.Unmarshal([]byte(jsonData), &data) + if err != nil { + t.Fatalf("Failed to parse JSON: %v", err) + } + + if data.Model.DisplayName != "Opus 4.5" { + t.Errorf("Model.DisplayName = %q, want %q", data.Model.DisplayName, "Opus 4.5") + } + if data.Workspace.CurrentDir != "/root/projects/statusline" { + t.Errorf("Workspace.CurrentDir = %q, want %q", data.Workspace.CurrentDir, "/root/projects/statusline") + } + if data.ContextWindow.ContextWindowSize != 200000 { + t.Errorf("ContextWindow.ContextWindowSize = %d, want %d", data.ContextWindow.ContextWindowSize, 200000) + } + if data.ContextWindow.CurrentUsage == nil { + t.Fatal("ContextWindow.CurrentUsage is nil") + } + if data.ContextWindow.CurrentUsage.InputTokens != 5000 { + t.Errorf("CurrentUsage.InputTokens = %d, want %d", data.ContextWindow.CurrentUsage.InputTokens, 5000) } } -func TestStripANSI_TrueColor(t *testing.T) { - // 24-bit true color escape sequences - input := "\033[38;2;255;0;0mred\033[0m" - result := stripANSI(input) - expected := "red" - if result != expected { - t.Errorf("stripANSI(truecolor) = %q, want %q", result, expected) +func TestStatusInputParsing_NilUsage(t *testing.T) { + jsonData := `{ + "model": {"display_name": "Sonnet"}, + "workspace": {"current_dir": "/tmp"}, + "context_window": {"context_window_size": 100000} + }` + + var data StatusInput + err := json.Unmarshal([]byte(jsonData), &data) + if err != nil { + t.Fatalf("Failed to parse JSON: %v", err) + } + if data.ContextWindow.CurrentUsage != nil { + t.Errorf("ContextWindow.CurrentUsage should be nil, got %+v", data.ContextWindow.CurrentUsage) } } -func TestStripANSI_Multiline(t *testing.T) { - input := "\033[31mline1\033[0m\n\033[32mline2\033[0m" - result := stripANSI(input) - expected := "line1\nline2" - if result != expected { - t.Errorf("stripANSI(multiline) = %q, want %q", result, expected) +func TestStatusInputParsing_EmptyJSON(t *testing.T) { + var data StatusInput + err := json.Unmarshal([]byte(`{}`), &data) + if err != nil { + t.Fatalf("Failed to parse empty JSON: %v", err) + } + if data.Model.DisplayName != "" { + t.Errorf("Expected empty DisplayName, got %q", data.Model.DisplayName) } } -func TestReadInputFromStdin_SingleLine(t *testing.T) { - reader := bufio.NewReader(strings.NewReader("single line")) - result := readInputFromStdin(reader) - if result != "single line" { - t.Errorf("readInputFromStdin(single) = %q, want 'single line'", result) +func TestStatusInputParsing_PartialJSON(t *testing.T) { + var data StatusInput + err := json.Unmarshal([]byte(`{"model": {"display_name": "Test"}}`), &data) + if err != nil { + t.Fatalf("Failed to parse partial JSON: %v", err) + } + if data.Model.DisplayName != "Test" { + t.Errorf("DisplayName = %q, want %q", data.Model.DisplayName, "Test") + } + if data.Workspace.CurrentDir != "" { + t.Errorf("Expected empty CurrentDir, got %q", data.Workspace.CurrentDir) } } -func TestReadInputFromStdin_JSONLike(t *testing.T) { - jsonStr := `{"key": "value", "nested": {"a": 1}}` - reader := bufio.NewReader(strings.NewReader(jsonStr)) - result := readInputFromStdin(reader) - if result != jsonStr { - t.Errorf("readInputFromStdin(json) = %q, want %q", result, jsonStr) +func TestStatusInputParsing_InvalidJSON(t *testing.T) { + var data StatusInput + if err := json.Unmarshal([]byte(`{invalid json}`), &data); err == nil { + t.Error("Expected error for invalid JSON, got nil") + } +} + +func TestANSIConstants(t *testing.T) { + tests := []struct { + name string + constant string + }{ + {"reset", reset}, + {"red", red}, + {"green", green}, + {"yellow", yellow}, + {"magenta", magenta}, + {"cyan", cyan}, + {"boldGreen", boldGreen}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if !strings.HasPrefix(tt.constant, "\033[") { + t.Errorf("%s constant doesn't start with ANSI escape", tt.name) + } + }) + } +} + +func TestReadInputFromStdin(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"multiline", "line1\nline2\nline3", "line1\nline2\nline3"}, + {"empty", "", ""}, + {"single line", "single line", "single line"}, + {"json", `{"key": "value"}`, `{"key": "value"}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader := bufio.NewReader(strings.NewReader(tt.input)) + result := readInputFromStdin(reader) + if result != tt.expected { + t.Errorf("readInputFromStdin = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestParseStatusInput_Valid(t *testing.T) { + data, err := parseStatusInput(`{"model": {"display_name": "Test"}, "workspace": {"current_dir": "/test"}}`) + if err != nil { + t.Fatalf("parseStatusInput failed: %v", err) + } + if data.Model.DisplayName != "Test" { + t.Errorf("DisplayName = %q, want Test", data.Model.DisplayName) + } + if data.Workspace.CurrentDir != "/test" { + t.Errorf("CurrentDir = %q, want /test", data.Workspace.CurrentDir) + } +} + +func TestParseStatusInput_Invalid(t *testing.T) { + if _, err := parseStatusInput("invalid json"); err == nil { + t.Error("parseStatusInput should fail on invalid JSON") } } @@ -726,15 +360,10 @@ func TestParseStatusInput_AllFields(t *testing.T) { if data.ContextWindow.CurrentUsage.CacheCreationTokens != 2000 { t.Errorf("CacheCreationTokens = %d, want 2000", data.ContextWindow.CurrentUsage.CacheCreationTokens) } - if data.ContextWindow.CurrentUsage.CacheReadInputTokens != 3000 { - t.Errorf("CacheReadInputTokens = %d, want 3000", data.ContextWindow.CurrentUsage.CacheReadInputTokens) - } } func TestParseStatusInput_ExtraFields(t *testing.T) { - // JSON with extra unknown fields should still parse - jsonStr := `{"model": {"display_name": "Test", "unknown": "field"}, "extra": "data"}` - data, err := parseStatusInput(jsonStr) + data, err := parseStatusInput(`{"model": {"display_name": "Test", "unknown": "field"}, "extra": "data"}`) if err != nil { t.Fatalf("parseStatusInput with extra fields failed: %v", err) } @@ -743,103 +372,145 @@ func TestParseStatusInput_ExtraFields(t *testing.T) { } } -func TestTokenUsage_ZeroValues(t *testing.T) { - usage := &TokenUsage{ - InputTokens: 0, - CacheCreationTokens: 0, - CacheReadInputTokens: 0, +func TestBuildStatusLine_ContainsComponents(t *testing.T) { + data := &StatusInput{} + data.Model.DisplayName = "TestModel" + data.Workspace.CurrentDir = "/home/user/project" + data.ContextWindow.ContextWindowSize = 100000 + data.ContextWindow.CurrentUsage = &TokenUsage{ + InputTokens: 5000, + CacheCreationTokens: 1000, + CacheReadInputTokens: 500, } - result := formatContextInfo(100000, usage) - expected := "0k/100k" - if result != expected { - t.Errorf("formatContextInfo(zero usage) = %q, want %q", result, expected) + + result := buildStatusLine(data) + + if !strings.Contains(result, "TestModel") { + t.Errorf("statusline missing model: %q", result) + } + if !strings.Contains(result, "project") { + t.Errorf("statusline missing directory: %q", result) + } + if !strings.Contains(result, "6k/100k") { + t.Errorf("statusline missing context info: %q", result) + } + if !strings.Contains(result, "●") { + t.Errorf("statusline missing gitea status: %q", result) } } -func TestDefaultStatuslineWidth_Constant(t *testing.T) { - if defaultStatuslineWidth < 40 || defaultStatuslineWidth > 300 { - t.Errorf("defaultStatuslineWidth = %d, expected between 40 and 300", defaultStatuslineWidth) +func TestBuildStatusLine_NilUsage(t *testing.T) { + data := &StatusInput{} + data.Model.DisplayName = "Model" + data.Workspace.CurrentDir = "/test" + data.ContextWindow.ContextWindowSize = 100000 + + result := buildStatusLine(data) + if !strings.Contains(result, "0/100k") { + t.Errorf("nil usage should show 0: %q", result) } } -// --- Coverage gap tests --- +func TestBuildStatusLine_EmptyDir(t *testing.T) { + data := &StatusInput{} + data.Model.DisplayName = "Model" + data.Workspace.CurrentDir = "" + data.ContextWindow.ContextWindowSize = 100000 -func TestRun_ValidInput(t *testing.T) { - jsonStr := `{"model":{"display_name":"Test"},"workspace":{"current_dir":"/tmp"},"context_window":{"context_window_size":100000}}` - r := bufio.NewReader(strings.NewReader(jsonStr)) - var out strings.Builder - err := run(r, &out) - if err != nil { - t.Fatalf("run() returned error: %v", err) - } - result := out.String() + result := buildStatusLine(data) if result == "" { - t.Error("run() produced empty output") - } - if !contains(result, "Test") { - t.Errorf("run() output missing model name: %q", result) + t.Error("buildStatusLine with empty dir should produce output") } } -func TestRun_InvalidJSON(t *testing.T) { - r := bufio.NewReader(strings.NewReader("not json")) - var out strings.Builder - err := run(r, &out) - if err == nil { - t.Error("run() should return error on invalid JSON") +func TestBuildStatusLine_LongModelName(t *testing.T) { + data := &StatusInput{} + data.Model.DisplayName = "Claude 3.5 Sonnet with Extended Context" + data.Workspace.CurrentDir = "/home/user/my-very-long-project-name-here" + data.ContextWindow.ContextWindowSize = 200000 + data.ContextWindow.CurrentUsage = &TokenUsage{InputTokens: 50000} + + result := buildStatusLine(data) + if !strings.Contains(result, "Claude 3.5 Sonnet with Extended Context") { + t.Errorf("long model name not in output: %q", result) + } + if !strings.Contains(result, "50k/200k") { + t.Errorf("context info not in output: %q", result) } } -func TestRun_EmptyInput(t *testing.T) { - r := bufio.NewReader(strings.NewReader("")) - var out strings.Builder - err := run(r, &out) - if err == nil { - t.Error("run() should return error on empty input") +func TestBuildStatusLine_RootDir(t *testing.T) { + data := &StatusInput{} + data.Model.DisplayName = "Model" + data.Workspace.CurrentDir = "/" + data.ContextWindow.ContextWindowSize = 100000 + + result := buildStatusLine(data) + if !strings.Contains(result, "Model") { + t.Errorf("root dir handling failed: %q", result) + } +} + +func TestGetGitInfo_CurrentRepo(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Skipf("Could not get working directory: %v", err) + } + result := getGitInfo(cwd) + if result == "" { + t.Skip("Not in a git repository") + } + if !strings.Contains(result, "git:(") { + t.Errorf("getGitInfo(%q) = %q, expected to contain 'git:('", cwd, result) + } +} + +func TestGetGitInfo_NonRepo(t *testing.T) { + result := getGitInfo("/tmp") + if result != "" && !strings.Contains(result, "git:(") { + t.Errorf("getGitInfo(/tmp) = %q, expected empty or valid git info", result) + } +} + +func TestGetGitInfo_InvalidPath(t *testing.T) { + if result := getGitInfo("/nonexistent/path"); result != "" { + t.Errorf("getGitInfo(invalid) = %q, expected empty string", result) + } +} + +func TestGetGitInfo_RootDir(t *testing.T) { + result := getGitInfo("/") + if result != "" && !strings.Contains(result, "git:(") { + t.Errorf("getGitInfo(/) = %q, expected empty or valid git info", result) } } func TestGetGitInfo_DetachedHEAD(t *testing.T) { - // Create a temp git repo and detach HEAD dir := t.TempDir() - - // Initialize repo, create a commit, then detach repo, err := git.PlainInit(dir, false) if err != nil { t.Fatalf("git init: %v", err) } - wt, err := repo.Worktree() if err != nil { t.Fatalf("worktree: %v", err) } - - // Create a file and commit if err := os.WriteFile(dir+"/file.txt", []byte("hello"), 0o644); err != nil { t.Fatalf("write: %v", err) } wt.Add("file.txt") hash, err := wt.Commit("initial", &git.CommitOptions{ - Author: &object.Signature{ - Name: "test", - Email: "test@test.com", - }, + Author: &object.Signature{Name: "test", Email: "test@test.com"}, }) if err != nil { t.Fatalf("commit: %v", err) } - - // Detach HEAD by checking out the commit hash - err = wt.Checkout(&git.CheckoutOptions{ - Hash: hash, - }) - if err != nil { + if err := wt.Checkout(&git.CheckoutOptions{Hash: hash}); err != nil { t.Fatalf("checkout: %v", err) } result := getGitInfo(dir) - shortHash := hash.String()[:7] - expected := " git:(" + shortHash + ")" + expected := " git:(" + hash.String()[:7] + ")" if result != expected { t.Errorf("getGitInfo(detached) = %q, want %q", result, expected) } @@ -847,135 +518,121 @@ func TestGetGitInfo_DetachedHEAD(t *testing.T) { func TestGetGitInfo_DirtyWorktree(t *testing.T) { dir := t.TempDir() - repo, err := git.PlainInit(dir, false) if err != nil { t.Fatalf("git init: %v", err) } - wt, err := repo.Worktree() if err != nil { t.Fatalf("worktree: %v", err) } - - // Create a file, commit, then modify it if err := os.WriteFile(dir+"/file.txt", []byte("hello"), 0o644); err != nil { t.Fatalf("write: %v", err) } wt.Add("file.txt") - _, err = wt.Commit("initial", &git.CommitOptions{ - Author: &object.Signature{ - Name: "test", - Email: "test@test.com", - }, - }) - if err != nil { + if _, err = wt.Commit("initial", &git.CommitOptions{ + Author: &object.Signature{Name: "test", Email: "test@test.com"}, + }); err != nil { t.Fatalf("commit: %v", err) } - - // Make the working tree dirty if err := os.WriteFile(dir+"/file.txt", []byte("modified"), 0o644); err != nil { t.Fatalf("write: %v", err) } result := getGitInfo(dir) - if !contains(result, "✗") { + if !strings.Contains(result, "✗") { t.Errorf("getGitInfo(dirty) = %q, expected dirty marker ✗", result) } - if !contains(result, "git:(master)") { + if !strings.Contains(result, "git:(master)") { t.Errorf("getGitInfo(dirty) = %q, expected branch name", result) } } func TestGetGitInfo_CleanWorktree(t *testing.T) { dir := t.TempDir() - repo, err := git.PlainInit(dir, false) if err != nil { t.Fatalf("git init: %v", err) } - wt, err := repo.Worktree() if err != nil { t.Fatalf("worktree: %v", err) } - if err := os.WriteFile(dir+"/file.txt", []byte("hello"), 0o644); err != nil { t.Fatalf("write: %v", err) } wt.Add("file.txt") - _, err = wt.Commit("initial", &git.CommitOptions{ - Author: &object.Signature{ - Name: "test", - Email: "test@test.com", - }, - }) - if err != nil { + if _, err = wt.Commit("initial", &git.CommitOptions{ + Author: &object.Signature{Name: "test", Email: "test@test.com"}, + }); err != nil { t.Fatalf("commit: %v", err) } result := getGitInfo(dir) - expected := " git:(master)" - if result != expected { - t.Errorf("getGitInfo(clean) = %q, want %q", result, expected) + if result != " git:(master)" { + t.Errorf("getGitInfo(clean) = %q, want %q", result, " git:(master)") } } func TestGetGitInfo_EmptyRepo(t *testing.T) { - // A repo with no commits — HEAD doesn't exist dir := t.TempDir() - _, err := git.PlainInit(dir, false) - if err != nil { + if _, err := git.PlainInit(dir, false); err != nil { t.Fatalf("git init: %v", err) } - - result := getGitInfo(dir) - // HEAD doesn't exist yet, so repo.Head() returns error - if result != "" { + if result := getGitInfo(dir); result != "" { t.Errorf("getGitInfo(empty repo) = %q, want empty", result) } } func TestGetGitInfo_UntrackedFile(t *testing.T) { dir := t.TempDir() - repo, err := git.PlainInit(dir, false) if err != nil { t.Fatalf("git init: %v", err) } - wt, err := repo.Worktree() if err != nil { t.Fatalf("worktree: %v", err) } - if err := os.WriteFile(dir+"/tracked.txt", []byte("tracked"), 0o644); err != nil { t.Fatalf("write: %v", err) } wt.Add("tracked.txt") - _, err = wt.Commit("initial", &git.CommitOptions{ - Author: &object.Signature{ - Name: "test", - Email: "test@test.com", - }, - }) - if err != nil { + if _, err = wt.Commit("initial", &git.CommitOptions{ + Author: &object.Signature{Name: "test", Email: "test@test.com"}, + }); err != nil { t.Fatalf("commit: %v", err) } - - // Add an untracked file — this makes the tree dirty if err := os.WriteFile(dir+"/untracked.txt", []byte("new"), 0o644); err != nil { t.Fatalf("write: %v", err) } result := getGitInfo(dir) - if !contains(result, "✗") { + if !strings.Contains(result, "✗") { t.Errorf("getGitInfo(untracked) = %q, expected dirty marker", result) } } +func TestGetGiteaStatus(t *testing.T) { + result := getGiteaStatus() + greenDot := green + "●" + reset + redDot := red + "●" + reset + if result != greenDot && result != redDot { + t.Errorf("getGiteaStatus() = %q, expected green or red dot", result) + } +} + +func TestGetGiteaStatus_ReturnsValidColor(t *testing.T) { + result := getGiteaStatus() + if !strings.Contains(result, "●") { + t.Errorf("getGiteaStatus() = %q, expected to contain dot", result) + } + if stripped := stripANSI(result); stripped != "●" { + t.Errorf("stripped getGiteaStatus() = %q, expected just dot", stripped) + } +} + func TestGetGiteaStatus_ProcessListError(t *testing.T) { - // Save and restore original orig := processLister t.Cleanup(func() { processLister = orig }) @@ -984,8 +641,7 @@ func TestGetGiteaStatus_ProcessListError(t *testing.T) { } result := getGiteaStatus() - expected := red + "●" + reset - if result != expected { + if expected := red + "●" + reset; result != expected { t.Errorf("getGiteaStatus(error) = %q, want red dot", result) } } @@ -999,37 +655,40 @@ func TestGetGiteaStatus_NoGiteaProcess(t *testing.T) { } result := getGiteaStatus() - expected := red + "●" + reset - if result != expected { + if expected := red + "●" + reset; result != expected { t.Errorf("getGiteaStatus(no gitea) = %q, want red dot", result) } } -func TestGetTerminalWidth_Success(t *testing.T) { - orig := termWidthFunc - t.Cleanup(func() { termWidthFunc = orig }) - - termWidthFunc = func() (int, error) { - return 120, nil +func TestRun_ValidInput(t *testing.T) { + jsonStr := `{"model":{"display_name":"Test"},"workspace":{"current_dir":"/tmp"},"context_window":{"context_window_size":100000}}` + r := bufio.NewReader(strings.NewReader(jsonStr)) + var out strings.Builder + if err := run(r, &out); err != nil { + t.Fatalf("run() returned error: %v", err) } - - width := getTerminalWidth() - if width != 120 { - t.Errorf("getTerminalWidth() = %d, want 120", width) + result := out.String() + if result == "" { + t.Error("run() produced empty output") + } + if !strings.Contains(result, "Test") { + t.Errorf("run() output missing model name: %q", result) } } -func TestGetTerminalWidth_Error(t *testing.T) { - orig := termWidthFunc - t.Cleanup(func() { termWidthFunc = orig }) - - termWidthFunc = func() (int, error) { - return 0, errors.New("not a terminal") +func TestRun_InvalidJSON(t *testing.T) { + r := bufio.NewReader(strings.NewReader("not json")) + var out strings.Builder + if err := run(r, &out); err == nil { + t.Error("run() should return error on invalid JSON") } +} - width := getTerminalWidth() - if width != 80 { - t.Errorf("getTerminalWidth(error) = %d, want 80", width) +func TestRun_EmptyInput(t *testing.T) { + r := bufio.NewReader(strings.NewReader("")) + var out strings.Builder + if err := run(r, &out); err == nil { + t.Error("run() should return error on empty input") } } @@ -1041,21 +700,21 @@ func BenchmarkFormatContextInfo(b *testing.B) { CacheCreationTokens: 10000, CacheReadInputTokens: 5000, } - for i := 0; i < b.N; i++ { + for b.Loop() { formatContextInfo(200000, usage) } } func BenchmarkStripANSI(b *testing.B) { input := "\033[32m●\033[0m \033[35mOpus\033[0m \033[1;32m➜\033[0m \033[36mproject\033[0m" - for i := 0; i < b.N; i++ { + for b.Loop() { stripANSI(input) } } func BenchmarkParseStatusInput(b *testing.B) { jsonStr := `{"model": {"display_name": "Test"}, "workspace": {"current_dir": "/test"}, "context_window": {"context_window_size": 200000, "current_usage": {"input_tokens": 50000}}}` - for i := 0; i < b.N; i++ { + for b.Loop() { parseStatusInput(jsonStr) } } @@ -1065,31 +724,15 @@ func BenchmarkBuildStatusLine(b *testing.B) { data.Model.DisplayName = "Claude Opus" data.Workspace.CurrentDir = "/home/user/project" data.ContextWindow.ContextWindowSize = 200000 - data.ContextWindow.CurrentUsage = &TokenUsage{ - InputTokens: 50000, - } - for i := 0; i < b.N; i++ { + data.ContextWindow.CurrentUsage = &TokenUsage{InputTokens: 50000} + for b.Loop() { buildStatusLine(data) } } -func BenchmarkCalculatePadding(b *testing.B) { - for i := 0; i < b.N; i++ { - calculatePadding("left side content", "right side", 120) - } -} - -func BenchmarkFormatOutput(b *testing.B) { - left := red + "left" + reset - right := green + "right" + reset - for i := 0; i < b.N; i++ { - formatOutput(left, right, 50) - } -} - func BenchmarkReadInputFromStdin(b *testing.B) { jsonStr := `{"model": {"display_name": "Test"}, "workspace": {"current_dir": "/test"}}` - for i := 0; i < b.N; i++ { + for b.Loop() { reader := bufio.NewReader(strings.NewReader(jsonStr)) readInputFromStdin(reader) }