001/*
002 * Copyright 2025 - 2025 the original author or authors.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * https://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package com.agentsflex.tool.commons;
017
018import com.agentsflex.core.model.chat.tool.annotation.ToolDef;
019import com.agentsflex.core.model.chat.tool.annotation.ToolParam;
020import com.agentsflex.core.util.StringUtil;
021
022import java.io.IOException;
023import java.nio.file.*;
024import java.nio.file.attribute.BasicFileAttributes;
025import java.util.ArrayList;
026import java.util.Comparator;
027import java.util.List;
028import java.util.stream.Stream;
029
030/**
031 * Pure Java glob implementation that doesn't require external tools. Uses Java NIO.2 for
032 * file pattern matching and traversal.
033 * <p>
034 * Generated with Claude Code AI assistant.
035 *
036 * @author Christian Tzolov
037 * @author Claude Code
038 * @author Michael Yang
039 */
040public class GlobTool {
041
042    private final int maxDepth;
043
044    private final int maxResults;
045
046    private final Path workingDirectory;
047
048    /**
049     * Constructor with configurable parameters.
050     *
051     * @param maxDepth         Maximum directory traversal depth to prevent infinite recursion
052     *                         (default: 100)
053     * @param maxResults       Maximum number of results to return (default: 1000)
054     * @param workingDirectory The working directory to use when path is not specified.
055     *                         If null, defaults to current JVM working directory.
056     */
057    protected GlobTool(int maxDepth, int maxResults, Path workingDirectory) {
058        this.maxDepth = maxDepth;
059        this.maxResults = maxResults;
060        this.workingDirectory = workingDirectory;
061    }
062
063    // @formatter:off
064        @ToolDef(name = "Glob", description = "- Fast file pattern matching tool that works with any codebase size\n" +
065        "- Supports glob patterns like \"**/*.js\" or \"src/**/*.ts\"\n" +
066        "- Returns matching file paths sorted by modification time\n" +
067        "- Use this tool when you need to find files by name patterns\n" +
068        "- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead\n" +
069        "- You can call multiple tools in a single response. It is always better to speculatively perform multiple searches in parallel if they are potentially useful.")
070        public String glob(
071                @ToolParam(name = "pattern", description = "The glob pattern to match files against") String pattern,
072                @ToolParam(name = "path", description = "The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter \\\"undefined\\\" or \\\"null\\\" - simply omit it for the default behavior. Must be a valid directory path if provided.", required = false) String path) { // @formatter:on
073
074        if (StringUtil.noText(pattern)) {
075            return "Error: The glob pattern must not be empty";
076        } else {
077            pattern = pattern.trim();
078        }
079
080        try {
081            // Determine search path - use configured workingDirectory if path not specified
082            Path searchPath;
083            if (StringUtil.hasText(path)) {
084                searchPath = Paths.get(path);
085            } else if (this.workingDirectory != null) {
086                searchPath = this.workingDirectory;
087            } else {
088                searchPath = Paths.get(".");
089            }
090
091            if (!Files.exists(searchPath)) {
092                return "Error: Path does not exist: " + searchPath.toAbsolutePath();
093            }
094
095            if (!Files.isDirectory(searchPath)) {
096                return "Error: Path is not a directory: " + searchPath.toAbsolutePath();
097            }
098
099            // Build glob matcher
100            PathMatcher matcher = this.buildGlobMatcher(pattern);
101
102            // Find matching files
103            List<FileInfo> matchingFiles = new ArrayList<>();
104
105            try (Stream<Path> paths = Files.walk(searchPath, this.maxDepth, FileVisitOption.FOLLOW_LINKS)) {
106                paths.filter(Files::isRegularFile)
107                    .filter(p -> !this.isIgnoredPath(p))
108                    .filter(p -> this.matchesPattern(p, searchPath, matcher))
109                    .limit(this.maxResults)
110                    .forEach(file -> {
111                        try {
112                            BasicFileAttributes attrs = Files.readAttributes(file, BasicFileAttributes.class);
113                            matchingFiles.add(new FileInfo(file, attrs.lastModifiedTime().toMillis()));
114                        } catch (IOException e) {
115                            // Skip files that can't be read
116                            matchingFiles.add(new FileInfo(file, 0));
117                        }
118                    });
119            }
120
121            if (matchingFiles.isEmpty()) {
122                return "No files found matching pattern: " + pattern;
123            }
124
125            // Sort by modification time (most recent first)
126            matchingFiles.sort(Comparator.comparingLong(FileInfo::modificationTime).reversed());
127
128            // Build result
129            StringBuilder result = new StringBuilder();
130            for (FileInfo fileInfo : matchingFiles) {
131                result.append(fileInfo.path().toString()).append("\n");
132            }
133
134            return result.toString().trim();
135
136        } catch (Exception e) {
137            return "Error executing glob: " + e.getMessage();
138        }
139    }
140
141    /**
142     * Build a PathMatcher from the glob pattern
143     */
144    private PathMatcher buildGlobMatcher(String pattern) {
145        // Handle both simple globs (*.java) and complex globs (**/*.java)
146        String globPattern = pattern.startsWith("**/") ? pattern : "**/" + pattern;
147        return FileSystems.getDefault().getPathMatcher("glob:" + globPattern);
148    }
149
150    /**
151     * Check if a path matches the glob pattern
152     */
153    private boolean matchesPattern(Path file, Path searchPath, PathMatcher matcher) {
154        // Try matching against the full path
155        if (matcher.matches(file)) {
156            return true;
157        }
158
159        // Also try matching against the relative path from the search directory
160        try {
161            Path relativePath = searchPath.relativize(file);
162            return matcher.matches(relativePath);
163        } catch (IllegalArgumentException e) {
164            // If we can't relativize, just use the matcher on the file itself
165            return false;
166        }
167    }
168
169    /**
170     * Check if a file should be ignored (common ignore patterns)
171     */
172    private boolean isIgnoredPath(Path path) {
173        String pathStr = path.toString();
174        return pathStr.contains("/.git/") || pathStr.contains("/node_modules/") || pathStr.contains("/target/")
175            || pathStr.contains("/build/") || pathStr.contains("/.idea/") || pathStr.contains("/.vscode/")
176            || pathStr.contains("/dist/") || pathStr.contains("/__pycache__/");
177    }
178
179    /**
180     * Record to hold file information
181     */
182    private static class FileInfo {
183        private final Path path;
184        private final long modificationTime;
185
186        public FileInfo(Path path, long modificationTime) {
187            this.path = path;
188            this.modificationTime = modificationTime;
189        }
190
191        public Path path() {
192            return path;
193        }
194
195        public long modificationTime() {
196            return modificationTime;
197        }
198    }
199
200    public static Builder builder() {
201        return new Builder();
202    }
203
204    public static class Builder {
205
206        private int maxDepth = 100;
207
208        private int maxResults = 1000;
209
210        private Path workingDirectory = null;
211
212        private Builder() {
213        }
214
215        public Builder maxDepth(int maxDepth) {
216            this.maxDepth = maxDepth;
217            return this;
218        }
219
220        public Builder maxResults(int maxResults) {
221            this.maxResults = maxResults;
222            return this;
223        }
224
225        /**
226         * Set the working directory to use when the agent doesn't specify a path.
227         * This allows tools to operate within a sandbox/workspace context.
228         *
229         * @param workingDirectory the working directory path
230         * @return this builder
231         */
232        public Builder workingDirectory(Path workingDirectory) {
233            this.workingDirectory = workingDirectory;
234            return this;
235        }
236
237        /**
238         * Set the working directory using a string path.
239         *
240         * @param workingDirectory the working directory path as string
241         * @return this builder
242         */
243        public Builder workingDirectory(String workingDirectory) {
244            this.workingDirectory = workingDirectory != null ? Paths.get(workingDirectory) : null;
245            return this;
246        }
247
248        public GlobTool build() {
249            return new GlobTool(maxDepth, maxResults, workingDirectory);
250        }
251
252    }
253
254}