File: Commands\Solution\Add\SolutionAddCommand.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 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 sealed class SolutionAddCommand : CommandBase<SolutionAddCommandDefinition>
{
    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(Definition.Parent.SlnArgument)!;
        _projects = (IReadOnlyCollection<string>)(parseResult.GetValue(Definition.ProjectPathArgument) ?? []);
        _inRoot = parseResult.GetValue(Definition.InRootOption);
        _solutionFolderPath = parseResult.GetValue(Definition.SolutionFolderOption);
        _includeReferences = parseResult.GetValue(Definition.IncludeReferencesOption);
        SolutionArgumentValidator.ParseAndValidateArguments(_fileOrDirectory, _projects, SolutionArgumentValidator.CommandType.Add, _inRoot, _solutionFolderPath);
        _solutionFileFullPath = SlnFileFactory.GetSolutionFileFullPath(_fileOrDirectory, includeSolutionFilterFiles: true);
    }

    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);

        List<string> fullProjectPaths = _projects.Select(project =>
        {
            var fullPath = Path.GetFullPath(project);
            return Directory.Exists(fullPath) ? MsbuildProject.GetProjectFileFromDirectory(fullPath) : fullPath;
        }).ToList();

        // Check if we're working with a solution filter file
        if (_solutionFileFullPath.HasExtension(SlnfFileHelper.SlnfExtension))
        {
            AddProjectsToSolutionFilter(fullProjectPaths);
        }
        else
        {
            // 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;
        }

        if (string.IsNullOrEmpty(relativeSolutionFolderPath))
        {
            return null;
        }

        // Check if a solution folder with this path already exists
        // Solution folder paths should be unique, so use SingleOrDefault
        var solutionFolderPath = GetSolutionFolderPathWithForwardSlashes(relativeSolutionFolderPath);
        var existingFolder = solution.SolutionFolders.SingleOrDefault(f => f.Path == solutionFolderPath);
        
        return existingFolder ?? solution.AddFolder(solutionFolderPath);
    }

    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);
            }
        }
    }

    private void AddProjectsToSolutionFilter(IEnumerable<string> projectPaths)
    {
        // Solution filter files don't support --in-root or --solution-folder options
        if (_inRoot || !string.IsNullOrEmpty(_solutionFolderPath))
        {
            throw new GracefulException(CliCommandStrings.SolutionFilterDoesNotSupportFolderOptions);
        }

        // Load the filtered solution to get the parent solution path and existing projects
        SolutionModel filteredSolution = SlnFileFactory.CreateFromFilteredSolutionFile(_solutionFileFullPath);
        string parentSolutionPath = filteredSolution.Description!; // The parent solution path is stored in Description

        // Load the parent solution to validate projects exist in it
        SolutionModel parentSolution = SlnFileFactory.CreateFromFileOrDirectory(parentSolutionPath);

        // Get existing projects in the filter (already normalized to OS separator by CreateFromFilteredSolutionFile)
        var existingProjects = filteredSolution.SolutionProjects.Select(p => p.FilePath).ToHashSet();

        // Get solution-relative paths for new projects
        var newProjects = ValidateAndGetNewProjects(projectPaths, parentSolution, parentSolutionPath, existingProjects);

        // Add new projects to the existing list and save
        var allProjects = existingProjects.Concat(newProjects).OrderBy(p => p);
        SlnfFileHelper.SaveSolutionFilter(_solutionFileFullPath, parentSolutionPath, allProjects);
    }

    private List<string> ValidateAndGetNewProjects(
        IEnumerable<string> projectPaths,
        SolutionModel parentSolution,
        string parentSolutionPath,
        HashSet<string> existingProjects)
    {
        var newProjects = new List<string>();
        string parentSolutionDirectory = Path.GetDirectoryName(parentSolutionPath) ?? string.Empty;
        
        foreach (var projectPath in projectPaths)
        {
            string parentSolutionRelativePath = Path.GetRelativePath(parentSolutionDirectory, projectPath);

            // Normalize to OS separator for consistent comparison
            parentSolutionRelativePath = SlnfFileHelper.NormalizePathSeparatorsToOS(parentSolutionRelativePath);

            // Check if project exists in parent solution
            var projectInParent = parentSolution.FindProject(parentSolutionRelativePath);
            if (projectInParent is null)
            {
                Reporter.Error.WriteLine(CliStrings.ProjectNotFoundInTheSolution, parentSolutionRelativePath, parentSolutionPath);
                continue;
            }

            // Check if project is already in the filter
            if (existingProjects.Contains(parentSolutionRelativePath))
            {
                Reporter.Output.WriteLine(CliStrings.SolutionAlreadyContainsProject, _solutionFileFullPath, parentSolutionRelativePath);
                continue;
            }

            newProjects.Add(parentSolutionRelativePath);
            Reporter.Output.WriteLine(CliStrings.ProjectAddedToTheSolution, parentSolutionRelativePath);
        }

        return newProjects;
    }
}