Forge
csharpdeeb25a2
1#nullable enable
2
3using System.Text;
4
5namespace CascadeIDE.Services;
6
7/// <summary>
8/// Проектные правила для MAF IDE-агента: текст из каталога решения/workspace (не шипнутый промпт приложения).
9/// Совместимо с уже принятой конвенцией скрытых файлов под <c>.cascade-ide/</c>.
10/// </summary>
11internal static class CascadeIdeMafProjectAgentRules
12{
13 /// <summary>Единый файл правил (проще всего версионировать в git).</summary>
14 internal const string SingleFileRelativePath = ".cascade-ide/maf-project-rules.md";
15
16 /// <summary>Необязательная папка: несколько модулей, порядок — по имени файла.</summary>
17 internal const string FragmentFolderRelativePath = ".cascade-ide/maf-project-rules";
18
19 internal const int MaxMergedCharacters = 64_000;
20
21 /// <summary>Возвращает объединённый markdown или null, если корень невалиден или нет ни одного источника.</summary>
22 internal static string? TryLoadMerged(string? workspaceRootAbsolute)
23 {
24 var root = workspaceRootAbsolute?.Trim();
25 if (string.IsNullOrEmpty(root) || !Directory.Exists(root))
26 return null;
27
28 var sb = new StringBuilder(4096);
29 var basePath = CanonicalFilePath.Normalize(root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
30
31 var singlePath = CanonicalFilePath.Normalize(Path.Combine(basePath, SingleFileRelativePath.Replace('/', Path.DirectorySeparatorChar)));
32 TryAppendUnderRoot(sb, basePath, singlePath, sectionTitle: null);
33
34 var fragmentDir = CanonicalFilePath.Normalize(Path.Combine(basePath, FragmentFolderRelativePath.Replace('/', Path.DirectorySeparatorChar)));
35 if (Directory.Exists(fragmentDir))
36 {
37 foreach (var file in Directory.EnumerateFiles(fragmentDir, "*.md", SearchOption.TopDirectoryOnly)
38 .OrderBy(p => p, StringComparer.OrdinalIgnoreCase))
39 {
40 var full = CanonicalFilePath.Normalize(file);
41 TryAppendUnderRoot(sb, basePath, full,
42 sectionTitle: "## " + Path.GetFileName(full));
43 }
44 }
45
46 var text = sb.ToString().Trim();
47 if (text.Length == 0)
48 return null;
49
50 if (text.Length <= MaxMergedCharacters)
51 return text;
52
53 var note =
54 $"\n\n<!-- cascade-ide:maf-project-rules truncated; merged length was over {MaxMergedCharacters} chars -->\n";
55 return text[..MaxMergedCharacters] + note + $"… (обрезано до {MaxMergedCharacters} символов)";
56 }
57
58 /// <summary>Не даём читать файл вне <paramref name="workspaceRootAbsolute"/>.</summary>
59 private static void TryAppendUnderRoot(StringBuilder sink, string workspaceRootAbsolute, string candidateAbsolutePath,
60 string? sectionTitle)
61 {
62 if (!File.Exists(candidateAbsolutePath))
63 return;
64
65 try
66 {
67 var canonicalWorkspace = CanonicalFilePath.Normalize(workspaceRootAbsolute);
68 var canonicalFile = CanonicalFilePath.Normalize(candidateAbsolutePath);
69 if (!IsStrictChildOrSameDirectory(canonicalWorkspace, canonicalFile))
70 return;
71
72 var body = File.ReadAllText(candidateAbsolutePath);
73 if (string.IsNullOrWhiteSpace(body))
74 return;
75
76 if (sink.Length > 0)
77 sink.Append("\n\n");
78
79 if (!string.IsNullOrEmpty(sectionTitle))
80 {
81 sink.Append(sectionTitle.Trim());
82 sink.Append('\n').Append('\n');
83 }
84
85 sink.Append(body.Trim());
86 }
87 catch (IOException)
88 {
89 /* ignore unreadable rules */
90 }
91 catch (UnauthorizedAccessException)
92 {
93 /* ignore */
94 }
95 }
96
97 private static bool IsStrictChildOrSameDirectory(string workspaceDir, string filePath)
98 {
99 workspaceDir = Path.TrimEndingDirectorySeparator(CanonicalFilePath.Normalize(workspaceDir));
100 filePath = CanonicalFilePath.Normalize(filePath);
101
102 var wr = Path.GetPathRoot(workspaceDir);
103 var fr = Path.GetPathRoot(filePath);
104 if (string.IsNullOrEmpty(wr) || string.IsNullOrEmpty(fr)
105 || !string.Equals(wr, fr, StringComparison.OrdinalIgnoreCase))
106 return false;
107
108 var rel = Path.GetRelativePath(workspaceDir, filePath);
109 return rel != ".." && !rel.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal);
110 }
111}
112
View only · write via MCP/CIDE