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);
}