Skip to content

sln-add: Refactor logic for autogenerating solution folders #48726

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
May 5, 2025
Merged
166 changes: 97 additions & 69 deletions src/Cli/dotnet/Commands/Solution/Add/SolutionAddCommand.cs
Original file line number Diff line number Diff line change
@@ -15,12 +15,11 @@ namespace Microsoft.DotNet.Cli.Commands.Solution.Add;

internal class SolutionAddCommand : CommandBase
{
private static readonly string[] _defaultPlatforms = ["Any CPU", "x64", "x86"];
private static readonly string[] _defaultBuildTypes = ["Debug", "Release"];
private readonly string _fileOrDirectory;
private readonly bool _inRoot;
private readonly IReadOnlyCollection<string> _projects;
private readonly string? _solutionFolderPath;
private string _solutionFileFullPath = string.Empty;

private static string GetSolutionFolderPathWithForwardSlashes(string path)
{
@@ -29,13 +28,21 @@ private static string GetSolutionFolderPathWithForwardSlashes(string path)
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);
SolutionArgumentValidator.ParseAndValidateArguments(_fileOrDirectory, _projects, SolutionArgumentValidator.CommandType.Add, _inRoot, _solutionFolderPath);
_solutionFileFullPath = SlnFileFactory.GetSolutionFileFullPath(_fileOrDirectory);
}

public override int Execute()
@@ -44,115 +51,135 @@ public override int Execute()
{
throw new GracefulException(CliStrings.SpecifyAtLeastOneProjectToAdd);
}
string solutionFileFullPath = SlnFileFactory.GetSolutionFileFullPath(_fileOrDirectory);

try
// Get project paths from the command line arguments
PathUtility.EnsureAllPathsExist(_projects, CliStrings.CouldNotFindProjectOrDirectory, true);

IEnumerable<string> fullProjectPaths = _projects.Select(project =>
{
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;
});
AddProjectsToSolutionAsync(solutionFileFullPath, fullProjectPaths, CancellationToken.None).GetAwaiter().GetResult();
return 0;
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;
}
catch (Exception ex) when (ex is not GracefulException)

string relativeSolutionFolderPath = string.Empty;

if (string.IsNullOrEmpty(_solutionFolderPath))
{
// Generate the solution folder path based on the project path
relativeSolutionFolderPath = Path.GetDirectoryName(relativeProjectPath);

// 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)]);
Copy link
Preview

Copilot AI May 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Using a collection expression directly within Path.Combine may reduce code clarity. Consider extracting the computed array into a separate variable before passing it to Path.Combine.

Suggested change
relativeSolutionFolderPath = Path.Combine([.. relativeSolutionFolderPath.Split(Path.DirectorySeparatorChar).SkipLast(1)]);
var parentDirectories = relativeSolutionFolderPath.Split(Path.DirectorySeparatorChar).SkipLast(1).ToArray();
relativeSolutionFolderPath = Path.Combine(parentDirectories);

Copilot uses AI. Check for mistakes.

}

// If the generated path is outside the solution directory, we need to set it to empty
if (!IsSolutionFolderPathInDirectoryScope(relativeSolutionFolderPath))
{
if (ex is SolutionException || ex.InnerException is SolutionException)
{
throw new GracefulException(CliStrings.InvalidSolutionFormatString, solutionFileFullPath, ex.Message);
}
throw new GracefulException(ex.Message, ex);
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(string solutionFileFullPath, IEnumerable<string> projectPaths, CancellationToken cancellationToken)
private async Task AddProjectsToSolutionAsync(IEnumerable<string> projectPaths, CancellationToken cancellationToken)
{
SolutionModel solution = SlnFileFactory.CreateFromFileOrDirectory(solutionFileFullPath);
SolutionModel solution = SlnFileFactory.CreateFromFileOrDirectory(_solutionFileFullPath);
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 _defaultPlatforms)
foreach (var platform in SlnFileFactory.DefaultPlatforms)
{
solution.AddPlatform(platform);
}
foreach (var buildType in _defaultBuildTypes)

foreach (var buildType in SlnFileFactory.DefaultBuildTypes)
{
solution.AddBuildType(buildType);
}
}

SolutionFolderModel? solutionFolder = !_inRoot && !string.IsNullOrEmpty(_solutionFolderPath)
? solution.AddFolder(GetSolutionFolderPathWithForwardSlashes(_solutionFolderPath))
: null;

foreach (var projectPath in projectPaths)
{
string relativePath = Path.GetRelativePath(Path.GetDirectoryName(solutionFileFullPath), projectPath);
// Add fallback solution folder if relative path does not contain `..`.
string relativeSolutionFolder = relativePath.Split(Path.DirectorySeparatorChar).Any(p => p == "..")
? string.Empty : Path.GetDirectoryName(relativePath);

if (!_inRoot && solutionFolder is null && !string.IsNullOrEmpty(relativeSolutionFolder))
{
if (relativeSolutionFolder.Split(Path.DirectorySeparatorChar).LastOrDefault() == Path.GetFileNameWithoutExtension(relativePath))
{
relativeSolutionFolder = Path.Combine([.. relativeSolutionFolder.Split(Path.DirectorySeparatorChar).SkipLast(1)]);
}
if (!string.IsNullOrEmpty(relativeSolutionFolder))
{
solutionFolder = solution.AddFolder(GetSolutionFolderPathWithForwardSlashes(relativeSolutionFolder));
}
}

try
{
AddProject(solution, relativePath, projectPath, solutionFolder, serializer);
}
catch (InvalidProjectFileException ex)
{
Reporter.Error.WriteLine(string.Format(CliStrings.InvalidProjectWithExceptionMessage, projectPath, ex.Message));
}
catch (SolutionArgumentException ex) when (solution.FindProject(relativePath) != null || ex.Type == SolutionErrorType.DuplicateProjectName)
{
Reporter.Output.WriteLine(CliStrings.SolutionAlreadyContainsProject, solutionFileFullPath, relativePath);
}
AddProject(solution, projectPath, serializer);
}
await serializer.SaveAsync(solutionFileFullPath, solution, cancellationToken);

