Skip to content

Commit a066ca4

Browse files
fix: search review follow-ups (503 only when dir unreadable)
Fresh Claude installs were getting 503 on search when ~/.claude/projects did not exist yet. 503 now means the path is there but listdir failed. CI smoke test uses create_app(testing=True). Search page nags on empty query and highlights snippets by code point.
1 parent 3b22f3f commit a066ca4

12 files changed

Lines changed: 138 additions & 39 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ jobs:
3838
run: |
3939
python - <<'PY'
4040
from app import create_app
41-
app = create_app()
41+
app = create_app(testing=True)
4242
client = app.test_client()
4343
assert client.get("/").status_code == 200
4444
PY

api/search.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,15 @@ def _parse_since_days(raw: str | None) -> int | None:
6565
return min(days, _MAX_SEARCH_SINCE_DAYS)
6666

6767

68-
def _projects_dir_available(projects_dir: str) -> bool:
68+
def _projects_dir_inaccessible(projects_dir: str) -> bool:
69+
"""True when the projects path exists but cannot be listed (503 case)."""
6970
try:
7071
if not os.path.isdir(projects_dir):
7172
return False
7273
os.listdir(projects_dir)
73-
return True
74-
except OSError:
7574
return False
75+
except OSError:
76+
return True
7677

7778

7879
def _message_searchable_text(msg: MessageDict) -> str:
@@ -108,7 +109,7 @@ def _index_hit_excluded(
108109
file_path,
109110
exc_info=True,
110111
)
111-
return False
112+
return True
112113
return is_session_excluded(rules, session, project_name)
113114

114115

@@ -270,7 +271,7 @@ def search() -> FlaskReturn:
270271
)
271272

