File: Commands\New\MSBuildEvaluation\ProjectCapabilityConstraint.cs
Web Access
Project: src\src\sdk\src\Cli\dotnet\dotnet.csproj (dotnet)
// 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.Evaluation;
using Microsoft.Extensions.Logging;
using Microsoft.TemplateEngine.Abstractions;
using Microsoft.TemplateEngine.Abstractions.Constraints;
using Microsoft.TemplateEngine.Cli.Commands;
using System.Text.Json;
using System.Text.Json.Nodes;
using MSBuildProject = Microsoft.Build.Evaluation.Project;

namespace Microsoft.DotNet.Cli.Commands.New.MSBuildEvaluation;

internal class ProjectCapabilityConstraintFactory : ITemplateConstraintFactory
{
    public string Type => "project-capability";

    public Guid Id => Guid.Parse("{85F3EFFB-315F-4F2F-AA0A-99EABE3B6FB3}");

    public Task<ITemplateConstraint> CreateTemplateConstraintAsync(IEngineEnvironmentSettings environmentSettings, CancellationToken cancellationToken)
    {
        MSBuildEvaluator? evaluator = environmentSettings.Components.OfType<MSBuildEvaluator>().FirstOrDefault();

        if (evaluator == null)
        {
            environmentSettings.Host.Logger.LogDebug("{0}: '{1}' component is not available.", nameof(ProjectCapabilityConstraintFactory), nameof(MSBuildEvaluator));
            throw new Exception(string.Format(CliCommandStrings.ProjectCapabilityConstraintFactory_Exception_NoEvaluator, Type, nameof(MSBuildEvaluator)));
        }

        try
        {
            MSBuildEvaluationResult evaluationResult = evaluator.EvaluateProject(environmentSettings);
            return Task.FromResult((ITemplateConstraint)new ProjectCapabilityConstraint(environmentSettings, this, evaluationResult));
        }
        catch (Exception e)
        {
            environmentSettings.Host.Logger.LogDebug("{0}: Failed to evaluate the project: {1}.", nameof(ProjectCapabilityConstraintFactory), e.Message);
            throw new Exception(string.Format(CliCommandStrings.ProjectCapabilityConstraintFactory_Exception_EvaluationFailed, Type, e.Message), e);
        }

    }

    internal class ProjectCapabilityConstraint : ITemplateConstraint
    {
        private readonly IEngineEnvironmentSettings _environmentSettings;
        private readonly ITemplateConstraintFactory _factory;
        private readonly MSBuildEvaluationResult _evaluationResult;
        private readonly IReadOnlyList<string> _projectCapabilities;
        private readonly ILogger _logger;

        internal ProjectCapabilityConstraint(IEngineEnvironmentSettings environmentSettings, ITemplateConstraintFactory factory, MSBuildEvaluationResult evaluationResult)
        {
            _environmentSettings = environmentSettings;
            _logger = _environmentSettings.Host.LoggerFactory.CreateLogger(nameof(MSBuildEvaluator));
            _factory = factory;
            _evaluationResult = evaluationResult;
            _projectCapabilities = GetProjectCapabilities(evaluationResult);
        }

        public string Type => _factory.Type;

        public string DisplayName => CliCommandStrings.ProjectCapabilityConstraint_DisplayName;

