| 151 | } |
| 152 | |
| 153 | func (t *DocsSearchTool) Execute(ctx context.Context, input map[string]any, tc *tools.ToolContext) (any, error) { |
| 154 | query, _ := input["query"].(string) |
| 155 | if strings.TrimSpace(query) == "" { |
| 156 | return nil, errors.New("query cannot be empty") |
| 157 | } |
| 158 | queryLower := strings.ToLower(query) |
| 159 | |
| 160 | subdir, _ := input["subdir"].(string) |
| 161 | globPattern, _ := input["glob"].(string) |
| 162 | |
| 163 | maxResults := 50 |
| 164 | if v, ok := input["max_results"].(float64); ok && int(v) > 0 { |
| 165 | maxResults = int(v) |
| 166 | } |
| 167 | |
| 168 | root := t.baseDir |
| 169 | if subdir != "" { |
| 170 | if strings.Contains(subdir, "..") { |
| 171 | return nil, errors.New("subdir path traversal is not allowed") |
| 172 | } |
| 173 | root = filepath.Join(t.baseDir, filepath.FromSlash(subdir)) |
| 174 | } |
| 175 | |
| 176 | rootAbs, err := filepath.Abs(root) |
| 177 | if err != nil { |
| 178 | return nil, fmt.Errorf("resolve root: %w", err) |
| 179 | } |
| 180 | if !strings.HasPrefix(rootAbs, t.baseDir) { |
| 181 | return nil, errors.New("subdir outside baseDir is not allowed") |
| 182 | } |
| 183 | |
| 184 | type Match struct { |
| 185 | Path string `json:"path"` |
| 186 | LineNumber int `json:"line_number"` |
| 187 | Line string `json:"line"` |
| 188 | } |
| 189 | |
| 190 | matches := make([]Match, 0, maxResults) |
| 191 | |
| 192 | walkErr := filepath.Walk(rootAbs, func(path string, info os.FileInfo, err error) error { |
| 193 | if err != nil { |
| 194 | return nil // skip error |
| 195 | } |
| 196 | if info.IsDir() { |
| 197 | return nil |
| 198 | } |
| 199 | |
| 200 | rel, err := filepath.Rel(t.baseDir, path) |
| 201 | if err != nil { |
| 202 | return nil |
| 203 | } |
| 204 | rel = filepath.ToSlash(rel) |
| 205 | |
| 206 | if globPattern != "" { |
| 207 | ok, _ := filepath.Match(globPattern, filepath.Base(rel)) |
| 208 | if !ok { |
| 209 | return nil |
| 210 | } |