Skip to content

feat(search): add max_snippet_lines to cap returned snippets (semble#198)#80

Open
amondnet wants to merge 1 commit into
mainfrom
amondnet/max-snippet-lines
Open

feat(search): add max_snippet_lines to cap returned snippets (semble#198)#80
amondnet wants to merge 1 commit into
mainfrom
amondnet/max-snippet-lines

Conversation

@amondnet

@amondnet amondnet commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Addresses #75. Ports upstream semble#198 (d561953).

What & why

semble#198's insight: a search snippet is a locator, not the final content — the agent usually only needs enough to confirm it found the right place, then navigates to the file. Returning full chunks by default wastes tokens. max_snippet_lines lets results carry a short preview instead.

Semantics (utils::format_results / result_to_dict):

  • None → full chunk content
  • 0 → omit content (file path + line range only)
  • N > 0 → first N lines

Surface:

flag / param default rationale
CLI search / find-related --max-snippet-lines N None (full) human-facing
MCP search / find_related max_snippet_lines 10 (preview) agent-facing, token-frugal

MCP default is tri-state via a serde field default: field absent → 10; JSON null → full chunk; 0 → location only.

Wire-shape flatten (faithful to upstream)

Per discussion, this follows upstream faithfully. #198 also flattened the wire dict, so results are now:

{ "file_path": "...", "start_line": 1, "end_line": 9, "score": 0.87, "content": "..." }

top-level, dropping the nested chunk wrapper, location, and language. Context: csp's previous nested shape was itself a faithful mirror of upstream's pre-#198 SearchResult.to_dictupstream reshaped it in #198, so matching it keeps parity. The library SearchResult struct is unchanged; only the CLI/MCP JSON envelope changed.

Out of scope

  • semble#206 (savings correctness) — csp does not currently wire save_search_stats into the search flow at all (CspIndex has no file_sizes), so savings telemetry isn't recorded yet and there's nothing for #206 to correct. Wiring savings is a separate pre-existing gap; #206 rides on it. Recommend a dedicated issue.
  • CLAUDE.md --agent/flags line — deliberately untouched to avoid a merge conflict with docs: correct the csp --agent list in CLAUDE.md #79 (which already rewrites that exact line). The --max-snippet-lines flag should be added to that Public API bullet once docs: correct the csp --agent list in CLAUDE.md #79 lands.

Verification

  • cargo fmt --all ✅ · cargo clippy --all-targets --all-features -- -D warnings
  • cargo test --workspace ✅ — 270 lib + 21 CLI, incl. new unit tests for None/N/0 truncation (utils), CLI search_output_caps_snippet_lines, MCP search_tool_respects_max_snippet_lines_zero, and the tri-state param default (mcp_server).
  • CLI smoke on a 5-line file: default → full (flat shape, no chunk/location); --max-snippet-lines 2 → first 2 lines; --max-snippet-lines 0 → no content, location kept.
  • READMEs (EN + KO) updated for the CLI flag and the MCP default.

Summary by cubic

Adds a max_snippet_lines cap to search results so they return a short preview instead of full chunks, reducing token usage for agents. Also flattens the results JSON to top-level fields for parity with upstream.

  • New Features

    • CLI: --max-snippet-lines N on search and find-related. None → full chunk (default), 0 → no content, N>0 → first N lines. Negative values clamp to 0.
    • MCP: max_snippet_lines param on search and find_related. Default is 10. Pass null for full chunk, or 0 for location-only.
    • Docs and tests updated.
  • Migration

    • Result shape is now flat: { file_path, start_line, end_line, score, content? }. The nested chunk/location/language fields were removed; update any parsers.
    • Defaults: CLI returns full content by default; MCP returns a 10-line preview by default. Use null (MCP) or omit the flag (CLI) for full chunks, or 0 for location-only.

Written for commit 3fb210a. Summary will update on new commits.

…198)

Port semble#198's max_snippet_lines: results can return a preview of each
chunk instead of the full content, so an agent spends fewer tokens
confirming a location before navigating to the file.

Semantics (utils::format_results / result_to_dict):
- None  → full chunk content
- 0     → omit `content` (file path + line range only)
- N > 0 → first N lines

Also flattens the wire dict to match upstream after #198: results are now
`{file_path, start_line, end_line, score, content?}` at the top level
(dropping the nested `chunk` wrapper, `location`, and `language`). This
follows the upstream shape csp had faithfully mirrored before #198
reshaped it; the library `SearchResult` is unchanged.

Surface:
- CLI `search` / `find-related`: `--max-snippet-lines N`, default None
  (full content — human-facing).
- MCP `search` / `find_related`: `max_snippet_lines` param, default 10
  (token-frugal preview — agent-facing). Absent → 10, JSON null → full,
  0 → location only (tri-state via serde field default).

Not in scope: savings accounting (semble#206). csp does not yet wire
save_search_stats into the search flow (no file_sizes on CspIndex), so
there is nothing to correct until savings telemetry is wired — tracked
separately.

Refs #75
@codacy-production

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 10 complexity · 2 duplication

Metric Results
Complexity 10
Duplication 2

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 6 files

Architecture diagram
sequenceDiagram
    participant CLI as CLI User (csp search)
    participant MCP as MCP Client (agent)
    participant Handler as Search Handler (CLI / MCP)
    participant Utils as format_results / result_to_dict
    participant Search as IndexCache / Search Engine

    Note over CLI,Search: NEW: max_snippet_lines parameter & flat wire shape

    CLI->>Handler: csp search --max-snippet-lines N "query" ./repo (default: None)
    MCP->>Handler: JSON-RPC search(query, repo, max_snippet_lines=10|null) (default: 10)
    Handler->>Search: search(query, repo, top_k)
    Search-->>Handler: Vec<SearchResult>
    Handler->>Utils: format_results(query, results, max_snippet_lines)
    loop per result
        Utils->>Utils: Build flat dict: file_path, start_line, end_line, score
        alt max_snippet_lines = None
            Utils->>Utils: include full content
        else max_snippet_lines = 0
            Utils->>Utils: omit content
        else max_snippet_lines = N
            Utils->>Utils: include first N lines as content
        end
    end
    Note over Utils: CHANGED: no nested "chunk"/"location"/"language" fields
    Utils-->>Handler: JSON { query, results: [flat dicts] }
    Handler-->>CLI: Print JSON (flat)
    Handler-->>MCP: JSON-RPC response (flat)
Loading

Re-trigger cubic

@codecov

codecov Bot commented Jul 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.92265% with 11 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/csp/src/mcp.rs 86.95% 6 Missing ⚠️
crates/csp/src/bin/csp/main.rs 90.19% 5 Missing ⚠️

📢 Thoughts on this report? Let us know!

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
3.1% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

@greptile-apps

greptile-apps Bot commented Jul 11, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a max_snippet_lines parameter to both the CLI (--max-snippet-lines N) and MCP tools, allowing callers to cap how much source code is returned per search result. It also flattens the wire shape from a nested { chunk: { ... }, score } envelope to a flat { file_path, start_line, end_line, score, content? } dict, faithfully porting upstream semble#198.

  • utils.rs: result_to_dict now accepts a tri-state Option<usize>None for full content, Some(0) to omit the field, Some(n) for the first N lines — and the wire shape is flattened (dropping language and location).
  • mcp_server.rs / main.rs: Both surfaces wire the new parameter through; the MCP default is Some(10) via a serde field default, while the CLI default is None (full chunk). Tests cover all three states including the serde tri-state.
  • The flat wire-shape change is a breaking change for any existing JSON consumers of the CLI or MCP output; this is intentional and matches upstream, but deployers should be aware.

Confidence Score: 4/5

Safe to merge; the logic is correct and the new parameter is well-tested across all three states.

The core truncation logic in result_to_dict has a minor inconsistency: when Some(n) is requested but the chunk content is empty, join produces an empty string and the field is emitted as content: '' rather than being omitted. The resolve_snippet_lines helper is also duplicated verbatim between the two binary modules instead of living in the shared library crate. Both are low-impact and do not affect normal use.

crates/csp/src/utils.rs — the Some(n) branch in result_to_dict; crates/csp/src/bin/csp/mcp_server.rs — the duplicated resolve_snippet_lines.

Important Files Changed

Filename Overview
crates/csp/src/utils.rs Core change: result_to_dict flattens the wire shape and accepts max_snippet_lines tri-state; format_results threads it through. Logic is correct with good unit-test coverage; a subtle inconsistency exists in the Some(n) branch when content is empty.
crates/csp/src/bin/csp/mcp_server.rs Adds max_snippet_lines: Option<i64> to both param structs with a serde default of Some(10) for the absent-field case. resolve_snippet_lines helper is a duplicate of the one in main.rs; both could live in the shared library crate.
crates/csp/src/bin/csp/main.rs CLI gains --max-snippet-lines N for both search and find-related. resolve_snippet_lines correctly clamps negatives to 0. Plumbing through search_output/find_related_output is clean and fully tested.
crates/csp/src/mcp.rs Transport-agnostic handlers search_tool and find_related_tool gain max_snippet_lines: Option<usize> and thread it to format_results. The #[allow(clippy::too_many_arguments)] is justified by an inline comment.
README.md Documents the new --max-snippet-lines CLI flag and the max_snippet_lines MCP parameter default, including the tri-state semantics (absent → 10, null → full, 0 → location only).
README.ko.md Korean README updated in parallel with the English README for the new flag and MCP parameter.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    CLI["CLI: --max-snippet-lines N\nOption i64, default absent = full"]
    MCP["MCP: max_snippet_lines\nabsent=Some10, null=None"]

    CLI -->|resolve_snippet_lines| R1["Option usize"]
    MCP -->|resolve_snippet_lines| R2["Option usize"]

    R1 --> FMT["format_results"]
    R2 --> FMT

    FMT --> RTD["result_to_dict"]

    RTD --> NONE{max_snippet_lines}
    NONE -->|None| FULL["content: full chunk text"]
    NONE -->|Some 0| OMIT["content field omitted"]
    NONE -->|Some n| TRUNC["content: first N lines"]

    FULL --> OUT["Flat JSON output\nfile_path, start_line, end_line, score, content"]
    OMIT --> OUT
    TRUNC --> OUT
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    CLI["CLI: --max-snippet-lines N\nOption i64, default absent = full"]
    MCP["MCP: max_snippet_lines\nabsent=Some10, null=None"]

    CLI -->|resolve_snippet_lines| R1["Option usize"]
    MCP -->|resolve_snippet_lines| R2["Option usize"]

    R1 --> FMT["format_results"]
    R2 --> FMT

    FMT --> RTD["result_to_dict"]

    RTD --> NONE{max_snippet_lines}
    NONE -->|None| FULL["content: full chunk text"]
    NONE -->|Some 0| OMIT["content field omitted"]
    NONE -->|Some n| TRUNC["content: first N lines"]

    FULL --> OUT["Flat JSON output\nfile_path, start_line, end_line, score, content"]
    OMIT --> OUT
    TRUNC --> OUT
Loading

Fix All in Claude Code

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
crates/csp/src/utils.rs:30-33
When `Some(n)` is requested but `content` is an empty string, `lines()` yields nothing, `take(n)` yields nothing, and `join("
")` produces `""` — so the field is emitted as `content: ""` rather than being omitted. This is inconsistent with `Some(0)` which skips the field entirely, and could confuse callers that check `result.content.is_some()` to decide whether to navigate to the file. An explicit guard keeps the three cases consistently distinct.

```suggestion
        Some(n) => {
            let snippet: Vec<&str> = c.content.lines().take(n).collect();
            if !snippet.is_empty() {
                entry["content"] = json!(snippet.join("\n"));
            }
        }
```

### Issue 2 of 2
crates/csp/src/bin/csp/mcp_server.rs:29-31
**Duplicated `resolve_snippet_lines` helper**

`resolve_snippet_lines` (and its `default_max_snippet_lines` companion) is defined identically in both `mcp_server.rs` and `main.rs`. Since both binaries already depend on the `csp` library crate (they import from `csp::utils`, `csp::mcp`, etc.), moving this pair to `csp::utils` (or a dedicated `csp::snippet` submodule) and re-exporting it would eliminate the duplication and keep the single source of truth close to `result_to_dict`, which it directly feeds.

Reviews (1): Last reviewed commit: "feat(search): add max_snippet_lines to c..." | Re-trigger Greptile

Comment thread crates/csp/src/utils.rs
Comment on lines +30 to +33
Some(n) => {
let snippet: Vec<&str> = c.content.lines().take(n).collect();
entry["content"] = json!(snippet.join("\n"));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 When Some(n) is requested but content is an empty string, lines() yields nothing, take(n) yields nothing, and join(" ") produces "" — so the field is emitted as content: "" rather than being omitted. This is inconsistent with Some(0) which skips the field entirely, and could confuse callers that check result.content.is_some() to decide whether to navigate to the file. An explicit guard keeps the three cases consistently distinct.

Suggested change
Some(n) => {
let snippet: Vec<&str> = c.content.lines().take(n).collect();
entry["content"] = json!(snippet.join("\n"));
}
Some(n) => {
let snippet: Vec<&str> = c.content.lines().take(n).collect();
if !snippet.is_empty() {
entry["content"] = json!(snippet.join("\n"));
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/csp/src/utils.rs
Line: 30-33

Comment:
When `Some(n)` is requested but `content` is an empty string, `lines()` yields nothing, `take(n)` yields nothing, and `join("
")` produces `""` — so the field is emitted as `content: ""` rather than being omitted. This is inconsistent with `Some(0)` which skips the field entirely, and could confuse callers that check `result.content.is_some()` to decide whether to navigate to the file. An explicit guard keeps the three cases consistently distinct.

```suggestion
        Some(n) => {
            let snippet: Vec<&str> = c.content.lines().take(n).collect();
            if !snippet.is_empty() {
                entry["content"] = json!(snippet.join("\n"));
            }
        }
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

Comment on lines +29 to +31
fn resolve_snippet_lines(value: Option<i64>) -> Option<usize> {
value.map(|n| n.max(0) as usize)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Duplicated resolve_snippet_lines helper

resolve_snippet_lines (and its default_max_snippet_lines companion) is defined identically in both mcp_server.rs and main.rs. Since both binaries already depend on the csp library crate (they import from csp::utils, csp::mcp, etc.), moving this pair to csp::utils (or a dedicated csp::snippet submodule) and re-exporting it would eliminate the duplication and keep the single source of truth close to result_to_dict, which it directly feeds.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/csp/src/bin/csp/mcp_server.rs
Line: 29-31

Comment:
**Duplicated `resolve_snippet_lines` helper**

`resolve_snippet_lines` (and its `default_max_snippet_lines` companion) is defined identically in both `mcp_server.rs` and `main.rs`. Since both binaries already depend on the `csp` library crate (they import from `csp::utils`, `csp::mcp`, etc.), moving this pair to `csp::utils` (or a dedicated `csp::snippet` submodule) and re-exporting it would eliminate the duplication and keep the single source of truth close to `result_to_dict`, which it directly feeds.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

@codspeed-hq

codspeed-hq Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 5 untouched benchmarks


Comparing amondnet/max-snippet-lines (3fb210a) with main (1d2ea9e)

Open in CodSpeed

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

이번 풀 리퀘스트는 csp searchcsp find-related 명령어와 MCP 서버에 결과당 반환되는 코드 라인 수를 제한할 수 있는 max-snippet-lines 옵션을 추가하고 관련 문서와 테스트를 업데이트합니다. 리뷰어는 main.rsmcp_server.rs에 중복 구현된 resolve_snippet_lines 함수를 공통 유틸리티 파일인 utils.rs로 이동하여 DRY 원칙을 준수할 것을 제안했습니다.

Comment thread crates/csp/src/utils.rs
Comment on lines +8 to +17
/// Serialize a search result to the flat CLI/MCP wire dict — **snake_case**
/// fields (`file_path`, `start_line`, `end_line`, `score`, optional `content`),
/// matching semble `utils.format_results` after semble#198.
///
/// `max_snippet_lines` caps the `content` field so agents can spend fewer tokens
/// confirming a location before navigating to the file:
/// - `None` → full chunk content
/// - `Some(0)` → omit `content` entirely (path + line range only)
/// - `Some(n)` → the first `n` lines of content
pub fn result_to_dict(result: &SearchResult, max_snippet_lines: Option<usize>) -> Value {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[MEDIUM] 중복된 유틸리티 함수 공통화 (resolve_snippet_lines)

Symptom: resolve_snippet_lines 함수가 main.rsmcp_server.rs 두 곳에 동일하게 중복 구현되어 있습니다.
Source: Fowler — Refactoring (Duplicate Code) / Hunt & Thomas — Pragmatic Programmer (DRY)
Consequence: 동일한 변환 로직이 여러 곳에 분산되어 있으면, 향후 타입 변경이나 범위 제한 로직 수정 시 일관성이 깨질 위험이 있습니다.
Remedy: 이 함수를 공통 라이브러리 유틸리티인 crates/csp/src/utils.rs에 정의하고, 각 바이너리 모듈에서는 이를 가져와 사용하도록 합니다.

/// Map the CLI/MCP --max-snippet-lines value to the Option<usize> cap. Absent
/// (None) -> full chunk content; a negative value clamps to 0 (no code).
pub fn resolve_snippet_lines(value: Option<i64>) -> Option<usize> {
    value.map(|n| n.max(0) as usize)
}

/// Serialize a search result to the flat CLI/MCP wire dict - snake_case
/// fields (file_path, start_line, end_line, score, optional content),
/// matching semble utils.format_results after semble#198.
///
/// max_snippet_lines caps the content field so agents can spend fewer tokens
/// confirming a location before navigating to the file:
/// - None -> full chunk content
/// - Some(0) -> omit content entirely (path + line range only)
/// - Some(n) -> the first n lines of content
pub fn result_to_dict(result: &SearchResult, max_snippet_lines: Option<usize>) -> Value {

Comment on lines +221 to +225
/// Map the CLI `--max-snippet-lines` value to the `Option<usize>` cap. Absent
/// (`None`) → full chunk content; a negative value clamps to `0` (no code).
fn resolve_snippet_lines(value: Option<i64>) -> Option<usize> {
value.map(|n| n.max(0) as usize)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[MEDIUM] 중복 구현된 resolve_snippet_lines 제거 및 공통 함수 사용

Symptom: resolve_snippet_lines 함수가 main.rs에 로컬로 중복 구현되어 있습니다.
Source: Fowler — Refactoring (Duplicate Code) / Hunt & Thomas — Pragmatic Programmer (DRY)
Consequence: 동일한 유틸리티 로직이 여러 곳에 중복 존재하여 유지보수성이 저하됩니다.
Remedy: 로컬 구현을 제거하고 csp::utils::resolve_snippet_lines를 가져와 사용하도록 변경합니다.

Suggested change
/// Map the CLI `--max-snippet-lines` value to the `Option<usize>` cap. Absent
/// (`None`) → full chunk content; a negative value clamps to `0` (no code).
fn resolve_snippet_lines(value: Option<i64>) -> Option<usize> {
value.map(|n| n.max(0) as usize)
}
use csp::utils::resolve_snippet_lines;

Comment on lines +27 to +31
/// Convert the wire value to the `Option<usize>` the handlers take. `None` (the
/// caller passed JSON `null`) → full content; a negative value clamps to `0`.
fn resolve_snippet_lines(value: Option<i64>) -> Option<usize> {
value.map(|n| n.max(0) as usize)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[MEDIUM] 중복 구현된 resolve_snippet_lines 제거 및 공통 함수 사용

Symptom: resolve_snippet_lines 함수가 mcp_server.rs에 로컬로 중복 구현되어 있습니다.
Source: Fowler — Refactoring (Duplicate Code) / Hunt & Thomas — Pragmatic Programmer (DRY)
Consequence: 동일한 유틸리티 로직이 여러 곳에 중복 존재하여 유지보수성이 저하됩니다.
Remedy: 로컬 구현을 제거하고 csp::utils::resolve_snippet_lines를 가져와 사용하도록 변경합니다.

Suggested change
/// Convert the wire value to the `Option<usize>` the handlers take. `None` (the
/// caller passed JSON `null`) → full content; a negative value clamps to `0`.
fn resolve_snippet_lines(value: Option<i64>) -> Option<usize> {
value.map(|n| n.max(0) as usize)
}
use csp::utils::resolve_snippet_lines;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant