File: AssemblyReferenceConflictEventArgs.cs
Web Access
Project: Microsoft.Build.Framework.csproj (Microsoft.Build.Framework)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using Microsoft.Build.Framework.Utilities;

namespace Microsoft.Build.Framework;

/// <summary>
/// Describes why a reference lost a conflict with another reference of the same simple name.
/// </summary>
public enum AssemblyConflictLossReason
{
    /// <summary>
    /// The reference did not lose a conflict.
    /// This value represents the internal "no conflict" state and does not occur in logged conflict events.
    /// </summary>
    DidNotLose = 0,

    /// <summary>
    /// The reference matched another assembly that had a higher version number.
    /// </summary>
    HadLowerVersion = 1,

    /// <summary>
    /// The two assemblies cannot be reconciled.
    /// </summary>
    InsolubleConflict = 2,

    /// <summary>
    /// This reference was a dependency. The other reference was a primary reference that the project specified directly.
    /// </summary>
    WasNotPrimary = 3,

    /// <summary>
    /// The two references were equivalent according to fusion and also have the same version.
    /// </summary>
    FusionEquivalentWithSameVersion = 4,
}

/// <summary>
/// Describes a reference that required the conflicting assembly.
/// It also identifies the project items that caused MSBuild to resolve the reference.
/// </summary>
[Serializable]
public sealed class AssemblyConflictDependee
{
    internal AssemblyConflictDependee(string dependeeFullPath, IReadOnlyList<string> sourceItemSpecs)
    {
        DependeeFullPath = dependeeFullPath;
        SourceItemSpecs = sourceItemSpecs;
    }

    /// <summary>
    /// Gets the full path of the reference that depended on the conflicting assembly.
    /// </summary>
    public string DependeeFullPath { get; }

    /// <summary>
    /// Gets the item specs of the project items that caused <see cref="DependeeFullPath"/> to be resolved.
    /// </summary>
    public IReadOnlyList<string> SourceItemSpecs { get; }

    internal void WriteToStream(BinaryWriter writer)
    {
        writer.WriteOptionalString(DependeeFullPath);
        writer.Write7BitEncodedInt(SourceItemSpecs.Count);
        for (int i = 0; i < SourceItemSpecs.Count; i++)
        {
            writer.WriteOptionalString(SourceItemSpecs[i]);
        }
    }

    internal static AssemblyConflictDependee CreateFromStream(BinaryReader reader)
    {
        string? dependeeFullPath = reader.ReadOptionalString();
        int count = reader.Read7BitEncodedInt();
        var sourceItemSpecs = new string[count];
        for (int i = 0; i < count; i++)
        {
            sourceItemSpecs[i] = reader.ReadOptionalString() ?? string.Empty;
        }

        return new AssemblyConflictDependee(dependeeFullPath ?? string.Empty, sourceItemSpecs);
    }
}

/// <summary>
/// Describes the victor or victim of an assembly conflict.
/// It includes the references and project items that caused assembly resolution.
/// </summary>
[Serializable]
public sealed class AssemblyConflictReferenceDetails
{
    internal AssemblyConflictReferenceDetails(
        string fusionName,
        string? fullPath,
        bool isPrimary,
        bool isResolved,
        string? unresolvedPrimaryItemSpec,
        IReadOnlyList<string> primarySourceItemSpecs,
        IReadOnlyList<AssemblyConflictDependee> dependees)
    {
        FusionName = fusionName;
        FullPath = fullPath;
        IsPrimary = isPrimary;
        IsResolved = isResolved;
        UnresolvedPrimaryItemSpec = unresolvedPrimaryItemSpec;
        PrimarySourceItemSpecs = primarySourceItemSpecs;
        Dependees = dependees;
    }

    /// <summary>
    /// Gets the display identity (fusion name) of this reference.
    /// </summary>
    public string FusionName { get; }

    /// <summary>
    /// Gets the resolved full path of this reference, when known.
    /// </summary>
    public string? FullPath { get; }

    /// <summary>
    /// Gets a value that indicates whether the project directly specified this primary reference.
    /// </summary>
    public bool IsPrimary { get; }

