package tools import ( "context" "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "strings" "agentu/pkg/llm" ) const defaultOutlineMaxFiles = 200 // ──────────────────────────────────────────────────────────────────────────── // ProjectOutlineTool — gitignore-aware file tree + symbol outline // ──────────────────────────────────────────────────────────────────────────── type ProjectOutlineTool struct { workingDir string } func NewProjectOutlineTool(workingDir string) *ProjectOutlineTool { return &ProjectOutlineTool{workingDir: workingDir} } func (t *ProjectOutlineTool) Definition() llm.Tool { return llm.Tool{ Type: "function", Function: llm.ToolFunction{ Name: "project_outline", Description: "Get the project file tree and code symbol outline in one call. Respects .gitignore. Use this first to understand project structure before reading individual files.", Parameters: JSONSchema(`{ "type": "object", "properties": { "path": {"type": "string", "description": "Subdirectory to outline. Defaults to the project root."}, "max_files": {"type": "integer", "description": "Maximum files to include in the outline. Defaults to 200."} }, "additionalProperties": false }`), }, } } type outlineArgs struct { Path string `json:"path"` MaxFiles int `json:"max_files"` } func (t *ProjectOutlineTool) Execute(ctx context.Context, raw json.RawMessage) (string, error) { var args outlineArgs if err := json.Unmarshal(raw, &args); err != nil { return "", fmt.Errorf("parse arguments: %w", err) } root, err := resolvePath(t.workingDir, args.Path) if err != nil { return "", err } limit := args.MaxFiles if limit <= 0 { limit = defaultOutlineMaxFiles } // Parse .gitignore files from the root upward. ignorer := newGitignoreMatcher(root) var out strings.Builder out.WriteString(root) out.WriteString("\n") count := 0 truncated := false err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { return nil } if ctx.Err() != nil { return ctx.Err() } // Skip always-ignored directories. if d.IsDir() { name := d.Name() // Always skip VCS and common build-artifact directories. switch name { case ".git", ".hg", ".svn", "vendor", "node_modules", "dist", "build", "__pycache__", "target": return filepath.SkipDir } // Skip hidden directories other than .github and similar. if strings.HasPrefix(name, ".") && name != ".github" && name != ".vscode" && name != ".devcontainer" { return filepath.SkipDir } } rel, _ := filepath.Rel(root, path) if rel == "." { return nil } // Check gitignore. if ignorer.shouldIgnore(rel, d.IsDir()) { if d.IsDir() { return filepath.SkipDir } return nil } if count >= limit { truncated = true return filepath.SkipAll } // Build indented tree entry. depth := strings.Count(rel, string(filepath.Separator)) indent := strings.Repeat(" ", depth) name := d.Name() if d.IsDir() { out.WriteString(fmt.Sprintf("%s%s/\n", indent, name)) return nil } count++ out.WriteString(indent + name) // Extract symbols for source files. ext := filepath.Ext(name) if sourceFileExtensions[ext] { data, readErr := os.ReadFile(path) if readErr == nil && len(data) <= 256*1024 { symbols, symErr := extractSymbols(path, data) if symErr == nil && len(symbols) > 0 { out.WriteString(" — ") out.WriteString(compactSymbolSummary(symbols)) } } } out.WriteString("\n") return nil }) if err != nil && !errors.Is(err, filepath.SkipAll) { return "", err } if truncated { out.WriteString(fmt.Sprintf("\n... (%d files shown, more available; use file_list or code_symbols for details)\n", count)) } return FormatResult(out.String()), nil } // ──────────────────────────────────────────────────────────────────────────── // Compact symbol summary for inline display in the file tree // ──────────────────────────────────────────────────────────────────────────── func compactSymbolSummary(symbols []symbolInfo) string { var parts []string // Group by kind. funcs := []string{} methods := []string{} types := []string{} for _, s := range symbols { switch s.Kind { case "function": funcs = append(funcs, s.Name) case "method": methods = append(methods, s.Name) case "class", "interface", "struct", "enum", "trait", "alias", "func", "type", "impl": types = append(types, s.Name) } // Skip imports and package for compact view. } if len(funcs) > 0 { parts = append(parts, fmt.Sprintf("fn: %s", strings.Join(funcs, ", "))) } if len(methods) > 0 { parts = append(parts, fmt.Sprintf("method: %s", strings.Join(methods, ", "))) } if len(types) > 0 { parts = append(parts, fmt.Sprintf("type: %s", strings.Join(types, ", "))) } return strings.Join(parts, "; ") } // ──────────────────────────────────────────────────────────────────────────── // Simple .gitignore matcher // ──────────────────────────────────────────────────────────────────────────── type gitignoreMatcher struct { patterns []string } func newGitignoreMatcher(root string) *gitignoreMatcher { m := &gitignoreMatcher{} // Walk up from root, collecting .gitignore patterns. for dir := root; dir != "" && dir != string(filepath.Separator); dir = filepath.Dir(dir) { giPath := filepath.Join(dir, ".gitignore") data, err := os.ReadFile(giPath) if err != nil { continue } for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } // Remove leading / for anchored patterns; we handle all as relative. line = strings.TrimPrefix(line, "/") // Remove trailing / for directory-only patterns. line = strings.TrimSuffix(line, "/") m.patterns = append(m.patterns, line) } } return m } func (m *gitignoreMatcher) shouldIgnore(path string, isDir bool) bool { name := filepath.Base(path) for _, pat := range m.patterns { // Simple match against full relative path or just the basename. matched, _ := filepath.Match(pat, path) if matched { return true } matched, _ = filepath.Match(pat, name) if matched { return true } } return false }