< Summary

Information
Class: DotNetApiDiff.Commands.CompareCommandSettings
Assembly: DotNetApiDiff
File(s): /home/runner/work/dotnet-api-diff/dotnet-api-diff/src/DotNetApiDiff/Commands/CompareCommand.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 12
Coverable lines: 12
Total lines: 619
Line coverage: 0%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_SourceAssemblyPath()100%210%
get_TargetAssemblyPath()100%210%
get_ConfigFile()100%210%
get_OutputFormat()100%210%
get_OutputFile()100%210%
get_NamespaceFilters()100%210%
get_ExcludePatterns()100%210%
get_TypePatterns()100%210%
get_IncludeInternals()100%210%
get_IncludeCompilerGenerated()100%210%
get_NoColor()100%210%
get_Verbose()100%210%

File(s)

/home/runner/work/dotnet-api-diff/dotnet-api-diff/src/DotNetApiDiff/Commands/CompareCommand.cs

#LineLine coverage
 1// Copyright DotNet API Diff Project Contributors - SPDX Identifier: MIT
 2using DotNetApiDiff.Interfaces;
 3using DotNetApiDiff.Models;
 4using DotNetApiDiff.Models.Configuration;
 5using Microsoft.Extensions.DependencyInjection;
 6using Microsoft.Extensions.Logging;
 7using Spectre.Console;
 8using Spectre.Console.Cli;
 9using System.ComponentModel;
 10using System.Diagnostics.CodeAnalysis;
 11using System.Reflection;
 12
 13namespace DotNetApiDiff.Commands;
 14
 15/// <summary>
 16/// Settings for the compare command
 17/// </summary>
 18[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicPro
 19public class CompareCommandSettings : CommandSettings
 20{
 21    [CommandArgument(0, "<sourceAssembly>")]
 22    [Description("Path to the source/baseline assembly")]
 023    public string? SourceAssemblyPath { get; set; }
 24
 25    [CommandArgument(1, "<targetAssembly>")]
 26    [Description("Path to the target/current assembly")]
 027    public string? TargetAssemblyPath { get; set; }
 28
 29    [CommandOption("-c|--config <configFile>")]
 30    [Description("Path to configuration file")]
 031    public string? ConfigFile { get; set; }
 32
 33    [CommandOption("-o|--output <format>")]
 34    [Description("Output format (console, json, html, markdown)")]
 035    public string? OutputFormat { get; set; }
 36
 37    [CommandOption("-p|--output-file <path>")]
 38    [Description("Output file path (required for json, html, markdown formats)")]
 039    public string? OutputFile { get; set; }
 40
 41    [CommandOption("-f|--filter <namespace>")]
 42    [Description("Filter to specific namespaces (can be specified multiple times)")]
 043    public string[]? NamespaceFilters { get; set; }
 44
 45    [CommandOption("-e|--exclude <pattern>")]
 46    [Description("Exclude types matching pattern (can be specified multiple times)")]
 047    public string[]? ExcludePatterns { get; set; }
 48
 49    [CommandOption("-t|--type <pattern>")]
 50    [Description("Filter to specific type patterns (can be specified multiple times)")]
 051    public string[]? TypePatterns { get; set; }
 52
 53    [CommandOption("--include-internals")]
 54    [Description("Include internal types in the comparison")]
 55    [DefaultValue(false)]
 056    public bool IncludeInternals { get; set; }
 57
 58    [CommandOption("--include-compiler-generated")]
 59    [Description("Include compiler-generated types in the comparison")]
 60    [DefaultValue(false)]
 061    public bool IncludeCompilerGenerated { get; set; }
 62
 63    [CommandOption("--no-color")]
 64    [Description("Disable colored output")]
 65    [DefaultValue(false)]
 066    public bool NoColor { get; set; }
 67
 68    [CommandOption("-v|--verbose")]
 69    [Description("Enable verbose output")]
 70    [DefaultValue(false)]
 071    public bool Verbose { get; set; }
 72}
 73
 74/// <summary>
 75/// Command to compare two assemblies
 76/// </summary>
 77public class CompareCommand : Command<CompareCommandSettings>
 78{
 79    private readonly IServiceProvider _serviceProvider;
 80    private readonly ILogger<CompareCommand> _logger;
 81    private readonly IExitCodeManager _exitCodeManager;
 82    private readonly IGlobalExceptionHandler _exceptionHandler;
 83
 84    /// <summary>
 85    /// Initializes a new instance of the <see cref="CompareCommand"/> class.
 86    /// </summary>
 87    /// <param name="serviceProvider">The service provider.</param>
 88    /// <param name="logger">The logger.</param>
 89    /// <param name="exitCodeManager">The exit code manager.</param>
 90    /// <param name="exceptionHandler">The global exception handler.</param>
 91    public CompareCommand(
 92        IServiceProvider serviceProvider,
 93        ILogger<CompareCommand> logger,
 94        IExitCodeManager exitCodeManager,
 95        IGlobalExceptionHandler exceptionHandler)
 96    {
 97        _serviceProvider = serviceProvider;
 98        _logger = logger;
 99        _exitCodeManager = exitCodeManager;
 100        _exceptionHandler = exceptionHandler;
 101    }
 102
 103    /// <summary>
 104    /// Validates the command settings
 105    /// </summary>
 106    /// <param name="context">The command context</param>
 107    /// <param name="settings">The command settings</param>
 108    /// <returns>ValidationResult indicating success or failure</returns>
 109    public override ValidationResult Validate([NotNull] CommandContext context, [NotNull] CompareCommandSettings setting
 110    {
 111        // Validate source assembly path
 112        if (string.IsNullOrEmpty(settings.SourceAssemblyPath))
 113        {
 114            return ValidationResult.Error("Source assembly path is required");
 115        }
 116
 117        if (!File.Exists(settings.SourceAssemblyPath))
 118        {
 119            return ValidationResult.Error($"Source assembly file not found: {settings.SourceAssemblyPath}");
 120        }
 121
 122        // Validate target assembly path
 123        if (string.IsNullOrEmpty(settings.TargetAssemblyPath))
 124        {
 125            return ValidationResult.Error("Target assembly path is required");
 126        }
 127
 128        if (!File.Exists(settings.TargetAssemblyPath))
 129        {
 130            return ValidationResult.Error($"Target assembly file not found: {settings.TargetAssemblyPath}");
 131        }
 132
 133        // Validate config file if specified
 134        if (!string.IsNullOrEmpty(settings.ConfigFile) && !File.Exists(settings.ConfigFile))
 135        {
 136            return ValidationResult.Error($"Configuration file not found: {settings.ConfigFile}");
 137        }
 138
 139        // Validate output format if provided
 140        if (!string.IsNullOrEmpty(settings.OutputFormat))
 141        {
 142            string format = settings.OutputFormat.ToLowerInvariant();
 143            if (format != "console" && format != "json" && format != "html" && format != "markdown")
 144            {
 145                return ValidationResult.Error($"Invalid output format: {settings.OutputFormat}. Valid formats are: conso
 146            }
 147
 148            // Validate output file requirements
 149            if (format == "html")
 150            {
 151                // HTML format requires an output file
 152                if (string.IsNullOrEmpty(settings.OutputFile))
 153                {
 154                    return ValidationResult.Error($"Output file is required for {settings.OutputFormat} format. Use --ou
 155                }
 156
 157                // Validate output directory exists
 158                var outputDir = Path.GetDirectoryName(settings.OutputFile);
 159                if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir))
 160                {
 161                    return ValidationResult.Error($"Output directory does not exist: {outputDir}");
 162                }
 163            }
 164        }
 165        else if (!string.IsNullOrEmpty(settings.OutputFile))
 166        {
 167            // If output file is specified for non-HTML formats, validate the directory exists
 168            var outputDir = Path.GetDirectoryName(settings.OutputFile);
 169            if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir))
 170            {
 171                return ValidationResult.Error($"Output directory does not exist: {outputDir}");
 172            }
 173        }
 174
 175        return ValidationResult.Success();
 176    }
 177
 178    /// <summary>
 179    /// Executes the command
 180    /// </summary>
 181    /// <param name="context">The command context</param>
 182    /// <param name="settings">The command settings</param>
 183    /// <returns>Exit code (0 for success, non-zero for failure)</returns>
 184    public override int Execute([NotNull] CommandContext context, [NotNull] CompareCommandSettings settings)
 185    {
 186        try
 187        {
 188            // Create a logging scope for this command execution
 189            using (_logger.BeginScope("Compare command execution"))
 190            {
 191                // Set up logging level based on verbose flag
 192                if (settings.Verbose)
 193                {
 194                    _logger.LogInformation("Verbose logging enabled");
 195                }
 196
 197                // Configure console output
 198                if (settings.NoColor)
 199                {
 200                    _logger.LogDebug("Disabling colored output");
 201                    AnsiConsole.Profile.Capabilities.ColorSystem = ColorSystem.NoColors;
 202                }
 203
 204                // Load configuration
 205                ComparisonConfiguration config;
 206                if (!string.IsNullOrEmpty(settings.ConfigFile))
 207                {
 208                    using (_logger.BeginScope("Configuration loading"))
 209                    {
 210                        _logger.LogInformation("Loading configuration from {ConfigFile}", settings.ConfigFile);
 211
 212                        try
 213                        {
 214                            // Verify the file exists and is accessible
 215                            if (!File.Exists(settings.ConfigFile))
 216                            {
 217                                throw new FileNotFoundException($"Configuration file not found: {settings.ConfigFile}", 
 218                            }
 219
 220                            // Try to load the configuration
 221                            config = ComparisonConfiguration.LoadFromJsonFile(settings.ConfigFile);
 222                            _logger.LogInformation("Configuration loaded successfully");
 223                        }
 224                        catch (Exception ex)
 225                        {
 226                            _logger.LogError(ex, "Error loading configuration from {ConfigFile}", settings.ConfigFile);
 227                            AnsiConsole.MarkupLine($"[red]Error loading configuration:[/] {ex.Message}");
 228
 229                            // Use the ExitCodeManager to determine the appropriate exit code for errors
 230                            return _exitCodeManager.GetExitCodeForException(ex);
 231                        }
 232                    }
 233                }
 234                else
 235                {
 236                    _logger.LogInformation("Using default configuration");
 237                    config = ComparisonConfiguration.CreateDefault();
 238                }
 239
 240                // Apply command-line filters and options
 241                ApplyCommandLineOptions(settings, config);
 242
 243                // NOW CREATE THE COMMAND-SPECIFIC CONTAINER
 244                _logger.LogInformation("Creating command-specific service container with loaded configuration");
 245
 246                var commandServices = new ServiceCollection();
 247
 248                // Reuse shared services from root container
 249                var loggerFactory = _serviceProvider.GetRequiredService<ILoggerFactory>();
 250                commandServices.AddSingleton(loggerFactory);
 251                commandServices.AddLogging(); // This adds ILogger<T> services
 252
 253                // Add the loaded configuration
 254                commandServices.AddSingleton(config);                // Add all business logic services with configurati
 255                commandServices.AddScoped<IAssemblyLoader, AssemblyLoading.AssemblyLoader>();
 256                commandServices.AddScoped<IApiExtractor, ApiExtraction.ApiExtractor>();
 257                commandServices.AddScoped<IMemberSignatureBuilder, ApiExtraction.MemberSignatureBuilder>();
 258                commandServices.AddScoped<ITypeAnalyzer, ApiExtraction.TypeAnalyzer>();
 259                commandServices.AddScoped<IDifferenceCalculator, ApiExtraction.DifferenceCalculator>();
 260                commandServices.AddScoped<IReportGenerator, Reporting.ReportGenerator>();
 261
 262                // Add configuration-specific services
 263                commandServices.AddScoped<INameMapper>(provider =>
 264                {
 265                    return new ApiExtraction.NameMapper(
 266                        config.Mappings,
 267                        loggerFactory.CreateLogger<ApiExtraction.NameMapper>());
 268                });
 269
 270                commandServices.AddScoped<IChangeClassifier>(provider =>
 271                    new ApiExtraction.ChangeClassifier(
 272                        config.BreakingChangeRules,
 273                        config.Exclusions,
 274                        loggerFactory.CreateLogger<ApiExtraction.ChangeClassifier>()));
 275
 276                // Add the main comparison service that depends on configured services
 277                commandServices.AddScoped<IApiComparer>(provider =>
 278                    new ApiExtraction.ApiComparer(
 279                        provider.GetRequiredService<IApiExtractor>(),
 280                        provider.GetRequiredService<IDifferenceCalculator>(),
 281                        provider.GetRequiredService<INameMapper>(),
 282                        provider.GetRequiredService<IChangeClassifier>(),
 283                        config,
 284                        provider.GetRequiredService<ILogger<ApiExtraction.ApiComparer>>()));
 285
 286                // Execute the command with the configured services
 287                using (var commandProvider = commandServices.BuildServiceProvider())
 288                {
 289                    return ExecuteWithConfiguredServices(settings, config, commandProvider);
 290                }
 291            }
 292        }
 293        catch (Exception ex)
 294        {
 295            // Use our centralized exception handler for any unhandled exceptions
 296            return _exceptionHandler.HandleException(ex, "Compare command execution");
 297        }
 298    }
 299
 300    /// <summary>
 301    /// Extracts the member name from a full element name
 302    /// </summary>
 303    /// <param name="elementName">The full element name</param>
 304    /// <returns>The member name</returns>
 305    private static string ExtractMemberName(string elementName)
 306    {
 307        if (string.IsNullOrEmpty(elementName))
 308        {
 309            return "Unknown"; // Or throw an exception if this is not expected
 310        }
 311
 312        // For full names like "Namespace.Class.Method", extract just "Method"
 313        var lastDotIndex = elementName.LastIndexOf('.');
 314        return lastDotIndex >= 0 ? elementName.Substring(lastDotIndex + 1) : elementName;
 315    }
 316
 317    /// <summary>
 318    /// Executes the comparison logic using the configured services
 319    /// </summary>
 320    /// <param name="settings">Command settings</param>
 321    /// <param name="config">Loaded configuration</param>
 322    /// <param name="serviceProvider">Command-specific service provider</param>
 323    /// <returns>Exit code</returns>
 324    private int ExecuteWithConfiguredServices(CompareCommandSettings settings, ComparisonConfiguration config, IServiceP
 325    {
 326        // Load assemblies
 327        Assembly sourceAssembly;
 328        Assembly targetAssembly;
 329
 330        using (_logger.BeginScope("Assembly loading"))
 331        {
 332            _logger.LogInformation("Loading source assembly: {Path}", settings.SourceAssemblyPath);
 333            _logger.LogInformation("Loading target assembly: {Path}", settings.TargetAssemblyPath);
 334
 335            var assemblyLoader = serviceProvider.GetRequiredService<IAssemblyLoader>();
 336
 337            try
 338            {
 339                sourceAssembly = assemblyLoader.LoadAssembly(settings.SourceAssemblyPath!);
 340            }
 341            catch (Exception ex)
 342            {
 343                _logger.LogError(ex, "Failed to load source assembly: {Path}", settings.SourceAssemblyPath);
 344                AnsiConsole.MarkupLine($"[red]Error loading source assembly:[/] {ex.Message}");
 345
 346                return _exitCodeManager.GetExitCodeForException(ex);
 347            }
 348
 349            try
 350            {
 351                targetAssembly = assemblyLoader.LoadAssembly(settings.TargetAssemblyPath!);
 352            }
 353            catch (Exception ex)
 354            {
 355                _logger.LogError(ex, "Failed to load target assembly: {Path}", settings.TargetAssemblyPath);
 356                AnsiConsole.MarkupLine($"[red]Error loading target assembly:[/] {ex.Message}");
 357
 358                return _exitCodeManager.GetExitCodeForException(ex);
 359            }
 360        }
 361
 362        // Extract API information
 363        using (_logger.BeginScope("API extraction"))
 364        {
 365            _logger.LogInformation("Extracting API information from assemblies");
 366            var apiExtractor = serviceProvider.GetRequiredService<IApiExtractor>();
 367
 368            // Pass the filter configuration to the API extractor
 369            var sourceApi = apiExtractor.ExtractApiMembers(sourceAssembly, config.Filters);
 370            var targetApi = apiExtractor.ExtractApiMembers(targetAssembly, config.Filters);
 371
 372            // Log the number of API members extracted
 373            _logger.LogInformation(
 374                "Extracted {SourceCount} API members from source and {TargetCount} API members from target",
 375                sourceApi.Count(),
 376                targetApi.Count());
 377        }
 378
 379        // Compare APIs
 380        Models.ComparisonResult comparisonResult;
 381        using (_logger.BeginScope("API comparison"))
 382        {
 383            _logger.LogInformation("Comparing APIs");
 384            var apiComparer = serviceProvider.GetRequiredService<IApiComparer>();
 385
 386            try
 387            {
 388                // Use the single CompareAssemblies method - configuration is now injected into dependencies
 389                comparisonResult = apiComparer.CompareAssemblies(sourceAssembly, targetAssembly);
 390
 391                // Update configuration with actual command-line values ONLY if explicitly provided by user
 392                if (!string.IsNullOrEmpty(settings.OutputFormat) && Enum.TryParse<ReportFormat>(settings.OutputFormat, t
 393                {
 394                    comparisonResult.Configuration.OutputFormat = outputFormat;
 395                }
 396
 397                if (!string.IsNullOrEmpty(settings.OutputFile))
 398                {
 399                    comparisonResult.Configuration.OutputPath = settings.OutputFile;
 400                }
 401            }
 402            catch (Exception ex)
 403            {
 404                _logger.LogError(ex, "Error comparing assemblies");
 405                AnsiConsole.MarkupLine($"[red]Error comparing assemblies:[/] {ex.Message}");
 406
 407                return _exitCodeManager.GetExitCodeForException(ex);
 408            }
 409        }
 410
 411        // Create ApiComparison from ComparisonResult
 412        var comparison = CreateApiComparisonFromResult(comparisonResult);
 413
 414        // Generate report
 415        using (_logger.BeginScope("Report generation"))
 416        {
 417            // Use the configuration from the comparison result, which now has the correct precedence applied
 418            var effectiveFormat = comparisonResult.Configuration.OutputFormat;
 419            var effectiveOutputFile = comparisonResult.Configuration.OutputPath;
 420
 421            _logger.LogInformation("Generating {Format} report", effectiveFormat);
 422            var reportGenerator = serviceProvider.GetRequiredService<IReportGenerator>();
 423
 424            string report;
 425            try
 426            {
 427                if (string.IsNullOrEmpty(effectiveOutputFile))
 428                {
 429                    // No output file specified - output to console regardless of format
 430                    report = reportGenerator.GenerateReport(comparisonResult, effectiveFormat);
 431
 432                    // Output the formatted report to the console
 433                    // Use Console.Write to avoid format string interpretation issues
 434                    Console.Write(report);
 435                }
 436                else
 437                {
 438                    // Output file specified - save to the specified file
 439                    reportGenerator.SaveReportAsync(comparisonResult, effectiveFormat, effectiveOutputFile).GetAwaiter()
 440                    _logger.LogInformation("Report saved to {OutputFile}", effectiveOutputFile);
 441                }
 442            }
 443            catch (Exception ex)
 444            {
 445                _logger.LogError(ex, "Error generating {Format} report", effectiveFormat);
 446                AnsiConsole.MarkupLine($"[red]Error generating report:[/] {ex.Message}");
 447
 448                return _exitCodeManager.GetExitCodeForException(ex);
 449            }
 450        }
 451
 452        // Use the ExitCodeManager to determine the appropriate exit code
 453        int exitCode = _exitCodeManager.GetExitCode(comparison);
 454
 455        if (comparison.HasBreakingChanges)
 456        {
 457            _logger.LogWarning("{Count} breaking changes detected", comparison.BreakingChangesCount);
 458        }
 459        else
 460        {
 461            _logger.LogInformation("Comparison completed successfully with no breaking changes");
 462        }
 463
 464        _logger.LogInformation(
 465            "Exiting with code {ExitCode}: {Description}",
 466            exitCode,
 467            _exitCodeManager.GetExitCodeDescription(exitCode));
 468
 469        return exitCode;
 470    }
 471
 472    /// <summary>
 473    /// Applies command-line options to the configuration
 474    /// </summary>
 475    /// <param name="settings">Command settings</param>
 476    /// <param name="config">Configuration to update</param>
 477    /// <param name="logger">Logger for diagnostic information</param>
 478    private void ApplyCommandLineOptions(CompareCommandSettings settings, Models.Configuration.ComparisonConfiguration c
 479    {
 480        using (_logger.BeginScope("Applying command-line options"))
 481        {
 482            // Apply namespace filters if specified
 483            if (settings.NamespaceFilters != null && settings.NamespaceFilters.Length > 0)
 484            {
 485                _logger.LogInformation("Applying namespace filters: {Filters}", string.Join(", ", settings.NamespaceFilt
 486
 487                // Add namespace filters to the configuration
 488                config.Filters.IncludeNamespaces.AddRange(settings.NamespaceFilters);
 489
 490                // If we have explicit includes, we're filtering to only those namespaces
 491                if (config.Filters.IncludeNamespaces.Count > 0)
 492                {
 493                    _logger.LogInformation("Filtering to only include specified namespaces");
 494                }
 495            }
 496
 497            // Apply type pattern filters if specified
 498            if (settings.TypePatterns != null && settings.TypePatterns.Length > 0)
 499            {
 500                _logger.LogInformation("Applying type pattern filters: {Patterns}", string.Join(", ", settings.TypePatte
 501
 502                // Add type pattern filters to the configuration
 503                config.Filters.IncludeTypes.AddRange(settings.TypePatterns);
 504
 505                _logger.LogInformation("Filtering to only include types matching specified patterns");
 506            }
 507
 508            // Apply command-line exclusions if specified
 509            if (settings.ExcludePatterns != null && settings.ExcludePatterns.Length > 0)
 510            {
 511                _logger.LogInformation("Applying exclusion patterns: {Patterns}", string.Join(", ", settings.ExcludePatt
 512
 513                // Add exclusion patterns to the configuration
 514                foreach (var pattern in settings.ExcludePatterns)
 515                {
 516                    // Determine if this is a namespace or type pattern based on presence of dot
 517                    if (pattern.Contains('.'))
 518                    {
 519                        // Assume it's a type pattern if it contains a dot
 520                        config.Exclusions.ExcludedTypePatterns.Add(pattern);
 521                    }
 522                    else
 523                    {
 524                        // Otherwise assume it's a namespace pattern
 525                        config.Filters.ExcludeNamespaces.Add(pattern);
 526                    }
 527                }
 528            }
 529
 530            // Apply internal types inclusion if specified
 531            if (settings.IncludeInternals)
 532            {
 533                _logger.LogInformation("Including internal types in comparison");
 534                config.Filters.IncludeInternals = true;
 535            }
 536
 537            // Apply compiler-generated types inclusion if specified
 538            if (settings.IncludeCompilerGenerated)
 539            {
 540                _logger.LogInformation("Including compiler-generated types in comparison");
 541                config.Filters.IncludeCompilerGenerated = true;
 542            }
 543        }
 544    }
 545
 546    /// <summary>
 547    /// Creates an ApiComparison object from a ComparisonResult
 548    /// </summary>
 549    /// <param name="comparisonResult">The comparison result to convert</param>
 550    /// <returns>An ApiComparison object</returns>
 551    private Models.ApiComparison CreateApiComparisonFromResult(Models.ComparisonResult comparisonResult)
 552    {
 553        return new Models.ApiComparison
 554        {
 555            Additions = comparisonResult.Differences
 556                .Where(d => d.ChangeType == Models.ChangeType.Added)
 557                .Select(d => new Models.ApiChange
 558                {
 559                    Type = Models.ChangeType.Added,
 560                    Description = d.Description,
 561                    TargetMember = new Models.ApiMember
 562                    {
 563                        Name = ExtractMemberName(d.ElementName),
 564                        FullName = d.ElementName,
 565                        Signature = d.NewSignature ?? "Unknown"
 566                    },
 567                    IsBreakingChange = d.IsBreakingChange
 568                }).ToList(),
 569            Removals = comparisonResult.Differences
 570                .Where(d => d.ChangeType == Models.ChangeType.Removed)
 571                .Select(d => new Models.ApiChange
 572                {
 573                    Type = Models.ChangeType.Removed,
 574                    Description = d.Description,
 575                    SourceMember = new Models.ApiMember
 576                    {
 577                        Name = ExtractMemberName(d.ElementName),
 578                        FullName = d.ElementName,
 579                        Signature = d.OldSignature ?? "Unknown"
 580                    },
 581                    IsBreakingChange = d.IsBreakingChange
 582                }).ToList(),
 583            Modifications = comparisonResult.Differences
 584                .Where(d => d.ChangeType == Models.ChangeType.Modified)
 585                .Select(d => new Models.ApiChange
 586                {
 587                    Type = Models.ChangeType.Modified,
 588                    Description = d.Description,
 589                    SourceMember = new Models.ApiMember
 590                    {
 591                        Name = ExtractMemberName(d.ElementName),
 592                        FullName = d.ElementName,
 593                        Signature = d.OldSignature ?? "Unknown"
 594                    },
 595                    TargetMember = new Models.ApiMember
 596                    {
 597                        Name = ExtractMemberName(d.ElementName),
 598                        FullName = d.ElementName,
 599                        Signature = d.NewSignature ?? "Unknown"
 600                    },
 601                    IsBreakingChange = d.IsBreakingChange
 602                }).ToList(),
 603            Excluded = comparisonResult.Differences
 604                .Where(d => d.ChangeType == Models.ChangeType.Excluded)
 605                .Select(d => new Models.ApiChange
 606                {
 607                    Type = Models.ChangeType.Excluded,
 608                    Description = d.Description,
 609                    SourceMember = new Models.ApiMember
 610                    {
 611                        Name = ExtractMemberName(d.ElementName),
 612                        FullName = d.ElementName,
 613                        Signature = "Unknown"
 614                    },
 615                    IsBreakingChange = false
 616                }).ToList()
 617        };
 618    }
 619}