Forge
csharpdeeb25a2
1using System.Text.Json;
2using System.Xml.Linq;
3using DotNetBuildTestParsers;
4
5namespace CascadeIDE.Services;
6
7/// <summary>
8/// Доменная логика MCP-команд «dotnet build / test / format» без привязки к UI.
9/// MainWindowViewModel остаётся оркестратором: путь решения и обновление панелей — на стороне VM.
10/// </summary>
11public sealed class McpDotnetBuildTestService(IDotnetCommandRunner dotnetRunner)
12{
13 private static readonly JsonSerializerOptions JsonCompact = new() { WriteIndented = false };
14
15 /// <summary><c>dotnet build</c> + binlog: поток в панель «Сборка» батчами (ADR 0094), в возврате — полный сырой лог (парсер, MCP).</summary>
16 /// <param name="appendBatchedToLog">Снято с канала и сбатчено (~8K) в панель; с фона безопасно вызывать <c>BuildOutputPanel.Append</c>.</param>
17 public async Task<(string Output, bool Success, int ExitCode, string BinlogPath)> BuildWithBinlogAsync(
18 string solutionPath,
19 Action<string> appendBatchedToLog,
20 CancellationToken cancellationToken = default)
21 {
22 var artifactsDir = Path.Combine(Path.GetDirectoryName(solutionPath) ?? "", ".cascade-ide", "build-artifacts");
23 Directory.CreateDirectory(artifactsDir);
24 var binlogPath = Path.Combine(artifactsDir, $"build-{DateTime.UtcNow:yyyyMMdd-HHmmss-fff}.binlog");
25 var workDir = Path.GetDirectoryName(solutionPath) ?? "";
26 var acc = new OutputAccumulator(DotnetCommandRunner.MaxOutputChars);
27 var channel = BuildLogIngestion.CreateBuildLogChannel();
28 var drain = BuildLogIngestion.DrainToAppendAsync(
29 channel.Reader,
30 appendBatchedToLog,
31 8192,
32 onEachDequeuedChunk: c => acc.Append(c.AsSpan()),
33 cancellationToken);
34 var run = dotnetRunner.RunWithChunkWriterAsync(
35 ["build", solutionPath, $"-bl:{binlogPath}"],
36 workDir,
37 channel.Writer,
38 cancellationToken);
39 await Task.WhenAll(drain, run).ConfigureAwait(false);
40 var (success, exitCode) = await run.ConfigureAwait(false);
41
42 if (!success && exitCode != 0)
43 {
44 var suffix = $"\r\nExit code: {exitCode}";
45 acc.Append(suffix.AsSpan());
46 appendBatchedToLog(suffix);
47 }
48
49 return (acc.ToStringAndTrim(), success, exitCode, binlogPath);
50 }
51
52 public static string SerializeStructuredBuild(string rawOutput, string? binlogPath)
53 {
54 var parsed = BuildOutputParser.Parse(rawOutput);
55 const int maxRawChars = 4000;
56 var rawTruncated = rawOutput.Length > maxRawChars ? rawOutput[..maxRawChars] + "\n... (output truncated)" : rawOutput;
57 var result = new
58 {
59 success = parsed.Success,
60 exit_code = parsed.ExitCode,
61 errors = parsed.Errors.Select(e => new { e.File, e.Line, e.Column, e.Code, e.Message }).ToList(),
62 warnings = parsed.Warnings.Select(w => new { w.File, w.Line, w.Column, w.Code, w.Message }).ToList(),
63 binlog_path = binlogPath,
64 raw_output = rawTruncated
65 };
66 return JsonSerializer.Serialize(result, JsonCompact);
67 }
68
69 public sealed record TestRunOutcome(
70 string JsonPayload,
71 TestParseResult Parsed,
72 string ConsoleOutput,
73 string? TrxPath,
74 string? FilterExpression,
75 string Mode,
76 IReadOnlyList<string>? Tokens);
77
78 public async Task<TestRunOutcome> RunTestsAsync(
79 string solutionPath,
80 string? filterExpression,
81 string mode,
82 IReadOnlyList<string>? tokens = null,
83 CancellationToken cancellationToken = default)
84 {
85 var resultsDir = Path.Combine(Path.GetDirectoryName(solutionPath) ?? "", ".cascade-ide", "test-artifacts");
86 Directory.CreateDirectory(resultsDir);
87 var trxFileName = $"tests-{DateTime.UtcNow:yyyyMMdd-HHmmss-fff}.trx";
88 var trxPath = Path.Combine(resultsDir, trxFileName);
89 var workDir = Path.GetDirectoryName(solutionPath) ?? "";
90 var args = new List<string>
91 {
92 "test",
93 solutionPath,
94 "--logger",
95 "console;verbosity=detailed",
96 "--logger",
97 $"trx;LogFileName={trxFileName}",
98 "--results-directory",
99 resultsDir
100 };
101 if (!string.IsNullOrWhiteSpace(filterExpression))
102 {
103 args.Add("--filter");
104 args.Add(filterExpression);
105 }
106
107 var (success, exitCode, output) = await dotnetRunner.RunAsync(args, workDir, cancellationToken).ConfigureAwait(false);
108 var outStr = output;
109 if (!success && exitCode != 0)
110 outStr += $"\nExit code: {exitCode}";
111 var parsed = File.Exists(trxPath)
112 ? TryParseTrx(trxPath) ?? TestOutputParser.Parse(outStr)
113 : TestOutputParser.Parse(outStr);
114
115 var payload = new
116 {
117 success = parsed.Success,
118 total = parsed.Total,
119 passed = parsed.Passed,
120 failed = parsed.Failed,
121 skipped = parsed.Skipped,
122 failed_tests = parsed.FailedTests.Select(t => new { t.Name, t.Message, duration_ms = t.DurationMs }).ToList(),
123 mode,
124 filter = filterExpression,
125 tokens,
126 trx_path = File.Exists(trxPath) ? trxPath : null
127 };
128 var json = JsonSerializer.Serialize(payload, JsonCompact);
129 return new TestRunOutcome(json, parsed, outStr, File.Exists(trxPath) ? trxPath : null, filterExpression, mode, tokens);
130 }
131
132 public static string SerializeTestRunFailure(string message, string mode, string? filterExpression) =>
133 JsonSerializer.Serialize(new { success = false, error = message, mode, filter = filterExpression });
134
135 /// <summary><c>dotnet format</c> на решение: поток в панель батчами, полный вывод в <c>RawOutput</c> (JSON MCP).</summary>
136 /// <param name="appendBatchedToLog">Батч в панель «Сборка»; с фона безопасен.</param>
137 public async Task<(bool Success, int ExitCode, string RawOutput)> RunCodeCleanupAsync(
138 string solutionPath,
139 string? includePath,
140 Action<string> appendBatchedToLog,
141 CancellationToken cancellationToken = default)
142 {
143 var workDir = Path.GetDirectoryName(solutionPath) ?? "";
144 var args = new List<string>
145 {
146 "format",
147 solutionPath,
148 "--no-restore",
149 "--verbosity",
150 "minimal"
151 };
152
153 if (!string.IsNullOrWhiteSpace(includePath))
154 {
155 string includeArg;
156 try
157 {
158 includeArg = CanonicalFilePath.Normalize(includePath);
159 }
160 catch
161 {
162 includeArg = includePath;
163 }
164 args.Add("--include");
165 args.Add(includeArg);
166 }
167
168 var acc = new OutputAccumulator(DotnetCommandRunner.MaxOutputChars);
169 var channel = BuildLogIngestion.CreateBuildLogChannel();
170 var drain = BuildLogIngestion.DrainToAppendAsync(
171 channel.Reader,
172 appendBatchedToLog,
173 8192,
174 onEachDequeuedChunk: c => acc.Append(c.AsSpan()),
175 cancellationToken);
176 var run = dotnetRunner.RunWithChunkWriterAsync(args, workDir, channel.Writer, cancellationToken);
177 await Task.WhenAll(drain, run).ConfigureAwait(false);
178 var (success, exitCode) = await run.ConfigureAwait(false);
179 return (success, exitCode, acc.ToStringAndTrim());
180 }
181
182 public static TestParseResult? TryParseTrx(string trxPath)
183 {
184 try
185 {
186 var doc = XDocument.Load(trxPath);
187 var root = doc.Root;
188 if (root is null)
189 return null;
190
191 XNamespace ns = root.Name.Namespace;
192 var counters = doc.Descendants(ns + "Counters").FirstOrDefault();
193 int total = ParseInt(counters?.Attribute("total")?.Value);
194 int passed = ParseInt(counters?.Attribute("passed")?.Value);
195 int failed = ParseInt(counters?.Attribute("failed")?.Value);
196 int skipped = ParseInt(counters?.Attribute("notExecuted")?.Value);
197
198 var failedTests = new List<TestResultItem>();
199 foreach (var unitTestResult in doc.Descendants(ns + "UnitTestResult"))
200 {
201 var outcome = unitTestResult.Attribute("outcome")?.Value;
202 if (!string.Equals(outcome, "Failed", StringComparison.OrdinalIgnoreCase))
203 continue;
204
205 var name = unitTestResult.Attribute("testName")?.Value ?? "";
206 var duration = ParseDurationMs(unitTestResult.Attribute("duration")?.Value);
207 var message = unitTestResult
208 .Descendants(ns + "Message")
209 .Select(m => m.Value)
210 .FirstOrDefault() ?? "";
211 failedTests.Add(new TestResultItem(name, Passed: false, Message: message, DurationMs: duration));
212 }
213
214 return new TestParseResult(
215 Total: total,
216 Passed: passed,
217 Failed: failed,
218 Skipped: skipped,
219 FailedTests: failedTests);
220 }
221 catch
222 {
223 return null;
224 }
225
226 static int ParseInt(string? raw) => int.TryParse(raw, out var value) ? value : 0;
227 static int? ParseDurationMs(string? raw)
228 {
229 if (string.IsNullOrWhiteSpace(raw))
230 return null;
231 return TimeSpan.TryParse(raw, out var ts) ? (int)ts.TotalMilliseconds : null;
232 }
233 }
234
235 public static IReadOnlyList<string> BuildAffectedTestTokens(IReadOnlyList<string>? changedPaths)
236 {
237 if (changedPaths is null || changedPaths.Count == 0)
238 return Array.Empty<string>();
239
240 var tokens = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
241 foreach (var rawPath in changedPaths)
242 {
243 if (string.IsNullOrWhiteSpace(rawPath))
244 continue;
245
246 var fileName = Path.GetFileNameWithoutExtension(rawPath);
247 if (string.IsNullOrWhiteSpace(fileName))
248 continue;
249
250 if (fileName.Contains("test", StringComparison.OrdinalIgnoreCase))
251 {
252 tokens.Add(fileName);
253 continue;
254 }
255
256 tokens.Add(fileName + "Test");
257 tokens.Add(fileName + "Tests");
258 }
259 return tokens.Take(24).ToList();
260 }
261}
262
View only · write via MCP/CIDE