File: Commands\Solution\Add\SolutionAddCommand.cs
Web Access
Project: ..\..\..\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 System.CommandLine;
using System.Diagnostics;
using Microsoft.Build.Construction;
using Microsoft.Build.Exceptions;
using Microsoft.Build.Execution;
using Microsoft.DotNet.Cli.Extensions;
using Microsoft.DotNet.Cli.Utils;
using Microsoft.VisualStudio.SolutionPersistence;
using Microsoft.VisualStudio.SolutionPersistence.Model;
using Microsoft.VisualStudio.SolutionPersistence.Serializer.SlnV12;
 
namespace Microsoft.DotNet.Cli.Commands.Solution.Add;
 
internal class SolutionAddCommand : CommandBase
{
    private readonly string _fileOrDirectory;
    private readonly bool _inRoot;
    private readonly IReadOnlyCollection<string> _projects;
    private readonly string? _solutionFolderPath;
    private string _solutionFileFullPath = string.Empty;
    private bool _includeReferences;
 
    private static string GetSolutionFolderPathWithForwardSlashes(string path)
    {
        // SolutionModel::AddFolder expects paths to have leading, trailing and inner forward slashes
        // https://github.com/microsoft/vs-solutionpersistence/blob/87ee8ea069662d55c336a9bd68fe4851d0384fa5/src/Microsoft.VisualStudio.SolutionPersistence/Model/SolutionModel.cs#L171C1-L172C1
        return "/" + string.Join("/", PathUtility.GetPathWithDirectorySeparator(path).Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)) + "/";
    }
 
    private static bool IsSolutionFolderPathInDirectoryScope(string relativePath)
    {
        return !string.IsNullOrWhiteSpace(relativePath)
            && !Path.IsPathRooted(relativePath) // This means path is in a different volume
            && !relativePath.StartsWith(".."); // This means path is outside the solution directory
    }
 
    public SolutionAddCommand(ParseResult parseResult) : base(parseResult)
    {
        _fileOrDirectory = parseResult.GetValue(SolutionCommandParser.SlnArgument)!;
        _projects = (IReadOnlyCollection<string>)(parseResult.GetValue(SolutionAddCommandParser.ProjectPathArgument) ?? []);
        _inRoot = parseResult.GetValue(SolutionAddCommandParser.InRootOption);
        _solutionFolderPath = parseResult.GetValue(SolutionAddCommandParser.SolutionFolderOption);
        _includeReferences = parseResult.GetValue(SolutionAddCommandParser.IncludeReferencesOption);
        SolutionArgumentValidator.ParseAndValidateArguments(_fileOrDirectory, _projects, SolutionArgumentValidator.CommandType.Add, _inRoot, _solutionFolderPath);
        _solutionFileFullPath = SlnFileFactory.GetSolutionFileFullPath(_fileOrDirectory);
    }
 
    public override int Execute()
    {
        if (_projects.Count == 0)
        {
            throw new GracefulException(CliStrings.SpecifyAtLeastOneProjectToAdd);
        }
 
        // Get project paths from the command line arguments
        PathUtility.EnsureAllPathsExist(_projects, CliStrings.CouldNotFindProjectOrDirectory, true);
 
        IEnumerable<string> fullProjectPaths = _projects.Select(project =>
        {
            var fullPath = Path.GetFullPath(project);
            return Directory.Exists(fullPath) ? MsbuildProject.GetProjectFileFromDirectory(fullPath).FullName : fullPath;
        });
 
        // Add projects to the solution
        AddProjectsToSolutionAsync(fullProjectPaths, CancellationToken.None).GetAwaiter().GetResult();
        return 0;
    }
 
    private SolutionFolderModel? GenerateIntermediateSolutionFoldersForProjectPath(SolutionModel solution, string relativeProjectPath)
    {
        if (_inRoot)
        {
            return null;
        }
 
        string? relativeSolutionFolderPath = string.Empty;
 
        if (string.IsNullOrEmpty(_solutionFolderPath))
        {
            // Generate the solution folder path based on the project path
            relativeSolutionFolderPath = Path.GetDirectoryName(relativeProjectPath);
            Debug.Assert(relativeSolutionFolderPath is not null);
 
            // If the project is in a folder with the same name as the project, we need to go up one level
            if (relativeSolutionFolderPath.Split(Path.DirectorySeparatorChar).LastOrDefault() == Path.GetFileNameWithoutExtension(relativeProjectPath))
            {
                relativeSolutionFolderPath = Path.Combine([.. relativeSolutionFolderPath.Split(Path.DirectorySeparatorChar).SkipLast(1)]);
            }
 
            // If the generated path is outside the solution directory, we need to set it to empty
            if (!IsSolutionFolderPathInDirectoryScope(relativeSolutionFolderPath))
            {
                relativeSolutionFolderPath = string.Empty;
            }
        }
        else
        {
            // Use the provided solution folder path
            relativeSolutionFolderPath = _solutionFolderPath;
        }
 
        return string.IsNullOrEmpty(relativeSolutionFolderPath)
            ? null
            : solution.AddFolder(GetSolutionFolderPathWithForwardSlashes(relativeSolutionFolderPath));
    }
 
    private async Task AddProjectsToSolutionAsync(IEnumerable<string> projectPaths, CancellationToken cancellationToken)
    {
        SolutionModel solution = SlnFileFactory.CreateFromFileOrDirectory(_solutionFileFullPath);
        Debug.Assert(solution.SerializerExtension is not null);
        ISolutionSerializer serializer = solution.SerializerExtension.Serializer;
 
        // set UTF8 BOM encoding for .sln
        if (serializer is ISolutionSerializer<SlnV12SerializerSettings> v12Serializer)
        {
            solution.SerializerExtension = v12Serializer.CreateModelExtension(new()
            {
                Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)
            });
 
            // Set default configurations and platforms for sln file
            foreach (var platform in SlnFileFactory.DefaultPlatforms)
            {
                solution.AddPlatform(platform);
            }
 
            foreach (var buildType in SlnFileFactory.DefaultBuildTypes)
            {
                solution.AddBuildType(buildType);
            }
        }
 
        foreach (var projectPath in projectPaths)
        {
            AddProject(solution, projectPath, serializer);
        }
 
        await serializer.SaveAsync(_solutionFileFullPath, solution, cancellationToken);
    }
 
    private void AddProject(SolutionModel solution, string fullProjectPath, ISolutionSerializer? serializer = null, bool showMessageOnDuplicate = true)
    {
        string solutionRelativeProjectPath = Path.GetRelativePath(Path.GetDirectoryName(_solutionFileFullPath)!, fullProjectPath);
 
        // Open project instance to see if it is a valid project
        ProjectRootElement projectRootElement;
        try
        {
            projectRootElement = ProjectRootElement.Open(fullProjectPath);
        }
        catch (InvalidProjectFileException ex)
        {
            Reporter.Error.WriteLine(string.Format(CliStrings.InvalidProjectWithExceptionMessage, fullProjectPath, ex.Message));
            return;
        }
 
        ProjectInstance projectInstance = new ProjectInstance(projectRootElement);
 
        string projectTypeGuid = solution.ProjectTypes.FirstOrDefault(t => t.Extension == Path.GetExtension(fullProjectPath))?.ProjectTypeId.ToString()
            ?? projectRootElement.GetProjectTypeGuid() ?? projectInstance.GetDefaultProjectTypeGuid();
 
        // Generate the solution folder path based on the project path
        SolutionFolderModel? solutionFolder = GenerateIntermediateSolutionFoldersForProjectPath(solution, solutionRelativeProjectPath);
 
        // Check if a project with the same filename already exists in the solution folder
        string projectFileName = Path.GetFileName(solutionRelativeProjectPath);
        if (solutionFolder != null)
        {
            var rootFolder = solutionFolder;
            while (rootFolder.Parent is SolutionFolderModel parentFolder)
            {
                rootFolder = parentFolder;
            }
 
            var existingProjectWithSameName = solution.SolutionProjects.FirstOrDefault(
                p => IsInSameFolderHierarchy(p.Parent, rootFolder) && Path.GetFileName(p.FilePath).Equals(projectFileName, StringComparison.OrdinalIgnoreCase));
 
            if (existingProjectWithSameName != null)
            {
                throw new GracefulException(string.Format(CliStrings.SolutionFolderAlreadyContainsProjectWithFilename, rootFolder.Name, projectFileName));
            }
        }
 
        static bool IsInSameFolderHierarchy(SolutionItemModel? projectParent, SolutionFolderModel rootFolder)
        {
            var current = projectParent;
            while (current != null)
            {
                if (current == rootFolder)
                    return true;
                current = current.Parent;
            }
            return false;
        }
 
        SolutionProjectModel project;
 
        try
        {
            project = solution.AddProject(solutionRelativeProjectPath, projectTypeGuid, solutionFolder);
        }
        catch (SolutionArgumentException ex) when (ex.Type == SolutionErrorType.InvalidProjectTypeReference)
        {
            Reporter.Error.WriteLine(CliStrings.UnsupportedProjectType, fullProjectPath);
            return;
        }
        catch (SolutionArgumentException ex) when (ex.Type == SolutionErrorType.DuplicateProjectName || solution.FindProject(solutionRelativeProjectPath) is not null)
        {
            if (showMessageOnDuplicate)
            {
                Reporter.Output.WriteLine(CliStrings.SolutionAlreadyContainsProject, _solutionFileFullPath, solutionRelativeProjectPath);
            }
            return;
        }
 
        // Add settings based on existing project instance
        string projectInstanceId = projectInstance.GetProjectId();
 
        if (!string.IsNullOrEmpty(projectInstanceId) && serializer is ISolutionSerializer<SlnV12SerializerSettings>)
        {
            project.Id = new Guid(projectInstanceId);
        }
 
        var projectInstanceBuildTypes = projectInstance.GetConfigurations();
        var projectInstancePlatforms = projectInstance.GetPlatforms();
 
        foreach (var solutionPlatform in solution.Platforms)
        {
            var projectPlatform = projectInstancePlatforms.FirstOrDefault(
                platform => platform.Replace(" ", string.Empty) == solutionPlatform.Replace(" ", string.Empty), projectInstancePlatforms.FirstOrDefault()!);
            project.AddProjectConfigurationRule(new ConfigurationRule(BuildDimension.Platform, "*", solutionPlatform, projectPlatform));
        }
 
        foreach (var solutionBuildType in solution.BuildTypes)
        {
            var projectBuildType = projectInstanceBuildTypes.FirstOrDefault(
                buildType => buildType.Replace(" ", string.Empty) == solutionBuildType.Replace(" ", string.Empty), projectInstanceBuildTypes.FirstOrDefault()!);
            project.AddProjectConfigurationRule(new ConfigurationRule(BuildDimension.BuildType, solutionBuildType, "*", projectBuildType));
        }
 
        Reporter.Output.WriteLine(CliStrings.ProjectAddedToTheSolution, solutionRelativeProjectPath);
 
        // Get referencedprojects from the project instance
        var referencedProjectsFullPaths = projectInstance.GetItems("ProjectReference")
            .Select(item => Path.GetFullPath(item.EvaluatedInclude, Path.GetDirectoryName(fullProjectPath)!));
 
        if (_includeReferences)
        {
            foreach (var referencedProjectFullPath in referencedProjectsFullPaths)
            {
                AddProject(solution, referencedProjectFullPath, serializer, showMessageOnDuplicate: false);
            }
        }
    }
}