| | 1 | | // Copyright DotNet API Diff Project Contributors - SPDX Identifier: MIT |
| | 2 | | using DotNetApiDiff.Interfaces; |
| | 3 | | using DotNetApiDiff.Models; |
| | 4 | | using Spectre.Console; |
| | 5 | | using System.Text; |
| | 6 | |
|
| | 7 | | namespace DotNetApiDiff.Reporting; |
| | 8 | |
|
| | 9 | | /// <summary> |
| | 10 | | /// Formatter for console output with colored text for different change types |
| | 11 | | /// </summary> |
| | 12 | | public class ConsoleFormatter : IReportFormatter |
| | 13 | | { |
| | 14 | | private readonly bool _testMode; |
| | 15 | |
|
| | 16 | | /// <summary> |
| | 17 | | /// Initializes a new instance of the <see cref="ConsoleFormatter"/> class. |
| | 18 | | /// </summary> |
| | 19 | | /// <param name="testMode">Whether to run in test mode (simplified output)</param> |
| 12 | 20 | | public ConsoleFormatter(bool testMode = false) |
| 12 | 21 | | { |
| 12 | 22 | | _testMode = testMode; |
| 12 | 23 | | } |
| | 24 | |
|
| | 25 | | /// <summary> |
| | 26 | | /// Formats a comparison result for console output with colored text |
| | 27 | | /// </summary> |
| | 28 | | /// <param name="result">The comparison result to format</param> |
| | 29 | | /// <returns>Formatted console output as a string</returns> |
| | 30 | | public string Format(ComparisonResult result) |
| 9 | 31 | | { |
| 9 | 32 | | if (_testMode) |
| 9 | 33 | | { |
| 9 | 34 | | return FormatForTests(result); |
| | 35 | | } |
| | 36 | |
|
| 0 | 37 | | var output = new StringBuilder(); |
| | 38 | |
|
| | 39 | | // Create header with assembly information |
| 0 | 40 | | output.AppendLine(FormatHeader(result)); |
| 0 | 41 | | output.AppendLine(); |
| | 42 | |
|
| | 43 | | // Format summary statistics |
| 0 | 44 | | output.AppendLine(FormatSummary(result)); |
| 0 | 45 | | output.AppendLine(); |
| | 46 | |
|
| | 47 | | // Format breaking changes (if any) |
| 0 | 48 | | if (result.HasBreakingChanges) |
| 0 | 49 | | { |
| 0 | 50 | | output.AppendLine(FormatBreakingChanges(result)); |
| 0 | 51 | | output.AppendLine(); |
| 0 | 52 | | } |
| | 53 | |
|
| | 54 | | // Format all differences by category |
| 0 | 55 | | output.AppendLine(FormatDetailedChanges(result)); |
| | 56 | |
|
| 0 | 57 | | return output.ToString(); |
| 9 | 58 | | } |
| | 59 | |
|
| | 60 | | private string FormatForTests(ComparisonResult result) |
| 9 | 61 | | { |
| 9 | 62 | | var output = new StringBuilder(); |
| | 63 | |
|
| | 64 | | // Header |
| 9 | 65 | | output.AppendLine("API Comparison Report"); |
| 9 | 66 | | output.AppendLine($"Source Assembly: {Path.GetFileName(result.OldAssemblyPath)}"); |
| 9 | 67 | | output.AppendLine($"Target Assembly: {Path.GetFileName(result.NewAssemblyPath)}"); |
| 9 | 68 | | output.AppendLine($"Comparison Date: {result.ComparisonTimestamp:yyyy-MM-dd HH:mm:ss}"); |
| 9 | 69 | | output.AppendLine($"Total Differences: {result.TotalDifferences}"); |
| | 70 | |
|
| 9 | 71 | | if (result.HasBreakingChanges) |
| 7 | 72 | | { |
| 24 | 73 | | output.AppendLine($"Breaking Changes: {result.Differences.Count(d => d.IsBreakingChange)}"); |
| 7 | 74 | | } |
| | 75 | |
|
| 9 | 76 | | output.AppendLine(); |
| | 77 | |
|
| | 78 | | // Summary |
| 9 | 79 | | output.AppendLine("Summary"); |
| 9 | 80 | | output.AppendLine($"Added: {result.Summary.AddedCount}"); |
| 9 | 81 | | output.AppendLine($"Removed: {result.Summary.RemovedCount}"); |
| 9 | 82 | | output.AppendLine($"Modified: {result.Summary.ModifiedCount}"); |
| 9 | 83 | | output.AppendLine($"Breaking Changes: {result.Summary.BreakingChangesCount}"); |
| 9 | 84 | | output.AppendLine($"Total Changes: {result.Summary.TotalChanges}"); |
| 9 | 85 | | output.AppendLine(); |
| | 86 | |
|
| | 87 | | // Breaking Changes |
| 9 | 88 | | if (result.HasBreakingChanges) |
| 7 | 89 | | { |
| 7 | 90 | | output.AppendLine("Breaking Changes"); |
| 62 | 91 | | foreach (var change in result.Differences.Where(d => d.IsBreakingChange)) |
| 12 | 92 | | { |
| 12 | 93 | | output.AppendLine($"{change.ElementType} | {change.ElementName} | {change.Description} | {change.Severit |
| 12 | 94 | | } |
| | 95 | |
|
| 7 | 96 | | output.AppendLine(); |
| 7 | 97 | | } |
| | 98 | |
|
| | 99 | | // Group differences by change type |
| 27 | 100 | | var addedItems = result.Differences.Where(d => d.ChangeType == ChangeType.Added).ToList(); |
| 27 | 101 | | var removedItems = result.Differences.Where(d => d.ChangeType == ChangeType.Removed).ToList(); |
| 27 | 102 | | var modifiedItems = result.Differences.Where(d => d.ChangeType == ChangeType.Modified).ToList(); |
| 27 | 103 | | var excludedItems = result.Differences.Where(d => d.ChangeType == ChangeType.Excluded).ToList(); |
| | 104 | |
|
| | 105 | | // Added Items |
| 9 | 106 | | if (addedItems.Any()) |
| 5 | 107 | | { |
| 5 | 108 | | output.AppendLine($"Added Items ({addedItems.Count})"); |
| 25 | 109 | | foreach (var change in addedItems) |
| 5 | 110 | | { |
| 5 | 111 | | output.AppendLine($"{change.ElementType} | {change.ElementName} | {change.Description}"); |
| 5 | 112 | | if (!string.IsNullOrEmpty(change.NewSignature)) |
| 4 | 113 | | { |
| 4 | 114 | | output.AppendLine($"+ {change.NewSignature}"); |
| 4 | 115 | | } |
| 5 | 116 | | } |
| | 117 | |
|
| 5 | 118 | | output.AppendLine(); |
| 5 | 119 | | } |
| | 120 | |
|
| | 121 | | // Removed Items |
| 9 | 122 | | if (removedItems.Any()) |
| 6 | 123 | | { |
| 6 | 124 | | output.AppendLine($"Removed Items ({removedItems.Count})"); |
| 30 | 125 | | foreach (var change in removedItems) |
| 6 | 126 | | { |
| 6 | 127 | | output.AppendLine($"{change.ElementType} | {change.ElementName} | {change.Description}"); |
| 6 | 128 | | if (!string.IsNullOrEmpty(change.OldSignature)) |
| 4 | 129 | | { |
| 4 | 130 | | output.AppendLine($"- {change.OldSignature}"); |
| 4 | 131 | | } |
| 6 | 132 | | } |
| | 133 | |
|
| 6 | 134 | | output.AppendLine(); |
| 6 | 135 | | } |
| | 136 | |
|
| | 137 | | // Modified Items |
| 9 | 138 | | if (modifiedItems.Any()) |
| 6 | 139 | | { |
| 6 | 140 | | output.AppendLine($"Modified Items ({modifiedItems.Count})"); |
| 30 | 141 | | foreach (var change in modifiedItems) |
| 6 | 142 | | { |
| 6 | 143 | | output.AppendLine($"{change.ElementType} | {change.ElementName} | {change.Description}"); |
| 6 | 144 | | if (!string.IsNullOrEmpty(change.OldSignature)) |
| 4 | 145 | | { |
| 4 | 146 | | output.AppendLine($"- {change.OldSignature}"); |
| 4 | 147 | | } |
| | 148 | |
|
| 6 | 149 | | if (!string.IsNullOrEmpty(change.NewSignature)) |
| 4 | 150 | | { |
| 4 | 151 | | output.AppendLine($"+ {change.NewSignature}"); |
| 4 | 152 | | } |
| 6 | 153 | | } |
| | 154 | |
|
| 6 | 155 | | output.AppendLine(); |
| 6 | 156 | | } |
| | 157 | |
|
| | 158 | | // Excluded Items |
| 9 | 159 | | if (excludedItems.Any()) |
| 1 | 160 | | { |
| 1 | 161 | | output.AppendLine($"Excluded Items ({excludedItems.Count})"); |
| 5 | 162 | | foreach (var change in excludedItems) |
| 1 | 163 | | { |
| 1 | 164 | | output.AppendLine($"{change.ElementType} | {change.ElementName} | {change.Description}"); |
| 1 | 165 | | } |
| 1 | 166 | | } |
| | 167 | |
|
| 9 | 168 | | return output.ToString(); |
| 9 | 169 | | } |
| | 170 | |
|
| | 171 | | private string FormatHeader(ComparisonResult result) |
| 0 | 172 | | { |
| 0 | 173 | | var table = new Table(); |
| | 174 | |
|
| 0 | 175 | | table.AddColumn("API Comparison Report"); |
| 0 | 176 | | table.AddColumn(new TableColumn("Value").RightAligned()); |
| | 177 | |
|
| 0 | 178 | | table.AddRow("Source Assembly", Path.GetFileName(result.OldAssemblyPath)); |
| 0 | 179 | | table.AddRow("Target Assembly", Path.GetFileName(result.NewAssemblyPath)); |
| 0 | 180 | | table.AddRow("Comparison Date", result.ComparisonTimestamp.ToString("yyyy-MM-dd HH:mm:ss")); |
| 0 | 181 | | table.AddRow("Total Differences", result.TotalDifferences.ToString()); |
| | 182 | |
|
| 0 | 183 | | if (result.HasBreakingChanges) |
| 0 | 184 | | { |
| 0 | 185 | | table.AddRow("[bold red]Breaking Changes[/]", $"[bold red]{result.Differences.Count(d => d.IsBreakingChange) |
| 0 | 186 | | } |
| | 187 | |
|
| | 188 | | // Render the table to a string using AnsiConsole |
| 0 | 189 | | using var writer = new StringWriter(); |
| 0 | 190 | | var console = AnsiConsole.Create(new AnsiConsoleSettings |
| 0 | 191 | | { |
| 0 | 192 | | Out = new AnsiConsoleOutput(writer), |
| 0 | 193 | | ColorSystem = ColorSystemSupport.Standard |
| 0 | 194 | | }); |
| 0 | 195 | | console.Write(table); |
| 0 | 196 | | return writer.ToString(); |
| 0 | 197 | | } |
| | 198 | |
|
| | 199 | | private string FormatSummary(ComparisonResult result) |
| 0 | 200 | | { |
| 0 | 201 | | var panel = new Panel(new Rows( |
| 0 | 202 | | new Text("Summary Statistics"), |
| 0 | 203 | | new Text(" "), |
| 0 | 204 | | new Markup($"Added: [green]{result.Summary.AddedCount}[/]"), |
| 0 | 205 | | new Markup($"Removed: [red]{result.Summary.RemovedCount}[/]"), |
| 0 | 206 | | new Markup($"Modified: [yellow]{result.Summary.ModifiedCount}[/]"), |
| 0 | 207 | | new Markup($"Breaking Changes: [bold red]{result.Summary.BreakingChangesCount}[/]"), |
| 0 | 208 | | new Text(" "), |
| 0 | 209 | | new Markup($"Total Changes: [blue]{result.Summary.TotalChanges}[/]"))) |
| 0 | 210 | | { |
| 0 | 211 | | Header = new PanelHeader("Summary"), |
| 0 | 212 | | Border = BoxBorder.Rounded, |
| 0 | 213 | | Expand = true |
| 0 | 214 | | }; |
| | 215 | |
|
| | 216 | | // Render the panel to a string using AnsiConsole |
| 0 | 217 | | using var writer = new StringWriter(); |
| 0 | 218 | | var console = AnsiConsole.Create(new AnsiConsoleSettings |
| 0 | 219 | | { |
| 0 | 220 | | Out = new AnsiConsoleOutput(writer), |
| 0 | 221 | | ColorSystem = ColorSystemSupport.Standard |
| 0 | 222 | | }); |
| 0 | 223 | | console.Write(panel); |
| 0 | 224 | | return writer.ToString(); |
| 0 | 225 | | } |
| | 226 | |
|
| | 227 | | private string FormatBreakingChanges(ComparisonResult result) |
| 0 | 228 | | { |
| 0 | 229 | | var breakingChanges = result.Differences.Where(d => d.IsBreakingChange).ToList(); |
| | 230 | |
|
| 0 | 231 | | if (!breakingChanges.Any()) |
| 0 | 232 | | { |
| 0 | 233 | | return string.Empty; |
| | 234 | | } |
| | 235 | |
|
| 0 | 236 | | var table = new Table(); |
| 0 | 237 | | table.AddColumn("Type"); |
| 0 | 238 | | table.AddColumn("Element"); |
| 0 | 239 | | table.AddColumn("Description"); |
| 0 | 240 | | table.AddColumn("Severity"); |
| | 241 | |
|
| 0 | 242 | | table.Title = new TableTitle("[bold red]Breaking Changes[/]"); |
| 0 | 243 | | table.Border = TableBorder.Rounded; |
| | 244 | |
|
| 0 | 245 | | foreach (var change in breakingChanges) |
| 0 | 246 | | { |
| 0 | 247 | | string severityText = change.Severity switch |
| 0 | 248 | | { |
| 0 | 249 | | SeverityLevel.Critical => $"[bold red]{change.Severity}[/]", |
| 0 | 250 | | SeverityLevel.Error => $"[red]{change.Severity}[/]", |
| 0 | 251 | | SeverityLevel.Warning => $"[yellow]{change.Severity}[/]", |
| 0 | 252 | | _ => $"[blue]{change.Severity}[/]" |
| 0 | 253 | | }; |
| | 254 | |
|
| 0 | 255 | | table.AddRow( |
| 0 | 256 | | change.ElementType.ToString(), |
| 0 | 257 | | change.ElementName, |
| 0 | 258 | | change.Description, |
| 0 | 259 | | severityText); |
| 0 | 260 | | } |
| | 261 | |
|
| | 262 | | // Render the table to a string using AnsiConsole |
| 0 | 263 | | using var writer = new StringWriter(); |
| 0 | 264 | | var console = AnsiConsole.Create(new AnsiConsoleSettings |
| 0 | 265 | | { |
| 0 | 266 | | Out = new AnsiConsoleOutput(writer), |
| 0 | 267 | | ColorSystem = ColorSystemSupport.Standard |
| 0 | 268 | | }); |
| 0 | 269 | | console.Write(table); |
| 0 | 270 | | return writer.ToString(); |
| 0 | 271 | | } |
| | 272 | |
|
| | 273 | | private string FormatDetailedChanges(ComparisonResult result) |
| 0 | 274 | | { |
| 0 | 275 | | var output = new StringBuilder(); |
| | 276 | |
|
| | 277 | | // Group differences by change type |
| 0 | 278 | | var addedItems = result.Differences.Where(d => d.ChangeType == ChangeType.Added).ToList(); |
| 0 | 279 | | var removedItems = result.Differences.Where(d => d.ChangeType == ChangeType.Removed).ToList(); |
| 0 | 280 | | var modifiedItems = result.Differences.Where(d => d.ChangeType == ChangeType.Modified).ToList(); |
| 0 | 281 | | var excludedItems = result.Differences.Where(d => d.ChangeType == ChangeType.Excluded).ToList(); |
| | 282 | |
|
| | 283 | | // Format added items |
| 0 | 284 | | if (addedItems.Any()) |
| 0 | 285 | | { |
| 0 | 286 | | output.AppendLine(FormatChangeGroup("Added Items", addedItems, "green")); |
| 0 | 287 | | output.AppendLine(); |
| 0 | 288 | | } |
| | 289 | |
|
| | 290 | | // Format removed items |
| 0 | 291 | | if (removedItems.Any()) |
| 0 | 292 | | { |
| 0 | 293 | | output.AppendLine(FormatChangeGroup("Removed Items", removedItems, "red")); |
| 0 | 294 | | output.AppendLine(); |
| 0 | 295 | | } |
| | 296 | |
|
| | 297 | | // Format modified items |
| 0 | 298 | | if (modifiedItems.Any()) |
| 0 | 299 | | { |
| 0 | 300 | | output.AppendLine(FormatChangeGroup("Modified Items", modifiedItems, "yellow")); |
| 0 | 301 | | output.AppendLine(); |
| 0 | 302 | | } |
| | 303 | |
|
| | 304 | | // Format excluded items |
| 0 | 305 | | if (excludedItems.Any()) |
| 0 | 306 | | { |
| 0 | 307 | | output.AppendLine(FormatChangeGroup("Excluded Items", excludedItems, "gray")); |
| 0 | 308 | | } |
| | 309 | |
|
| 0 | 310 | | return output.ToString(); |
| 0 | 311 | | } |
| | 312 | |
|
| | 313 | | private string FormatChangeGroup(string title, List<ApiDifference> changes, string color) |
| 0 | 314 | | { |
| 0 | 315 | | var table = new Table(); |
| | 316 | |
|
| 0 | 317 | | table.AddColumn("Type"); |
| 0 | 318 | | table.AddColumn("Element"); |
| 0 | 319 | | table.AddColumn("Details"); |
| | 320 | |
|
| 0 | 321 | | if (changes.Any(c => c.IsBreakingChange)) |
| 0 | 322 | | { |
| 0 | 323 | | table.AddColumn("Breaking"); |
| 0 | 324 | | } |
| | 325 | |
|
| | 326 | | // Ensure color is not empty or null to avoid Spectre Console parsing errors |
| 0 | 327 | | var safeColor = string.IsNullOrWhiteSpace(color) ? "default" : color; |
| 0 | 328 | | table.Title = new TableTitle($"[bold {safeColor}]{title}[/] ({changes.Count})"); |
| 0 | 329 | | table.Border = TableBorder.Rounded; |
| | 330 | |
|
| | 331 | | // Group changes by element type for better organization |
| 0 | 332 | | var groupedChanges = changes.GroupBy(c => c.ElementType).OrderBy(g => g.Key); |
| | 333 | |
|
| 0 | 334 | | foreach (var group in groupedChanges) |
| 0 | 335 | | { |
| 0 | 336 | | foreach (var change in group.OrderBy(c => c.ElementName)) |
| 0 | 337 | | { |
| 0 | 338 | | var row = new List<string> |
| 0 | 339 | | { |
| 0 | 340 | | change.ElementType.ToString(), |
| 0 | 341 | | string.IsNullOrWhiteSpace(change.ElementName) ? "[dim]<unnamed>[/]" : change.ElementName, |
| 0 | 342 | | FormatChangeDetails(change) |
| 0 | 343 | | }; |
| | 344 | |
|
| 0 | 345 | | if (changes.Any(c => c.IsBreakingChange)) |
| 0 | 346 | | { |
| 0 | 347 | | row.Add(change.IsBreakingChange ? "[red]Yes[/]" : "No"); |
| 0 | 348 | | } |
| | 349 | |
|
| 0 | 350 | | table.AddRow(row.ToArray()); |
| 0 | 351 | | } |
| | 352 | |
|
| | 353 | | // Add a separator between groups |
| 0 | 354 | | if (group.Key != groupedChanges.Last().Key) |
| 0 | 355 | | { |
| 0 | 356 | | table.AddEmptyRow(); |
| 0 | 357 | | } |
| 0 | 358 | | } |
| | 359 | |
|
| | 360 | | // Render the table to a string using AnsiConsole |
| 0 | 361 | | using var writer = new StringWriter(); |
| 0 | 362 | | var console = AnsiConsole.Create(new AnsiConsoleSettings |
| 0 | 363 | | { |
| 0 | 364 | | Out = new AnsiConsoleOutput(writer), |
| 0 | 365 | | ColorSystem = ColorSystemSupport.Standard |
| 0 | 366 | | }); |
| 0 | 367 | | console.Write(table); |
| 0 | 368 | | return writer.ToString(); |
| 0 | 369 | | } |
| | 370 | |
|
| | 371 | | private string FormatChangeDetails(ApiDifference change) |
| 0 | 372 | | { |
| | 373 | | // Escape any markup in the description to prevent parsing errors |
| 0 | 374 | | var safeDescription = string.IsNullOrWhiteSpace(change.Description) |
| 0 | 375 | | ? "No description available" |
| 0 | 376 | | : EscapeMarkup(change.Description); |
| | 377 | |
|
| 0 | 378 | | var details = new StringBuilder(safeDescription); |
| | 379 | |
|
| 0 | 380 | | if (!string.IsNullOrWhiteSpace(change.OldSignature)) |
| 0 | 381 | | { |
| 0 | 382 | | details.AppendLine(); |
| 0 | 383 | | details.AppendLine($"[red]- {EscapeMarkup(change.OldSignature)}[/]"); |
| 0 | 384 | | } |
| | 385 | |
|
| 0 | 386 | | if (!string.IsNullOrWhiteSpace(change.NewSignature)) |
| 0 | 387 | | { |
| 0 | 388 | | details.AppendLine(); |
| 0 | 389 | | details.AppendLine($"[green]+ {EscapeMarkup(change.NewSignature)}[/]"); |
| 0 | 390 | | } |
| | 391 | |
|
| 0 | 392 | | return details.ToString(); |
| 0 | 393 | | } |
| | 394 | |
|
| | 395 | | /// <summary> |
| | 396 | | /// Escapes markup characters to prevent Spectre Console parsing errors |
| | 397 | | /// </summary> |
| | 398 | | /// <param name="text">The text to escape</param> |
| | 399 | | /// <returns>Escaped text safe for Spectre Console</returns> |
| | 400 | | private string EscapeMarkup(string text) |
| 0 | 401 | | { |
| 0 | 402 | | if (string.IsNullOrEmpty(text)) |
| 0 | 403 | | { |
| 0 | 404 | | return text; |
| | 405 | | } |
| | 406 | |
|
| | 407 | | // Escape square brackets which are used for markup |
| 0 | 408 | | return text.Replace("[", "[[").Replace("]", "]]"); |
| 0 | 409 | | } |
| | 410 | | } |