mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-06-05 22:23:50 +00:00
5ad975ee7a
* 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.
707 lines
19 KiB
Go
707 lines
19 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
)
|
|
|
|
func TestNew(t *testing.T) {
|
|
// Create temp directory for testing
|
|
tmpDir := t.TempDir()
|
|
|
|
cfg := &config.Config{
|
|
WorkspaceRoot: tmpDir,
|
|
EnableLSP: false, // Disable LSP for simpler testing
|
|
}
|
|
|
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
|
|
|
srv, err := New(cfg, logger)
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
|
|
if srv == nil {
|
|
t.Fatal("New() returned nil server")
|
|
return
|
|
}
|
|
if srv.cfg != cfg {
|
|
t.Error("server config mismatch")
|
|
}
|
|
|
|
if srv.parser == nil {
|
|
t.Error("parser should not be nil")
|
|
}
|
|
|
|
if srv.matcher == nil {
|
|
t.Error("matcher should not be nil")
|
|
}
|
|
|
|
if srv.editor == nil {
|
|
t.Error("editor should not be nil")
|
|
}
|
|
}
|
|
|
|
func TestHandleFileRead(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create a test file
|
|
testFile := filepath.Join(tmpDir, "test.go")
|
|
content := `package main
|
|
|
|
// Hello says hello
|
|
func Hello() {
|
|
println("Hello, World!")
|
|
}
|
|
`
|
|
if err := os.WriteFile(testFile, []byte(content), 0600); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
cfg := &config.Config{WorkspaceRoot: tmpDir, EnableLSP: false}
|
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
|
|
|
srv, err := New(cfg, logger)
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = map[string]interface{}{
|
|
"path": testFile,
|
|
}
|
|
|
|
result, err := srv.handleFileRead(ctx, req)
|
|
if err != nil {
|
|
t.Errorf("handleFileRead() error = %v", err)
|
|
}
|
|
|
|
if result == nil {
|
|
t.Fatal("handleFileRead() returned nil result")
|
|
return
|
|
}
|
|
|
|
contents := result.Content
|
|
if len(contents) == 0 {
|
|
t.Fatal("handleFileRead() returned empty content")
|
|
}
|
|
|
|
textContent, ok := contents[0].(mcp.TextContent)
|
|
if !ok {
|
|
t.Fatal("handleFileRead() did not return text content")
|
|
}
|
|
|
|
// Should contain the file content
|
|
if textContent.Text == "" {
|
|
t.Error("handleFileRead() returned empty text")
|
|
}
|
|
}
|
|
|
|
func TestHandleFileReadWithAST(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create a test file
|
|
testFile := filepath.Join(tmpDir, "test.go")
|
|
content := `package main
|
|
|
|
// Hello says hello
|
|
func Hello() {
|
|
println("Hello, World!")
|
|
}
|
|
`
|
|
if err := os.WriteFile(testFile, []byte(content), 0600); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
cfg := &config.Config{WorkspaceRoot: tmpDir, EnableLSP: false}
|
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
|
|
|
srv, err := New(cfg, logger)
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = map[string]interface{}{
|
|
"path": testFile,
|
|
"include_ast": true,
|
|
}
|
|
|
|
result, err := srv.handleFileRead(ctx, req)
|
|
if err != nil {
|
|
t.Errorf("handleFileRead() error = %v", err)
|
|
}
|
|
|
|
if result == nil {
|
|
t.Fatal("handleFileRead() returned nil result")
|
|
return
|
|
}
|
|
|
|
contents := result.Content
|
|
if len(contents) == 0 {
|
|
t.Fatal("handleFileRead() returned empty content")
|
|
}
|
|
|
|
textContent, ok := contents[0].(mcp.TextContent)
|
|
if !ok {
|
|
t.Fatal("handleFileRead() did not return text content")
|
|
}
|
|
|
|
// Should contain "Symbols:" section when include_ast is true
|
|
if textContent.Text == "" {
|
|
t.Error("handleFileRead() returned empty text")
|
|
}
|
|
}
|
|
|
|
func TestHandleFileReadNotFound(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
cfg := &config.Config{WorkspaceRoot: tmpDir, EnableLSP: false}
|
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
|
|
|
srv, err := New(cfg, logger)
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = map[string]interface{}{
|
|
"path": filepath.Join(tmpDir, "nonexistent.go"),
|
|
}
|
|
|
|
result, err := srv.handleFileRead(ctx, req)
|
|
// Should return error for non-existent file
|
|
if err == nil && result != nil {
|
|
// Check if result indicates an error
|
|
contents := result.Content
|
|
if len(contents) > 0 {
|
|
textContent, ok := contents[0].(mcp.TextContent)
|
|
if ok && textContent.Text == "" {
|
|
t.Log("handleFileRead() returned empty text for non-existent file")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleASTQuery(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create a test file
|
|
testFile := filepath.Join(tmpDir, "test.go")
|
|
content := `package main
|
|
|
|
func Hello() error {
|
|
return nil
|
|
}
|
|
|
|
func Goodbye() error {
|
|
return nil
|
|
}
|
|
`
|
|
if err := os.WriteFile(testFile, []byte(content), 0600); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
cfg := &config.Config{WorkspaceRoot: tmpDir, EnableLSP: false}
|
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
|
|
|
srv, err := New(cfg, logger)
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = map[string]interface{}{
|
|
"pattern": "func $NAME() error",
|
|
"language": "go",
|
|
"paths": []interface{}{tmpDir},
|
|
}
|
|
|
|
result, err := srv.handleASTQuery(ctx, req)
|
|
if err != nil {
|
|
t.Errorf("handleASTQuery() error = %v", err)
|
|
}
|
|
|
|
if result == nil {
|
|
t.Fatal("handleASTQuery() returned nil result")
|
|
return
|
|
}
|
|
|
|
contents := result.Content
|
|
if len(contents) == 0 {
|
|
t.Fatal("handleASTQuery() returned empty content")
|
|
}
|
|
}
|
|
|
|
func TestHandleEditApply(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create a test file
|
|
testFile := filepath.Join(tmpDir, "test.go")
|
|
content := `package main
|
|
|
|
func Hello() {
|
|
println("Hello")
|
|
}
|
|
`
|
|
if err := os.WriteFile(testFile, []byte(content), 0600); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
cfg := &config.Config{WorkspaceRoot: tmpDir, EnableLSP: false}
|
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
|
|
|
srv, err := New(cfg, logger)
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = map[string]interface{}{
|
|
"file": testFile,
|
|
"operation": "replace",
|
|
"selector_kind": "function_declaration",
|
|
"selector_name": "Hello",
|
|
"new_content": "func Hello() {\n\tprintln(\"Goodbye\")\n}",
|
|
}
|
|
|
|
result, err := srv.handleEditApply(ctx, req)
|
|
if err != nil {
|
|
t.Errorf("handleEditApply error = %v", err)
|
|
}
|
|
|
|
if result == nil {
|
|
t.Fatal("handleEditApply returned nil result")
|
|
return
|
|
}
|
|
|
|
// Verify file WAS modified
|
|
fileContent, _ := os.ReadFile(testFile)
|
|
if string(fileContent) == content {
|
|
t.Error("handleEditApply should modify the file")
|
|
}
|
|
}
|
|
|
|
// TestHandleFileReadMaxFileSize verifies that handleFileRead rejects files
|
|
// exceeding MaxFileSize via os.Stat before loading them into memory (T-03, S-01).
|
|
func TestHandleFileReadMaxFileSize(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create a test file
|
|
testFile := filepath.Join(tmpDir, "big.txt")
|
|
content := make([]byte, 1024) // 1KB file
|
|
for i := range content {
|
|
content[i] = 'A'
|
|
}
|
|
if err := os.WriteFile(testFile, content, 0600); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
// Set MaxFileSize smaller than the file
|
|
cfg := &config.Config{
|
|
WorkspaceRoot: tmpDir,
|
|
EnableLSP: false,
|
|
MaxFileSize: 512, // 512 bytes — smaller than our 1KB file
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
|
|
|
srv, err := New(cfg, logger)
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = map[string]interface{}{
|
|
"path": testFile,
|
|
}
|
|
|
|
result, err := srv.handleFileRead(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("handleFileRead() returned Go error: %v", err)
|
|
}
|
|
|
|
// The result should indicate an error (file too large)
|
|
if result == nil {
|
|
t.Fatal("handleFileRead() returned nil result")
|
|
}
|
|
if !result.IsError {
|
|
t.Error("expected IsError=true for file exceeding MaxFileSize")
|
|
}
|
|
contents := result.Content
|
|
if len(contents) == 0 {
|
|
t.Fatal("expected non-empty content with error message")
|
|
}
|
|
textContent, ok := contents[0].(mcp.TextContent)
|
|
if !ok {
|
|
t.Fatal("expected text content")
|
|
}
|
|
if !strings.Contains(textContent.Text, "too large") {
|
|
t.Errorf("expected 'too large' error message, got: %s", textContent.Text)
|
|
}
|
|
}
|
|
|
|
// TestSplitLinesLargeFile tests the splitLines function with a large file (>1MB)
|
|
// to exercise the bufio.Scanner path including the scanner.Err() check (T-07, C-05).
|
|
func TestSplitLinesLargeFile(t *testing.T) {
|
|
// Build a string >1MB with known line count
|
|
lineCount := 20000
|
|
var sb strings.Builder
|
|
for i := 0; i < lineCount; i++ {
|
|
sb.WriteString(strings.Repeat("x", 60))
|
|
sb.WriteByte('\n')
|
|
}
|
|
largeContent := sb.String()
|
|
|
|
// Verify it's large enough to trigger the scanner path
|
|
if len(largeContent) <= 1024*1024 {
|
|
t.Fatalf("test content too small: %d bytes, need >1MB", len(largeContent))
|
|
}
|
|
|
|
lines := splitLines(largeContent)
|
|
|
|
// String ends with \n, so splitLines adds an empty trailing element
|
|
// (matching the behavior of strings.Split for the small-file path)
|
|
expectedLines := lineCount + 1 // lineCount lines + 1 trailing empty
|
|
if len(lines) != expectedLines {
|
|
t.Errorf("splitLines returned %d lines, expected %d", len(lines), expectedLines)
|
|
}
|
|
|
|
// Check first and last actual lines
|
|
if lines[0] != strings.Repeat("x", 60) {
|
|
t.Errorf("first line mismatch: got %q", lines[0][:20])
|
|
}
|
|
if lines[lineCount-1] != strings.Repeat("x", 60) {
|
|
t.Errorf("last content line mismatch: got %q", lines[lineCount-1][:20])
|
|
}
|
|
if lines[lineCount] != "" {
|
|
t.Errorf("expected empty trailing line, got %q", lines[lineCount])
|
|
}
|
|
}
|
|
|
|
// TestSplitLinesLargeFileNoTrailingNewline verifies splitLines for large files
|
|
// without a trailing newline.
|
|
func TestSplitLinesLargeFileNoTrailingNewline(t *testing.T) {
|
|
lineCount := 20000
|
|
var sb strings.Builder
|
|
for i := 0; i < lineCount; i++ {
|
|
if i > 0 {
|
|
sb.WriteByte('\n')
|
|
}
|
|
sb.WriteString(strings.Repeat("y", 60))
|
|
}
|
|
largeContent := sb.String()
|
|
|
|
if len(largeContent) <= 1024*1024 {
|
|
t.Fatalf("test content too small: %d bytes", len(largeContent))
|
|
}
|
|
|
|
lines := splitLines(largeContent)
|
|
if len(lines) != lineCount {
|
|
t.Errorf("splitLines returned %d lines, expected %d", len(lines), lineCount)
|
|
}
|
|
}
|
|
|
|
// TestSplitLinesLongLine verifies the scanner gracefully handles very long lines
|
|
// (up to the 1MB buffer limit set in C-05 fix).
|
|
func TestSplitLinesLongLine(t *testing.T) {
|
|
// Create content with one very long line (500KB) embedded in a >1MB file
|
|
shortLines := strings.Repeat("short line content\n", 60000) // ~60KB * ~1 = ~1.08MB
|
|
longLine := strings.Repeat("L", 500*1024) // 500KB line
|
|
largeContent := shortLines + longLine + "\n"
|
|
|
|
if len(largeContent) <= 1024*1024 {
|
|
t.Fatalf("test content too small: %d bytes", len(largeContent))
|
|
}
|
|
|
|
lines := splitLines(largeContent)
|
|
|
|
// Should not crash and should return some lines
|
|
if len(lines) == 0 {
|
|
t.Fatal("splitLines returned no lines for valid content")
|
|
}
|
|
|
|
// The long line should be present somewhere in the output
|
|
foundLong := false
|
|
for _, line := range lines {
|
|
if len(line) >= 500*1024 {
|
|
foundLong = true
|
|
break
|
|
}
|
|
}
|
|
if !foundLong {
|
|
t.Error("the 500KB long line was not found in splitLines output")
|
|
}
|
|
}
|
|
|
|
// TestHandleEditApplyResponseCount verifies the default response=count format "+N -M".
|
|
func TestHandleEditApplyResponseCount(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
testFile := filepath.Join(tmpDir, "test.go")
|
|
content := `package main
|
|
|
|
func Hello() {
|
|
println("Hello")
|
|
}
|
|
`
|
|
if err := os.WriteFile(testFile, []byte(content), 0600); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
cfg := &config.Config{WorkspaceRoot: tmpDir, EnableLSP: false}
|
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
|
srv, err := New(cfg, logger)
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = map[string]interface{}{
|
|
"file": testFile,
|
|
"operation": "replace",
|
|
"selector_kind": "function_declaration",
|
|
"selector_name": "Hello",
|
|
"new_content": "func Hello() {\n\tprintln(\"Goodbye\")\n}",
|
|
// no response flag → default "count"
|
|
}
|
|
|
|
result, err := srv.handleEditApply(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("handleEditApply error = %v", err)
|
|
}
|
|
if result == nil || len(result.Content) == 0 {
|
|
t.Fatal("handleEditApply returned empty result")
|
|
}
|
|
textContent, ok := result.Content[0].(mcp.TextContent)
|
|
if !ok {
|
|
t.Fatal("expected text content")
|
|
}
|
|
// Should be "+N -M" format
|
|
text := textContent.Text
|
|
if !strings.HasPrefix(text, "+") {
|
|
t.Errorf("response=count should start with +, got: %q", text)
|
|
}
|
|
if !strings.Contains(text, " -") {
|
|
t.Errorf("response=count should contain -N, got: %q", text)
|
|
}
|
|
// Must NOT contain diff syntax or old preamble
|
|
if strings.Contains(text, "@@") || strings.Contains(text, "Edit Applied") {
|
|
t.Errorf("response=count must not contain diff markers, got: %q", text)
|
|
}
|
|
}
|
|
|
|
// TestHandleEditApplyResponseDiff verifies response=diff returns unified diff.
|
|
func TestHandleEditApplyResponseDiff(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
testFile := filepath.Join(tmpDir, "test.go")
|
|
content := `package main
|
|
|
|
func Hello() {
|
|
println("Hello")
|
|
}
|
|
`
|
|
if err := os.WriteFile(testFile, []byte(content), 0600); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
cfg := &config.Config{WorkspaceRoot: tmpDir, EnableLSP: false}
|
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
|
srv, err := New(cfg, logger)
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = map[string]interface{}{
|
|
"file": testFile,
|
|
"operation": "replace",
|
|
"selector_kind": "function_declaration",
|
|
"selector_name": "Hello",
|
|
"new_content": "func Hello() {\n\tprintln(\"Goodbye\")\n}",
|
|
"response": "diff",
|
|
}
|
|
|
|
result, err := srv.handleEditApply(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("handleEditApply error = %v", err)
|
|
}
|
|
if result == nil || len(result.Content) == 0 {
|
|
t.Fatal("handleEditApply returned empty result")
|
|
}
|
|
textContent, ok := result.Content[0].(mcp.TextContent)
|
|
if !ok {
|
|
t.Fatal("expected text content")
|
|
}
|
|
text := textContent.Text
|
|
if !strings.Contains(text, "diff") {
|
|
t.Errorf("response=diff should contain diff, got: %q", text)
|
|
}
|
|
// Must NOT have old "Edit Applied Successfully" preamble
|
|
if strings.Contains(text, "Edit Applied Successfully") {
|
|
t.Errorf("v2 diff should not have old preamble, got: %q", text)
|
|
}
|
|
}
|
|
|
|
// TestHandleEditApplyResponseNone verifies response=none returns empty string.
|
|
func TestHandleEditApplyResponseNone(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
testFile := filepath.Join(tmpDir, "test.go")
|
|
content := `package main
|
|
|
|
func Hello() {
|
|
println("Hello")
|
|
}
|
|
`
|
|
if err := os.WriteFile(testFile, []byte(content), 0600); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
cfg := &config.Config{WorkspaceRoot: tmpDir, EnableLSP: false}
|
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
|
srv, err := New(cfg, logger)
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = map[string]interface{}{
|
|
"file": testFile,
|
|
"operation": "replace",
|
|
"selector_kind": "function_declaration",
|
|
"selector_name": "Hello",
|
|
"new_content": "func Hello() {\n\tprintln(\"Goodbye\")\n}",
|
|
"response": "none",
|
|
}
|
|
|
|
result, err := srv.handleEditApply(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("handleEditApply error = %v", err)
|
|
}
|
|
if result == nil || len(result.Content) == 0 {
|
|
t.Fatal("handleEditApply returned empty result")
|
|
}
|
|
textContent, ok := result.Content[0].(mcp.TextContent)
|
|
if !ok {
|
|
t.Fatal("expected text content")
|
|
}
|
|
if textContent.Text != "" {
|
|
t.Errorf("response=none should return empty string, got: %q", textContent.Text)
|
|
}
|
|
}
|
|
|
|
// TestHandleFileReadBatchDedup verifies that identical files in batch mode emit [duplicate of ...].
|
|
func TestHandleFileReadBatchDedup(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
testFile := filepath.Join(tmpDir, "a.go")
|
|
content := `package main
|
|
|
|
func Hello() {}
|
|
`
|
|
if err := os.WriteFile(testFile, []byte(content), 0600); err != nil {
|
|
t.Fatalf("failed to write a.go: %v", err)
|
|
}
|
|
// Make b.go with identical content
|
|
testFile2 := filepath.Join(tmpDir, "b.go")
|
|
if err := os.WriteFile(testFile2, []byte(content), 0600); err != nil {
|
|
t.Fatalf("failed to write b.go: %v", err)
|
|
}
|
|
// c.go with different content
|
|
testFile3 := filepath.Join(tmpDir, "c.go")
|
|
if err := os.WriteFile(testFile3, []byte("package main\n"), 0600); err != nil {
|
|
t.Fatalf("failed to write c.go: %v", err)
|
|
}
|
|
|
|
cfg := &config.Config{WorkspaceRoot: tmpDir, EnableLSP: false, MaxFileSize: 1024 * 1024}
|
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
|
srv, err := New(cfg, logger)
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = map[string]interface{}{
|
|
"paths": []interface{}{testFile, testFile2, testFile3},
|
|
}
|
|
|
|
result, err := srv.handleFileRead(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("handleFileRead() error = %v", err)
|
|
}
|
|
if result == nil || len(result.Content) == 0 {
|
|
t.Fatal("handleFileRead() returned empty result")
|
|
}
|
|
textContent, ok := result.Content[0].(mcp.TextContent)
|
|
if !ok {
|
|
t.Fatal("expected text content")
|
|
}
|
|
text := textContent.Text
|
|
if !strings.Contains(text, "[duplicate of") {
|
|
t.Errorf("expected duplicate pointer for b.go, got:\n%s", text)
|
|
}
|
|
// a.go should have full content
|
|
if !strings.Contains(text, "--- "+testFile+" ---") {
|
|
t.Errorf("expected a.go header, got:\n%s", text)
|
|
}
|
|
// c.go should have full content (different hash)
|
|
if !strings.Contains(text, "--- "+testFile3+" ---") {
|
|
t.Errorf("expected c.go header, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
// TestHandleFileSearchVerbose verifies verbose=true emits "Found N matches in M files:" preamble.
|
|
func TestHandleFileSearchVerbose(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
testFile := filepath.Join(tmpDir, "test.go")
|
|
if err := os.WriteFile(testFile, []byte("package main\n\nfunc Hello() {}\n"), 0600); err != nil {
|
|
t.Fatalf("write test file: %v", err)
|
|
}
|
|
cfg := &config.Config{WorkspaceRoot: tmpDir, EnableLSP: false, MaxFileSize: 1024 * 1024, SearchTimeout: 10 * time.Second}
|
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
|
srv, err := New(cfg, logger)
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
if srv.searcher == nil {
|
|
t.Skip("ripgrep not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = map[string]interface{}{
|
|
"pattern": "Hello",
|
|
"paths": []interface{}{tmpDir},
|
|
"verbose": true,
|
|
}
|
|
result, err := srv.handleFileSearch(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("handleFileSearch error = %v", err)
|
|
}
|
|
if result == nil || len(result.Content) == 0 {
|
|
t.Fatal("handleFileSearch returned empty")
|
|
}
|
|
textContent, ok := result.Content[0].(mcp.TextContent)
|
|
if !ok {
|
|
t.Fatal("expected text content")
|
|
}
|
|
if !strings.Contains(textContent.Text, "Found ") {
|
|
t.Errorf("verbose=true should emit preamble, got:\n%s", textContent.Text)
|
|
}
|
|
}
|