    /// <summary>
    /// Gets a value that indicates whether MSBuild resolved this reference to a file.
    /// </summary>
    public bool IsResolved { get; }

    /// <summary>
    /// Gets the escaped include of the unresolved primary source item.
    /// This value matches the text from the item's <c>ToString()</c> method.
    /// The value is available when <see cref="IsPrimary"/> is <see langword="true"/> and <see cref="IsResolved"/> is <see langword="false"/>.
    /// Otherwise, the value is <see langword="null"/>.
    /// </summary>
    public string? UnresolvedPrimaryItemSpec { get; }

    /// <summary>
    /// Gets the project items that caused MSBuild to resolve this primary reference.
    /// </summary>
    public IReadOnlyList<string> PrimarySourceItemSpecs { get; }

    /// <summary>
    /// Gets the dependee references that required this reference.
    /// Each entry also contains the project items that caused MSBuild to resolve the dependee.
    /// </summary>
    public IReadOnlyList<AssemblyConflictDependee> Dependees { get; }

    internal void WriteToStream(BinaryWriter writer)
    {
        writer.WriteOptionalString(FusionName);
        writer.WriteOptionalString(FullPath);
        writer.Write(IsPrimary);
        writer.Write(IsResolved);
        writer.WriteOptionalString(UnresolvedPrimaryItemSpec);
        writer.Write7BitEncodedInt(PrimarySourceItemSpecs.Count);
        for (int i = 0; i < PrimarySourceItemSpecs.Count; i++)
        {
            writer.WriteOptionalString(PrimarySourceItemSpecs[i]);
        }

        writer.Write7BitEncodedInt(Dependees.Count);
        for (int i = 0; i < Dependees.Count; i++)
        {
            Dependees[i].WriteToStream(writer);
        }
    }

    internal static AssemblyConflictReferenceDetails CreateFromStream(BinaryReader reader)
    {
        string? fusionName = reader.ReadOptionalString();
        string? fullPath = reader.ReadOptionalString();
        bool isPrimary = reader.ReadBoolean();
        bool isResolved = reader.ReadBoolean();
        string? unresolvedPrimaryItemSpec = reader.ReadOptionalString();

        int primarySourceItemSpecCount = reader.Read7BitEncodedInt();
        var primarySourceItemSpecs = new string[primarySourceItemSpecCount];
        for (int i = 0; i < primarySourceItemSpecCount; i++)
        {
            primarySourceItemSpecs[i] = reader.ReadOptionalString() ?? string.Empty;
        }

        int dependeeCount = reader.Read7BitEncodedInt();
        var dependees = new AssemblyConflictDependee[dependeeCount];
        for (int i = 0; i < dependeeCount; i++)
        {
            dependees[i] = AssemblyConflictDependee.CreateFromStream(reader);
        }

        return new AssemblyConflictReferenceDetails(
            fusionName ?? string.Empty,
            fullPath,
            isPrimary,
            isResolved,
            unresolvedPrimaryItemSpec,
            primarySourceItemSpecs,
            dependees);
    }
}

/// <summary>
/// Formats conflict reference details and complete conflict messages.
/// Both structured conflict event types use this class to produce identical text.
/// </summary>
internal static class AssemblyConflictMessageFormatter
{
    private static readonly ConditionalWeakTable<CultureInfo, MessageFormats> s_formatsByCulture = new();
    private const string FourSpaces = "    ";
    private const string EightSpaces = "        ";
    private const string TenSpaces = "          ";
    private const string TwelveSpaces = "            ";

    internal static string FormatDependencyDetails(
        AssemblyConflictReferenceDetails victor,
        AssemblyConflictReferenceDetails victim) =>
        FormatDependencyDetails(victor, victim, CultureInfo.InvariantCulture);

    internal static string FormatDependencyDetails(
        AssemblyConflictReferenceDetails victor,
        AssemblyConflictReferenceDetails victim,
        CultureInfo culture)
    {
        MessageFormats formats = GetMessageFormats(culture);
        var log = new StringBuilder();
        AppendDependencyDetails(log, victor, victim, formats, culture);
        return log.ToString();
    }

