cc-context-stats 1.7.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/package.json +9 -1
  2. package/scripts/context-stats.sh +1 -1
  3. package/scripts/statusline.js +128 -18
  4. package/.editorconfig +0 -60
  5. package/.eslintrc.json +0 -35
  6. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -49
  7. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -31
  8. package/.github/PULL_REQUEST_TEMPLATE.md +0 -33
  9. package/.github/dependabot.yml +0 -44
  10. package/.github/workflows/ci.yml +0 -294
  11. package/.github/workflows/release.yml +0 -151
  12. package/.pre-commit-config.yaml +0 -74
  13. package/.prettierrc +0 -33
  14. package/.shellcheckrc +0 -10
  15. package/CHANGELOG.md +0 -163
  16. package/CLAUDE.md +0 -66
  17. package/CODE_OF_CONDUCT.md +0 -59
  18. package/CONTRIBUTING.md +0 -240
  19. package/RELEASE_NOTES.md +0 -19
  20. package/SECURITY.md +0 -44
  21. package/TODOS.md +0 -72
  22. package/assets/logo/favicon.svg +0 -19
  23. package/assets/logo/logo-black.svg +0 -24
  24. package/assets/logo/logo-full.svg +0 -40
  25. package/assets/logo/logo-icon.svg +0 -27
  26. package/assets/logo/logo-mark.svg +0 -28
  27. package/assets/logo/logo-white.svg +0 -24
  28. package/assets/logo/logo-wordmark.svg +0 -6
  29. package/config/settings-example.json +0 -7
  30. package/config/settings-node.json +0 -7
  31. package/config/settings-python.json +0 -7
  32. package/docs/ARCHITECTURE.md +0 -128
  33. package/docs/CSV_FORMAT.md +0 -42
  34. package/docs/DEPLOYMENT.md +0 -71
  35. package/docs/DEVELOPMENT.md +0 -161
  36. package/docs/configuration.md +0 -118
  37. package/docs/context-stats.md +0 -143
  38. package/docs/installation.md +0 -255
  39. package/docs/scripts.md +0 -140
  40. package/docs/troubleshooting.md +0 -278
  41. package/images/claude-statusline-token-graph.gif +0 -0
  42. package/images/claude-statusline.png +0 -0
  43. package/images/context-status-dumbzone.png +0 -0
  44. package/images/context-status.png +0 -0
  45. package/images/statusline-detail.png +0 -0
  46. package/images/token-graph.jpeg +0 -0
  47. package/images/token-graph.png +0 -0
  48. package/images/v1.6.1.png +0 -0
  49. package/install +0 -351
  50. package/install.sh +0 -298
  51. package/jest.config.js +0 -11
  52. package/pyproject.toml +0 -115
  53. package/requirements-dev.txt +0 -12
  54. package/scripts/statusline-full.sh +0 -304
  55. package/scripts/statusline-git.sh +0 -88
  56. package/scripts/statusline-minimal.sh +0 -67
  57. package/scripts/statusline.py +0 -485
  58. package/src/claude_statusline/__init__.py +0 -11
  59. package/src/claude_statusline/__main__.py +0 -6
  60. package/src/claude_statusline/cli/__init__.py +0 -1
  61. package/src/claude_statusline/cli/context_stats.py +0 -512
  62. package/src/claude_statusline/cli/explain.py +0 -228
  63. package/src/claude_statusline/cli/statusline.py +0 -169
  64. package/src/claude_statusline/core/__init__.py +0 -1
  65. package/src/claude_statusline/core/colors.py +0 -124
  66. package/src/claude_statusline/core/config.py +0 -148
  67. package/src/claude_statusline/core/git.py +0 -78
  68. package/src/claude_statusline/core/state.py +0 -323
  69. package/src/claude_statusline/formatters/__init__.py +0 -1
  70. package/src/claude_statusline/formatters/layout.py +0 -67
  71. package/src/claude_statusline/formatters/time.py +0 -50
  72. package/src/claude_statusline/formatters/tokens.py +0 -70
  73. package/src/claude_statusline/graphs/__init__.py +0 -1
  74. package/src/claude_statusline/graphs/renderer.py +0 -366
  75. package/src/claude_statusline/graphs/statistics.py +0 -92
  76. package/src/claude_statusline/ui/__init__.py +0 -1
  77. package/src/claude_statusline/ui/icons.py +0 -93
  78. package/src/claude_statusline/ui/waiting.py +0 -62
  79. package/tests/bash/test_delta_parity.bats +0 -199
  80. package/tests/bash/test_install.bats +0 -29
  81. package/tests/bash/test_parity.bats +0 -315
  82. package/tests/bash/test_statusline_full.bats +0 -139
  83. package/tests/bash/test_statusline_git.bats +0 -42
  84. package/tests/bash/test_statusline_minimal.bats +0 -37
  85. package/tests/fixtures/json/comma_in_path.json +0 -31
  86. package/tests/fixtures/json/high_usage.json +0 -17
  87. package/tests/fixtures/json/low_usage.json +0 -17
  88. package/tests/fixtures/json/medium_usage.json +0 -17
  89. package/tests/fixtures/json/valid_full.json +0 -30
  90. package/tests/fixtures/json/valid_minimal.json +0 -9
  91. package/tests/node/rotation.test.js +0 -89
  92. package/tests/node/statusline.test.js +0 -240
  93. package/tests/python/conftest.py +0 -84
  94. package/tests/python/test_colors.py +0 -105
  95. package/tests/python/test_config_colors.py +0 -78
  96. package/tests/python/test_data_pipeline.py +0 -446
  97. package/tests/python/test_explain.py +0 -177
  98. package/tests/python/test_icons.py +0 -152
  99. package/tests/python/test_layout.py +0 -127
  100. package/tests/python/test_state_rotation_validation.py +0 -232
  101. package/tests/python/test_statusline.py +0 -215
  102. package/tests/python/test_waiting.py +0 -127