272273
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
273-
if not _projects_dir_available(base):
274+
if _projects_dir_inaccessible(base):
274275
return error_response(
275276
ErrorCode.SEARCH_PROJECTS_UNAVAILABLE,
276277
"Claude projects directory is not accessible",

app.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,11 @@ def validate_startup_cli(args: argparse.Namespace) -> None:
9292
def create_app(
9393
base_dir: str | None = None,
9494
exclusion_rules_path: str | None = None,
95+
*,
96+
testing: bool = False,
9597
) -> Flask:
9698
app = Flask(__name__)
99+
app.config["TESTING"] = testing
97100
app.config["CLAUDE_PROJECTS_DIR"] = base_dir
98101

99102
resolved = resolve_exclusion_rules_path(exclusion_rules_path)
@@ -106,7 +109,7 @@ def create_app(
106109
app.register_blueprint(export_bp)
107110
app.register_blueprint(schema_report_bp)
108111

109-
if not app.config.get("TESTING"):
112+
if not testing:
110113
try:
111114
from utils.search_index import start_search_index_background
112115

docs/api-reference.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ Extra fields may appear for specific codes (for example `since` on invalid bulk-
5959
| `SEARCH_EMPTY_QUERY` | 400 | `GET /api/search` | Query param `q` is empty or whitespace |
6060
| `SEARCH_QUERY_TOO_LONG` | 400 | `GET /api/search` | Query param `q` exceeds 500 characters |
6161
| `SEARCH_INVALID_SINCE_DAYS` | 400 | `GET /api/search` | Query param `since_days` is not a positive integer |
62-
| `SEARCH_PROJECTS_UNAVAILABLE` | 503 | `GET /api/search` | Claude projects directory is missing or not readable |
62+
| `SEARCH_PROJECTS_UNAVAILABLE` | 503 | `GET /api/search` | Claude projects directory exists but is not readable |
6363
| `SEARCH_INDEX_UNAVAILABLE` | 503 | `GET /api/search` | FTS index is locked during rebuild |
6464
| `INVALID_PATH` | 400 | Session, stats, export session | Path traversal or rejected URL segment |
6565
| `SESSION_NOT_FOUND` | 404 | Session, stats, export session | File missing on disk or session excluded by rules |
@@ -319,7 +319,7 @@ By default, messages older than 30 days are excluded. Sessions without a parseab
319319
| 400 | `SEARCH_QUERY_TOO_LONG` | `q` exceeds 500 characters |
320320
| 400 | `SEARCH_INVALID_LIMIT` | `limit` not a positive integer (e.g. `abc`, `0`, `1.5`) |
321321
| 400 | `SEARCH_INVALID_SINCE_DAYS` | `since_days` not a positive integer |
322-
| 503 | `SEARCH_PROJECTS_UNAVAILABLE` | Projects directory missing or not readable |
322+
| 503 | `SEARCH_PROJECTS_UNAVAILABLE` | Projects directory exists but is not readable |
323323
| 503 | `SEARCH_INDEX_UNAVAILABLE` | FTS index locked during rebuild |
324324
| 500 | `INTERNAL_ERROR` | Unexpected server failure |
325325

static/js/search.js

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,24 @@ let lastSearchRequestId = 0;
1313
export function highlightSnippet(snippet, query) {
1414
if (!snippet) return '';
1515
if (!query) return esc(snippet);
16-
const hay = snippet.toLowerCase();
17-
const needle = query.toLowerCase();
18-
const idx = hay.indexOf(needle);
19-
if (idx < 0) return esc(snippet);
20-
return (
21-
esc(snippet.slice(0, idx))
22-
+ '<mark>'
23-
+ esc(snippet.slice(idx, idx + query.length))
24-
+ '</mark>'
25-
+ esc(snippet.slice(idx + query.length))
26-
);
16+
const chars = [...snippet];
17+
const needle = [...query].map((ch) => ch.toLowerCase());
18+
const hay = chars.map((ch) => ch.toLowerCase());
19+
for (let i = 0; i <= hay.length - needle.length; i += 1) {
20+
let matched = true;
21+
for (let j = 0; j < needle.length; j += 1) {
22+
if (hay[i + j] !== needle[j]) {
23+
matched = false;
24+
break;
25+
}
26+
}
27+
if (!matched) continue;
28+
const before = chars.slice(0, i).join('');
29+
const match = chars.slice(i, i + needle.length).join('');
30+
const after = chars.slice(i + needle.length).join('');
31+
return esc(before) + '<mark>' + esc(match) + '</mark>' + esc(after);
32+
}
33+
return esc(snippet);
2734
}
2835

2936
export function showSearchPage() {
@@ -77,11 +84,14 @@ export async function doSearch() {
7784
const localRequestId = ++lastSearchRequestId;
7885
const input = document.getElementById('search-input');
7986
if (!input) { showSearchPage(); return; }
87+
const container = document.getElementById('search-results');
8088
const query = input.value.trim();
81-
if (!query) return;
89+
if (!query) {
90+
container.innerHTML = '<p class="search-error">Enter a search term.</p>';
91+
return;
92+
}
8293

8394
const allHistory = document.getElementById('search-all-history')?.checked;
84-
const container = document.getElementById('search-results');
8595
container.innerHTML = '<div class="search-loading">Searching...</div>';
8696

8797
const params = new URLSearchParams({

static/js/search.test.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@ describe('search page', () => {
5656
expect(document.getElementById('content').innerHTML).toContain('30 days');
5757
});
5858

59+
it('doSearch prompts when query is empty', async () => {
60+
showSearchPage();
61+
document.getElementById('search-input').value = ' ';
62+
63+
await doSearch();
64+
65+
expect(document.getElementById('search-results').innerHTML).toContain('Enter a search term');
66+
expect(fetch).not.toHaveBeenCalled();
67+
});
68+
5969
it('doSearch renders results with highlighted snippet text', async () => {
6070
showSearchPage();
6171
fetch.mockResolvedValue({

tests/benchmarks/conftest.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,5 @@ def export_corpus(tmp_path: Path, request: pytest.FixtureRequest) -> Path:
119119
def bench_client_search_corpus(tmp_path: Path):
120120
"""Flask test client backed by a 50-session synthetic project tree."""
121121
seed_search_corpus(tmp_path)
122-
app = create_app(base_dir=str(tmp_path))
123-
app.config["TESTING"] = True
122+
app = create_app(base_dir=str(tmp_path), testing=True)
124123
return app.test_client()

tests/conftest.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,7 @@ def _make_test_client(tmp_path, session_files: Mapping[str, str] | None = None):
4141
project_dir.mkdir(parents=True)
4242
for dest_name, fixture_name in session_files.items():
4343
shutil.copy(FIXTURES / fixture_name, project_dir / dest_name)
44-
app = create_app(base_dir=str(tmp_path))
45-
app.config["TESTING"] = True
44+
app = create_app(base_dir=str(tmp_path), testing=True)
4645
return app.test_client()
4746

4847

tests/test_api_routes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def test_session_stats_excluded_session_returns_404(tmp_path, export_state_file)
3939
rules_path = tmp_path / "exclusion-rules.txt"
4040
rules_path.write_text("integration fixture\n", encoding="utf-8")
4141

42-
app = create_app(base_dir=str(tmp_path), exclusion_rules_path=str(rules_path))
42+
app = create_app(base_dir=str(tmp_path), exclusion_rules_path=str(rules_path), testing=True)
4343
app.config["TESTING"] = True
4444
excluded_client = app.test_client()
4545

tests/test_search.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from pathlib import Path
1212
from unittest.mock import patch
1313

14+
from api.search import _index_hit_excluded
1415
from app import create_app
1516
from tests.conftest import FIXTURES, assert_error_response
1617
from utils.search_index import build_search_index, reset_background_for_tests
@@ -105,12 +106,18 @@ def test_invalid_since_days_zero(client_single):
105106

106107

107108
def test_projects_unavailable(client_single, monkeypatch):
108-
monkeypatch.setattr("api.search._projects_dir_available", lambda _path: False)
109+
monkeypatch.setattr("api.search._projects_dir_inaccessible", lambda _path: True)
109110
resp = client_single.get("/api/search?q=Hello")
110111
assert resp.status_code == 503
111112
assert_error_response(resp, expected_code="SEARCH_PROJECTS_UNAVAILABLE")
112113

113114

115+
def test_missing_projects_dir_is_not_unavailable(client_single, monkeypatch):
116+
monkeypatch.setattr("api.search._projects_dir_inaccessible", lambda _path: False)
117+
resp = client_single.get("/api/search?q=Hello")
118+
assert resp.status_code == 200
119+
120+
114121
def test_index_unavailable_when_locked(tmp_path, monkeypatch):
115122
recent_ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
116123
client = _seed_indexed_client(tmp_path, monkeypatch, timestamp=recent_ts)
@@ -154,8 +161,7 @@ def _seed_indexed_client(tmp_path, monkeypatch, *, timestamp: str):
154161
with patches[0]:
155162
assert build_search_index(str(tmp_path / "projects"), [], force=True) is True
156163

157-
app = create_app(base_dir=str(tmp_path / "projects"))
158-
app.config["TESTING"] = True
164+
app = create_app(base_dir=str(tmp_path / "projects"), testing=True)
159165
return app.test_client()
160166

161167

@@ -185,6 +191,36 @@ def test_search_uses_index_when_usable(tmp_path, monkeypatch):
185191
assert len(resp.get_json()) >= 1
186192

187193

194+
def test_index_hit_excluded_fails_closed_when_session_unreadable():
195+
rules = [[("word", "secret")]]
196+
with (
197+
patch("api.search.get_summary", return_value=None),
198+
patch("api.search.get_cached_session", side_effect=OSError("unreadable")),
199+
):
200+
assert _index_hit_excluded(
201+
rules,
202+
"rules-fp",
203+
project_name="demo",
204+
file_path="/tmp/session.jsonl",
205+
mtime=1.0,
206+
)
207+
208+
209+
def test_search_falls_back_on_tokenless_query(tmp_path, monkeypatch):
210+
recent_ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
211+
client = _seed_indexed_client(tmp_path, monkeypatch, timestamp=recent_ts)
212+
seen: list[bool] = []
213+
214+
def _fake_live_scan(*_args, **_kwargs):
215+
seen.append(True)
216+
return []
217+
218+
with patch("api.search._search_live_scan", side_effect=_fake_live_scan):
219+
resp = client.get("/api/search?q=!!!")
220+
assert resp.status_code == 200
221+
assert seen == [True]
222+
223+
188224
def test_search_falls_back_when_index_query_fails(tmp_path, monkeypatch):
189225
recent_ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
190226
client = _seed_indexed_client(tmp_path, monkeypatch, timestamp=recent_ts)

0 commit comments

Comments
 (0)