    internal static string FormatWarningMessage(
        string simpleAssemblyName,
        string body) =>
        FormatWarningMessage(simpleAssemblyName, body, CultureInfo.InvariantCulture);

    internal static string FormatWarningMessage(
        string simpleAssemblyName,
        string body,
        CultureInfo culture)
        => Format(culture, GetMessageFormats(culture).FoundConflicts, simpleAssemblyName, body);

    /// <summary>
    /// Formats the conflict header and dependency details without the outer MSB3277 wrapper.
    /// This text matches the legacy warning body.
    /// <c>UnresolvedAssemblyConflicts</c> items use this text for the <c>logMessage</c> metadata.
    /// The warning <see cref="BuildEventArgs.Message"/> adds the outer wrapper.
    /// </summary>
    internal static string FormatWarningBody(
        AssemblyConflictLossReason lossReason,
        AssemblyConflictReferenceDetails victor,
        AssemblyConflictReferenceDetails victim) =>
        FormatWarningBody(lossReason, victor, victim, CultureInfo.InvariantCulture);

    internal static string FormatWarningBody(
        AssemblyConflictLossReason lossReason,
        AssemblyConflictReferenceDetails victor,
        AssemblyConflictReferenceDetails victim,
        CultureInfo culture)
    {
        MessageFormats formats = GetMessageFormats(culture);
        var log = new StringBuilder();
        log.Append(FormatHeaderOnly(victor.FusionName, victim.FusionName, lossReason, victim.IsPrimary, formats, culture));
        log.AppendLine();
        AppendDependencyDetails(log, victor, victim, formats, culture);
        return log.ToString();
    }

    /// <summary>
    /// Formats the conflict header without dependency details.
    /// The result matches the standalone message for a conflict that does not produce a warning.
    /// </summary>
    internal static string FormatHeaderOnly(
        string victorFusionName,
        string victimFusionName,
        AssemblyConflictLossReason lossReason,
        bool victimIsPrimary) =>
        FormatHeaderOnly(victorFusionName, victimFusionName, lossReason, victimIsPrimary, CultureInfo.InvariantCulture);

    internal static string FormatHeaderOnly(
        string victorFusionName,
        string victimFusionName,
        AssemblyConflictLossReason lossReason,
        bool victimIsPrimary,
        CultureInfo culture)
        => FormatHeaderOnly(
            victorFusionName,
            victimFusionName,
            lossReason,
            victimIsPrimary,
            GetMessageFormats(culture),
            culture);

    private static string FormatHeaderOnly(
        string victorFusionName,
        string victimFusionName,
        AssemblyConflictLossReason lossReason,
        bool victimIsPrimary,
        MessageFormats formats,
        CultureInfo culture)
    {
        string header = Format(culture, formats.ConflictFound, victorFusionName, victimFusionName);
        return lossReason switch
        {
            AssemblyConflictLossReason.HadLowerVersion
                => string.Concat(header, Environment.NewLine, FourSpaces, Format(culture, formats.ConflictHigherVersionChosen, victorFusionName)),
            AssemblyConflictLossReason.WasNotPrimary
                => string.Concat(header, Environment.NewLine, FourSpaces, Format(culture, formats.ConflictPrimaryChosen, victorFusionName, victimFusionName)),
            AssemblyConflictLossReason.InsolubleConflict when !victimIsPrimary
                => string.Concat(header, Environment.NewLine, Format(culture, formats.ConflictUnsolvable, victorFusionName, victimFusionName)),
            _ => header,
        };
    }

    private static void AppendDependencyDetails(
        StringBuilder log,
        AssemblyConflictReferenceDetails victor,
        AssemblyConflictReferenceDetails victim,
        MessageFormats formats,
        CultureInfo culture)
    {
        AppendReferenceDetails(log, victor, formats.ReferenceDependsOn, formats, culture);
        log.AppendLine();
        AppendReferenceDetails(log, victim, formats.UnifiedReferenceDependsOn, formats, culture);
    }

