1package tools
  2
  3import (
  4	"context"
  5	_ "embed"
  6	"encoding/json"
  7	"fmt"
  8	"os"
  9	"path/filepath"
 10	"strings"
 11
 12	"github.com/charmbracelet/crush/internal/fsext"
 13	"github.com/charmbracelet/crush/internal/permission"
 14	"github.com/charmbracelet/crush/internal/proto"
 15)
 16
 17type (
 18	LSParams            = proto.LSParams
 19	LSPermissionsParams = proto.LSPermissionsParams
 20	LSResponseMetadata  = proto.LSResponseMetadata
 21	TreeNode            = proto.TreeNode
 22)
 23
 24type lsTool struct {
 25	workingDir  string
 26	permissions permission.Service
 27}
 28
 29const (
 30	LSToolName = proto.LSToolName
 31	MaxLSFiles = 1000
 32)
 33
 34//go:embed ls.md
 35var lsDescription []byte
 36
 37func NewLsTool(permissions permission.Service, workingDir string) BaseTool {
 38	return &lsTool{
 39		workingDir:  workingDir,
 40		permissions: permissions,
 41	}
 42}
 43
 44func (l *lsTool) Name() string {
 45	return LSToolName
 46}
 47
 48func (l *lsTool) Info() ToolInfo {
 49	return ToolInfo{
 50		Name:        LSToolName,
 51		Description: string(lsDescription),
 52		Parameters: map[string]any{
 53			"path": map[string]any{
 54				"type":        "string",
 55				"description": "The path to the directory to list (defaults to current working directory)",
 56			},
 57			"ignore": map[string]any{
 58				"type":        "array",
 59				"description": "List of glob patterns to ignore",
 60				"items": map[string]any{
 61					"type": "string",
 62				},
 63			},
 64		},
 65		Required: []string{"path"},
 66	}
 67}
 68
 69func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
 70	var params LSParams
 71	if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
 72		return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
 73	}
 74
 75	searchPath := params.Path
 76	if searchPath == "" {
 77		searchPath = l.workingDir
 78	}
 79
 80	var err error
 81	searchPath, err = fsext.Expand(searchPath)
 82	if err != nil {
 83		return ToolResponse{}, fmt.Errorf("error expanding path: %w", err)
 84	}
 85
 86	if !filepath.IsAbs(searchPath) {
 87		searchPath = filepath.Join(l.workingDir, searchPath)
 88	}
 89
 90	// Check if directory is outside working directory and request permission if needed
 91	absWorkingDir, err := filepath.Abs(l.workingDir)
 92	if err != nil {
 93		return ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
 94	}
 95
 96	absSearchPath, err := filepath.Abs(searchPath)
 97	if err != nil {
 98		return ToolResponse{}, fmt.Errorf("error resolving search path: %w", err)
 99	}