        public TemplateConstraintResult Evaluate(string? args)
        {
            if (string.IsNullOrWhiteSpace(args))
            {
                throw new ArgumentException($"{nameof(args)} cannot be null or whitespace.", nameof(args));
            }

            _logger.LogDebug("Configuration: '{0}'", args);
            JsonNode? token;
            try
            {
                token = JsonNode.Parse(args!);
            }
            catch (Exception e)
            {
                _logger.LogDebug("Failed to parse configuration: '{0}', reason: {1}", args, e.Message);
                throw new Exception($"{CliCommandStrings.ProjectCapabilityConstraint_Error_InvalidConstraintConfiguration}:{CliCommandStrings.ProjectCapabilityConstraint_Error_InvalidJson}.", e);
            }

            string configuredCapabiltiesExpression;

            if (token is JsonValue v && v.GetValueKind() == JsonValueKind.String)
            {
                string? configuredCapability = token.GetValue<string>();
                if (string.IsNullOrWhiteSpace(configuredCapability))
                {
                    _logger.LogDebug("Invalid configuration: '{0}', reason: arguments should not contain empty values.", args);
                    throw new Exception($"{CliCommandStrings.ProjectCapabilityConstraint_Error_InvalidConstraintConfiguration}: {CliCommandStrings.ProjectCapabilityConstraint_Error_ArgumentShouldNotBeEmpty}.");
                }
                configuredCapabiltiesExpression = configuredCapability;
            }
            else
            {
                _logger.LogDebug("Invalid configuration: '{0}', reason: argument should be a string.", args);
                throw new Exception($"{CliCommandStrings.ProjectCapabilityConstraint_Error_InvalidConstraintConfiguration}: {CliCommandStrings.ProjectCapabilityConstraint_Error_ArgumentShouldBeString}.");
            }

            if (_evaluationResult.Status == MSBuildEvaluationResult.EvalStatus.NoProjectFound)
            {
                _logger.LogDebug("No project found. This template can only be created inside the project.");
                return TemplateConstraintResult.CreateRestricted(
                    this,
                    _evaluationResult.ErrorMessage ?? CliCommandStrings.MSBuildEvaluationResult_Error_NoProjectFound,
                    CliCommandStrings.ProjectCapabilityConstraint_Restricted_NoProjectFound_CTA);
            }
            if (_evaluationResult.Status == MSBuildEvaluationResult.EvalStatus.MultipleProjectFound)
            {
                string foundProjects = string.Join("; ", (_evaluationResult as MultipleProjectsEvaluationResult)?.ProjectPaths ?? [_evaluationResult.ProjectPath]);
                _logger.LogDebug("Multiple projects found: {0}, specify the project to use.", foundProjects);
                return TemplateConstraintResult.CreateRestricted(
                    this,
                    _evaluationResult.ErrorMessage ?? string.Format(CliCommandStrings.MultipleProjectsEvaluationResult_Error, foundProjects),
                    string.Format(CliCommandStrings.ProjectCapabilityConstraint_Restricted_MultipleProjectsFound_CTA, SharedOptionsFactory.ProjectOptionName));
            }
            if (_evaluationResult.Status == MSBuildEvaluationResult.EvalStatus.NoRestore)
            {
                _logger.LogDebug("The project is not restored. Run 'dotnet restore {0}' to restore the project.", _evaluationResult.ProjectPath);
                return TemplateConstraintResult.CreateRestricted(
                    this,
                    _evaluationResult.ErrorMessage ?? string.Format(CliCommandStrings.MSBuildEvaluationResult_Error_NotRestored, _evaluationResult.ProjectPath),
                    string.Format(CliCommandStrings.ProjectCapabilityConstraint_Restricted_NotRestored_CTA, _evaluationResult.ProjectPath));
            }
            if (_evaluationResult.Status == MSBuildEvaluationResult.EvalStatus.Failed || _evaluationResult.Status == MSBuildEvaluationResult.EvalStatus.NotEvaluated || _evaluationResult.EvaluatedProject == null)
            {
                _logger.LogDebug("Failed to evaluate project context: {0}", _evaluationResult.ErrorMessage);
                return TemplateConstraintResult.CreateRestricted(this, string.Format(CliCommandStrings.ProjectCapabilityConstraint_Restricted_EvaluationFailed_Message, _evaluationResult.ErrorMessage));
            }
            if (_evaluationResult is NonSDKStyleEvaluationResult)
            {
                _logger.LogDebug("The project {0} is not an SDK style project, and is not supported for evaluation.", _evaluationResult.ProjectPath);
                return TemplateConstraintResult.CreateRestricted(this, string.Format(CliCommandStrings.ProjectCapabilityConstraint_Restricted_NonSDKStyle_Message, _evaluationResult.ProjectPath));
            }

            try
            {
                _logger.LogDebug("Evaluating '{0}' on '{1}' set.", configuredCapabiltiesExpression, string.Join(", ", _projectCapabilities));
                if (!CapabilityExpressionEvaluator.Evaluate(configuredCapabiltiesExpression, _projectCapabilities))
                {
                    _logger.LogDebug("Expression evaluated to 'false'.");
                    return TemplateConstraintResult.CreateRestricted(
                        this,
                        string.Format(CliCommandStrings.ProjectCapabilityConstraint_Restricted_Message, configuredCapabiltiesExpression, _evaluationResult.ProjectPath));
                }
                _logger.LogDebug("Expression evaluated to 'true'.");
                return TemplateConstraintResult.CreateAllowed(this);
            }
            catch (ArgumentException ae)
            {
                _logger.LogDebug("Invalid expression '{0}'.", configuredCapabiltiesExpression);
                throw new Exception($"{CliCommandStrings.ProjectCapabilityConstraint_Error_InvalidConstraintConfiguration}:{ae.Message}.", ae);
            }
        }

        private static IReadOnlyList<string> GetProjectCapabilities(MSBuildEvaluationResult result)
        {
            HashSet<string> capabilities = new(StringComparer.OrdinalIgnoreCase);
            AddProjectCapabilities(capabilities, result.EvaluatedProject);

            //in case of multi-target project, consider project capabilities for all target frameworks
            if (result is MultiTargetEvaluationResult multiTargetResult)
            {
                foreach (MSBuildProject? tfmBasedEvaluation in multiTargetResult.EvaluatedProjects.Values)
                {
                    AddProjectCapabilities(capabilities, tfmBasedEvaluation);
                }
            }
            return [.. capabilities];

            static void AddProjectCapabilities(HashSet<string> collection, MSBuildProject? evaluatedProject)
            {
                if (evaluatedProject == null)
                {
                    return;
                }
                foreach (ProjectItem capability in evaluatedProject.GetItems("ProjectCapability"))
                {
                    if (!string.IsNullOrWhiteSpace(capability.EvaluatedInclude))
                    {
                        collection.Add(capability.EvaluatedInclude);
                    }
                }
            }
        }
    }
}