@@ -1,177 +0,0 @@
1
- """Tests for the context-stats explain command."""
2
-
3
- import json
4
- import subprocess
5
- import sys
6
- from pathlib import Path
7
-
8
- PROJECT_ROOT = Path(__file__).parent.parent.parent
9
- FIXTURES_DIR = PROJECT_ROOT / "tests" / "fixtures" / "json"
10
-
11
-
12
- class TestExplainCommand:
13
- """Tests for `context-stats explain`."""
14
-
15
- def _run_explain(self, input_data, extra_args=None):
16
- """Run context-stats explain with JSON input and return stdout."""
17
- cmd = [sys.executable, "-m", "claude_statusline.cli.context_stats", "explain"]
18
- if extra_args:
19
- cmd.extend(extra_args)
20
- result = subprocess.run(
21
- cmd,
22
- input=json.dumps(input_data),
23
- capture_output=True,
24
- text=True,
25
- timeout=10,
26
- )
27
- return result
28
-
29
- def test_explain_shows_model(self):
30
- data = {"model": {"display_name": "Opus 4.5", "id": "claude-opus-4-5"}}
31
- result = self._run_explain(data)
32
- assert result.returncode == 0
33
- assert "Opus 4.5" in result.stdout
34
- assert "claude-opus-4-5" in result.stdout
35
-
36
- def test_explain_shows_workspace(self):
37
- data = {
38
- "workspace": {
39
- "current_dir": "/home/user/project",
40
- "project_dir": "/home/user/project",
41
- }
42
- }
43
- result = self._run_explain(data)
44
- assert result.returncode == 0
45
- assert "/home/user/project" in result.stdout
46
-
47
- def test_explain_shows_context_window(self):
48
- data = {
49
- "context_window": {
50
- "context_window_size": 200000,
51
- "current_usage": {
52
- "input_tokens": 50000,
53
- "cache_creation_input_tokens": 10000,
54
- "cache_read_input_tokens": 20000,
55
- },
56
- }
57
- }
58
- result = self._run_explain(data)
59
- assert result.returncode == 0
60
- assert "200,000" in result.stdout
61
- assert "50,000" in result.stdout
62
- assert "context_used" in result.stdout
63
-
64
- def test_explain_shows_cost(self):
65
- data = {
66
- "cost": {
67
- "total_cost_usd": 0.1234,
68
- "total_lines_added": 100,
69
- "total_lines_removed": 50,
70
- }
71
- }
72
- result = self._run_explain(data)
73
- assert result.returncode == 0
74
- assert "$0.1234" in result.stdout
75
-
76
- def test_explain_shows_session(self):
77
- data = {"session_id": "abc-123", "version": "2.0.0"}
78
- result = self._run_explain(data)
79
- assert result.returncode == 0
80
- assert "abc-123" in result.stdout
81
- assert "2.0.0" in result.stdout
82
-
83
- def test_explain_shows_absent_fields(self):
84
- data = {}
85
- result = self._run_explain(data)
86
- assert result.returncode == 0
87
- assert "(absent)" in result.stdout
88
-
89
- def test_explain_shows_raw_json(self):
90
- data = {"model": {"display_name": "Test"}}
91
- result = self._run_explain(data)
92
- assert result.returncode == 0
93
- assert "Raw JSON" in result.stdout
94
- assert '"display_name": "Test"' in result.stdout
95
-
96
- def test_explain_shows_config(self):
97
- data = {}
98
- result = self._run_explain(data)
99
- assert result.returncode == 0
100
- assert "Active Config" in result.stdout
101
-
102
- def test_explain_with_full_fixture(self):
103
- with open(FIXTURES_DIR / "valid_full.json") as f:
104
- data = json.load(f)
105
- result = self._run_explain(data)
106
- assert result.returncode == 0
107
- assert "Opus 4.5" in result.stdout
108
- assert "test-session-123" in result.stdout
109
-
110
- def test_explain_invalid_json_fails(self):
111
- result = subprocess.run(
112
- [sys.executable, "-m", "claude_statusline.cli.context_stats", "explain"],
113
- input="not valid json",
114
- capture_output=True,
115
- text=True,
116
- timeout=10,
117
- )
118
- assert result.returncode == 1
119
- assert "invalid JSON" in result.stderr
120
-
121
- def test_explain_shows_derived_free_tokens(self):
122
- data = {
123
- "context_window": {
124
- "context_window_size": 200000,
125
- "current_usage": {
126
- "input_tokens": 50000,
127
- "cache_creation_input_tokens": 10000,
128
- "cache_read_input_tokens": 20000,
129
- },
130
- }
131
- }
132
- result = self._run_explain(data)
133
- assert result.returncode == 0
134
- # 200000 - (50000+10000+20000) = 120000
135
- assert "120,000" in result.stdout
136
- assert "60.0%" in result.stdout
137
-
138
- def test_explain_no_color_flag(self):
139
- data = {"model": {"display_name": "Test"}}
140
- result = subprocess.run(
141
- [sys.executable, "-m", "claude_statusline.cli.context_stats", "explain", "--no-color"],
142
- input=json.dumps(data),
143
- capture_output=True,
144
- text=True,
145
- timeout=10,
146
- )
147
- assert result.returncode == 0
148
- assert "Test" in result.stdout
149
- # No ANSI escape codes when --no-color is passed
150
- assert "\x1b[" not in result.stdout
151
-
152
- def test_explain_shows_vim_mode(self):
153
- data = {"vim": {"mode": "NORMAL"}}
154
- result = self._run_explain(data)
155
- assert result.returncode == 0
156
- assert "NORMAL" in result.stdout
157
- assert "Extensions" in result.stdout
158
-
159
- def test_explain_shows_agent(self):
160
- data = {"agent": {"name": "my-agent"}}
161
- result = self._run_explain(data)
162
- assert result.returncode == 0
163
- assert "my-agent" in result.stdout
164
- assert "Extensions" in result.stdout
165
-
166
- def test_explain_shows_output_style(self):
167
- data = {"output_style": {"name": "concise"}}
168
- result = self._run_explain(data)
169
- assert result.returncode == 0
170
- assert "concise" in result.stdout
171
- assert "Extensions" in result.stdout
172
-
173
- def test_explain_no_extensions_section_when_absent(self):
174
- data = {"model": {"display_name": "Test"}}
175
- result = self._run_explain(data)
176
- assert result.returncode == 0
177
- assert "Extensions" not in result.stdout
@@ -1,152 +0,0 @@
1
- """Tests for activity tier detection."""
2
-
3
- import time
4
-
5
- from claude_statusline.core.state import StateEntry
6
- from claude_statusline.graphs.statistics import detect_spike
7
- from claude_statusline.ui.icons import (
8
- ActivityTier,
9
- get_activity_tier,
10
- get_tier_label,
11
- )
12
-
13
-
14
- def _make_entry(
15
- timestamp: int = 0,
16
- current_input: int = 1000,
17
- cache_creation: int = 0,
18
- cache_read: int = 0,
19
- context_window_size: int = 200_000,
20
- ) -> StateEntry:
21
- """Helper to create a StateEntry for testing."""
22
- return StateEntry(
23
- timestamp=timestamp,
24
- total_input_tokens=current_input,
25
- total_output_tokens=0,
26
- current_input_tokens=current_input,
27
- current_output_tokens=0,
28
- cache_creation=cache_creation,
29
- cache_read=cache_read,
30
- cost_usd=0.0,
31
- lines_added=0,
32
- lines_removed=0,
33
- session_id="test",
34
- model_id="test-model",
35
- workspace_project_dir="/tmp/test",
36
- context_window_size=context_window_size,
37
- )
38
-
39
-
40
- class TestDetectSpike:
41
- """Tests for spike detection logic."""
42
-
43
- def test_empty_deltas(self):
44
- assert detect_spike([], 200_000) is False
45
-
46
- def test_no_spike_small_delta(self):
47
- deltas = [1000, 1200, 1100, 900, 1000]
48
- assert detect_spike(deltas, 200_000) is False
49
-
50
- def test_spike_absolute_threshold(self):
51
- """Delta > 15% of context window is a spike."""
52
- deltas = [1000, 1200, 1100, 900, 35_000]
53
- assert detect_spike(deltas, 200_000) is True
54
-
55
- def test_spike_relative_threshold(self):
56
- """Delta > 3x rolling average is a spike."""
57
- deltas = [100, 100, 100, 100, 500]
58
- assert detect_spike(deltas, 200_000) is True
59
-
60
- def test_no_spike_when_all_similar(self):
61
- deltas = [1000, 1000, 1000, 1000, 1000]
62
- assert detect_spike(deltas, 200_000) is False
63
-
64
- def test_single_delta_no_spike(self):
65
- """Single delta can't be a relative spike (no average to compare)."""
66
- deltas = [1000]
67
- assert detect_spike(deltas, 200_000) is False
68
-
69
- def test_spike_with_zero_context_window(self):
70
- """Only relative threshold applies when context_window is 0."""
71
- deltas = [100, 100, 100, 100, 500]
72
- assert detect_spike(deltas, 0) is True
73
-
74
-
75
- class TestActivityTier:
76
- """Tests for activity tier determination."""
77
-
78
- def test_idle_with_no_entries(self):
79
- assert get_activity_tier([], 200_000) == ActivityTier.IDLE
80
-
81
- def test_idle_with_single_entry(self):
82
- entry = _make_entry(timestamp=int(time.time()))
83
- assert get_activity_tier([entry], 200_000) == ActivityTier.IDLE
84
-
85
- def test_idle_when_stale(self):
86
- """Entries older than 30s should be idle."""
87
- old_time = int(time.time()) - 60
88
- entries = [
89
- _make_entry(timestamp=old_time, current_input=1000),
90
- _make_entry(timestamp=old_time + 5, current_input=2000),
91
- ]
92
- assert get_activity_tier(entries, 200_000) == ActivityTier.IDLE
93
-
94
- def test_low_activity(self):
95
- """Small delta (<2% of window) = low."""
96
- now = int(time.time())
97
- entries = [
98
- _make_entry(timestamp=now - 5, current_input=1000),
99
- _make_entry(timestamp=now, current_input=2000), # delta=1000, 0.5% of 200k
100
- ]
101
- assert get_activity_tier(entries, 200_000) == ActivityTier.LOW
102
-
103
- def test_medium_activity(self):
104
- """Delta 2-5% of window = medium."""
105
- now = int(time.time())
106
- entries = [
107
- _make_entry(timestamp=now - 5, current_input=1000),
108
- _make_entry(timestamp=now, current_input=7000), # delta=6000, 3% of 200k
109
- ]
110
- assert get_activity_tier(entries, 200_000) == ActivityTier.MEDIUM
111
-
112
- def test_high_activity(self):
113
- """Delta 5-15% of window = high."""
114
- now = int(time.time())
115
- entries = [
116
- _make_entry(timestamp=now - 5, current_input=1000),
117
- _make_entry(timestamp=now, current_input=21000), # delta=20000, 10% of 200k
118
- ]
119
- assert get_activity_tier(entries, 200_000) == ActivityTier.HIGH
120
-
121
- def test_spike_activity(self):
122
- """Delta > 15% of window = spike."""
123
- now = int(time.time())
124
- entries = [
125
- _make_entry(timestamp=now - 5, current_input=1000),
126
- _make_entry(timestamp=now, current_input=41000), # delta=40000, 20% of 200k
127
- ]
128
- assert get_activity_tier(entries, 200_000) == ActivityTier.SPIKE
129
-
130
- def test_zero_delta_is_idle(self):
131
- """No token change = idle (even if recent)."""
132
- now = int(time.time())
133
- entries = [
134
- _make_entry(timestamp=now - 5, current_input=1000),
135
- _make_entry(timestamp=now, current_input=1000), # delta=0
136
- ]
137
- assert get_activity_tier(entries, 200_000) == ActivityTier.IDLE
138
-
139
-
140
- class TestGetTierLabel:
141
- """Tests for tier labels."""
142
-
143
- def test_all_tiers_have_labels(self):
144
- for tier in ActivityTier:
145
- label = get_tier_label(tier)
146
- assert len(label) > 0
147
-
148
- def test_idle_label(self):
149
- assert get_tier_label(ActivityTier.IDLE) == "Idle"
150
-
151
- def test_spike_label(self):
152
- assert get_tier_label(ActivityTier.SPIKE) == "Spike!"
@@ -1,127 +0,0 @@
1
- """Tests for layout helpers (visible_width, get_terminal_width, fit_to_width)."""
2
-
3
- from unittest.mock import patch
4
-
5
- from claude_statusline.formatters.layout import (
6
- fit_to_width,
7
- get_terminal_width,
8
- visible_width,
9
- )
10
-
11
-
12
- class TestVisibleWidth:
13
- """Tests for visible_width()."""
14
-
15
- def test_plain_text(self):
16
- assert visible_width("hello") == 5
17
-
18
- def test_ansi_colored_string(self):
19
- s = "\033[0;32mgreen\033[0m"
20
- assert visible_width(s) == 5
21
-
22
- def test_multiple_ansi_codes(self):
23
- s = "\033[2m[\033[0m\033[0;34mdir\033[0m"
24
- assert visible_width(s) == 4 # [dir
25
-
26
- def test_empty_string(self):
27
- assert visible_width("") == 0
28
-
29
- def test_unicode_icons(self):
30
- assert visible_width("\u25cb") == 1 # ○
31
- assert visible_width("\u26a1") == 1 # ⚡
32
-
33
- def test_ansi_with_semicolons(self):
34
- s = "\033[1;31;42mtext\033[0m"
35
- assert visible_width(s) == 4
36
-
37
-
38
- class TestGetTerminalWidth:
39
- """Tests for get_terminal_width()."""
40
-
41
- def test_fallback_to_200_when_no_columns_env(self):
42
- """When COLUMNS is not set and shutil returns 80, use 200 (Claude Code subprocess)."""
43
- with (
44
- patch("claude_statusline.formatters.layout.shutil.get_terminal_size") as mock,
45
- patch.dict("os.environ", {}, clear=False),
46
- ):
47
- # Remove COLUMNS if present
48
- import os
49
-
50
- os.environ.pop("COLUMNS", None)
51
- mock.return_value = type("Size", (), {"columns": 80})()
52
- assert get_terminal_width() == 200
53
-
54
- def test_respects_columns_env_80(self):
55
- """When COLUMNS=80 is explicitly set, trust it."""
56
- with (
57
- patch("claude_statusline.formatters.layout.shutil.get_terminal_size") as mock,
58
- patch.dict("os.environ", {"COLUMNS": "80"}),
59
- ):
60
- mock.return_value = type("Size", (), {"columns": 80})()
61
- assert get_terminal_width() == 80
62
-
63
- def test_custom_width(self):
64
- with patch("claude_statusline.formatters.layout.shutil.get_terminal_size") as mock:
65
- mock.return_value = type("Size", (), {"columns": 120})()
66
- assert get_terminal_width() == 120
67
-
68
-
69
- class TestFitToWidth:
70
- """Tests for fit_to_width()."""
71
-
72
- def test_all_parts_fit(self):
73
- parts = ["base", " | git", " | ctx"]
74
- result = fit_to_width(parts, 80)
75
- assert result == "base | git | ctx"
76
-
77
- def test_drops_lowest_priority(self):
78
- parts = ["base", " | git", " | ctx", " session-uuid-here"]
79
- result = fit_to_width(parts, 25)
80
- assert "base" in result
81
- assert "session-uuid-here" not in result
82
-
83
- def test_base_always_included(self):
84
- parts = ["very-long-base-string-that-exceeds-width"]
85
- result = fit_to_width(parts, 10)
86
- assert result == "very-long-base-string-that-exceeds-width"
87
-
88
- def test_empty_parts_skipped(self):
89
- parts = ["base", "", " | ctx", "", " session"]
90
- result = fit_to_width(parts, 30)
91
- assert result == "base | ctx session"
92
-
93
- def test_exact_boundary(self):
94
- parts = ["12345", "67890"]
95
- result = fit_to_width(parts, 10)
96
- assert result == "1234567890"
97
-
98
- def test_one_char_over_boundary(self):
99
- parts = ["12345", "678901"]
100
- result = fit_to_width(parts, 10)
101
- assert result == "12345"
102
-
103
- def test_empty_parts_list(self):
104
- assert fit_to_width([], 80) == ""
105
-
106
- def test_realistic_ansi_strings(self):
107
- base = "\033[2m[Claude]\033[0m \033[0;34mdir\033[0m"
108
- git = " | \033[0;35mmain\033[0m"
109
- ctx = " | \033[0;32m150.0k free (75.0%)\033[0m"
110
- session = " \033[2mtest-session-uuid-1234\033[0m"
111
-
112
- # base=[Claude] dir = 12, git= | main = 7, ctx= | 150.0k free (75.0%) = 22,
113
- # session= test-session-uuid-1234 = 23 => total = 64
114
- result = fit_to_width([base, git, ctx, session], 80)
115
- assert visible_width(result) == 64
116
-
117
- # With tight width, session should be dropped
118
- result = fit_to_width([base, git, ctx, session], 50)
119
- assert "test-session-uuid-1234" not in result
120
- assert visible_width(result) <= 50
121
-
122
- def test_priority_order_preserved(self):
123
- parts = ["base", " A", " B", " C", " D"]
124
- # base=4, A=2, B=2, C=2, D=2 => total 12
125
- # max_width=8 => base + A + B fits (8), C dropped, D dropped
126
- result = fit_to_width(parts, 8)
127
- assert result == "base A B"
@@ -1,232 +0,0 @@
1
- """Tests for state file rotation and session ID validation."""
2
-
3
- import re
4
- import subprocess
5
- import sys
6
-
7
- import pytest
8
-
9
- from claude_statusline.core.state import StateFile, _validate_session_id
10
-
11
-
12
- # ---------------------------------------------------------------------------
13
- # Helpers
14
- # ---------------------------------------------------------------------------
15
-
16
-
17
- def _make_csv_line(index: int) -> str:
18
- """Generate a deterministic CSV state line for a given index."""
19
- return (
20
- f"{1710288000 + index},100,200,300,400,500,600,0.01,"
21
- f"10,5,sess-{index},model,/tmp/proj,200000"
22
- )
23
-
24
-
25
- # ---------------------------------------------------------------------------
26
- # State File Rotation
27
- # ---------------------------------------------------------------------------
28
-
29
-
30
- class TestStateFileRotation:
31
- """Tests for _maybe_rotate() in StateFile."""
32
-
33
- def test_below_threshold_no_rotation(self, tmp_path, monkeypatch):
34
- """File with fewer than 10,000 lines is not rotated."""
35
- monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
36
- monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
37
- (tmp_path / "old").mkdir()
38
-
39
- sf = StateFile("test-session")
40
- # Write 9,999 lines
41
- lines = [_make_csv_line(i) + "\n" for i in range(9_999)]
42
- sf.file_path.write_text("".join(lines))
43
-
44
- sf._maybe_rotate()
45
-
46
- result_lines = sf.file_path.read_text().splitlines()
47
- assert len(result_lines) == 9_999
48
-
49
- def test_at_threshold_no_rotation(self, tmp_path, monkeypatch):
50
- """File with exactly 10,000 lines is not rotated."""
51
- monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
52
- monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
53
- (tmp_path / "old").mkdir()
54
-
55
- sf = StateFile("test-session")
56
- lines = [_make_csv_line(i) + "\n" for i in range(10_000)]
57
- sf.file_path.write_text("".join(lines))
58
-
59
- sf._maybe_rotate()
60
-
61
- result_lines = sf.file_path.read_text().splitlines()
62
- assert len(result_lines) == 10_000
63
-
64
- def test_exceeds_threshold_truncates_to_5000(self, tmp_path, monkeypatch):
65
- """File with 10,001 lines is truncated to 5,000."""
66
- monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
67
- monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
68
- (tmp_path / "old").mkdir()
69
-
70
- sf = StateFile("test-session")
71
- lines = [_make_csv_line(i) + "\n" for i in range(10_001)]
72
- sf.file_path.write_text("".join(lines))
73
-
74
- sf._maybe_rotate()
75
-
76
- result_lines = sf.file_path.read_text().splitlines()
77
- assert len(result_lines) == 5_000
78
-
79
- def test_retained_lines_are_most_recent(self, tmp_path, monkeypatch):
80
- """After rotation, the retained lines are the last 5,000 of the original."""
81
- monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
82
- monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
83
- (tmp_path / "old").mkdir()
84
-
85
- sf = StateFile("test-session")
86
- total = 10_001
87
- lines = [_make_csv_line(i) + "\n" for i in range(total)]
88
- sf.file_path.write_text("".join(lines))
89
-
90
- sf._maybe_rotate()
91
-
92
- result_lines = sf.file_path.read_text().splitlines()
93
- # First retained line should be the one at index (total - 5000) = 5001
94
- assert f"sess-{total - 5000}" in result_lines[0]
95
- # Last retained line should be the last original line
96
- assert f"sess-{total - 1}" in result_lines[-1]
97
-
98
- def test_rotation_via_append_entry(self, tmp_path, monkeypatch):
99
- """append_entry triggers rotation when threshold is exceeded."""
100
- monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
101
- monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
102
- (tmp_path / "old").mkdir()
103
-
104
- sf = StateFile("test-session")
105
- # Write exactly 10,000 lines (at threshold, no rotation yet)
106
- lines = [_make_csv_line(i) + "\n" for i in range(10_000)]
107
- sf.file_path.write_text("".join(lines))
108
-
109
- # Import StateEntry to create a valid entry
110
- from claude_statusline.core.state import StateEntry
111
-
112
- entry = StateEntry(
113
- timestamp=1710298000,
114
- total_input_tokens=100,
115
- total_output_tokens=200,
116
- current_input_tokens=300,
117
- current_output_tokens=400,
118
- cache_creation=500,
119
- cache_read=600,
120
- cost_usd=0.01,
121
- lines_added=10,
122
- lines_removed=5,
123
- session_id="test-session",
124
- model_id="model",
125
- workspace_project_dir="/tmp/proj",
126
- context_window_size=200000,
127
- )
128
- sf.append_entry(entry)
129
-
130
- # Now file had 10,001 lines -> should have been rotated to 5,000
131
- result_lines = sf.file_path.read_text().splitlines()
132
- assert len(result_lines) == 5_000
133
-
134
- def test_no_temp_files_left_after_rotation(self, tmp_path, monkeypatch):
135
- """No .tmp files remain after successful rotation."""
136
- monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
137
- monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
138
- (tmp_path / "old").mkdir()
139
-
140
- sf = StateFile("test-session")
141
- lines = [_make_csv_line(i) + "\n" for i in range(10_001)]
142
- sf.file_path.write_text("".join(lines))
143
-
144
- sf._maybe_rotate()
145
-
146
- tmp_files = list(tmp_path.glob("*.tmp"))
147
- assert tmp_files == []
148
-
149
-
150
- # ---------------------------------------------------------------------------
151
- # Session ID Validation
152
- # ---------------------------------------------------------------------------
153
-
154
-
155
- class TestSessionIdValidation:
156
- """Tests for _validate_session_id and StateFile constructor validation."""
157
-
158
- def test_reject_forward_slash(self):
159
- with pytest.raises(ValueError, match="/"):
160
- _validate_session_id("../../etc/passwd")
161
-
162
- def test_reject_backslash(self):
163
- with pytest.raises(ValueError, match=re.escape("\\")):
164
- _validate_session_id("..\\..\\etc\\passwd")
165
-
166
- def test_reject_dot_dot(self):
167
- with pytest.raises(ValueError, match=r"\.\."):
168
- _validate_session_id("..hidden")
169
-
170
- def test_reject_null_byte(self):
171
- with pytest.raises(ValueError):
172
- _validate_session_id("session\0id")
173
-
174
- def test_accept_valid_uuid(self):
175
- _validate_session_id("abc-123-def-456") # Should not raise
176
-
177
- def test_accept_hyphens_underscores(self):
178
- _validate_session_id("my_session-id_123") # Should not raise
179
-
180
- def test_accept_alphanumeric(self):
181
- _validate_session_id("abcdef1234567890") # Should not raise
182
-
183
- def test_statefile_rejects_invalid_session(self, tmp_path, monkeypatch):
184
- monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
185
- monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
186
- (tmp_path / "old").mkdir()
187
-
188
- with pytest.raises(ValueError):
189
- StateFile("../../etc/passwd")
190
-
191
- def test_statefile_accepts_none_session(self, tmp_path, monkeypatch):
192
- monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
193
- monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
194
- (tmp_path / "old").mkdir()
195
-
196
- sf = StateFile(None) # Should not raise
197
- assert sf.session_id is None
198
-
199
- def test_statefile_accepts_valid_session(self, tmp_path, monkeypatch):
200
- monkeypatch.setattr(StateFile, "STATE_DIR", tmp_path)
201
- monkeypatch.setattr(StateFile, "OLD_STATE_DIR", tmp_path / "old")
202
- (tmp_path / "old").mkdir()
203
-
204
- sf = StateFile("valid-session-123") # Should not raise
205
- assert sf.session_id == "valid-session-123"
206
-
207
-
208
- # ---------------------------------------------------------------------------
209
- # CLI Session ID Rejection (subprocess test)
210
- # ---------------------------------------------------------------------------
211
-
212
-
213
- class TestCliSessionIdRejection:
214
- """Test that context-stats CLI rejects invalid session IDs."""
215
-
216
- def test_cli_rejects_path_traversal(self):
217
- result = subprocess.run(
218
- [sys.executable, "-m", "claude_statusline.cli.context_stats", "../../etc/passwd"],
219
- capture_output=True,
220
- text=True,
221
- )
222
- assert result.returncode != 0
223
- assert "Invalid session_id" in result.stderr
224
-
225
- def test_cli_rejects_backslash(self):
226
- result = subprocess.run(
227
- [sys.executable, "-m", "claude_statusline.cli.context_stats", "test\\bad"],
228
- capture_output=True,
229
- text=True,
230
- )
231
- assert result.returncode != 0
232
- assert "Invalid session_id" in result.stderr