Skip to content

Commit 681138b

Browse files
authored
Make dotnet project convert interactive (#49660)
1 parent ad9e0a7 commit 681138b

23 files changed

+789
-85
lines changed

src/Cli/dotnet/Commands/CliCommandStrings.resx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1501,6 +1501,37 @@ Tool '{1}' (version '{2}') was successfully installed. Entry is added to the man
15011501
<data name="ProjectConvertAppFullName" xml:space="preserve">
15021502
<value>Convert a file-based program to a project-based program.</value>
15031503
</data>
1504+
<data name="ProjectConvertAskForOutputDirectory" xml:space="preserve">
1505+
<value>Specify the output directory ({0}):</value>
1506+
<comment>{0} is the default value</comment>
1507+
</data>
1508+
<data name="ProjectConvertDryRun" xml:space="preserve">
1509+
<value>Determines changes without actually modifying the file system</value>
1510+
</data>
1511+
<data name="ProjectConvertWouldCreateDirectory" xml:space="preserve">
1512+
<value>Dry run: would create directory: {0}</value>
1513+
<comment>{0} is the directory full path.</comment>
1514+
</data>
1515+
<data name="ProjectConvertWouldCopyFile" xml:space="preserve">
1516+
<value>Dry run: would copy file '{0}' to '{1}'.</value>
1517+
<comment>{0} and {1} are file full paths.</comment>
1518+
</data>
1519+
<data name="ProjectConvertWouldConvertFile" xml:space="preserve">
1520+
<value>Dry run: would remove file-level directives from file: {0}</value>
1521+
<comment>{0} is the file full path.</comment>
1522+
</data>
1523+
<data name="ProjectConvertWouldMoveFile" xml:space="preserve">
1524+
<value>Dry run: would move file '{0}' to '{1}'.</value>
1525+
<comment>{0} and {1} are file full paths.</comment>
1526+
</data>
1527+
<data name="ProjectConvertWouldDeleteFile" xml:space="preserve">
1528+
<value>Dry run: would delete file: {0}</value>
1529+
<comment>{0} is the file full path.</comment>
1530+
</data>
1531+
<data name="ProjectConvertWouldCreateFile" xml:space="preserve">
1532+
<value>Dry run: would create file: {0}</value>
1533+
<comment>{0} is the file full path.</comment>
1534+
</data>
15041535
<data name="ProjectManifest" xml:space="preserve">
15051536
<value>PROJECT_MANIFEST</value>
15061537
</data>

src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs

Lines changed: 89 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.CommandLine;
5+
using System.Diagnostics.CodeAnalysis;
56
using Microsoft.Build.Evaluation;
67
using Microsoft.DotNet.Cli.Commands.Run;
78
using Microsoft.DotNet.Cli.Utils;
@@ -17,17 +18,14 @@ internal sealed class ProjectConvertCommand(ParseResult parseResult) : CommandBa
1718

1819
public override int Execute()
1920
{
21+
// Check the entry point file path.
2022
string file = Path.GetFullPath(_file);
2123
if (!VirtualProjectBuildingCommand.IsValidEntryPointPath(file))
2224
{
2325
throw new GracefulException(CliCommandStrings.InvalidFilePath, file);
2426
}
2527

26-
string targetDirectory = _outputDirectory ?? Path.ChangeExtension(file, null);
27-
if (Directory.Exists(targetDirectory))
28-
{
29-
throw new GracefulException(CliCommandStrings.DirectoryAlreadyExists, targetDirectory);
30-
}
28+
string targetDirectory = DetermineOutputDirectory(file);
3129

3230
// Find directives (this can fail, so do this before creating the target directory).
3331
var sourceFile = VirtualProjectBuildingCommand.LoadSourceFile(file);
@@ -36,28 +34,37 @@ public override int Execute()
3634
// Find other items to copy over, e.g., default Content items like JSON files in Web apps.
3735
var includeItems = FindIncludedItems().ToList();
3836

39-
Directory.CreateDirectory(targetDirectory);
37+
bool dryRun = _parseResult.GetValue(ProjectConvertCommandParser.DryRunOption);
38+
39+
CreateDirectory(targetDirectory);
4040

4141
var targetFile = Path.Join(targetDirectory, Path.GetFileName(file));
4242

43-
// If there were any directives, remove them from the file.
44-
if (directives.Length != 0)
43+
// Process the entry point file.
44+
if (dryRun)
4545
{
46-
VirtualProjectBuildingCommand.RemoveDirectivesFromFile(directives, sourceFile.Text, targetFile);
47-
File.Delete(file);
46+
Reporter.Output.WriteLine(CliCommandStrings.ProjectConvertWouldCopyFile, file, targetFile);
47+
Reporter.Output.WriteLine(CliCommandStrings.ProjectConvertWouldConvertFile, targetFile);
4848
}
4949
else
5050
{
51-
File.Move(file, targetFile);
51+
VirtualProjectBuildingCommand.RemoveDirectivesFromFile(directives, sourceFile.Text, targetFile);
5252
}
5353

5454
// Create project file.
5555
string projectFile = Path.Join(targetDirectory, Path.GetFileNameWithoutExtension(file) + ".csproj");
56-
using var stream = File.Open(projectFile, FileMode.Create, FileAccess.Write);
57-
using var writer = new StreamWriter(stream, Encoding.UTF8);
58-
VirtualProjectBuildingCommand.WriteProjectFile(writer, directives, isVirtualProject: false);
56+
if (dryRun)
57+
{
58+
Reporter.Output.WriteLine(CliCommandStrings.ProjectConvertWouldCreateFile, projectFile);
59+
}
60+
else
61+
{
62+
using var stream = File.Open(projectFile, FileMode.Create, FileAccess.Write);
63+
using var writer = new StreamWriter(stream, Encoding.UTF8);
64+
VirtualProjectBuildingCommand.WriteProjectFile(writer, directives, isVirtualProject: false);
65+
}
5966

60-
// Copy over included items.
67+
// Copy or move over included items.
6168
foreach (var item in includeItems)
6269
{
6370
string targetItemFullPath = Path.Combine(targetDirectory, item.RelativePath);
@@ -69,12 +76,39 @@ public override int Execute()
6976
}
7077

7178
string targetItemDirectory = Path.GetDirectoryName(targetItemFullPath)!;
72-
Directory.CreateDirectory(targetItemDirectory);
73-
File.Copy(item.FullPath, targetItemFullPath);
79+
CreateDirectory(targetItemDirectory);
80+
CopyFile(item.FullPath, targetItemFullPath);
7481
}
7582

7683
return 0;
7784

85+
void CreateDirectory(string path)
86+
{
87+
if (dryRun)
88+
{
89+
if (!Directory.Exists(path))
90+
{
91+
Reporter.Output.WriteLine(CliCommandStrings.ProjectConvertWouldCreateDirectory, path);
92+
}
93+
}
94+
else
95+
{
96+
Directory.CreateDirectory(path);
97+
}
98+
}
99+
100+
void CopyFile(string source, string target)
101+
{
102+
if (dryRun)
103+
{
104+
Reporter.Output.WriteLine(CliCommandStrings.ProjectConvertWouldCopyFile, source, target);
105+
}
106+
else
107+
{
108+
File.Copy(source, target);
109+
}
110+
}
111+
78112
IEnumerable<(string FullPath, string RelativePath)> FindIncludedItems()
79113
{
80114
string entryPointFileDirectory = PathUtility.EnsureTrailingSlash(Path.GetDirectoryName(file)!);
@@ -118,4 +152,42 @@ public override int Execute()
118152
}
119153
}
120154
}
155+
156+
private string DetermineOutputDirectory(string file)
157+
{
158+
string defaultValue = Path.ChangeExtension(file, null);
159+
string defaultValueRelative = Path.GetRelativePath(relativeTo: Environment.CurrentDirectory, defaultValue);
160+
string targetDirectory = _outputDirectory
161+
?? TryAskForOutputDirectory(defaultValueRelative)
162+
?? defaultValue;
163+
if (Directory.Exists(targetDirectory))
164+
{
165+
throw new GracefulException(CliCommandStrings.DirectoryAlreadyExists, targetDirectory);
166+
}
167+
168+
return targetDirectory;
169+
}
170+
171+
private string? TryAskForOutputDirectory(string defaultValueRelative)
172+
{
173+
return InteractiveConsole.Ask<string?>(
174+
string.Format(CliCommandStrings.ProjectConvertAskForOutputDirectory, defaultValueRelative),
175+
_parseResult,
176+
(path, out result, [NotNullWhen(returnValue: false)] out error) =>
177+
{
178+
if (Directory.Exists(path))
179+
{
180+
result = null;
181+
error = string.Format(CliCommandStrings.DirectoryAlreadyExists, Path.GetFullPath(path));
182+
return false;
183+
}
184+
185+
result = path is null ? null : Path.GetFullPath(path);
186+
error = null;
187+
return true;
188+
},
189+
out var result)
190+
? result
191+
: null;
192+
}
121193
}