    private static void AppendReferenceDetails(
        StringBuilder log,
        AssemblyConflictReferenceDetails details,
        string headerFormat,
        MessageFormats formats,
        CultureInfo culture)
    {
        log.Append(FourSpaces);
        log.Append(Format(culture, headerFormat, details.FusionName, details.FullPath));

        if (details.IsPrimary && !details.IsResolved)
        {
            log.AppendLine().Append(EightSpaces).Append(Format(culture, formats.UnresolvedPrimaryItemSpec, details.UnresolvedPrimaryItemSpec));
        }
        else if (details.IsPrimary && details.IsResolved)
        {
            log.AppendLine().Append(EightSpaces).AppendLine(details.FullPath);
            log.Append(TenSpaces).Append(Format(culture, formats.PrimarySourceItemsForReference, details.FullPath));
            for (int i = 0; i < details.PrimarySourceItemSpecs.Count; i++)
            {
                log.AppendLine().Append(TwelveSpaces).Append(details.PrimarySourceItemSpecs[i]);
            }
        }

        for (int i = 0; i < details.Dependees.Count; i++)
        {
            AssemblyConflictDependee dependee = details.Dependees[i];
            log.AppendLine().Append(EightSpaces).AppendLine(dependee.DependeeFullPath);
            log.Append(TenSpaces).Append(Format(culture, formats.PrimarySourceItemsForReference, dependee.DependeeFullPath));
            for (int j = 0; j < dependee.SourceItemSpecs.Count; j++)
            {
                log.AppendLine().Append(TwelveSpaces).Append(dependee.SourceItemSpecs[j]);
            }
        }
    }

    private static MessageFormats GetMessageFormats(CultureInfo culture)
        => s_formatsByCulture.GetValue(culture, static culture => new MessageFormats(culture));

    private static string Format(CultureInfo culture, string format, object? arg0)
        => MessageFormatter.Format(culture, format, arg0);

    private static string Format(CultureInfo culture, string format, object? arg0, object? arg1)
        => MessageFormatter.Format(culture, format, arg0, arg1);

    internal sealed class LocalizedMessage(CultureInfo culture, string message)
    {
        internal CultureInfo Culture { get; } = culture;
        internal string Message { get; } = message;
    }

    private sealed class MessageFormats
    {
        internal MessageFormats(CultureInfo culture)
        {
            ConflictFound = GetResource("AssemblyConflict_ConflictFound", culture);
            ConflictHigherVersionChosen = GetResource("AssemblyConflict_ConflictHigherVersionChosen", culture);
            ConflictPrimaryChosen = GetResource("AssemblyConflict_ConflictPrimaryChosen", culture);
            ConflictUnsolvable = GetResource("AssemblyConflict_ConflictUnsolvable", culture);
            ReferenceDependsOn = GetResource("AssemblyConflict_ReferenceDependsOn", culture);
            UnifiedReferenceDependsOn = GetResource("AssemblyConflict_UnifiedReferenceDependsOn", culture);
            UnresolvedPrimaryItemSpec = GetResource("AssemblyConflict_UnResolvedPrimaryItemSpec", culture);
            PrimarySourceItemsForReference = GetResource("AssemblyConflict_PrimarySourceItemsForReference", culture);

            string foundConflicts = GetResource("AssemblyConflict_FoundConflicts", culture);
            FoundConflicts = MessageParser.TryStripAnyCode(foundConflicts, out string? strippedMessage)
                ? strippedMessage
                : foundConflicts;
        }

        internal string ConflictFound { get; }
        internal string ConflictHigherVersionChosen { get; }
        internal string ConflictPrimaryChosen { get; }
        internal string ConflictUnsolvable { get; }
        internal string ReferenceDependsOn { get; }
        internal string UnifiedReferenceDependsOn { get; }
        internal string UnresolvedPrimaryItemSpec { get; }
        internal string PrimarySourceItemsForReference { get; }
        internal string FoundConflicts { get; }

        private static string GetResource(string resourceName, CultureInfo culture)
            => Resources.SR.ResourceManager.GetString(resourceName, culture)
                ?? throw new InvalidOperationException($"The resource string '{resourceName}' was not found for culture '{culture.Name}'.");
    }
}

