using System.IO; using System.Text; using System.Text.Json; namespace AxCopilot.Services.Agent; /// /// 작업 폴더의 디렉토리 트리 구조를 생성하는 도구. /// LLM이 프로젝트 전체 구조를 파악하고 적절한 파일을 찾을 수 있도록 돕습니다. /// public class FolderMapTool : IAgentTool { public string Name => "folder_map"; public string Description => "Generate a directory tree map of the work folder or a specified subfolder. " + "Shows folders and files in a tree structure. Use this only when the user explicitly asks for folder contents, directory structure, or workspace layout. " + "Do not use it to locate a specific file when glob/grep or a targeted read would work. " + "Supports sorting, size filtering, date filtering, and multi-extension filtering."; public ToolParameterSchema Parameters => new() { Properties = new() { ["path"] = new() { Type = "string", Description = "Subdirectory to map. Optional, defaults to work folder root." }, ["depth"] = new() { Type = "integer", Description = "Maximum depth to traverse (1-10). Default: 2." }, ["include_files"] = new() { Type = "boolean", Description = "Whether to include files. Default: true." }, ["pattern"] = new() { Type = "string", Description = "Single file extension filter (e.g. '.cs', '.py'). Optional. Use 'extensions' for multiple extensions." }, ["extensions"] = new() { Type = "array", Description = "Filter by multiple extensions, e.g. [\".cs\", \".json\"]. Takes precedence over 'pattern' if both are provided.", Items = new ToolProperty { Type = "string" }, }, ["sort_by"] = new() { Type = "string", Description = "Sort files/dirs within each level: 'name' (default), 'size' (descending), 'modified' (newest first)." }, ["show_dir_sizes"] = new() { Type = "boolean", Description = "If true, show the total size of each directory in parentheses. Default: false." }, ["modified_after"] = new() { Type = "string", Description = "ISO date string (e.g. '2024-01-01'). Only show files modified after this date." }, ["max_file_size"] = new() { Type = "string", Description = "Only show files smaller than this size, e.g. '1MB', '500KB', '2048B'." }, }, Required = [] }; // 무시할 디렉토리 (빌드 산출물, 패키지 캐시 등) private static readonly HashSet IgnoredDirs = new(StringComparer.OrdinalIgnoreCase) { "bin", "obj", "node_modules", ".git", ".vs", ".idea", ".vscode", "__pycache__", ".mypy_cache", ".pytest_cache", "dist", "build", "packages", ".nuget", "TestResults", "coverage", ".next", "target", ".gradle", ".cargo", }; private const int MaxEntries = 500; public Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct) { // ── path ────────────────────────────────────────────────────────── var subPath = args.SafeTryGetProperty("path", out var p) ? p.SafeGetString() ?? "" : ""; // ── depth ───────────────────────────────────────────────────────── var depth = 2; if (args.SafeTryGetProperty("depth", out var d)) { if (d.ValueKind == JsonValueKind.Number) depth = d.GetInt32(); else if (d.ValueKind == JsonValueKind.String && int.TryParse(d.SafeGetString(), out var dv)) depth = dv; } if (depth < 1) depth = 1; var maxDepth = Math.Min(depth, 10); // ── include_files ───────────────────────────────────────────────── var includeFiles = true; if (args.SafeTryGetProperty("include_files", out var inc)) { if (inc.ValueKind == JsonValueKind.True || inc.ValueKind == JsonValueKind.False) includeFiles = inc.GetBoolean(); else includeFiles = !string.Equals(inc.SafeGetString(), "false", StringComparison.OrdinalIgnoreCase); } // ── extensions / pattern ────────────────────────────────────────── HashSet? extSet = null; if (args.SafeTryGetProperty("extensions", out var extsEl) && extsEl.ValueKind == JsonValueKind.Array) { var list = extsEl.EnumerateArray() .Select(e => e.SafeGetString() ?? "") .Where(s => !string.IsNullOrWhiteSpace(s)) .Select(s => s.StartsWith('.') ? s : "." + s) .ToHashSet(StringComparer.OrdinalIgnoreCase); if (list.Count > 0) extSet = list; } // Fall back to single pattern if extensions not provided string extFilter = ""; if (extSet == null) extFilter = args.SafeTryGetProperty("pattern", out var pat) ? pat.SafeGetString() ?? "" : ""; // ── sort_by ─────────────────────────────────────────────────────── var sortBy = args.SafeTryGetProperty("sort_by", out var sb2) ? sb2.SafeGetString() ?? "name" : "name"; if (sortBy != "size" && sortBy != "modified") sortBy = "name"; // ── show_dir_sizes ──────────────────────────────────────────────── var showDirSizes = false; if (args.SafeTryGetProperty("show_dir_sizes", out var sds)) { if (sds.ValueKind == JsonValueKind.True || sds.ValueKind == JsonValueKind.False) showDirSizes = sds.GetBoolean(); else showDirSizes = string.Equals(sds.SafeGetString(), "true", StringComparison.OrdinalIgnoreCase); } // ── modified_after ──────────────────────────────────────────────── DateTime? modifiedAfter = null; if (args.SafeTryGetProperty("modified_after", out var maEl) && maEl.ValueKind == JsonValueKind.String) { if (DateTime.TryParse(maEl.SafeGetString(), out var mdt)) modifiedAfter = mdt; } // ── max_file_size ───────────────────────────────────────────────── long? maxFileSizeBytes = null; if (args.SafeTryGetProperty("max_file_size", out var mfsEl) && mfsEl.ValueKind == JsonValueKind.String) maxFileSizeBytes = ParseSizeString(mfsEl.SafeGetString() ?? ""); // ── resolve base directory ──────────────────────────────────────── var baseDir = string.IsNullOrEmpty(subPath) ? context.WorkFolder : FileReadTool.ResolvePath(subPath, 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 options = new TreeOptions(maxDepth, includeFiles, extFilter, extSet, sortBy, showDirSizes, modifiedAfter, maxFileSizeBytes); var sb = new StringBuilder(); var dirName = Path.GetFileName(baseDir); if (string.IsNullOrEmpty(dirName)) dirName = baseDir; long rootTotalSize = 0; sb.Append($"{dirName}/"); int entryCount = 0; int totalFiles = 0; int totalDirs = 0; BuildTree(sb, baseDir, "", 0, options, context, ref entryCount, ref totalFiles, ref totalDirs, ref rootTotalSize); if (showDirSizes) sb.Insert(sb.ToString().IndexOf('/') + 1, $" ({FormatSize(rootTotalSize)})"); sb.AppendLine(); // newline after root if (entryCount >= MaxEntries) sb.AppendLine($"\n... ({MaxEntries} entry limit reached; adjust depth or filters)"); var summary = $"Folder map complete — {totalFiles} files, {totalDirs} dirs, {FormatSize(rootTotalSize)} total (depth {maxDepth})"; return Task.FromResult(ToolResult.Ok($"{summary}\n\n{sb}")); } catch (Exception ex) { return Task.FromResult(ToolResult.Fail($"폴더 맵 생성 실패: {ex.Message}")); } } // ─── Tree builder ──────────────────────────────────────────────────── private static void BuildTree( StringBuilder sb, string dir, string prefix, int currentDepth, TreeOptions opts, AgentContext context, ref int entryCount, ref int totalFiles, ref int totalDirs, ref long accumSize) { if (currentDepth >= opts.MaxDepth || entryCount >= MaxEntries) return; // ── Collect subdirectories ──────────────────────────────────────── List subDirs; try { subDirs = new DirectoryInfo(dir).GetDirectories() .Where(d => !d.Attributes.HasFlag(FileAttributes.Hidden) && !IgnoredDirs.Contains(d.Name)) .ToList(); } catch { return; } // ── Collect files ───────────────────────────────────────────────── List files = []; if (opts.IncludeFiles) { try { files = new DirectoryInfo(dir).GetFiles() .Where(f => !f.Attributes.HasFlag(FileAttributes.Hidden)) .Where(f => MatchesExtension(f, opts.ExtFilter, opts.ExtSet)) .Where(f => opts.ModifiedAfter == null || f.LastWriteTime > opts.ModifiedAfter.Value) .Where(f => opts.MaxFileSizeBytes == null || f.Length <= opts.MaxFileSizeBytes.Value) .ToList(); } catch { /* ignore */ } } // ── Sort ────────────────────────────────────────────────────────── subDirs = opts.SortBy == "modified" ? subDirs.OrderByDescending(d => d.LastWriteTime).ToList() : subDirs.OrderBy(d => d.Name).ToList(); files = opts.SortBy switch { "size" => files.OrderByDescending(f => f.Length).ToList(), "modified" => files.OrderByDescending(f => f.LastWriteTime).ToList(), _ => files.OrderBy(f => f.Name).ToList(), }; var totalItems = subDirs.Count + files.Count; var index = 0; // ── Render subdirectories ───────────────────────────────────────── foreach (var sub in subDirs) { if (entryCount >= MaxEntries) break; index++; var isLast = index == totalItems; var connector = isLast ? "└── " : "├── "; var childPrefix = isLast ? " " : "│ "; long subSize = 0; int subFiles = 0, subDirsCount = 0; _ = subFiles; _ = subDirsCount; // suppress unused warnings (reserved for future use) if (context.IsPathAllowed(sub.FullName)) { // We always recurse to gather sizes; count only when rendered if (opts.ShowDirSizes) { // Pre-compute directory size (best-effort, no error propagation) try { subSize = ComputeDirSize(sub.FullName); } catch { } } totalDirs++; entryCount++; var dirLabel = opts.ShowDirSizes ? $"{sub.Name}/ ({FormatSize(subSize)})" : $"{sub.Name}/"; sb.AppendLine($"{prefix}{connector}{dirLabel}"); BuildTree(sb, sub.FullName, prefix + childPrefix, currentDepth + 1, opts, context, ref entryCount, ref totalFiles, ref totalDirs, ref subSize); accumSize += subSize; } else { totalDirs++; entryCount++; sb.AppendLine($"{prefix}{connector}{sub.Name}/ [access denied]"); } } // ── Render files ────────────────────────────────────────────────── foreach (var file in files) { if (entryCount >= MaxEntries) break; index++; var isLast = index == totalItems; var connector = isLast ? "└── " : "├── "; string annotation = opts.SortBy switch { "modified" => $"({file.LastWriteTime:yyyy-MM-dd}, {FormatSize(file.Length)})", "size" => $"({FormatSize(file.Length)})", _ => $"({FormatSize(file.Length)})", }; sb.AppendLine($"{prefix}{connector}{file.Name} {annotation}"); accumSize += file.Length; totalFiles++; entryCount++; } } // ─── Helpers ────────────────────────────────────────────────────────── private static bool MatchesExtension(FileInfo f, string extFilter, HashSet? extSet) { if (extSet != null) return extSet.Contains(f.Extension); if (!string.IsNullOrEmpty(extFilter)) return f.Extension.Equals(extFilter, StringComparison.OrdinalIgnoreCase); return true; } private static long ComputeDirSize(string dir) { long total = 0; foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories)) { try { total += new FileInfo(f).Length; } catch { } } return total; } private static long? ParseSizeString(string s) { if (string.IsNullOrWhiteSpace(s)) return null; s = s.Trim(); if (s.EndsWith("GB", StringComparison.OrdinalIgnoreCase) && double.TryParse(s[..^2], out var gb)) return (long)(gb * 1024 * 1024 * 1024); if (s.EndsWith("MB", StringComparison.OrdinalIgnoreCase) && double.TryParse(s[..^2], out var mb)) return (long)(mb * 1024 * 1024); if (s.EndsWith("KB", StringComparison.OrdinalIgnoreCase) && double.TryParse(s[..^2], out var kb)) return (long)(kb * 1024); if (s.EndsWith("B", StringComparison.OrdinalIgnoreCase) && double.TryParse(s[..^1], out var b)) return (long)b; if (long.TryParse(s, out var raw)) return raw; return null; } private static string FormatSize(long bytes) => bytes switch { < 1024 => $"{bytes} B", < 1024 * 1024 => $"{bytes / 1024.0:F1} KB", < 1024L * 1024 * 1024 => $"{bytes / (1024.0 * 1024.0):F1} MB", _ => $"{bytes / (1024.0 * 1024.0 * 1024.0):F2} GB", }; // ─── Options record ──────────────────────────────────────────────────── private sealed record TreeOptions( int MaxDepth, bool IncludeFiles, string ExtFilter, HashSet? ExtSet, string SortBy, bool ShowDirSizes, DateTime? ModifiedAfter, long? MaxFileSizeBytes); }