src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommandParser.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,21 @@ internal sealed class ProjectConvertCommandParser
2020
Arity = ArgumentArity.Zero,
2121
};
2222

23+
public static readonly Option<bool> DryRunOption = new("--dry-run")
24+
{
25+
Description = CliCommandStrings.ProjectConvertDryRun,
26+
Arity = ArgumentArity.Zero,
27+
};
28+
2329
public static Command GetCommand()
2430
{
2531
Command command = new("convert", CliCommandStrings.ProjectConvertAppFullName)
2632
{
2733
FileArgument,
2834
SharedOptions.OutputOption,
2935
ForceOption,
36+
CommonOptions.InteractiveOption(),
37+
DryRunOption,
3038
};
3139

3240
command.SetAction((parseResult) => new ProjectConvertCommand(parseResult).Execute());

src/Cli/dotnet/Commands/Tool/Execute/ToolExecuteCommand.cs

Lines changed: 1 addition & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ internal class ToolExecuteCommand(ParseResult result, ToolManifestFinder? toolMa
3030
private readonly string[] _addSource = result.GetValue(ToolExecuteCommandParser.AddSourceOption) ?? [];
3131
private readonly bool _interactive = result.GetValue(ToolExecuteCommandParser.InteractiveOption);
3232
private readonly VerbosityOptions _verbosity = result.GetValue(ToolExecuteCommandParser.VerbosityOption);
33-
private readonly bool _yes = result.GetValue(ToolExecuteCommandParser.YesOption);
3433
private readonly IToolPackageDownloader _toolPackageDownloader = ToolPackageFactory.CreateToolPackageStoresAndDownloader().downloader;
3534

3635
private readonly RestoreActionConfig _restoreActionConfig = new RestoreActionConfig(DisableParallel: result.GetValue(ToolCommandRestorePassThroughOptions.DisableParallelOption),
@@ -128,47 +127,7 @@ public override int Execute()
128127

129128
private bool UserAgreedToRunFromSource(PackageId packageId, NuGetVersion version, PackageSource source)
130129
{
131-
if (_yes)
132-
{
133-
return true;
134-
}
135-
136-
if (!_interactive)
137-
{
138-
return false;
139-
}
140-
141130
string promptMessage = string.Format(CliCommandStrings.ToolDownloadConfirmationPrompt, packageId, version.ToString(), source.Source);
142-
143-
static string AddPromptOptions(string message)
144-
{
145-
return $"{message} [{CliCommandStrings.ConfirmationPromptYesValue}/{CliCommandStrings.ConfirmationPromptNoValue}] ({CliCommandStrings.ConfirmationPromptYesValue}): ";
146-
}
147-
148-
Console.Write(AddPromptOptions(promptMessage));
149-
150-
static bool KeyMatches(ConsoleKeyInfo pressedKey, string valueKey)
151-
{
152-
// Apparently you can't do invariant case insensitive comparison on a char directly, so we have to convert it to a string.
153-
// The resource string should be a single character, but we take the first character just to be sure.
154-
return pressedKey.KeyChar.ToString().ToLowerInvariant().Equals(
155-
valueKey.ToLowerInvariant().Substring(0, 1));
156-
}
157-
158-
while (true)
159-
{
160-
var key = Console.ReadKey();
161-
Console.WriteLine();
162-
if (key.Key == ConsoleKey.Enter || KeyMatches(key, CliCommandStrings.ConfirmationPromptYesValue))
163-
{
164-
return true;
165-
}
166-
if (key.Key == ConsoleKey.Escape || KeyMatches(key, CliCommandStrings.ConfirmationPromptNoValue))
167-
{
168-
return false;
169-
}
170-
171-
Console.Write(AddPromptOptions(string.Format(CliCommandStrings.ConfirmationPromptInvalidChoiceMessage, CliCommandStrings.ConfirmationPromptYesValue, CliCommandStrings.ConfirmationPromptNoValue)));
172-
}
131+
return InteractiveConsole.Confirm(promptMessage, _parseResult, acceptEscapeForFalse: true) == true;
173132
}
174133
}

src/Cli/dotnet/Commands/xlf/CliCommandStrings.cs.xlf

Lines changed: 40 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Cli/dotnet/Commands/xlf/CliCommandStrings.de.xlf

Lines changed: 40 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)