100
101	relPath, err := filepath.Rel(absWorkingDir, absSearchPath)
102	if err != nil || strings.HasPrefix(relPath, "..") {
103		// Directory is outside working directory, request permission
104		sessionID, messageID := GetContextValues(ctx)
105		if sessionID == "" || messageID == "" {
106			return ToolResponse{}, fmt.Errorf("session ID and message ID are required for accessing directories outside working directory")
107		}
108
109		granted := l.permissions.Request(
110			permission.CreatePermissionRequest{
111				SessionID:   sessionID,
112				Path:        absSearchPath,
113				ToolCallID:  call.ID,
114				ToolName:    LSToolName,
115				Action:      "list",
116				Description: fmt.Sprintf("List directory outside working directory: %s", absSearchPath),
117				Params:      LSPermissionsParams(params),
118			},
119		)
120
121		if !granted {
122			return ToolResponse{}, permission.ErrorPermissionDenied
123		}
124	}
125
126	output, err := ListDirectoryTree(searchPath, params.Ignore)
127	if err != nil {
128		return ToolResponse{}, err
129	}
130
131	// Get file count for metadata
132	files, truncated, err := fsext.ListDirectory(searchPath, params.Ignore, MaxLSFiles)
133	if err != nil {
134		return ToolResponse{}, fmt.Errorf("error listing directory for metadata: %w", err)
135	}
136
137	return WithResponseMetadata(
138		NewTextResponse(output),
139		LSResponseMetadata{
140			NumberOfFiles: len(files),
141			Truncated:     truncated,
142		},
143	), nil
144}
145
146func ListDirectoryTree(searchPath string, ignore []string) (string, error) {
147	if _, err := os.Stat(searchPath); os.IsNotExist(err) {
148		return "", fmt.Errorf("path does not exist: %s", searchPath)
149	}
150
151	files, truncated, err := fsext.ListDirectory(searchPath, ignore, MaxLSFiles)
152	if err != nil {
153		return "", fmt.Errorf("error listing directory: %w", err)
154	}
155
156	tree := createFileTree(files, searchPath)
157	output := printTree(tree, searchPath)
158
159	if truncated {
160		output = fmt.Sprintf("There are more than %d files in the directory. Use a more specific path or use the Glob tool to find specific files. The first %d files and directories are included below:\n\n%s", MaxLSFiles, MaxLSFiles, output)
161	}
162
163	return output, nil
164}
165
166func createFileTree(sortedPaths []string, rootPath string) []*TreeNode {
167	root := []*TreeNode{}
168	pathMap := make(map[string]*TreeNode)
169
170	for _, path := range sortedPaths {
171		relativePath := strings.TrimPrefix(path, rootPath)
172		parts := strings.Split(relativePath, string(filepath.Separator))
173		currentPath := ""
174		var parentPath string
175
176		var cleanParts []string
177		for _, part := range parts {
178			if part != "" {
179				cleanParts = append(cleanParts, part)
180			}
181		}
182		parts = cleanParts
183
184		if len(parts) == 0 {
185			continue
186		}
187
188		for i, part := range parts {
189			if currentPath == "" {
190				currentPath = part
191			} else {
192				currentPath = filepath.Join(currentPath, part)
193			}
194
195			if _, exists := pathMap[currentPath]; exists {
196				parentPath = currentPath
197				continue
198			}
199
200			isLastPart := i == len(parts)-1
201			isDir := !isLastPart || strings.HasSuffix(relativePath, string(filepath.Separator))
202			nodeType := "file"
203			if isDir {
204				nodeType = "directory"
205			}
206			newNode := &TreeNode{
207				Name:     part,
208				Path:     currentPath,
209				Type:     nodeType,
210				Children: []*TreeNode{},
211			}
212
213			pathMap[currentPath] = newNode
214
215			if i > 0 && parentPath != "" {
216				if parent, ok := pathMap[parentPath]; ok {
217					parent.Children = append(parent.Children, newNode)
218				}
219			} else {
220				root = append(root, newNode)
221			}
222
223			parentPath = currentPath
224		}
225	}
226
227	return root
228}
229
230func printTree(tree []*TreeNode, rootPath string) string {
231	var result strings.Builder
232
233	result.WriteString("- ")
234	result.WriteString(rootPath)
235	if rootPath[len(rootPath)-1] != '/' {
236		result.WriteByte(filepath.Separator)
237	}
238	result.WriteByte('\n')
239
240	for _, node := range tree {
241		printNode(&result, node, 1)
242	}
243
244	return result.String()
245}
246
247func printNode(builder *strings.Builder, node *TreeNode, level int) {
248	indent := strings.Repeat("  ", level)
249
250	nodeName := node.Name
251	if node.Type == "directory" {
252		nodeName = nodeName + string(filepath.Separator)
253	}
254
255	fmt.Fprintf(builder, "%s- %s\n", indent, nodeName)
256
257	if node.Type == "directory" && len(node.Children) > 0 {
258		for _, child := range node.Children {
259			printNode(builder, child, level+1)
260		}
261	}
262}