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

using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using NuGet;
using NuGet.Frameworks;
using NuGet.Packaging;
using NuGet.Packaging.Core;
using NuGet.Packaging.Licenses;
using NuGet.Versioning;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.Versioning;
using System.Text;
using System.Xml.Linq;

namespace Microsoft.DotNet.Build.Tasks.Packaging;

public class GenerateNuSpec : Microsoft.Build.Utilities.Task
{
    private static readonly XNamespace NuSpecXmlNamespace = @"http://schemas.microsoft.com/packaging/2013/01/nuspec.xsd";

    public string InputFileName { get; set; }

    [Required]
    public string OutputFileName { get; set; }

    public string MinClientVersion { get; set; }

    [Required]
    public string Id { get; set; }

    [Required]
    public string Version { get; set; }

    [Required]
    public string Title { get; set; }

    [Required]
    public string Authors { get; set; }

    [Required]
    public string Owners { get; set; }

    [Required]
    public string Description { get; set; }

    public string ReleaseNotes { get; set; }

    public string Summary { get; set; }

    public string Language { get; set; }

    public string ProjectUrl { get; set; }

    public string IconUrl { get; set; }

    public string Icon { get; set; }

    public string LicenseUrl { get; set; }

    public string PackageLicenseExpression { get; set; }

    public string RepositoryType { get; set; }

    public string RepositoryUrl { get; set; }

    public string RepositoryBranch { get; set; }

    public string RepositoryCommit { get; set; }

    public string Copyright { get; set; }

    public bool RequireLicenseAcceptance { get; set; }

    public bool DevelopmentDependency { get; set; }

    public bool Serviceable { get; set; }

    public string Tags { get; set; }

    public string[] PackageTypes { get; set; }
    public ITaskItem[] Dependencies { get; set; }

    public ITaskItem[] References { get; set; }

    public ITaskItem[] FrameworkReferences { get; set; }

    public ITaskItem[] Files { get; set; }

    public override bool Execute()
    {
        try
        {
            WriteNuSpecFile();
        }
        catch (Exception ex)
        {
            Log.LogError(ex.ToString());
            Log.LogErrorFromException(ex);
        }

        return !Log.HasLoggedErrors;
    }

    private void WriteNuSpecFile()
    {
        var manifest = CreateManifest();

        if (!IsDifferent(manifest))
        {
            Log.LogMessage("Skipping generation of .nuspec because contents are identical.");
            return;
        }

        var directory = Path.GetDirectoryName(OutputFileName);
        if (!String.IsNullOrEmpty(directory) && !Directory.Exists(directory))
        {
            Directory.CreateDirectory(directory);
        }

        using (var file = File.Create(OutputFileName))
        {
            Save(manifest, file);
        }
    }

    private bool IsDifferent(Manifest newManifest)
    {
        if (!File.Exists(OutputFileName))
            return true;

        // Note: don't use ReadAllText here, because it gets rid of the BOM
        // that is present at the beginning of the file.
        var oldSource = Encoding.UTF8.GetString(File.ReadAllBytes(OutputFileName));
        var newSource = "";
        using (var stream = new MemoryStream())
        {
            Save(newManifest, stream);
            stream.Seek(0, SeekOrigin.Begin);
            // UTF8.GetString preserves BOM.
            newSource = Encoding.UTF8.GetString(stream.ToArray());
        }

        return oldSource != newSource;
    }

    private void Save(Manifest manifest, Stream stream)
    {
        if (!string.IsNullOrEmpty(PackageLicenseExpression) && string.IsNullOrEmpty(LicenseUrl))
        {
            // nuget issue: https://github.com/NuGet/Home/issues/7894
            // remove licenseUrl that NuGet added from the expression.  It will still add the licenseUrl when packing, which won't break validation.
            using (var memStream = new MemoryStream())
            {
                manifest.Save(memStream);
                memStream.Seek(0, SeekOrigin.Begin);

                var nuspec = XDocument.Load(memStream);

                var licenseUrlElement = nuspec.Descendants(NuSpecXmlNamespace + "licenseUrl").Single();
                licenseUrlElement?.Remove();
                nuspec.Save(stream);
            }
        }
        else
        {
            manifest.Save(stream);
        }
    }

