---
phase: 18-cli-chunk-display
plan: "02"
type: execute
wave: 2
depends_on:
  - "18-01"
files_modified:
  - src/corpus_analyzer/search/formatter.py
  - src/corpus_analyzer/cli.py
  - tests/search/test_formatter.py
autonomous: true
requirements:
  - CHUNK-02

must_haves:
  truths:
    - "corpus search <query> prints path:start-end [type] score:X.XXX on the primary line"
    - "A second indented line shows the first 200 chars of chunk_text (truncated with '...' if longer)"
    - "Legacy rows (start_line=0, end_line=0) show no line range; rows with empty chunk_text show no preview line"
    - "Blank line between each result — no summary header"
    - "All 330+ existing tests remain green after the render loop is replaced"
    - "format_result() returns Rich markup strings; Console.print() owns rendering — function is pure and testable"
  artifacts:
    - path: "src/corpus_analyzer/search/formatter.py"
      provides: "format_result() function added alongside extract_snippet()"
      contains: "def format_result"
    - path: "src/corpus_analyzer/cli.py"
      provides: "search_command render loop updated to call format_result()"
      contains: "format_result"
  key_links:
    - from: "src/corpus_analyzer/cli.py"
      to: "src/corpus_analyzer/search/formatter.py"
      via: "from corpus_analyzer.search.formatter import format_result"
      pattern: "format_result\\(result, cwd\\)"
    - from: "src/corpus_analyzer/search/formatter.py"
      to: "rich.markup"
      via: "escape() applied to rel_path, construct_type, preview text"
      pattern: "rich.markup.escape"
---

<objective>
Implement `format_result()` in `search/formatter.py`, replace the `search_command` render loop in `cli.py`, and turn all 10 RED tests GREEN.

Purpose: Phase 17 landed the data (`chunk_text`, `start_line`, `end_line`, `chunk_name`). Phase 18 surfaces it — switching from the old 3-line snippet block to a grep/compiler-error format that IDEs can parse and humans can scan.
Output: `format_result()` function, updated render loop, all tests green.
</objective>

<execution_context>
@/home/ollie/.claude/get-shit-done/workflows/execute-plan.md
@/home/ollie/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/milestones/v2-ROADMAP.md
@.planning/STATE.md
@.planning/phases/18-cli-chunk-display/18-CONTEXT.md
@.planning/phases/18-cli-chunk-display/18-RESEARCH.md
@.planning/phases/18-cli-chunk-display/18-01-SUMMARY.md
@src/corpus_analyzer/search/formatter.py
@src/corpus_analyzer/cli.py
@tests/search/test_formatter.py
</context>

<tasks>

<task type="auto">
  <name>Task 1: Implement format_result() in search/formatter.py</name>
  <files>src/corpus_analyzer/search/formatter.py</files>
  <action>
    Add `format_result()` as a new exported function in `src/corpus_analyzer/search/formatter.py`. Do NOT modify or remove `extract_snippet()` — it stays for backward compat.

    Function signature:
    ```python
    def format_result(result: dict[str, Any], cwd: Path) -> tuple[str, str | None]:
    ```

    Add required imports at the top of the module (if not already present):
    - `from __future__ import annotations`
    - `from pathlib import Path`
    - `from typing import Any`
    - `from rich.markup import escape`

    Implementation rules (from locked CONTEXT.md decisions):

    1. **Relative path:** `Path(file_path).relative_to(cwd)` — wrap in try/except ValueError; fall back to absolute path string. Always call `escape()` on the resulting path string before embedding in Rich markup.

    2. **Line range:** if `start_line > 0 or end_line > 0`, format as `{rel_path}:{start_line}-{end_line}`. If BOTH are 0, omit entirely — show just `{rel_path}`. This is the legacy row rule: only BOTH zero triggers omission.

    3. **Construct type:** if `construct_type` is truthy (non-empty, non-None), add ` [dim][{escape(construct_type)}][/dim]`. If falsy, omit entirely — never show `[]`.

    4. **Score:** always 3 decimal places in cyan: ` [cyan]score:{score:.3f}[/cyan]`

    5. **Primary line:** `f"[bold]{escape(location)}[/bold]{construct_part}{score_part}"`

    6. **Preview line:**
       - If `chunk_text` is empty/falsy: return `(primary, None)`
       - Take `chunk_text.split("\n", 1)[0]` to get first line
       - If `len(first_line) > 200`: `preview = first_line[:200] + "..."`
       - Else: `preview = first_line`
       - Return `(primary, f"    {preview}")` — 4 spaces indent, no Rich escaping needed in the preview (it is printed as plain text, not markup)

    Note on preview escaping: The preview line is printed via `console.print()` which interprets markup. To avoid MarkupError on chunk_text with square brackets (TypeScript generics, markdown links), call `escape(preview)` before constructing the preview string: `f"    {escape(preview)}"`.

    After implementing, run: `uv run pytest tests/search/test_formatter.py -v`
    All 10 new `test_format_result_*` tests must be GREEN. Existing tests must remain green.
    Also run: `uv run ruff check src/corpus_analyzer/search/formatter.py` — must exit 0.
    And: `uv run mypy src/corpus_analyzer/search/formatter.py` — must exit 0.
  </action>
  <verify>
    <automated>cd /home/ollie/Development/Tools/vibe_coding/corpus-analyzer && uv run pytest tests/search/test_formatter.py -v && uv run ruff check src/corpus_analyzer/search/formatter.py && uv run mypy src/corpus_analyzer/search/formatter.py</automated>
    <manual>All 10 test_format_result_* tests pass; extract_snippet tests still green; no ruff or mypy errors in formatter.py</manual>
    <sampling_rate>run after implementing format_result, before Task 2</sampling_rate>
  </verify>
  <done>format_result() exists in search/formatter.py; all 10 RED tests are now GREEN; extract_snippet still exported and tested; ruff and mypy clean on the module</done>
