1package tools
  2
  3import (
  4	"bytes"
  5	"context"
  6	_ "embed"
  7	"fmt"
  8	"log/slog"
  9	"os/exec"
 10	"path/filepath"
 11	"sort"
 12	"strings"
 13
 14	"charm.land/fantasy"
 15	"github.com/charmbracelet/crush/internal/fsext"
 16)
 17
 18const GlobToolName = "glob"
 19
 20//go:embed glob.md
 21var globDescription []byte
 22
 23type GlobParams struct {
 24	Pattern string `json:"pattern" description:"The glob pattern to match files against"`
 25	Path    string `json:"path,omitempty" description:"The directory to search in. Defaults to the current working directory."`
 26}
 27
 28type GlobResponseMetadata struct {
 29	NumberOfFiles int  `json:"number_of_files"`
 30	Truncated     bool `json:"truncated"`
 31}
 32
 33func NewGlobTool(workingDir string) fantasy.AgentTool {
 34	return fantasy.NewAgentTool(
 35		GlobToolName,
 36		string(globDescription),
 37		func(ctx context.Context, params GlobParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
 38			if params.Pattern == "" {
 39				return fantasy.NewTextErrorResponse("pattern is required"), nil
 40			}
 41
 42			searchPath := params.Path
 43			if searchPath == "" {
 44				searchPath = workingDir
 45			}
 46
 47			files, truncated, err := globFiles(ctx, params.Pattern, searchPath, 100)
 48			if err != nil {
 49				return fantasy.ToolResponse{}, fmt.Errorf("error finding files: %w", err)
 50			}
 51
 52			var output string
 53			if len(files) == 0 {
 54				output = "No files found"
 55			} else {
 56				normalizeFilePaths(files)
 57				output = strings.Join(files, "\n")
 58				if truncated {
 59					output += "\n\n(Results are truncated. Consider using a more specific path or pattern.)"
 60				}
 61			}
 62
 63			return fantasy.WithResponseMetadata(
 64				fantasy.NewTextResponse(output),
 65				GlobResponseMetadata{
 66					NumberOfFiles: len(files),
 67					Truncated:     truncated,
 68				},
 69			), nil
 70		})
 71}
 72
 73func globFiles(ctx context.Context, pattern, searchPath string, limit int) ([]string, bool, error) {
 74	cmdRg := getRgCmd(ctx, pattern)
 75	if cmdRg != nil {
 76		cmdRg.Dir = searchPath
 77		matches, err := runRipgrep(cmdRg, searchPath, limit)
 78		if err == nil {
 79			return matches, len(matches) >= limit && limit > 0, nil
 80		}
 81		slog.Warn("Ripgrep execution failed, falling back to doublestar", "error", err)
 82	}
 83
 84	return fsext.GlobWithDoubleStar(pattern, searchPath, limit)
 85}
 86
 87func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) {
 88	out, err := cmd.CombinedOutput()
 89	if err != nil {
 90		if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
 91			return nil, nil
 92		}
 93		return nil, fmt.Errorf("ripgrep: %w\n%s", err, out)
 94	}
 95
 96	var matches []string
 97	for p := range bytes.SplitSeq(out, []byte{0}) {
 98		if len(p) == 0 {
 99			continue
100		}
101		absPath := string(p)
102		if !filepath.IsAbs(absPath) {
103			absPath = filepath.Join(searchRoot, absPath)
104		}
105		if fsext.SkipHidden(absPath) {
106			continue
107		}
108		matches = append(matches, absPath)
109	}
110
111	sort.SliceStable(matches, func(i, j int) bool {
112		return len(matches[i]) < len(matches[j])
113	})
114
115	if limit > 0 && len(matches) > limit {
116		matches = matches[:limit]
117	}
118	return matches, nil
119}
120
121func normalizeFilePaths(paths []string) {
122	for i, p := range paths {
123		paths[i] = filepath.ToSlash(p)
124	}
125}