/// <summary>
/// Reports the references and project items that caused an assembly conflict.
/// RAR logs this low-importance event when conflict resolution does not produce a warning.
/// The <see cref="BuildEventArgs.Message"/> is rendered in invariant English from the structured details.
/// </summary>
[Serializable]
public sealed class AssemblyConflictDependencyDetailsMessageEventArgs : BuildMessageEventArgs
{
    private string? _formattedMessage;
    [NonSerialized]
    private AssemblyConflictMessageFormatter.LocalizedMessage? _localizedMessage;

    internal AssemblyConflictDependencyDetailsMessageEventArgs()
    {
    }

    internal AssemblyConflictDependencyDetailsMessageEventArgs(
        AssemblyConflictReferenceDetails victor,
        AssemblyConflictReferenceDetails victim,
        string senderName,
        MessageImportance importance,
        DateTime eventTimestamp)
        : base(message: null, helpKeyword: null, senderName, importance, eventTimestamp)
    {
        Victor = victor;
        Victim = victim;
    }

    /// <summary>
    /// Gets the details of the reference that won the conflict.
    /// </summary>
    public AssemblyConflictReferenceDetails Victor { get; private set; } = null!;

    /// <summary>
    /// Gets the details of the reference that lost the conflict.
    /// </summary>
    public AssemblyConflictReferenceDetails Victim { get; private set; } = null!;

    /// <inheritdoc />
    public override string? Message
    {
        get
        {
            if (_formattedMessage is null && Victor is not null && Victim is not null)
            {
                _formattedMessage = AssemblyConflictMessageFormatter.FormatDependencyDetails(Victor, Victim);
            }

            return _formattedMessage ?? base.Message;
        }
    }

    /// <summary>
    /// Formats the message using resources and argument formatting for the specified culture.
    /// </summary>
    /// <param name="culture">The culture to use when formatting the message.</param>
    /// <returns>The formatted message.</returns>
    /// <exception cref="ArgumentNullException"><paramref name="culture"/> is <see langword="null"/>.</exception>
    public string FormatMessage(CultureInfo culture)
    {
        ArgumentNullException.ThrowIfNull(culture);

        if (culture.Equals(CultureInfo.InvariantCulture))
        {
            return Message ?? string.Empty;
        }

        AssemblyConflictMessageFormatter.LocalizedMessage? localizedMessage = Volatile.Read(ref _localizedMessage);
        if (localizedMessage is not null && localizedMessage.Culture.Equals(culture))
        {
            return localizedMessage.Message;
        }

        string message = AssemblyConflictMessageFormatter.FormatDependencyDetails(Victor, Victim, culture);
        Volatile.Write(ref _localizedMessage, new(culture, message));
        return message;
    }

    internal bool IsMessageMaterialized => _formattedMessage is not null;

    internal override void WriteToStream(BinaryWriter writer)
    {
        // The receiving node reconstructs the message from the structured fields.
        base.WriteToStream(writer);
        Victor.WriteToStream(writer);
        Victim.WriteToStream(writer);
    }

    internal override void CreateFromStream(BinaryReader reader, int version)
    {
        base.CreateFromStream(reader, version);
        Victor = AssemblyConflictReferenceDetails.CreateFromStream(reader);
        Victim = AssemblyConflictReferenceDetails.CreateFromStream(reader);
    }
}

/// <summary>
/// Reports an unresolved assembly version conflict (MSB3277).
/// The structured details identify the victor, the victim, and their dependency chains.
/// The <see cref="BuildEventArgs.Message"/> is rendered in invariant English from the structured details.
/// </summary>
[Serializable]
public sealed class AssemblyConflictWarningEventArgs : BuildWarningEventArgs
{
    private string? _formattedBody;
    private string? _formattedMessage;
    [NonSerialized]
    private AssemblyConflictMessageFormatter.LocalizedMessage? _localizedMessage;

    internal AssemblyConflictWarningEventArgs()
    {
    }