</task>

<task type="auto">
  <name>Task 2: Replace search_command render loop in cli.py and verify full suite</name>
  <files>src/corpus_analyzer/cli.py</files>
  <action>
    Update `src/corpus_analyzer/cli.py` to use the new `format_result()` function in the `search_command` render loop.

    Step 1 — Update the import in cli.py. Find the existing import of `extract_snippet` from `corpus_analyzer.search.formatter` and add `format_result` alongside it:
    ```python
    from corpus_analyzer.search.formatter import extract_snippet, format_result
    ```

    Step 2 — Capture CWD once before the render loop (not inside it):
    ```python
    cwd = Path.cwd()
    ```
    If `Path` is not already imported at the top of cli.py, add `from pathlib import Path`.

    Step 3 — Replace the existing render loop body. The current loop body (approximately lines 409-417) looks like:
    ```python
    for result in results:
        console.print(
            f"[bold blue]{result['file_path']}[/]  "
            f"[dim]{result.get('construct_type') or 'documentation'}[/]  "
            f"[dim]score: {result.get('_relevance_score', 0.0):.3f}[/]"
        )
        if result.get("summary"):
            console.print(f"  [italic]{result['summary']}[/italic]")
        console.print(f"  {extract_snippet(str(result['text']), query)}")
        console.print()
    ```

    Replace the body (the code INSIDE the for loop, not the for loop itself) with:
    ```python
        primary, preview = format_result(result, cwd)
        console.print(primary)
        if preview is not None:
            console.print(preview)
        console.print()
    ```

    Do NOT remove `extract_snippet` from the import line — it may still be used elsewhere in the file or in other modules. Only replace the for-loop body.

    Step 4 — Verify nothing else in cli.py uses the old render pattern fields directly that would break.

    Step 5 — Run the full test suite and linters:
    ```bash
    uv run pytest -v
    uv run ruff check src/corpus_analyzer/cli.py
    uv run mypy src/corpus_analyzer/cli.py
    ```

    All 330+ tests must pass. Ruff and mypy must exit 0.

    Note: If any CLI integration tests assert on the OLD format (e.g. asserting "documentation" appears in output, or asserting the old bold blue path format), those tests will fail and MUST be updated to match the new format. This is expected — update those test assertions to match the new grep-style output. Do not remove tests, only update the string assertions.
  </action>
  <verify>
    <automated>cd /home/ollie/Development/Tools/vibe_coding/corpus-analyzer && uv run pytest -v --tb=short 2>&1 | tail -30 && uv run ruff check src/corpus_analyzer/cli.py && uv run mypy src/corpus_analyzer/cli.py</automated>
    <manual>Check that `corpus search` output matches the new format when run manually against a real indexed source. Path should be relative, line range shown where available, score in 3 decimal places.</manual>
    <sampling_rate>run after wiring cli.py; this is the final gate</sampling_rate>
  </verify>
  <done>search_command render loop uses format_result(); full test suite (330+ tests) green; ruff and mypy clean on cli.py; no snapshot/assertion tests reference the old render format</done>
</task>

</tasks>

<verification>
1. `uv run pytest -v` — all 330+ tests pass (including 10 new format_result tests)
2. `uv run ruff check .` — exits 0
3. `uv run mypy src/` — exits 0
4. Manual spot-check: run `corpus search <any query>` on an indexed source — output shows `path:start-end [type] score:X.XXX` format with indented preview
</verification>

<success_criteria>
- `src/corpus_analyzer/search/formatter.py` exports `format_result(result, cwd) -> tuple[str, str | None]`
- `src/corpus_analyzer/cli.py` search_command render loop calls `format_result(result, cwd)` — old 3-line block replaced
- All 330+ tests pass; 10 new `test_format_result_*` tests are GREEN
- `uv run ruff check .` exits 0
- `uv run mypy src/` exits 0
- CHUNK-02 requirement satisfied: grep-style output is IDE-clickable, has 3-decimal scores, omits `:0-0` for legacy rows, skips preview for empty chunk_text
</success_criteria>

<output>
After completion, create `.planning/phases/18-cli-chunk-display/18-02-SUMMARY.md`
</output>