await serializer.SaveAsync(_solutionFileFullPath, solution, cancellationToken);
}

private static void AddProject(SolutionModel solution, string solutionRelativeProjectPath, string fullPath, SolutionFolderModel? solutionFolder, ISolutionSerializer serializer = null)
private void AddProject(SolutionModel solution, string fullProjectPath, ISolutionSerializer serializer = null)
{
string solutionRelativeProjectPath = Path.GetRelativePath(Path.GetDirectoryName(_solutionFileFullPath), fullProjectPath);
Copy link
Preview

Copilot AI May 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The variable name 'solutionRelativeProjectPath' is used in multiple scopes with similar contexts. Renaming one occurrence (or scoping it more tightly) may improve code clarity and reduce potential confusion.

Suggested change
string solutionRelativeProjectPath = Path.GetRelativePath(Path.GetDirectoryName(_solutionFileFullPath), fullProjectPath);
string relativeProjectPath = Path.GetRelativePath(Path.GetDirectoryName(_solutionFileFullPath), fullProjectPath);

Copilot uses AI. Check for mistakes.


// Open project instance to see if it is a valid project
ProjectRootElement projectRootElement = ProjectRootElement.Open(fullPath);
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);

SolutionProjectModel project;

try
{
project = solution.AddProject(solutionRelativeProjectPath, null, solutionFolder);
project = solution.AddProject(solutionRelativeProjectPath, projectTypeGuid, solutionFolder);
}
catch (SolutionArgumentException ex) when (ex.ParamName == "projectTypeName")
catch (SolutionArgumentException ex) when (ex.Type == SolutionErrorType.InvalidProjectTypeReference)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

== is seldom best except for primitives

{
// If guid is not identified by vs-solutionpersistence, check in project element itself
var guid = projectRootElement.GetProjectTypeGuid() ?? projectInstance.GetDefaultProjectTypeGuid();
if (string.IsNullOrEmpty(guid))
{
Reporter.Error.WriteLine(CliStrings.UnsupportedProjectType, fullPath);
return;
}
project = solution.AddProject(solutionRelativeProjectPath, guid, solutionFolder);
Reporter.Error.WriteLine(CliStrings.UnsupportedProjectType, fullProjectPath);
return;
}
catch (SolutionArgumentException ex) when (ex.Type == SolutionErrorType.DuplicateProjectName || solution.FindProject(solutionRelativeProjectPath) is not null)
{
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);
@@ -164,7 +191,7 @@ private static void AddProject(SolutionModel solution, string solutionRelativePr
foreach (var solutionPlatform in solution.Platforms)
{
var projectPlatform = projectInstancePlatforms.FirstOrDefault(
platform => platform.Replace(" ", string.Empty) == solutionPlatform.Replace(" ", string.Empty), projectInstancePlatforms.FirstOrDefault());
platform => platform.Replace(" ", string.Empty) == solutionPlatform.Replace(" ", string.Empty), projectInstancePlatforms.FirstOrDefault());
project.AddProjectConfigurationRule(new ConfigurationRule(BuildDimension.Platform, "*", solutionPlatform, projectPlatform));
}

@@ -174,6 +201,7 @@ private static void AddProject(SolutionModel solution, string solutionRelativePr
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);
}
}
20 changes: 15 additions & 5 deletions src/Cli/dotnet/SlnFileFactory.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// 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.Text.Json;
using Microsoft.DotNet.Cli.Utils;
using Microsoft.VisualStudio.SolutionPersistence;
@@ -11,6 +12,9 @@ namespace Microsoft.DotNet.Cli;

public static class SlnFileFactory
{
public static readonly string[] DefaultPlatforms = new[] { "Any CPU", "x64", "x86" };
public static readonly string[] DefaultBuildTypes = new[] { "Debug", "Release" };

public static string GetSolutionFileFullPath(string slnFileOrDirectory, bool includeSolutionFilterFiles = false, bool includeSolutionXmlFiles = true)
{
// Throw error if slnFileOrDirectory is an invalid path
@@ -63,11 +67,17 @@ public static SolutionModel CreateFromFileOrDirectory(string fileOrDirectory, bo
{
return CreateFromFilteredSolutionFile(solutionPath);
}
ISolutionSerializer serializer = SolutionSerializers.GetSerializerByMoniker(solutionPath) ?? throw new GracefulException(
CliStrings.CouldNotFindSolutionOrDirectory,
solutionPath);

return serializer.OpenAsync(solutionPath, CancellationToken.None).Result;
try
{
ISolutionSerializer serializer = SolutionSerializers.GetSerializerByMoniker(solutionPath)!;
return serializer.OpenAsync(solutionPath, CancellationToken.None).Result;
}
catch (Exception ex)
Copy link
Preview

Copilot AI May 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch block in CreateFromFileOrDirectory rethrows a new GracefulException using only ex.Message, which can lose the original stack trace. Consider including the exception object itself (e.g. passing 'ex' instead of 'ex.Message') to aid in debugging.

Copilot uses AI. Check for mistakes.

{
throw new GracefulException(
CliStrings.InvalidSolutionFormatString,
solutionPath, ex.Message);
}
}

public static SolutionModel CreateFromFilteredSolutionFile(string filteredSolutionPath)
Loading
Oops, something went wrong.