    internal AssemblyConflictWarningEventArgs(
        string simpleAssemblyName,
        AssemblyConflictLossReason lossReason,
        AssemblyConflictReferenceDetails victor,
        AssemblyConflictReferenceDetails victim,
        string code,
        string? file,
        int lineNumber,
        int columnNumber,
        string? helpKeyword,
        string senderName,
        DateTime eventTimestamp,
        string? formattedBody = null)
        : base(
            subcategory: null,
            code: code,
            file: file,
            lineNumber: lineNumber,
            columnNumber: columnNumber,
            endLineNumber: 0,
            endColumnNumber: 0,
            message: null,
            helpKeyword: helpKeyword,
            senderName: senderName,
            eventTimestamp: eventTimestamp)
    {
        SimpleAssemblyName = simpleAssemblyName;
        LossReason = lossReason;
        Victor = victor;
        Victim = victim;
        _formattedBody = formattedBody;
    }

    /// <summary>
    /// Gets the simple (short) name of the assembly for which conflicting versions were found.
    /// </summary>
    public string SimpleAssemblyName { get; private set; } = string.Empty;

    /// <summary>
    /// Gets the reason the victim lost the conflict.
    /// </summary>
    public AssemblyConflictLossReason LossReason { get; private set; }

    /// <summary>
    /// Gets the details of the reference that won the conflict.
    /// </summary>
    public AssemblyConflictReferenceDetails Victor { get; private set; } = null!;

    /// <summary>
    /// Gets the details of the reference that lost the conflict.
    /// </summary>
    public AssemblyConflictReferenceDetails Victim { get; private set; } = null!;

    /// <inheritdoc />
    public override string? Message
    {
        get
        {
            if (_formattedMessage is null && Victor is not null && Victim is not null)
            {
                string body = _formattedBody ?? AssemblyConflictMessageFormatter.FormatWarningBody(LossReason, Victor, Victim);
                _formattedMessage = AssemblyConflictMessageFormatter.FormatWarningMessage(SimpleAssemblyName, body);
                _formattedBody = null;
            }

            return _formattedMessage ?? base.Message;
        }
    }

    /// <summary>
    /// Formats the message using resources and argument formatting for the specified culture.
    /// </summary>
    /// <param name="culture">The culture to use when formatting the message.</param>
    /// <returns>The formatted message.</returns>
    /// <exception cref="ArgumentNullException"><paramref name="culture"/> is <see langword="null"/>.</exception>
    public string FormatMessage(CultureInfo culture)
    {
        ArgumentNullException.ThrowIfNull(culture);

        if (culture.Equals(CultureInfo.InvariantCulture))
        {
            return Message ?? string.Empty;
        }

        AssemblyConflictMessageFormatter.LocalizedMessage? localizedMessage = Volatile.Read(ref _localizedMessage);
        if (localizedMessage is not null && localizedMessage.Culture.Equals(culture))
        {
            return localizedMessage.Message;
        }

        string body = AssemblyConflictMessageFormatter.FormatWarningBody(LossReason, Victor, Victim, culture);
        string message = AssemblyConflictMessageFormatter.FormatWarningMessage(SimpleAssemblyName, body, culture);
        Volatile.Write(ref _localizedMessage, new(culture, message));
        return message;
    }

    internal bool IsMessageMaterialized => _formattedMessage is not null;

    internal override void WriteToStream(BinaryWriter writer)
    {
        // The receiving node reconstructs the message from the structured fields.
        base.WriteToStream(writer);
        writer.Write(SimpleAssemblyName);
        writer.Write7BitEncodedInt((int)LossReason);
        Victor.WriteToStream(writer);
        Victim.WriteToStream(writer);
    }

    internal override void CreateFromStream(BinaryReader reader, int version)
    {
        base.CreateFromStream(reader, version);
        SimpleAssemblyName = reader.ReadString();
        LossReason = (AssemblyConflictLossReason)reader.Read7BitEncodedInt();
        Victor = AssemblyConflictReferenceDetails.CreateFromStream(reader);
        Victim = AssemblyConflictReferenceDetails.CreateFromStream(reader);
    }
}