using System.IO; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; namespace AxCopilot.Services.Agent; /// 파일 패턴 검색 도구. glob 패턴으로 파일 목록을 반환합니다. public class GlobTool : IAgentTool { public string Name => "glob"; public string Description => "Find files matching a glob pattern (e.g. '**/*.cs', 'src/**/*.json', '*.txt'). Returns matching file paths relative to the search directory."; public ToolParameterSchema Parameters => new() { Properties = new() { ["pattern"] = new() { Type = "string", Description = "Glob pattern to match files. Supports ** (recursive), * (any chars), ? (single char). E.g. '**/*.cs', 'src/models/*.json', '*.txt'." }, ["path"] = new() { Type = "string", Description = "Directory to search in. Optional, defaults to work folder." }, ["sort_by"] = new() { Type = "string", Description = "'name' (default), 'size', or 'modified'. When size/modified, shows that info next to each file." }, ["max_results"] = new() { Type = "integer", Description = "Maximum number of results to return. Default 200, max 2000." }, ["include_hidden"] = new() { Type = "boolean", Description = "Include hidden files/directories (starting with '.'). Default false." }, ["exclude_pattern"] = new() { Type = "string", Description = "Glob pattern for files to exclude (e.g. '*.min.js', '*.g.cs'). Matched against file name only." }, }, Required = ["pattern"] }; public Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct) { var pattern = args.GetProperty("pattern").SafeGetString() ?? ""; var searchPath = args.SafeTryGetProperty("path", out var p) ? p.SafeGetString() ?? "" : ""; var sortBy = args.SafeTryGetProperty("sort_by", out var sb) ? sb.SafeGetString() ?? "name" : "name"; var maxResults = args.SafeTryGetProperty("max_results", out var mr) ? Math.Clamp(mr.GetInt32(), 1, 2000) : 200; var includeHidden = args.SafeTryGetProperty("include_hidden", out var ih) && ih.GetBoolean(); var excludePattern = args.SafeTryGetProperty("exclude_pattern", out var ep) ? ep.SafeGetString() ?? "" : ""; var baseDir = string.IsNullOrEmpty(searchPath) ? context.WorkFolder : FileReadTool.ResolvePath(searchPath, context.WorkFolder); if (string.IsNullOrEmpty(baseDir) || !Directory.Exists(baseDir)) return Task.FromResult(ToolResult.Fail($"디렉토리가 존재하지 않습니다: {baseDir}")); if (!context.IsPathAllowed(baseDir)) return Task.FromResult(ToolResult.Fail($"경로 접근 차단: {baseDir}")); try { var (searchDir, filePattern, recursive) = DecomposePattern(pattern, baseDir); if (!Directory.Exists(searchDir)) return Task.FromResult(ToolResult.Ok($"패턴 '{pattern}'에 일치하는 파일이 없습니다. (디렉토리 없음: {Path.GetRelativePath(baseDir, searchDir)})")); var option = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; // Build a regex from the file-name portion of the glob for exact matching var fileNameRegex = GlobSegmentToRegex(filePattern); // Build exclude regex if provided (matched against filename only) Regex? excludeRegex = string.IsNullOrEmpty(excludePattern) ? null : new Regex(GlobSegmentToRegex(excludePattern), RegexOptions.IgnoreCase | RegexOptions.Compiled); var files = Directory.EnumerateFiles(searchDir, "*", option) .Where(f => { if (!context.IsPathAllowed(f)) return false; var fileName = Path.GetFileName(f); // Hidden file/dir check if (!includeHidden) { // Check every path segment from searchDir downward var rel = Path.GetRelativePath(searchDir, f); if (rel.Split(Path.DirectorySeparatorChar) .Any(seg => seg.StartsWith('.'))) return false; } // File-name pattern match if (!Regex.IsMatch(fileName, fileNameRegex, RegexOptions.IgnoreCase)) return false; // Exclude pattern if (excludeRegex != null && excludeRegex.IsMatch(fileName)) return false; return true; }) .ToList(); if (files.Count == 0) return Task.FromResult(ToolResult.Ok($"패턴 '{pattern}'에 일치하는 파일이 없습니다.")); // Sort IEnumerable sorted = sortBy switch { "size" => files.OrderBy(f => new FileInfo(f).Length), "modified" => files.OrderByDescending(f => new FileInfo(f).LastWriteTime), _ => files.OrderBy(f => f, StringComparer.OrdinalIgnoreCase) }; var taken = sorted.Take(maxResults).ToList(); var truncated = files.Count > maxResults; var sb2 = new StringBuilder(); foreach (var f in taken) { var rel = Path.GetRelativePath(baseDir, f); if (sortBy == "size") { var size = new FileInfo(f).Length; sb2.AppendLine($"{rel} ({FormatSize(size)})"); } else if (sortBy == "modified") { var ts = new FileInfo(f).LastWriteTime; sb2.AppendLine($"{rel} ({ts:yyyy-MM-dd HH:mm:ss})"); } else { sb2.AppendLine(rel); } } var summary = truncated ? $"{taken.Count}개 파일 표시 (전체 {files.Count}개, 제한 {maxResults}개):" : $"{taken.Count}개 파일 발견:"; return Task.FromResult(ToolResult.Ok($"{summary}\n{sb2.ToString().TrimEnd()}")); } catch (Exception ex) { return Task.FromResult(ToolResult.Fail($"검색 실패: {ex.Message}")); } } /// /// Decomposes a glob pattern into (searchDirectory, fileNamePattern, recursive). /// Examples: /// "**/*.cs" → (baseDir, "*.cs", true) /// "*.txt" → (baseDir, "*.txt", false) /// "src/**/*.json" → (baseDir/src, "*.json", true) /// "src/models/*.cs" → (baseDir/src/models, "*.cs", false) /// "src/models/Foo.cs" → (baseDir/src/models, "Foo.cs", false) /// private static (string searchDir, string filePattern, bool recursive) DecomposePattern( string pattern, string baseDir) { // Normalise separators var normalised = pattern.Replace('\\', '/'); var segments = normalised.Split('/', StringSplitOptions.RemoveEmptyEntries); if (segments.Length == 0) return (baseDir, "*", true); // The last segment is always the file-name pattern (may contain * or ?) var filePattern = segments[^1]; var dirSegments = segments[..^1]; // everything before the last segment bool recursive = false; var pathParts = new List(); foreach (var seg in dirSegments) { if (seg == "**") { recursive = true; // Do not add ** itself to the path; it means "any depth" } else { pathParts.Add(seg); } } // If the file pattern itself is **, treat as recursive wildcard if (filePattern == "**") { recursive = true; filePattern = "*"; } var searchDir = pathParts.Count > 0 ? Path.Combine(new[] { baseDir }.Concat(pathParts).ToArray()) : baseDir; return (searchDir, filePattern, recursive); } /// Converts a simple glob segment (*, ?, literals) to a full-match regex string. private static string GlobSegmentToRegex(string glob) { var sb = new StringBuilder("^"); foreach (var ch in glob) { switch (ch) { case '*': sb.Append(".*"); break; case '?': sb.Append('.'); break; case '.': sb.Append("\\."); break; default: sb.Append(Regex.Escape(ch.ToString())); break; } } sb.Append('$'); return sb.ToString(); } private static string FormatSize(long bytes) { if (bytes >= 1_048_576) return $"{bytes / 1_048_576.0:F1} MB"; if (bytes >= 1_024) return $"{bytes / 1_024.0:F1} KB"; return $"{bytes} B"; } }