Forge
csharpdeeb25a2
1#nullable enable
2using System.Text;
3using CascadeIDE.Contracts;
4using CascadeIDE.Models;
5
6namespace CascadeIDE.Features.Workspace.DataAcquisition;
7
8/// <summary>Чтение/запись текстовых файлов workspace для агента (DAL, ADR 0102).</summary>
9[IoBoundary]
10public static class WorkspaceDocumentFileIo
11{
12 public static bool TryResolvePath(
13 string? workspaceRoot,
14 IReadOnlyList<string>? solutionRoots,
15 string filePath,
16 out string fullPath,
17 out string? error)
18 {
19 fullPath = "";
20 error = null;
21 if (string.IsNullOrWhiteSpace(filePath))
22 {
23 error = "file_path is required.";
24 return false;
25 }
26
27 var candidate = filePath.Trim();
28 if (!Path.IsPathRooted(candidate))
29 {
30 if (string.IsNullOrWhiteSpace(workspaceRoot))
31 {
32 error = "workspace is not open.";
33 return false;
34 }
35
36 candidate = Path.GetFullPath(Path.Combine(workspaceRoot.Trim(), candidate));
37 }
38 else if (!CanonicalFilePath.TryNormalize(candidate, out candidate))
39 {
40 error = "invalid file_path.";
41 return false;
42 }
43
44 if (!IsUnderAllowedRoots(candidate, workspaceRoot, solutionRoots))
45 {
46 error = "path is outside workspace.";
47 return false;
48 }
49
50 fullPath = candidate;
51 return true;
52 }
53
54 public static bool TryReadText(
55 string fullPath,
56 int? offsetLine,
57 int? limitLines,
58 int? maxChars,
59 out string json,
60 out string? error)
61 {
62 json = "";
63 error = null;
64 if (!File.Exists(fullPath))
65 {
66 error = "file not found.";
67 return false;
68 }
69
70 try
71 {
72 var text = ReadAllTextShared(fullPath);
73 var totalLength = text.Length;
74 if (offsetLine is > 0 || limitLines is > 0)
75 text = SliceByLines(text, offsetLine, limitLines);
76
77 var truncated = false;
78 if (maxChars is > 0 && text.Length > maxChars.Value)
79 {
80 text = text[..maxChars.Value];
81 truncated = true;
82 }
83
84 json = System.Text.Json.JsonSerializer.Serialize(new
85 {
86 file_path = fullPath,
87 length = totalLength,
88 returned_length = text.Length,
89 truncated,
90 offset = offsetLine,
91 limit = limitLines,
92 text
93 });
94 return true;
95 }
96 catch (Exception ex)
97 {
98 error = ex.Message;
99 return false;
100 }
101 }
102
103 public static bool TryWriteText(string fullPath, string content, bool createIfMissing, out string? error)
104 {
105 error = null;
106 try
107 {
108 if (!File.Exists(fullPath))
109 {
110 if (!createIfMissing)
111 {
112 error = "file not found.";
113 return false;
114 }
115
116 var dir = Path.GetDirectoryName(fullPath);
117 if (!string.IsNullOrEmpty(dir))
118 Directory.CreateDirectory(dir);
119 }
120
121 File.WriteAllText(fullPath, content ?? "", new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
122 return true;
123 }
124 catch (Exception ex)
125 {
126 error = ex.Message;
127 return false;
128 }
129 }
130
131 private static bool IsUnderAllowedRoots(
132 string fullPath,
133 string? workspaceRoot,
134 IReadOnlyList<string>? solutionRoots)
135 {
136 if (!string.IsNullOrWhiteSpace(workspaceRoot)
137 && CanonicalFilePath.TryNormalize(workspaceRoot.Trim(), out var ws)
138 && IsPathUnderRoot(fullPath, ws))
139 return true;
140
141 if (solutionRoots is null)
142 return false;
143
144 foreach (var root in solutionRoots)
145 {
146 if (string.IsNullOrWhiteSpace(root))
147 continue;
148 if (!CanonicalFilePath.TryNormalize(root.Trim(), out var normalized))
149 continue;
150 if (IsPathUnderRoot(fullPath, normalized))
151 return true;
152 }
153
154 return false;
155 }
156
157 private static bool IsPathUnderRoot(string fullPath, string rootDirectory)
158 {
159 if (!Directory.Exists(rootDirectory) && !File.Exists(rootDirectory))
160 return false;
161
162 var root = Path.GetFullPath(rootDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
163 + Path.DirectorySeparatorChar);
164 var full = Path.GetFullPath(fullPath);
165 return full.StartsWith(root, StringComparison.OrdinalIgnoreCase);
166 }
167
168 private static string ReadAllTextShared(string path)
169 {
170 for (var attempt = 0; attempt < 3; attempt++)
171 {
172 try
173 {
174 using var stream = new FileStream(
175 path,
176 FileMode.Open,
177 FileAccess.Read,
178 FileShare.ReadWrite | FileShare.Delete);
179 using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
180 return reader.ReadToEnd();
181 }
182 catch (IOException) when (attempt < 2)
183 {
184 Thread.Sleep(40);
185 }
186 catch (UnauthorizedAccessException) when (attempt < 2)
187 {
188 Thread.Sleep(40);
189 }
190 }
191
192 return File.ReadAllText(path);
193 }
194
195 private static string SliceByLines(string text, int? offsetLine, int? limitLines)
196 {
197 var lines = text.Split('\n');
198 var start = offsetLine is > 0 ? Math.Clamp(offsetLine.Value, 1, lines.Length) : 1;
199 var end = limitLines is > 0
200 ? Math.Min(lines.Length, start + limitLines.Value - 1)
201 : lines.Length;
202 if (start > lines.Length)
203 return "";
204 return string.Join('\n', lines.Skip(start - 1).Take(end - start + 1));
205 }
206}
207
View only · write via MCP/CIDE