    private Manifest CreateManifest()
    {
        Manifest manifest;
        ManifestMetadata manifestMetadata;
        if (!string.IsNullOrEmpty(InputFileName))
        {
            using (var stream = File.OpenRead(InputFileName))
            {
                manifest = Manifest.ReadFrom(stream, false);
            }
            if (manifest.Metadata == null)
            {
                manifest = new Manifest(new ManifestMetadata(), manifest.Files);
            }
        }
        else
        {
            manifest = new Manifest(new ManifestMetadata());
        }


        manifestMetadata = manifest.Metadata;

        manifestMetadata.UpdateMember(x => x.Authors, Authors?.Split(';'));
        manifestMetadata.UpdateMember(x => x.Copyright, Copyright);
        manifestMetadata.UpdateMember(x => x.DependencyGroups, GetDependencySets());
        manifestMetadata.UpdateMember(x => x.Description, Description);
        manifestMetadata.DevelopmentDependency |= DevelopmentDependency;
        manifestMetadata.UpdateMember(x => x.FrameworkReferences, GetFrameworkAssemblies());
        if (!string.IsNullOrEmpty(IconUrl))
        {
            manifestMetadata.SetIconUrl(IconUrl);
        }
        if (!string.IsNullOrEmpty(Icon))
        {
            manifestMetadata.Icon = Icon;
        }
        manifestMetadata.UpdateMember(x => x.Id, Id);
        manifestMetadata.UpdateMember(x => x.Language, Language);

        if (!string.IsNullOrEmpty(PackageLicenseExpression))
        {
            manifestMetadata.LicenseMetadata = new LicenseMetadata(
                type: LicenseType.Expression,
                license: PackageLicenseExpression,
                expression: NuGetLicenseExpression.Parse(PackageLicenseExpression),
                warningsAndErrors: null,
                LicenseMetadata.EmptyVersion);

        }
        else if (!string.IsNullOrEmpty(LicenseUrl))
        {
            manifestMetadata.SetLicenseUrl(LicenseUrl);
        }

        manifestMetadata.Repository = new RepositoryMetadata(RepositoryType ?? "", RepositoryUrl ?? "", RepositoryBranch ?? "", RepositoryCommit ?? "");

        manifestMetadata.UpdateMember(x => x.MinClientVersionString, MinClientVersion);
        manifestMetadata.UpdateMember(x => x.Owners, Owners?.Split(';'));
        if (!string.IsNullOrEmpty(ProjectUrl))
        {
            manifestMetadata.SetProjectUrl(ProjectUrl);
        }
        manifestMetadata.UpdateMember(x => x.PackageAssemblyReferences, GetReferenceSets());
        manifestMetadata.UpdateMember(x => x.ReleaseNotes, ReleaseNotes);
        manifestMetadata.RequireLicenseAcceptance |= RequireLicenseAcceptance;
        manifestMetadata.UpdateMember(x => x.Summary, Summary);
        manifestMetadata.UpdateMember(x => x.Tags, Tags);
        manifestMetadata.UpdateMember(x => x.Title, Title);
        manifestMetadata.UpdateMember(x => x.Version, Version != null ? new NuGetVersion(Version) : null);
        manifestMetadata.UpdateMember(x => x.PackageTypes, GetPackageTypes());
        manifestMetadata.Serviceable |= Serviceable;

        manifest.AddRangeToMember(x => x.Files, GetManifestFiles());

        return manifest;
    }

