mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-10-12 04:54:27 +00:00
Ho hum.
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
// Package search provides text search functionality using ripgrep.
|
||||
package search
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
)
|
||||
|
||||
// Searcher provides text search functionality using ripgrep.
|
||||
type Searcher struct {
|
||||
cfg *config.Config
|
||||
logger *slog.Logger
|
||||
rgPath string
|
||||
}
|
||||
|
||||
// Request represents a search request.
|
||||
type Request struct {
|
||||
Pattern string
|
||||
Paths []string
|
||||
FileTypes []string
|
||||
ContextLines int
|
||||
MaxResults int
|
||||
IgnoreCase bool
|
||||
Regex bool
|
||||
IncludeHidden bool
|
||||
FollowSymlinks bool
|
||||
}
|
||||
|
||||
// Result represents a single search result.
|
||||
type Result struct {
|
||||
File string `json:"file"`
|
||||
MatchText string `json:"match_text"`
|
||||
Language protocol.Language `json:"language"`
|
||||
Context ContextLines `json:"context"`
|
||||
Line int `json:"line"`
|
||||
Column int `json:"column"`
|
||||
}
|
||||
|
||||
// ContextLines holds lines before and after a match.
|
||||
type ContextLines struct {
|
||||
Before []string `json:"before"`
|
||||
After []string `json:"after"`
|
||||
}
|
||||
|
||||
// SearchResults holds the complete search results.
|
||||
type SearchResults struct {
|
||||
Results []Result `json:"results"`
|
||||
Truncated bool `json:"truncated"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// ripgrep JSON output types
|
||||
type rgMessage struct {
|
||||
Type string `json:"type"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
type rgMatch struct {
|
||||
Path struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"path"`
|
||||
Lines struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"lines"`
|
||||
Submatches []struct {
|
||||
Match struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"match"`
|
||||
Start int `json:"start"`
|
||||
End int `json:"end"`
|
||||
} `json:"submatches"`
|
||||
LineNumber int `json:"line_number"`
|
||||
AbsoluteOffset int `json:"absolute_offset"`
|
||||
}
|
||||
|
||||
type rgContext struct {
|
||||
Path struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"path"`
|
||||
Lines struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"lines"`
|
||||
LineNumber int `json:"line_number"`
|
||||
}
|
||||
|
||||
type rgSummary struct {
|
||||
ElapsedTotal struct {
|
||||
Secs int `json:"secs"`
|
||||
Nanos int `json:"nanos"`
|
||||
} `json:"elapsed_total"`
|
||||
Stats struct {
|
||||
Searches int `json:"searches"`
|
||||
SearchesWithMatch int `json:"searches_with_match"`
|
||||
BytesSearched int64 `json:"bytes_searched"`
|
||||
BytesPrinted int64 `json:"bytes_printed"`
|
||||
MatchedLines int `json:"matched_lines"`
|
||||
Matches int `json:"matches"`
|
||||
} `json:"stats"`
|
||||
}
|
||||
|
||||
// New creates a new Searcher instance.
|
||||
func New(cfg *config.Config, logger *slog.Logger) (*Searcher, error) {
|
||||
// Detect ripgrep binary
|
||||
rgPath, err := exec.LookPath("rg")
|
||||
if err != nil {
|
||||
return nil, errors.NewRipgrepNotFound()
|
||||
}
|
||||
|
||||
return &Searcher{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
rgPath: rgPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Search executes a search and returns results.
|
||||
func (s *Searcher) Search(ctx context.Context, req *Request) (*SearchResults, error) {
|
||||
if req.Pattern == "" {
|
||||
return nil, errors.New(errors.ErrInvalidPattern, "pattern cannot be empty").
|
||||
WithRemediation("Provide a non-empty search pattern")
|
||||
}
|
||||
|
||||
// Build ripgrep command
|
||||
args := s.buildArgs(req)
|
||||
|
||||
s.logger.Debug("executing ripgrep", "args", args)
|
||||
|
||||
// Create command with timeout
|
||||
ctx, cancel := context.WithTimeout(ctx, s.cfg.SearchTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, s.rgPath, args...) // #nosec G204 - rgPath is validated at initialization
|
||||
|
||||
// Set working directory to workspace root
|
||||
cmd.Dir = s.cfg.WorkspaceRoot
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
// Run command - ripgrep returns exit code 1 for no matches, which is not an error
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return nil, errors.NewSearchTimeout(req.Pattern, s.cfg.SearchTimeout.String())
|
||||
}
|
||||
// Exit code 1 means no matches, which is fine
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
|
||||
return &SearchResults{Results: []Result{}, Total: 0}, nil
|
||||
}
|
||||
// Exit code 2 means error
|
||||
if stderr.Len() > 0 {
|
||||
return nil, errors.Wrap(errors.ErrSearchFailed, "ripgrep search failed", err).
|
||||
WithContext("pattern", req.Pattern).
|
||||
WithContext("stderr", stderr.String()).
|
||||
WithRemediation("Check search pattern syntax and ensure files are readable")
|
||||
}
|
||||
return nil, errors.Wrap(errors.ErrSearchFailed, "ripgrep search failed", err).
|
||||
WithContext("pattern", req.Pattern).
|
||||
WithRemediation("Check search pattern syntax and ensure ripgrep is functioning correctly")
|
||||
}
|
||||
|
||||
// Parse JSON output
|
||||
return s.parseOutput(&stdout, req.MaxResults)
|
||||
}
|
||||
|
||||
// buildArgs builds the ripgrep command arguments.
|
||||
func (s *Searcher) buildArgs(req *Request) []string {
|
||||
args := []string{"--json"}
|
||||
|
||||
// Add context lines
|
||||
if req.ContextLines > 0 {
|
||||
args = append(args, fmt.Sprintf("--context=%d", req.ContextLines))
|
||||
}
|
||||
|
||||
// File type filtering
|
||||
for _, ft := range req.FileTypes {
|
||||
args = append(args, "--type", ft)
|
||||
}
|
||||
|
||||
// Case sensitivity
|
||||
if req.IgnoreCase {
|
||||
args = append(args, "--ignore-case")
|
||||
}
|
||||
|
||||
// Fixed strings (non-regex)
|
||||
if !req.Regex {
|
||||
args = append(args, "--fixed-strings")
|
||||
}
|
||||
|
||||
// Follow symlinks
|
||||
if req.FollowSymlinks || s.cfg.FollowSymlinks {
|
||||
args = append(args, "--follow")
|
||||
}
|
||||
|
||||
// Include hidden files
|
||||
if req.IncludeHidden {
|
||||
args = append(args, "--hidden")
|
||||
}
|
||||
|
||||
// Respect .gitignore (default behavior for rg)
|
||||
if !s.cfg.RespectGitignore {
|
||||
args = append(args, "--no-ignore")
|
||||
}
|
||||
|
||||
// Max count per file to limit results
|
||||
if req.MaxResults > 0 {
|
||||
args = append(args, fmt.Sprintf("--max-count=%d", req.MaxResults))
|
||||
}
|
||||
|
||||
// Add pattern
|
||||
args = append(args, "--", req.Pattern)
|
||||
|
||||
// Add paths (default to current directory which is workspace root)
|
||||
if len(req.Paths) > 0 {
|
||||
for _, p := range req.Paths {
|
||||
// Validate path is within workspace
|
||||
if s.cfg.IsPathAllowed(p) {
|
||||
args = append(args, p)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
args = append(args, ".")
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
// parseOutput parses ripgrep JSON output.
|
||||
func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchResults, error) {
|
||||
results := &SearchResults{
|
||||
Results: []Result{},
|
||||
}
|
||||
|
||||
// Track context by file and line
|
||||
contextBefore := make(map[string][]string) // file -> lines before current match
|
||||
currentFile := ""
|
||||
|
||||
scanner := bufio.NewScanner(output)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var msg rgMessage
|
||||
if err := json.Unmarshal(line, &msg); err != nil {
|
||||
s.logger.Debug("failed to parse ripgrep output line", "error", err, "line", string(line))
|
||||
continue
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "match":
|
||||
var match rgMatch
|
||||
if err := json.Unmarshal(msg.Data, &match); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check max results
|
||||
if maxResults > 0 && len(results.Results) >= maxResults {
|
||||
results.Truncated = true
|
||||
continue
|
||||
}
|
||||
|
||||
result := Result{
|
||||
File: match.Path.Text,
|
||||
Line: match.LineNumber,
|
||||
MatchText: strings.TrimRight(match.Lines.Text, "\n\r"),
|
||||
Language: protocol.DetectLanguage(match.Path.Text),
|
||||
}
|
||||
|
||||
// Add column from first submatch
|
||||
if len(match.Submatches) > 0 {
|
||||
result.Column = match.Submatches[0].Start + 1 // 1-indexed
|
||||
}
|
||||
|
||||
// Add context before
|
||||
if ctx, ok := contextBefore[match.Path.Text]; ok {
|
||||
result.Context.Before = ctx
|
||||
delete(contextBefore, match.Path.Text)
|
||||
}
|
||||
|
||||
results.Results = append(results.Results, result)
|
||||
currentFile = match.Path.Text
|
||||
|
||||
case "context":
|
||||
var ctx rgContext
|
||||
if err := json.Unmarshal(msg.Data, &ctx); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
lineText := strings.TrimRight(ctx.Lines.Text, "\n\r")
|
||||
|
||||
// Determine if this is before or after context
|
||||
if len(results.Results) > 0 {
|
||||
lastResult := &results.Results[len(results.Results)-1]
|
||||
if lastResult.File == ctx.Path.Text && ctx.LineNumber > lastResult.Line {
|
||||
// This is after context
|
||||
lastResult.Context.After = append(lastResult.Context.After, lineText)
|
||||
} else if ctx.Path.Text == currentFile || currentFile == "" {
|
||||
// This is before context for a potential upcoming match
|
||||
contextBefore[ctx.Path.Text] = append(contextBefore[ctx.Path.Text], lineText)
|
||||
}
|
||||
} else {
|
||||
// Before any match - store as potential before context
|
||||
contextBefore[ctx.Path.Text] = append(contextBefore[ctx.Path.Text], lineText)
|
||||
}
|
||||
|
||||
case "summary":
|
||||
var summary rgSummary
|
||||
if err := json.Unmarshal(msg.Data, &summary); err != nil {
|
||||
continue
|
||||
}
|
||||
results.Total = summary.Stats.Matches
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error reading ripgrep output: %w", err)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// FormatResults formats search results for display.
|
||||
func (s *Searcher) FormatResults(results *SearchResults) string {
|
||||
if len(results.Results) == 0 {
|
||||
return "No matches found."
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Group results by file
|
||||
fileResults := make(map[string][]Result)
|
||||
var fileOrder []string
|
||||
for _, r := range results.Results {
|
||||
if _, exists := fileResults[r.File]; !exists {
|
||||
fileOrder = append(fileOrder, r.File)
|
||||
}
|
||||
fileResults[r.File] = append(fileResults[r.File], r)
|
||||
}
|
||||
|
||||
// Write summary
|
||||
totalMatches := len(results.Results)
|
||||
fileCount := len(fileResults)
|
||||
sb.WriteString(fmt.Sprintf("Found %d matches in %d files", totalMatches, fileCount))
|
||||
if results.Truncated {
|
||||
sb.WriteString(fmt.Sprintf(" (truncated, total: %d)", results.Total))
|
||||
}
|
||||
sb.WriteString(":\n\n")
|
||||
|
||||
// Write results grouped by file
|
||||
for _, file := range fileOrder {
|
||||
// Make path relative to workspace root if possible
|
||||
relPath := file
|
||||
if absPath, err := filepath.Abs(file); err == nil {
|
||||
if rel, err := filepath.Rel(s.cfg.WorkspaceRoot, absPath); err == nil && !strings.HasPrefix(rel, "..") {
|
||||
relPath = rel
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("**%s**\n", relPath))
|
||||
|
||||
for _, r := range fileResults[file] {
|
||||
// Write context before
|
||||
for _, ctx := range r.Context.Before {
|
||||
sb.WriteString(fmt.Sprintf(" │ %s\n", truncateLine(ctx, 200)))
|
||||
}
|
||||
|
||||
// Write match line
|
||||
sb.WriteString(fmt.Sprintf("L%d│ %s\n", r.Line, truncateLine(r.MatchText, 200)))
|
||||
|
||||
// Write context after
|
||||
for _, ctx := range r.Context.After {
|
||||
sb.WriteString(fmt.Sprintf(" │ %s\n", truncateLine(ctx, 200)))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// truncateLine truncates a line if it exceeds maxLen.
|
||||
func truncateLine(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
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
|
||||
}{
|
||||
{
|
||||
name: "basic search",
|
||||
req: &Request{
|
||||
Pattern: "test",
|
||||
ContextLines: 2,
|
||||
Regex: true,
|
||||
},
|
||||
expected: []string{"--json", "--context=2", "--", "test", "."},
|
||||
},
|
||||
{
|
||||
name: "ignore case",
|
||||
req: &Request{
|
||||
Pattern: "test",
|
||||
IgnoreCase: true,
|
||||
Regex: true,
|
||||
},
|
||||
expected: []string{"--json", "--ignore-case", "--", "test", "."},
|
||||
},
|
||||
{
|
||||
name: "fixed strings",
|
||||
req: &Request{
|
||||
Pattern: "test",
|
||||
Regex: false,
|
||||
},
|
||||
expected: []string{"--json", "--fixed-strings", "--", "test", "."},
|
||||
},
|
||||
{
|
||||
name: "with file types",
|
||||
req: &Request{
|
||||
Pattern: "test",
|
||||
FileTypes: []string{"go", "ts"},
|
||||
Regex: true,
|
||||
},
|
||||
expected: []string{"--json", "--type", "go", "--type", "ts", "--", "test", "."},
|
||||
},
|
||||
{
|
||||
name: "with max results",
|
||||
req: &Request{
|
||||
Pattern: "test",
|
||||
MaxResults: 10,
|
||||
Regex: true,
|
||||
},
|
||||
expected: []string{"--json", "--max-count=10", "--", "test", "."},
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
defer 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), 0600)
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user