package main import ( "os" "path/filepath" "strings" "testing" ) // spec:26174-planctl-context-tokens/R4.4+D§3.8 func TestFormatTokenCount(t *testing.T) { cases := []struct { in int64 want string }{ {0, "< 1 k"}, {500, "< 1 k"}, {999, "< 1 k"}, {1000, "1 k"}, {1499, "1 k"}, {1500, "2 k"}, {143000, "143 k"}, {1000000, "1000 k"}, } for _, c := range cases { if got := formatTokenCount(c.in); got != c.want { t.Errorf("formatTokenCount(%d) = %q, want %q", c.in, got, c.want) } } } // spec:26174-planctl-context-tokens/R3.1+R3.2+R3.5+D§3.7 func TestClassify_bands(t *testing.T) { cases := []struct { name string ctx *TokenCtx wantSev string wantMsg string }{ {"nil ctx", nil, "", ""}, {"pct 0", &TokenCtx{Used: 0, Limit: 200000}, "", ""}, {"pct 69", &TokenCtx{Used: 138000, Limit: 200000}, "", ""}, {"pct 70 info lower", &TokenCtx{Used: 140000, Limit: 200000}, "info", "plan to wrap up this session soon."}, {"pct 84 info upper", &TokenCtx{Used: 168000, Limit: 200000}, "info", "plan to wrap up this session soon."}, {"pct 85 warn lower", &TokenCtx{Used: 170000, Limit: 200000}, "warn", "commit current work and start a new session after this task."}, {"pct 94 warn upper", &TokenCtx{Used: 188000, Limit: 200000}, "warn", "commit current work and start a new session after this task."}, {"pct 95 error lower", &TokenCtx{Used: 190000, Limit: 200000}, "error", "stop new work; commit and close out immediately."}, {"pct 100 error", &TokenCtx{Used: 200000, Limit: 200000}, "error", "stop new work; commit and close out immediately."}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { sev, msg := c.ctx.Classify() if sev != c.wantSev || msg != c.wantMsg { t.Errorf("Classify() = (%q, %q), want (%q, %q)", sev, msg, c.wantSev, c.wantMsg) } }) } } // spec:26174-planctl-context-tokens/R3.3+D§3.7 func TestClassify_unknownLimit(t *testing.T) { ctx := &TokenCtx{Used: 143000, Limit: 0} sev, msg := ctx.Classify() if sev != "" || msg != "" { t.Errorf("Classify() with Limit=0 = (%q, %q), want (\"\", \"\")", sev, msg) } } // spec:26174-planctl-context-tokens/R2.4+D§3.6 func TestReadLimit_set(t *testing.T) { t.Setenv("CLAUDE_CODE_MAX_CONTEXT_TOKENS", "200000") if got := readLimit(); got != 200000 { t.Errorf("readLimit() = %d, want 200000", got) } } // spec:26174-planctl-context-tokens/R2.4+D§3.6 func TestReadLimit_unset(t *testing.T) { t.Setenv("CLAUDE_CODE_MAX_CONTEXT_TOKENS", "") if got := readLimit(); got != 0 { t.Errorf("readLimit() unset = %d, want 0", got) } } // spec:26174-planctl-context-tokens/R2.4+D§3.6 func TestReadLimit_invalid(t *testing.T) { t.Setenv("CLAUDE_CODE_MAX_CONTEXT_TOKENS", "not-a-number") if got := readLimit(); got != 0 { t.Errorf("readLimit() invalid = %d, want 0", got) } } // spec:26174-planctl-context-tokens/R1.5+D§3.4 func TestTranscriptPath_slash(t *testing.T) { got := transcriptPath("abc", "/Users/x", "/Users/x/repos/foo") want := "/Users/x/.claude/projects/-Users-x-repos-foo/abc.jsonl" if got != want { t.Errorf("transcriptPath = %q, want %q", got, want) } } // spec:26174-planctl-context-tokens/R1.5+D§3.4 func TestTranscriptPath_nested(t *testing.T) { got := transcriptPath("uuid", "/home/u", "/a/b/c/d") want := "/home/u/.claude/projects/-a-b-c-d/uuid.jsonl" if got != want { t.Errorf("transcriptPath nested = %q, want %q", got, want) } } // spec:26174-planctl-context-tokens/R1.6+D§3.4 func TestTranscriptPath_fallbackCwd(t *testing.T) { got := transcriptPath("uuid", "/h", "") // fallback uses os.Getwd; just assert shape contains home and the uuid.jsonl suffix if !strings.HasPrefix(got, "/h/.claude/projects/") { t.Errorf("fallback path prefix wrong: %q", got) } if !strings.HasSuffix(got, "/uuid.jsonl") { t.Errorf("fallback path suffix wrong: %q", got) } } // spec:26174-planctl-context-tokens/R6.2+D§3.9 func TestParsePIDFile_valid(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "1234") if err := os.WriteFile(p, []byte("817fec78-6ff1-4fb2-a17c-5708ea5df968\n1700000000\n"), 0o600); err != nil { t.Fatal(err) } uuid, stamp, err := parsePIDFile(p) if err != nil { t.Fatalf("parsePIDFile: %v", err) } if uuid != "817fec78-6ff1-4fb2-a17c-5708ea5df968" { t.Errorf("uuid = %q", uuid) } if stamp != 1700000000 { t.Errorf("stamp = %d, want 1700000000", stamp) } } // spec:26174-planctl-context-tokens/R6.3+D§3.9 func TestParsePIDFile_missingLine2(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "1234") if err := os.WriteFile(p, []byte("817fec78-6ff1-4fb2-a17c-5708ea5df968\n"), 0o600); err != nil { t.Fatal(err) } if _, _, err := parsePIDFile(p); err == nil { t.Fatal("expected error for missing line 2") } } // spec:26174-planctl-context-tokens/R6.3+D§3.9 func TestParsePIDFile_malformedInt(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "1234") if err := os.WriteFile(p, []byte("817fec78-6ff1-4fb2-a17c-5708ea5df968\nnot-a-number\n"), 0o600); err != nil { t.Fatal(err) } if _, _, err := parsePIDFile(p); err == nil { t.Fatal("expected error for malformed int") } } // spec:26174-planctl-context-tokens/R6.3+D§3.9+D§4.1 func TestParsePIDFile_trailingContent(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "1234") if err := os.WriteFile(p, []byte("uuid-abc\n1700000000\nunexpected\n"), 0o600); err != nil { t.Fatal(err) } if _, _, err := parsePIDFile(p); err == nil { t.Fatal("expected error for trailing content") } } // spec:26174-planctl-context-tokens/R6.3+D§3.9+D§4.1 // Blank third line also disallowed — design §4.1 specifies exactly two newline-terminated lines. func TestParsePIDFile_blankThirdLine(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "1234") if err := os.WriteFile(p, []byte("uuid-abc\n1700000000\n\n"), 0o600); err != nil { t.Fatal(err) } if _, _, err := parsePIDFile(p); err == nil { t.Fatal("expected error for blank third line") } } // spec:26174-planctl-context-tokens/R6.3+D§3.9+D§4.1 // Missing final newline indicates a partial/truncated write. func TestParsePIDFile_missingFinalNewline(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "1234") if err := os.WriteFile(p, []byte("uuid-abc\n1700000000"), 0o600); err != nil { t.Fatal(err) } if _, _, err := parsePIDFile(p); err == nil { t.Fatal("expected error for missing final newline") } } // spec:26174-planctl-context-tokens/R2.1+R2.2+D§3.5 func TestParseLastUsage_valid(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "t.jsonl") content := `{"type":"user","message":{"content":"hi"}} {"type":"assistant","message":{"usage":{"input_tokens":10,"cache_creation_input_tokens":100,"cache_read_input_tokens":1000}}} {"type":"assistant","message":{"usage":{"input_tokens":20,"cache_creation_input_tokens":200,"cache_read_input_tokens":2000}}} ` if err := os.WriteFile(p, []byte(content), 0o600); err != nil { t.Fatal(err) } got, err := parseLastUsage(p) if err != nil { t.Fatalf("parseLastUsage: %v", err) } // last line wins: 20 + 200 + 2000 = 2220 if got != 2220 { t.Errorf("got %d, want 2220", got) } } // spec:26174-planctl-context-tokens/R2.3+D§3.5 func TestParseLastUsage_malformedMid(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "t.jsonl") content := `{"type":"assistant","message":{"usage":{"input_tokens":1}}} {not valid json at all {"type":"assistant","message":{"usage":{"input_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}} ` if err := os.WriteFile(p, []byte(content), 0o600); err != nil { t.Fatal(err) } got, err := parseLastUsage(p) if err != nil { t.Fatalf("parseLastUsage: %v", err) } if got != 5 { t.Errorf("got %d, want 5 (last good line after malformed skip)", got) } } // spec:26174-planctl-context-tokens/R2.3+D§3.5 func TestParseLastUsage_noAssistant(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "t.jsonl") content := `{"type":"user","message":{"content":"hi"}} {"type":"system","message":{"content":"boot"}} ` if err := os.WriteFile(p, []byte(content), 0o600); err != nil { t.Fatal(err) } if _, err := parseLastUsage(p); err == nil { t.Fatal("expected errNoUsageLine") } } // spec:26174-planctl-context-tokens/R2.3+D§3.5 func TestParseLastUsage_emptyFile(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "t.jsonl") if err := os.WriteFile(p, []byte(""), 0o600); err != nil { t.Fatal(err) } if _, err := parseLastUsage(p); err == nil { t.Fatal("expected errNoUsageLine for empty file") } } // spec:26174-planctl-context-tokens/R2.1+R2.6+D§3.5+D§7.1 // parseLastUsage must handle oversized-but-valid JSONL lines — design §3.5 // sizes the scanner buffer at 10 MB to accommodate large messages. Here we // stuff the assistant line with a 5 MB inert string field (ignored by the // transcriptUsage struct) to confirm the scanner doesn't truncate. func TestParseLastUsage_largeLine(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "big.jsonl") f, err := os.Create(p) if err != nil { t.Fatal(err) } pad := strings.Repeat("x", 5*1024*1024) if _, err := f.WriteString(`{"type":"user","message":{"content":"hi"}}` + "\n"); err != nil { t.Fatal(err) } // Single 5 MB assistant line with a harmless "noise" field plus the // usage object we do care about. line := `{"type":"assistant","noise":"` + pad + `","message":{"usage":{"input_tokens":11,"cache_creation_input_tokens":22,"cache_read_input_tokens":33}}}` + "\n" if _, err := f.WriteString(line); err != nil { t.Fatal(err) } if err := f.Close(); err != nil { t.Fatal(err) } got, err := parseLastUsage(p) if err != nil { t.Fatalf("parseLastUsage on 5 MB line: %v", err) } if got != 66 { t.Errorf("got %d, want 66 (11+22+33)", got) } } // spec:26174-planctl-context-tokens/R2.3+R2.6+D§3.5 // A line exceeding the scanner's 10 MB cap must error out cleanly (no panic) // — the design specifically sizes the buffer at 10 MB; lines larger than // that should surface a scanner error via parseLastUsage's sc.Err() check. func TestParseLastUsage_oversizedLineErrors(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "huge.jsonl") f, err := os.Create(p) if err != nil { t.Fatal(err) } pad := strings.Repeat("x", 11*1024*1024) // 11 MB > 10 MB buffer line := `{"type":"assistant","noise":"` + pad + `","message":{"usage":{"input_tokens":1}}}` + "\n" if _, err := f.WriteString(line); err != nil { t.Fatal(err) } if err := f.Close(); err != nil { t.Fatal(err) } if _, err := parseLastUsage(p); err == nil { t.Fatal("expected error on oversized line; got none") } } // spec:26174-planctl-context-tokens/R2.1+D§3.5 // Zero-usage lines still qualify per PRD R2.1 (presence, not non-zero). func TestParseLastUsage_zeroUsageStillQualifies(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "t.jsonl") content := `{"type":"assistant","message":{"usage":{"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}} ` if err := os.WriteFile(p, []byte(content), 0o600); err != nil { t.Fatal(err) } got, err := parseLastUsage(p) if err != nil { t.Fatalf("parseLastUsage: %v", err) } if got != 0 { t.Errorf("got %d, want 0", got) } } // spec:26174-planctl-context-tokens/R1.1+D§3.3 func TestResolveSessionUUID_envWins(t *testing.T) { t.Setenv("CLAUDE_SESSION_ID", "env-uuid-12345") got := resolveSessionUUID() if got != "env-uuid-12345" { t.Errorf("resolveSessionUUID = %q, want env-uuid-12345", got) } } // spec:26174-planctl-context-tokens/R1.2+R6.1+R6.3+D§3.3 func TestResolveViaPIDWalk_pidWalkFinds(t *testing.T) { dir := t.TempDir() // Ancestor chain: 100 -> 200 (winner) -> 300 -> 1 parents := map[int]int{100: 200, 200: 300, 300: 1} parentOf := func(pid int) int { return parents[pid] } alive := func(pid int) bool { return pid == 200 } stampOf := func(pid int) (int64, error) { if pid == 200 { return 1_700_000_000, nil } return 0, nil } p := filepath.Join(dir, "200") if err := os.WriteFile(p, []byte("the-uuid\n1700000000\n"), 0o600); err != nil { t.Fatal(err) } got := resolveViaPIDWalk(100, dir, parentOf, alive, stampOf) if got != "the-uuid" { t.Errorf("got %q, want the-uuid", got) } } // spec:26174-planctl-context-tokens/R6.3+D§3.3 func TestResolveViaPIDWalk_staleSkipped(t *testing.T) { dir := t.TempDir() parents := map[int]int{100: 200, 200: 1} parentOf := func(pid int) int { return parents[pid] } // dead: returns false → skip alive := func(_ int) bool { return false } stampOf := func(_ int) (int64, error) { return 1_700_000_000, nil } p := filepath.Join(dir, "200") if err := os.WriteFile(p, []byte("the-uuid\n1700000000\n"), 0o600); err != nil { t.Fatal(err) } got := resolveViaPIDWalk(100, dir, parentOf, alive, stampOf) if got != "" { t.Errorf("stale PID should not resolve, got %q", got) } } // spec:26174-planctl-context-tokens/R6.3+D§3.3 func TestResolveViaPIDWalk_startTimeMismatch(t *testing.T) { dir := t.TempDir() parents := map[int]int{100: 200, 200: 1} parentOf := func(pid int) int { return parents[pid] } alive := func(_ int) bool { return true } // file says 1700000000, actual says 9999999999 → mismatch → skip stampOf := func(_ int) (int64, error) { return 9_999_999_999, nil } p := filepath.Join(dir, "200") if err := os.WriteFile(p, []byte("the-uuid\n1700000000\n"), 0o600); err != nil { t.Fatal(err) } got := resolveViaPIDWalk(100, dir, parentOf, alive, stampOf) if got != "" { t.Errorf("start-time mismatch should not resolve, got %q", got) } } // spec:26174-planctl-context-tokens/R1.4+D§3.3 func TestResolveViaPIDWalk_exhausted(t *testing.T) { dir := t.TempDir() // Deep chain with no files anywhere. parentOf := func(pid int) int { if pid <= 1 { return 0 } return pid - 1 } alive := func(_ int) bool { return true } stampOf := func(_ int) (int64, error) { return 0, nil } got := resolveViaPIDWalk(100, dir, parentOf, alive, stampOf) if got != "" { t.Errorf("exhausted walk should return empty, got %q", got) } } // spec:26174-planctl-context-tokens/R1.1+R1.5+R2.1+R2.2+R2.4+D§3.2 func TestReadTokenCtx_happyPath(t *testing.T) { home := t.TempDir() projectDir := "/path/to/proj" slug := "-path-to-proj" uuid := "abc-123" dir := filepath.Join(home, ".claude", "projects", slug) if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatal(err) } content := `{"type":"assistant","message":{"usage":{"input_tokens":10,"cache_creation_input_tokens":100,"cache_read_input_tokens":1000}}} ` if err := os.WriteFile(filepath.Join(dir, uuid+".jsonl"), []byte(content), 0o600); err != nil { t.Fatal(err) } t.Setenv("HOME", home) t.Setenv("CLAUDE_PROJECT_DIR", projectDir) t.Setenv("CLAUDE_SESSION_ID", uuid) t.Setenv("CLAUDE_CODE_MAX_CONTEXT_TOKENS", "2000") ctx := ReadTokenCtx() if ctx == nil { t.Fatal("ReadTokenCtx returned nil, want non-nil") } if ctx.Used != 1110 { t.Errorf("Used = %d, want 1110", ctx.Used) } if ctx.Limit != 2000 { t.Errorf("Limit = %d, want 2000", ctx.Limit) } } // spec:26174-planctl-context-tokens/R1.4+D§3.2 func TestReadTokenCtx_noSession(t *testing.T) { t.Setenv("CLAUDE_SESSION_ID", "") // Re-route PID walk to an empty dir so ancestor probing finds nothing. empty := t.TempDir() origDir := pidSessionDir pidSessionDir = empty t.Cleanup(func() { pidSessionDir = origDir }) if ctx := ReadTokenCtx(); ctx != nil { t.Errorf("expected nil for no session, got %+v", ctx) } }