1package tools
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"os"
  8	"path/filepath"
  9	"strings"
 10
 11	"github.com/charmbracelet/crush/internal/config"
 12	"github.com/charmbracelet/crush/internal/fsext"
 13)
 14
 15type LSParams struct {
 16	Path   string   `json:"path"`
 17	Ignore []string `json:"ignore"`
 18}
 19
 20type TreeNode struct {
 21	Name     string      `json:"name"`
 22	Path     string      `json:"path"`
 23	Type     string      `json:"type"` // "file" or "directory"
 24	Children []*TreeNode `json:"children,omitempty"`
 25}
 26
 27type LSResponseMetadata struct {
 28	NumberOfFiles int  `json:"number_of_files"`
 29	Truncated     bool `json:"truncated"`
 30}
 31
 32type lsTool struct{}
 33
 34const (
 35	LSToolName    = "ls"
 36	MaxLSFiles    = 1000
 37	lsDescription = `Directory listing tool that shows files and subdirectories in a tree structure, helping you explore and understand the project organization.
 38
 39WHEN TO USE THIS TOOL:
 40- Use when you need to explore the structure of a directory
 41- Helpful for understanding the organization of a project
 42- Good first step when getting familiar with a new codebase
 43
 44HOW TO USE:
 45- Provide a path to list (defaults to current working directory)
 46- Optionally specify glob patterns to ignore
 47- Results are displayed in a tree structure
 48
 49FEATURES:
 50- Displays a hierarchical view of files and directories
 51- Automatically skips hidden files/directories (starting with '.')
 52- Skips common system directories like __pycache__
 53- Can filter out files matching specific patterns
 54
 55LIMITATIONS:
 56- Results are limited to 1000 files
 57- Very large directories will be truncated
 58- Does not show file sizes or permissions
 59- Cannot recursively list all directories in a large project
 60
 61WINDOWS NOTES:
 62- Hidden file detection uses Unix convention (files starting with '.')
 63- Windows-specific hidden files (with hidden attribute) are not automatically skipped
 64- Common Windows directories like System32, Program Files are not in default ignore list
 65- Path separators are handled automatically (both / and \ work)
 66
 67TIPS:
 68- Use Glob tool for finding files by name patterns instead of browsing
 69- Use Grep tool for searching file contents
 70- Combine with other tools for more effective exploration`
 71)
 72
 73func NewLsTool() BaseTool {
 74	return &lsTool{}
 75}
 76
 77func (l *lsTool) Info() ToolInfo {
 78	return ToolInfo{
 79		Name:        LSToolName,
 80		Description: lsDescription,
 81		Parameters: map[string]any{
 82			"path": map[string]any{
 83				"type":        "string",
 84				"description": "The path to the directory to list (defaults to current working directory)",
 85			},
 86			"ignore": map[string]any{
 87				"type":        "array",
 88				"description": "List of glob patterns to ignore",
 89				"items": map[string]any{
 90					"type": "string",
 91				},
 92			},
 93		},
 94		Required: []string{"path"},
 95	}
 96}
 97
 98func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
 99	var params LSParams
100	if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
101		return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
102	}
103
104	searchPath := params.Path
105	if searchPath == "" {
106		searchPath = config.WorkingDirectory()
107	}
108
109	if !filepath.IsAbs(searchPath) {
110		searchPath = filepath.Join(config.WorkingDirectory(), searchPath)
111	}
112
113	if _, err := os.Stat(searchPath); os.IsNotExist(err) {
114		return NewTextErrorResponse(fmt.Sprintf("path does not exist: %s", searchPath)), nil
115	}
116
117	files, truncated, err := fsext.ListDirectory(searchPath, params.Ignore, MaxLSFiles)
118	if err != nil {
119		return ToolResponse{}, fmt.Errorf("error listing directory: %w", err)
120	}
121
122	tree := createFileTree(files)
123	output := printTree(tree, searchPath)
124
125	if truncated {
126		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)
127	}
128
129	return WithResponseMetadata(
130		NewTextResponse(output),
131		LSResponseMetadata{
132			NumberOfFiles: len(files),
133			Truncated:     truncated,
134		},
135	), nil
136}
137
138func createFileTree(sortedPaths []string) []*TreeNode {
139	root := []*TreeNode{}
140	pathMap := make(map[string]*TreeNode)
141
142	for _, path := range sortedPaths {
143		parts := strings.Split(path, string(filepath.Separator))
144		currentPath := ""
145		var parentPath string
146
147		var cleanParts []string
148		for _, part := range parts {
149			if part != "" {
150				cleanParts = append(cleanParts, part)
151			}
152		}
153		parts = cleanParts
154
155		if len(parts) == 0 {
156			continue
157		}
158
159		for i, part := range parts {
160			if currentPath == "" {
161				currentPath = part
162			} else {
163				currentPath = filepath.Join(currentPath, part)
164			}
165
166			if _, exists := pathMap[currentPath]; exists {
167				parentPath = currentPath
168				continue
169			}
170
171			isLastPart := i == len(parts)-1
172			isDir := !isLastPart || strings.HasSuffix(path, string(filepath.Separator))
173			nodeType := "file"
174			if isDir {
175				nodeType = "directory"
176			}
177			newNode := &TreeNode{
178				Name:     part,
179				Path:     currentPath,
180				Type:     nodeType,
181				Children: []*TreeNode{},
182			}
183
184			pathMap[currentPath] = newNode
185
186			if i > 0 && parentPath != "" {
187				if parent, ok := pathMap[parentPath]; ok {
188					parent.Children = append(parent.Children, newNode)
189				}
190			} else {
191				root = append(root, newNode)
192			}
193
194			parentPath = currentPath
195		}
196	}
197
198	return root
199}
200
201func printTree(tree []*TreeNode, rootPath string) string {
202	var result strings.Builder
203
204	result.WriteString(fmt.Sprintf("- %s%s\n", rootPath, string(filepath.Separator)))
205
206	for _, node := range tree {
207		printNode(&result, node, 1)
208	}
209
210	return result.String()
211}
212
213func printNode(builder *strings.Builder, node *TreeNode, level int) {
214	indent := strings.Repeat("  ", level)
215
216	nodeName := node.Name
217	if node.Type == "directory" {
218		nodeName = nodeName + string(filepath.Separator)
219	}
220
221	fmt.Fprintf(builder, "%s- %s\n", indent, nodeName)
222
223	if node.Type == "directory" && len(node.Children) > 0 {
224		for _, child := range node.Children {
225			printNode(builder, child, level+1)
226		}
227	}
228}