    private List<ManifestFile> GetManifestFiles()
    {
        IEnumerable<ManifestFile> manifestFiles =
            from f in Files.NullAsEmpty()
            where !f.GetMetadata(Metadata.FileTarget).StartsWith("$none$", StringComparison.OrdinalIgnoreCase)
            select new ManifestFile()
            {
                Source = f.GetMetadata(Metadata.FileSource),
                // Pattern matching in PathResolver requires that we standardize to OS specific directory separator characters
                Target = f.GetMetadata(Metadata.FileTarget).Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar),
                Exclude = f.GetMetadata(Metadata.FileExclude)
            };

        return manifestFiles.OrderBy(f => f.Target, StringComparer.OrdinalIgnoreCase).ToList();
    }


    static FrameworkAssemblyReferenceComparer frameworkAssemblyReferenceComparer = new FrameworkAssemblyReferenceComparer();
    private List<FrameworkAssemblyReference> GetFrameworkAssemblies()
    {
        return (from fr in FrameworkReferences.NullAsEmpty()
                orderby fr.ItemSpec, StringComparer.Ordinal
                select new FrameworkAssemblyReference(fr.ItemSpec, new[] { fr.GetTargetFramework() })
                ).Distinct(frameworkAssemblyReferenceComparer).ToList();
    }

    private class FrameworkAssemblyReferenceComparer : EqualityComparer<FrameworkAssemblyReference>
    {
        public override bool Equals(FrameworkAssemblyReference x, FrameworkAssemblyReference y)
        {
            return Object.Equals(x, y) ||
                (   x != null && y != null &&
                    x.AssemblyName.Equals(y.AssemblyName) &&
                    x.SupportedFrameworks.SequenceEqual(y.SupportedFrameworks, NuGetFramework.Comparer)
                );
        }

        public override int GetHashCode(FrameworkAssemblyReference obj)
        {
            return obj.AssemblyName.GetHashCode();
        }
    }

    private List<PackageDependencyGroup> GetDependencySets()
    {
        var dependencies = from d in Dependencies.NullAsEmpty()
                           select new Dependency
                           {
                               Id = d.ItemSpec,
                               Version = d.GetVersion(),
                               TargetFramework = d.GetTargetFramework() ?? NuGetFramework.AnyFramework,
                               Include = d.GetValueList("Include"),
                               Exclude = d.GetValueList("Exclude")
                           };

        return (from dependency in dependencies
                group dependency by dependency.TargetFramework into dependenciesByFramework
                select new PackageDependencyGroup(
                    dependenciesByFramework.Key,
                    from dependency in dependenciesByFramework
                                    where dependency.Id != "_._"
                                    orderby dependency.Id, StringComparer.Ordinal
                                    group dependency by dependency.Id into dependenciesById
                                    select new PackageDependency(
                                        dependenciesById.Key,
                                        VersionRange.Parse(
                                            dependenciesById.Select(x => x.Version)
                                            .Aggregate(AggregateVersions)
                                            .ToStringSafe()),
                                        dependenciesById.Select(x => x.Include).Aggregate(AggregateInclude),
                                        dependenciesById.Select(x => x.Exclude).Aggregate(AggregateExclude)


                ))).OrderBy(s => s?.TargetFramework?.GetShortFolderName(), StringComparer.Ordinal)
                .ToList();
    }

    private IEnumerable<PackageReferenceSet> GetReferenceSets()
    {
        var references = from r in References.NullAsEmpty()
                         select new
                         {
                             File = r.ItemSpec,
                             TargetFramework = r.GetTargetFramework(),
                         };

        return (from reference in references
                group reference by reference.TargetFramework into referencesByFramework
                select new PackageReferenceSet(
                    referencesByFramework.Key,
                    from reference in referencesByFramework
                                   orderby reference.File, StringComparer.Ordinal
                                   select reference.File
                                   )
                ).ToList();
    }

    private List<PackageType> GetPackageTypes()
    {
        var listOfPackageTypes = new List<PackageType>();

        // Copied and slightly modified from ParsePackageTypes():
        // https://github.com/NuGet/NuGet.Client/blob/50af5271b98ac5cb2896a707569bc4cd1e87a017/src/NuGet.Core/NuGet.Build.Tasks.Pack/PackTaskLogic.cs#L338

        foreach (var packageType in PackageTypes.TrimAndExcludeNullOrEmpty())
        {
            string[] packageTypeSplitInPart = packageType.Split(new char[] { ',' });
            string packageTypeName = packageTypeSplitInPart[0].Trim();
            var version = PackageType.EmptyVersion;
            if (packageTypeSplitInPart.Length > 1)
            {
                string versionString = packageTypeSplitInPart[1];
                System.Version.TryParse(versionString, out version);
            }
            listOfPackageTypes.Add(new PackageType(packageTypeName, version));
        }

        return listOfPackageTypes;
    }
    private static VersionRange AggregateVersions(VersionRange aggregate, VersionRange next)
    {
        var versionRange = new VersionRange();
        SetMinVersion(ref versionRange, aggregate);
        SetMinVersion(ref versionRange, next);
        SetMaxVersion(ref versionRange, aggregate);
        SetMaxVersion(ref versionRange, next);

        if (versionRange.MinVersion == null && versionRange.MaxVersion == null)
        {
            versionRange = null;
        }

        return versionRange;
    }

    private static IReadOnlyList<string> AggregateInclude(IReadOnlyList<string> aggregate, IReadOnlyList<string> next)
    {
        // include is a union
        if (aggregate == null)
        {
            return next;
        }

        if (next == null)
        {
            return aggregate;
        }

        return aggregate.Union(next).ToArray();
    }

    private static IReadOnlyList<string> AggregateExclude(IReadOnlyList<string> aggregate, IReadOnlyList<string> next)
    {
        // exclude is an intersection
        if (aggregate == null || next == null)
        {
            return null;
        }

        return aggregate.Intersect(next).ToArray();
    }

    private static void SetMinVersion(ref VersionRange target, VersionRange source)
    {
        if (source == null || source.MinVersion == null)
        {
            return;
        }

        bool update = false;
        NuGetVersion minVersion = target.MinVersion;
        bool includeMinVersion = target.IsMinInclusive;

        if (target.MinVersion == null)
        {
            update = true;
            minVersion = source.MinVersion;
            includeMinVersion = source.IsMinInclusive;
        }

        if (target.MinVersion < source.MinVersion)
        {
            update = true;
            minVersion = source.MinVersion;
            includeMinVersion = source.IsMinInclusive;
        }

        if (target.MinVersion == source.MinVersion)
        {
            update = true;
            includeMinVersion = target.IsMinInclusive && source.IsMinInclusive;
        }

        if (update)
        {
            target = new VersionRange(minVersion, includeMinVersion, target.MaxVersion, target.IsMaxInclusive, target.Float, target.OriginalString);
        }
    }

    private static void SetMaxVersion(ref VersionRange target, VersionRange source)
    {
        if (source == null || source.MaxVersion == null)
        {
            return;
        }

        bool update = false;
        NuGetVersion maxVersion = target.MaxVersion;
        bool includeMaxVersion = target.IsMaxInclusive;

        if (target.MaxVersion == null)
        {
            update = true;
            maxVersion = source.MaxVersion;
            includeMaxVersion = source.IsMaxInclusive;
        }

        if (target.MaxVersion > source.MaxVersion)
        {
            update = true;
            maxVersion = source.MaxVersion;
            includeMaxVersion = source.IsMaxInclusive;
        }

        if (target.MaxVersion == source.MaxVersion)
        {
            update = true;
            includeMaxVersion = target.IsMaxInclusive && source.IsMaxInclusive;
        }

        if (update)
        {
            target = new VersionRange(target.MinVersion, target.IsMinInclusive, maxVersion, includeMaxVersion, target.Float, target.OriginalString);
        }
    }

    private class Dependency
    {
        public string Id { get; set; }

        public NuGetFramework TargetFramework { get; set; }

        public VersionRange Version { get; set; }

        public IReadOnlyList<string> Exclude { get; set; }

        public IReadOnlyList<string> Include { get; set; }
    }
}