| 1 | using System.Text.Json; |
| 2 | using System.Xml.Linq; |
| 3 | using DotNetBuildTestParsers; |
| 4 | |
| 5 | namespace CascadeIDE.Services; |
| 6 | |
| 7 | /// <summary> |
| 8 | /// Доменная логика MCP-команд «dotnet build / test / format» без привязки к UI. |
| 9 | /// MainWindowViewModel остаётся оркестратором: путь решения и обновление панелей — на стороне VM. |
| 10 | /// </summary> |
| 11 | public 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 | |