Files
filepuff-mcp/internal/search/search_test.go
T
lukaszraczylo 5ad975ee7a V2/token optimization (#11)
* v2.0: token-optimization overhaul

Additive (backward-compatible flags):
- file_read: skeleton mode, strip (imports/license/block_comments),
  compact_line_numbers, 8-char etag with prefix-match compat
- ast_query: format=verbose|compact|location, pagination cursor
- file_search: cluster mode, pagination cursor
- lsp_query (references): compact output

Breaking (v2):
- Preambles removed; opt-in verbose=true restores
- edit_apply: response=count|diff|none, default count
- ping tool removed
- symbol_at/find_definition/find_references merged into lsp_query
- Tool descriptions trimmed -83%, help moved to filepuff://help/<tool>
- Batch file_read dedups by etag

Protocol:
- ResourceLink returned for file_read >64 KiB (force_inline override)
- OnAfterInitialize hook reads capabilities.experimental.filepuff
  for session defaults (default_format, default_max_results,
  default_cluster, compact_refs, line_numbers,
  resource_link_threshold)

* fix: drop --max-total-count from ripgrep args

The flag does not exist in stable ripgrep (confirmed up to 15.1.0 --
"unrecognized flag --max-total-count, similar flags that are
available: --max-count"). Every file_search call failed on hosts with
stock rg. --max-count is per-file, not a drop-in replacement, so rely
on the in-process truncation in parseOutput that was already the
documented safety net.
2026-04-19 19:56:49 +01:00

343 lines
7.7 KiB
Go

package search
import (
"bytes"
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
)
func TestNew(t *testing.T) {
cfg := config.Default()
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
searcher, err := New(cfg, logger)
if err != nil {
// ripgrep might not be installed
if strings.Contains(err.Error(), "not found") {
t.Skip("ripgrep not installed, skipping test")
}
t.Fatalf("New failed: %v", err)
}
if searcher == nil {
t.Fatal("expected non-nil searcher")
}
}
func TestBuildArgs(t *testing.T) {
cfg := config.Default()
cfg.WorkspaceRoot = "/test/workspace"
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
// Create searcher without checking for rg binary
s := &Searcher{
cfg: cfg,
logger: logger,
rgPath: "/usr/bin/rg",
}
tests := []struct {
name string
req *Request
expected []string
notExpected []string // T-06: verify absence of unexpected flags
}{
{
name: "basic search",
req: &Request{
Pattern: "test",
ContextLines: 2,
Regex: true,
},
expected: []string{"--json", "--context=2", "--", "test", "."},
notExpected: []string{"--ignore-case", "--fixed-strings", "--max-total-count=0"},
},
{
name: "ignore case",
req: &Request{
Pattern: "test",
IgnoreCase: true,
Regex: true,
},
expected: []string{"--json", "--ignore-case", "--", "test", "."},
notExpected: []string{"--fixed-strings"},
},
{
name: "fixed strings",
req: &Request{
Pattern: "test",
Regex: false,
},
expected: []string{"--json", "--fixed-strings", "--", "test", "."},
notExpected: []string{"--ignore-case"},
},
{
name: "with file types",
req: &Request{
Pattern: "test",
FileTypes: []string{"go", "ts"},
Regex: true,
},
expected: []string{"--json", "--type", "go", "--type", "ts", "--", "test", "."},
notExpected: []string{"--ignore-case", "--fixed-strings"},
},
{
name: "with max results",
req: &Request{
Pattern: "test",
MaxResults: 10,
Regex: true,
},
expected: []string{"--json", "--", "test", "."},
notExpected: []string{"--ignore-case", "--fixed-strings", "--max-total-count=10", "--max-count=10"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
args := s.buildArgs(tt.req)
// Check that all expected args are present
for _, exp := range tt.expected {
found := false
for _, arg := range args {
if arg == exp {
found = true
break
}
}
if !found {
t.Errorf("expected arg %q not found in %v", exp, args)
}
}
// T-06: Check that unexpected args are absent
for _, notExp := range tt.notExpected {
for _, arg := range args {
if arg == notExp {
t.Errorf("unexpected arg %q found in %v", notExp, args)
break
}
}
}
})
}
}
func TestFormatResults(t *testing.T) {
cfg := config.Default()
cfg.WorkspaceRoot = "/test/workspace"
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
s := &Searcher{
cfg: cfg,
logger: logger,
rgPath: "/usr/bin/rg",
}
tests := []struct {
name string
results *SearchResults
contains []string
}{
{
name: "empty results",
results: &SearchResults{
Results: []Result{},
},
contains: []string{"No matches found"},
},
{
name: "single result",
results: &SearchResults{
Results: []Result{
{
File: "test.go",
Line: 10,
Column: 5,
MatchText: "func TestSomething()",
},
},
Total: 1,
},
contains: []string{"test.go", "L10", "TestSomething"},
},
{
name: "truncated results",
results: &SearchResults{
Results: []Result{
{
File: "test.go",
Line: 10,
MatchText: "match",
},
},
Truncated: true,
Total: 100,
},
contains: []string{"truncated", "100"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output := s.FormatResults(tt.results)
for _, exp := range tt.contains {
if !strings.Contains(output, exp) {
t.Errorf("expected output to contain %q, got:\n%s", exp, output)
}
}
})
}
}
func TestParseOutput(t *testing.T) {
cfg := config.Default()
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
s := &Searcher{
cfg: cfg,
logger: logger,
rgPath: "/usr/bin/rg",
}
// Sample ripgrep JSON output
jsonOutput := `{"type":"begin","data":{"path":{"text":"test.go"}}}
{"type":"match","data":{"path":{"text":"test.go"},"lines":{"text":"func TestSomething() {\n"},"line_number":10,"absolute_offset":100,"submatches":[{"match":{"text":"Test"},"start":5,"end":9}]}}
{"type":"end","data":{"path":{"text":"test.go"},"stats":{"bytes_searched":1000}}}
{"type":"summary","data":{"elapsed_total":{"secs":0,"nanos":1000000},"stats":{"searches":1,"searches_with_match":1,"bytes_searched":1000,"bytes_printed":100,"matched_lines":1,"matches":1}}}
`
buf := bytes.NewBufferString(jsonOutput)
results, err := s.parseOutput(buf, 0)
if err != nil {
t.Fatalf("parseOutput failed: %v", err)
}
if len(results.Results) != 1 {
t.Errorf("expected 1 result, got %d", len(results.Results))
}
if results.Results[0].File != "test.go" {
t.Errorf("expected file 'test.go', got %q", results.Results[0].File)
}
if results.Results[0].Line != 10 {
t.Errorf("expected line 10, got %d", results.Results[0].Line)
}
if results.Results[0].Column != 6 { // 1-indexed
t.Errorf("expected column 6, got %d", results.Results[0].Column)
}
}
func TestTruncateLine(t *testing.T) {
tests := []struct {
input string
expected string
maxLen int
}{
{
input: "short",
maxLen: 10,
expected: "short",
},
{
input: "this is a very long line that should be truncated",
maxLen: 20,
expected: "this is a very lo...",
},
{
input: "exact",
maxLen: 5,
expected: "exact",
},
}
for _, tt := range tests {
result := truncateLine(tt.input, tt.maxLen)
if result != tt.expected {
t.Errorf("truncateLine(%q, %d) = %q, want %q", tt.input, tt.maxLen, result, tt.expected)
}
}
}
func TestSearchIntegration(t *testing.T) {
// Create a temporary directory with test files
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-search-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
// Create test files
testFile := filepath.Join(tmpDir, "test.go")
content := `package main
func main() {
println("Hello, World!")
}
`
err = os.WriteFile(testFile, []byte(content), 0o600)
if err != nil {
t.Fatalf("failed to write test file: %v", err)
}
cfg := config.Default()
cfg.WorkspaceRoot = tmpDir
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
searcher, err := New(cfg, logger)
if err != nil {
t.Skip("ripgrep not installed, skipping integration test")
}
ctx := context.Background()
req := &Request{
Pattern: "Hello",
ContextLines: 1,
Regex: false,
}
results, err := searcher.Search(ctx, req)
if err != nil {
t.Fatalf("Search failed: %v", err)
}
if len(results.Results) != 1 {
t.Errorf("expected 1 result, got %d", len(results.Results))
}
if len(results.Results) > 0 && !strings.Contains(results.Results[0].MatchText, "Hello") {
t.Errorf("expected match to contain 'Hello', got %q", results.Results[0].MatchText)
}
}
func TestSearchEmptyPattern(t *testing.T) {
cfg := config.Default()
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
s := &Searcher{
cfg: cfg,
logger: logger,
rgPath: "/usr/bin/rg",
}
ctx := context.Background()
req := &Request{
Pattern: "",
}
_, err := s.Search(ctx, req)
if err == nil {
t.Error("expected error for empty pattern")
}
}