using System.IO; using System.Text; using System.Text.RegularExpressions; namespace AxCopilot.Services.Agent; /// /// 프로젝트별 규칙 파일(.ax/rules/*.md)을 로드하고 컨텍스트에 맞는 규칙을 선별합니다. /// 각 규칙 파일은 YAML 프론트매터로 적용 조건을 정의합니다. /// public static class ProjectRulesService { /// 파싱된 프로젝트 규칙. public class ProjectRule { /// 규칙 파일 경로. public string FilePath { get; set; } = ""; /// 규칙 이름 (프론트매터 name 또는 파일명). public string Name { get; set; } = ""; /// 규칙 설명. public string Description { get; set; } = ""; /// 적용 대상 글로브 패턴. 예: "*.cs", "src/**/*.ts" public string AppliesTo { get; set; } = ""; /// 적용 시점. 예: "always", "code-review", "document", "refactor" public string When { get; set; } = "always"; /// 규칙 본문 (프론트매터 제외). public string Body { get; set; } = ""; } // YAML 프론트매터 경계 private static readonly Regex FrontMatterRegex = new( @"^---\s*\n(.*?)\n---\s*\n", RegexOptions.Singleline | RegexOptions.Compiled); // YAML 키-값 파싱 (단순 1줄 값만) private static readonly Regex YamlKeyValue = new( @"^\s*(\w[\w-]*)\s*:\s*(.+?)\s*$", RegexOptions.Multiline | RegexOptions.Compiled); /// /// 작업 폴더에서 .ax/rules/ 디렉토리의 모든 규칙을 로드합니다. /// 최대 3단계 상위 폴더까지 .ax/rules/ 를 탐색합니다. /// public static List LoadRules(string workFolder) { var rules = new List(); if (string.IsNullOrEmpty(workFolder)) return rules; var rulesDir = FindRulesDirectory(workFolder); if (rulesDir == null) return rules; try { foreach (var file in Directory.GetFiles(rulesDir, "*.md")) { var rule = ParseRuleFile(file); if (rule != null) rules.Add(rule); } } catch { /* 디렉토리 읽기 실패 시 빈 목록 반환 */ } return rules; } /// /// 컨텍스트에 맞는 규칙만 필터링합니다. /// /// 전체 규칙 목록 /// 현재 컨텍스트 (예: "code-review", "document", "always") /// 현재 작업 대상 파일 경로들 (applies-to 매칭용) public static List FilterRules( List rules, string when = "always", IEnumerable? filePaths = null) { var result = new List(); foreach (var rule in rules) { // when 조건 체크 var ruleWhen = rule.When.ToLowerInvariant().Trim(); if (ruleWhen != "always" && ruleWhen != when.ToLowerInvariant()) continue; // applies-to 조건 체크 if (!string.IsNullOrEmpty(rule.AppliesTo) && filePaths != null) { var pattern = rule.AppliesTo.Trim(); if (!filePaths.Any(fp => MatchesGlob(fp, pattern))) continue; } result.Add(rule); } return result; } /// /// 규칙 목록을 시스템 프롬프트용 텍스트로 포맷합니다. /// public static string FormatForSystemPrompt(List rules) { if (rules.Count == 0) return ""; var sb = new StringBuilder(); sb.AppendLine("\n## 프로젝트 규칙 (.ax/rules/)"); sb.AppendLine("아래 규칙을 반드시 준수하세요:\n"); foreach (var rule in rules) { if (!string.IsNullOrEmpty(rule.Name)) sb.AppendLine($"### {rule.Name}"); if (!string.IsNullOrEmpty(rule.Description)) sb.AppendLine($"*{rule.Description}*\n"); sb.AppendLine(rule.Body.Trim()); sb.AppendLine(); } return sb.ToString(); } /// .ax/rules/ 디렉토리를 작업 폴더에서 최대 3단계 상위까지 탐색합니다. internal static string? FindRulesDirectory(string workFolder) { var dir = workFolder; for (int i = 0; i < 3; i++) { if (string.IsNullOrEmpty(dir)) break; var rulesPath = Path.Combine(dir, ".ax", "rules"); if (Directory.Exists(rulesPath)) return rulesPath; dir = Directory.GetParent(dir)?.FullName; } return null; } /// 규칙 파일을 파싱합니다 (YAML 프론트매터 + 본문). internal static ProjectRule? ParseRuleFile(string filePath) { try { var content = File.ReadAllText(filePath, Encoding.UTF8); if (string.IsNullOrWhiteSpace(content)) return null; var rule = new ProjectRule { FilePath = filePath, Name = Path.GetFileNameWithoutExtension(filePath), }; // 프론트매터 파싱 var fmMatch = FrontMatterRegex.Match(content); if (fmMatch.Success) { var yaml = fmMatch.Groups[1].Value; foreach (Match kv in YamlKeyValue.Matches(yaml)) { var key = kv.Groups[1].Value.ToLowerInvariant(); var val = kv.Groups[2].Value.Trim().Trim('"', '\''); switch (key) { case "name": rule.Name = val; break; case "description": rule.Description = val; break; case "applies-to" or "appliesto": rule.AppliesTo = val; break; case "when": rule.When = val; break; } } rule.Body = content[(fmMatch.Index + fmMatch.Length)..]; } else { rule.Body = content; } return string.IsNullOrWhiteSpace(rule.Body) ? null : rule; } catch { return null; } } /// 간단한 글로브 패턴 매칭 (*, ** 지원). private static bool MatchesGlob(string path, string pattern) { // "*.cs" → 확장자 매칭 if (pattern.StartsWith("*.")) return path.EndsWith(pattern[1..], StringComparison.OrdinalIgnoreCase); // "**/*.cs" → 경로 내 확장자 매칭 if (pattern.StartsWith("**/")) { var subPattern = pattern[3..]; return MatchesGlob(Path.GetFileName(path), subPattern); } // 정확한 파일명 매칭 return Path.GetFileName(path).Equals(pattern, StringComparison.OrdinalIgnoreCase); } }