4 Commits
Author SHA1 Message Date
qwsdcvghyu89 207805bad3 Improve project file version handling and error recovery
Refactored the publish workflow to use a more robust error handling and cleanup mechanism with a RestoreActions list. The version increment logic now resets lower version components appropriately based on the increment target. ProjectFile now sets both Version and PackageVersion properties to ensure consistency. Improved process output handling and destination processing feedback.
2025-09-27 17:19:30 +10:00
qwsdcvghyu89 6904e0cfd8 Improve process handling and async support in publish tool
Refactored process execution to use async methods and cancellation tokens for better responsiveness, especially when handling 'dotnet pack' and 'dotnet nuget push' commands. Updated .csproj to use C# preview language features and added 'bin/' to .gitignore.
2025-09-27 16:26:12 +10:00
qwsdcvghyu89 093da5d0f3 Improve error reporting for process failures
Captures and displays standard error output when 'dotnet pack' or 'dotnet nuget push' commands fail, providing more detailed error information to the user.
2025-09-27 16:15:30 +10:00
qwsdcvghyu89 c6570c1e2c Improve error handling for dotnet pack failures
Enhanced the error message for 'dotnet pack' failures to include the exit code. Updated the resource string and its usage to provide more informative feedback. Added cleanup of the temporary directory on cancellation and improved progress bar visibility.
2025-09-27 16:11:58 +10:00
6 changed files with 435 additions and 298 deletions
+1
View File
@@ -0,0 +1 @@
bin/
+1 -1
View File
@@ -96,7 +96,7 @@ namespace aeqw89.tools.Publish {
} }
/// <summary> /// <summary>
/// Looks up a localized string similar to Failed to pack; ensure that &apos;dotnet build&apos; succeeds before running this program.. /// Looks up a localized string similar to Failed to pack with exit code &apos;{0}&apos;; ensure that &apos;dotnet build&apos; succeeds before running this program..
/// </summary> /// </summary>
internal static string dotnet_pack_failure { internal static string dotnet_pack_failure {
get { get {
+1 -1
View File
@@ -58,7 +58,7 @@
<value>The project file '{0}' is irreparable becuase it is missing a '{1}' property, and the value cannot be guessed.</value> <value>The project file '{0}' is irreparable becuase it is missing a '{1}' property, and the value cannot be guessed.</value>
</data> </data>
<data name="dotnet_pack_failure" xml:space="preserve"> <data name="dotnet_pack_failure" xml:space="preserve">
<value>Failed to pack; ensure that 'dotnet build' succeeds before running this program.</value> <value>Failed to pack with exit code '{0}'; ensure that 'dotnet build' succeeds before running this program.</value>
</data> </data>
<data name="failed_to_clean_up" xml:space="preserve"> <data name="failed_to_clean_up" xml:space="preserve">
<value>Could not delete temporary directory '{0}' due to error '{1}'</value> <value>Could not delete temporary directory '{0}' due to error '{1}'</value>
+177 -46
View File
@@ -1,5 +1,6 @@
using System.Collections; using System.Collections;
using System.Diagnostics; using System.Diagnostics;
using System.Text;
using Renci.SshNet; using Renci.SshNet;
using Spectre.Console; using Spectre.Console;
using aeqw89.xml.ProjectFile; using aeqw89.xml.ProjectFile;
@@ -26,6 +27,8 @@ public static class Program {
public static Dictionary<string, string[]> Flags { get; set; } public static Dictionary<string, string[]> Flags { get; set; }
public static bool Verbose { get; set; } = false; public static bool Verbose { get; set; } = false;
public static List<Action> RestoreActions { get; set; } = [];
public static void ReadArgs(string[] args) { public static void ReadArgs(string[] args) {
if (args.Length < 1) { if (args.Length < 1) {
ShowError(Exceptions.missing_mode.EscapeMarkup()); ShowError(Exceptions.missing_mode.EscapeMarkup());
@@ -108,9 +111,15 @@ public static class Program {
public static async Task Main(string[] args) { public static async Task Main(string[] args) {
ReadArgs(args); ReadArgs(args);
Console.CancelKeyPress += (sender, eventArgs) => {
RestoreActions.ForEach(x => x());
};
string packageId = ""; string packageId = "";
string version = ""; string version = "";
int destinationsProcessed = 0;
try {
var result = AnsiConsole.Status() var result = AnsiConsole.Status()
.Spinner(Spinner.Known.Dots) .Spinner(Spinner.Known.Dots)
.Start<bool>("Preparing project", ctx => { .Start<bool>("Preparing project", ctx => {
@@ -124,10 +133,11 @@ public static class Program {
try { try {
projectFile.Backup(); projectFile.Backup();
Console.CancelKeyPress += (sender, eventArgs) => { RestoreActions.Add(() => {
projectFile.Restore(); projectFile.Restore();
AnsiConsole.MarkupLine("[yellow]Restored project file from backup.[/]"); AnsiConsole.MarkupLine("[yellow]Restored project file from backup.[/]");
}; });
if (Verbose) if (Verbose)
AnsiConsole.WriteLine( AnsiConsole.WriteLine(
@@ -153,7 +163,8 @@ public static class Program {
} }
if (!int.TryParse(deltaStrings[0], out delta)) { if (!int.TryParse(deltaStrings[0], out delta)) {
ShowError(Exceptions.flag_parameter_type_incorrect.EscapeMarkup(), "--delta", 0, nameof(Int32), ShowError(Exceptions.flag_parameter_type_incorrect.EscapeMarkup(), "--delta", 0,
nameof(Int32),
deltaStrings[0]); deltaStrings[0]);
projectFile.Restore(); projectFile.Restore();
ShowHelp(); ShowHelp();
@@ -163,18 +174,14 @@ public static class Program {
ctx.Status = "Updating version"; ctx.Status = "Updating version";
var version = projectFile.GetVersion(); var version = projectFile.GetVersion();
version = ChangeVersion(version, version = ChangeVersion(version, delta, Target ?? IncrementTarget.Patch);
Target == IncrementTarget.Patch ? delta : -1,
Target == IncrementTarget.Minor ? delta : -1,
Target == IncrementTarget.Major ? delta : -1,
(x, y) => y < 0 ? x : x + y);
projectFile.SetVersion(version); projectFile.SetVersion(version);
} }
} }
catch (Exception e) { catch (Exception e) {
ShowError(Exceptions.generic_error.EscapeMarkup(), e.ToString().EscapeMarkup()); ShowError(Exceptions.generic_error.EscapeMarkup(), e.ToString().EscapeMarkup());
projectFile.Restore(); RestoreActions.ForEach(x => x());
return false; return false;
} }
@@ -195,21 +202,23 @@ public static class Program {
visited.Add(reference.Include); visited.Add(reference.Include);
if (Verbose) if (Verbose)
AnsiConsole.WriteLine($"Processing project reference {reference.Include} out of {visited.Count} so far"); AnsiConsole.WriteLine(
$"Processing project reference {reference.Include} out of {visited.Count} so far");
projectFile.SetPrivateAssets(reference, PrivateAssetsValue.All); projectFile.SetPrivateAssets(reference, PrivateAssetsValue.All);
string pathToReferencedProjectFile = projectFile.GetAbsoluteIncludePath(reference); string pathToReferencedProjectFile = projectFile.GetAbsoluteIncludePath(reference);
if (!ProjectFile.TryLoad(pathToReferencedProjectFile, out var referencedProjectFile, if (!ProjectFile.TryLoad(pathToReferencedProjectFile, out var referencedProjectFile,
out error)) { out error)) {
ShowError(error.EscapeMarkup()); ShowError(error.EscapeMarkup());
projectFile.Restore(); RestoreActions.ForEach(x => x());
return false; return false;
} }
var referencedPackageReferences = referencedProjectFile.GetPackageReferences(); var referencedPackageReferences = referencedProjectFile.GetPackageReferences();
foreach (var package in referencedPackageReferences) { foreach (var package in referencedPackageReferences) {
if (Verbose) if (Verbose)
AnsiConsole.WriteLine($"Hoisting package {package.Include} from {pathToReferencedProjectFile}"); AnsiConsole.WriteLine(
$"Hoisting package {package.Include} from {pathToReferencedProjectFile}");
var hoisted = projectFile.AddPackage(package); var hoisted = projectFile.AddPackage(package);
projectFile.SetTransitive(hoisted, true); projectFile.SetTransitive(hoisted, true);
projectFile.SetPrivateAssets(hoisted, PrivateAssetsValue.None); projectFile.SetPrivateAssets(hoisted, PrivateAssetsValue.None);
@@ -222,9 +231,10 @@ public static class Program {
projectReferences.Enqueue(project); projectReferences.Enqueue(project);
} }
} }
} catch (Exception e) { }
catch (Exception e) {
ShowError(Exceptions.generic_error.EscapeMarkup(), e.ToString().EscapeMarkup()); ShowError(Exceptions.generic_error.EscapeMarkup(), e.ToString().EscapeMarkup());
projectFile.Restore(); RestoreActions.ForEach(x => x());
return false; return false;
} }
} }
@@ -238,29 +248,80 @@ public static class Program {
} }
var outDir = Path.GetRandomFileName(); var outDir = Path.GetRandomFileName();
result = AnsiConsole.Status() RestoreActions.Add(() => {
try {
if (!Directory.Exists(outDir)) return;
Directory.Delete(outDir, true);
AnsiConsole.MarkupLine("[yellow]Cleaned up temporary directory[/]");
}
catch (Exception e) {
ShowError(string.Format(Exceptions.failed_to_clean_up.EscapeMarkup(), outDir.EscapeMarkup(),
e.ToString().EscapeMarkup()));
}
});
string processError = "";
var exitCode = await AnsiConsole.Status()
.Spinner(Spinner.Known.Dots) .Spinner(Spinner.Known.Dots)
.Start<bool>("Creating package with 'dotnet pack' ", ctx => { .StartAsync<int>("Creating package with 'dotnet pack' ", async ctx => {
var p = Process.Start(new ProcessStartInfo() { var p = Process.Start(new ProcessStartInfo() {
FileName = "dotnet", FileName = "dotnet",
Arguments = $"pack -o {outDir}", Arguments = $"pack -o {outDir}",
WorkingDirectory = Environment.CurrentDirectory, WorkingDirectory = Environment.CurrentDirectory,
UseShellExecute = Verbose, UseShellExecute = false,
RedirectStandardOutput = !Verbose, RedirectStandardOutput = true,
RedirectStandardError = !Verbose RedirectStandardError = true
});
p?.WaitForExit();
return p?.ExitCode == 0;
}); });
if (!result) { CancellationTokenSource cts = new CancellationTokenSource();
ShowError(Exceptions.dotnet_pack_failure.EscapeMarkup()); StringBuilder errorLines = new();
p?.ErrorDataReceived += (sender, eventArgs) => {
cts.Cancel();
if (Verbose && eventArgs.Data != null)
AnsiConsole.WriteLine(eventArgs.Data);
};
bool success = false;
p?.OutputDataReceived += (sender, eventArgs) => {
if (eventArgs.Data?.ToLower().Contains("press any key") == true)
cts.Cancel();
if (Verbose && eventArgs.Data != null)
AnsiConsole.WriteLine(eventArgs.Data);
// Successfully created package 'C:\Users\qwsdc\source\repos\Beam\aeqw89.Beam\tozsxqaj.alp\Beam.1.0.0.nupkg'.
if (eventArgs.Data?.ToLower()
.Contains($"successfully created package '{Path.GetFullPath(outDir)}") == true) {
AnsiConsole.MarkupLine($"[bold]{eventArgs.Data}[/]");
success = true;
}
};
p?.BeginOutputReadLine();
p?.BeginErrorReadLine();
try {
await (p?.WaitForExitAsync(cts.Token) ?? Task.CompletedTask);
}
catch (TaskCanceledException) {
p?.Kill();
}
processError = errorLines.ToString().EscapeMarkup();
return success == true ? 0 : p?.ExitCode ?? -1;
});
if (exitCode != 0) {
ShowError(processError.EscapeMarkup());
ShowError(Exceptions.dotnet_pack_failure.EscapeMarkup(), exitCode);
RestoreActions.ForEach(x => x());
return; return;
} }
if (Verbose)
AnsiConsole.MarkupLine("Successfully created package with exit code [green]{0}[/]. Processing destinations.", exitCode);
var package = Directory.GetFiles(outDir, "*.nupkg").FirstOrDefault(); var package = Directory.GetFiles(outDir, "*.nupkg").FirstOrDefault();
if (package == null) { if (package == null) {
ShowError(Exceptions.generic_error.EscapeMarkup()); ShowError(Exceptions.generic_error.EscapeMarkup());
RestoreActions.ForEach(x => x());
return; return;
} }
@@ -270,7 +331,7 @@ public static class Program {
try { try {
await AnsiConsole.Progress() await AnsiConsole.Progress()
.AutoClear(true) .AutoClear(true)
.HideCompleted(true) .HideCompleted(false)
.Columns(new ProgressColumn[] { .Columns(new ProgressColumn[] {
new TaskDescriptionColumn(), new TaskDescriptionColumn(),
new ProgressBarColumn() new ProgressBarColumn()
@@ -292,7 +353,8 @@ public static class Program {
if (dest.StartsWith("local-")) { if (dest.StartsWith("local-")) {
var name = dest[("local-".Length)..]; var name = dest[("local-".Length)..];
var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), var path = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
name, Path.GetFileName(package)); name, Path.GetFileName(package));
if (!Directory.Exists(Path.GetDirectoryName(path))) if (!Directory.Exists(Path.GetDirectoryName(path)))
Directory.CreateDirectory(Path.GetDirectoryName(path)!); Directory.CreateDirectory(Path.GetDirectoryName(path)!);
@@ -308,7 +370,8 @@ public static class Program {
else if (dest.StartsWith("cloud-")) { else if (dest.StartsWith("cloud-")) {
var name = dest[("cloud-".Length)..]; var name = dest[("cloud-".Length)..];
var connectionTask = ctx.AddTaskBefore($"Preparing cloud-{name}", new ProgressTaskSettings() { var connectionTask = ctx.AddTaskBefore($"Preparing cloud-{name}",
new ProgressTaskSettings() {
MaxValue = 100 MaxValue = 100
}, task); }, task);
@@ -337,41 +400,51 @@ public static class Program {
if (os == "windows") { if (os == "windows") {
var userDirC = sshClient.RunCommand("cmd /c echo %USERPROFILE%"); var userDirC = sshClient.RunCommand("cmd /c echo %USERPROFILE%");
if (userDirC.ExitStatus != 0) { if (userDirC.ExitStatus != 0) {
ShowError(Exceptions.failed_to_prepare_server_directory, "n/a", name, os, userDirC.Result); ShowError(Exceptions.failed_to_prepare_server_directory, "n/a", name, os,
userDirC.Result);
return; return;
} }
var userDir = userDirC.Result.Trim(); var userDir = userDirC.Result.Trim();
remoteDirectory = RemotePath.Combine(RemoteOs.Windows,userDir, "dotnet-packages"); remoteDirectory = RemotePath.Combine(RemoteOs.Windows, userDir, "dotnet-packages");
packageFileDirectory = RemotePath.Combine(RemoteOs.Windows, remoteDirectory, Path.GetFileName(package)); packageFileDirectory = RemotePath.Combine(RemoteOs.Windows, remoteDirectory,
Path.GetFileName(package));
var mkdirC = sshClient.RunCommand($"cmd /c if not exist \"{remoteDirectory}\" mkdir \"{remoteDirectory}\""); var mkdirC = sshClient.RunCommand(
$"cmd /c if not exist \"{remoteDirectory}\" mkdir \"{remoteDirectory}\"");
if (mkdirC.ExitStatus != 0) { if (mkdirC.ExitStatus != 0) {
ShowError(Exceptions.failed_to_prepare_server_directory, remoteDirectory, name, os, mkdirC.Result); ShowError(Exceptions.failed_to_prepare_server_directory, remoteDirectory, name,
os, mkdirC.Result);
return; return;
} }
} }
else if (os == "linux") { else if (os == "linux") {
var homeDirC = sshClient.RunCommand("printf %s \"$HOME\""); var homeDirC = sshClient.RunCommand("printf %s \"$HOME\"");
if (homeDirC.ExitStatus != 0) { if (homeDirC.ExitStatus != 0) {
ShowError(Exceptions.failed_to_prepare_server_directory, "n/a", name, os, homeDirC.Result); ShowError(Exceptions.failed_to_prepare_server_directory, "n/a", name, os,
homeDirC.Result);
return; return;
} }
var homeDir = homeDirC.Result.Trim(); // no CRLF on unix, but Trim() is safest var homeDir = homeDirC.Result.Trim(); // no CRLF on unix, but Trim() is safest
remoteDirectory = RemotePath.Combine(RemoteOs.Unix, homeDir, ".dotnet-packages"); remoteDirectory = RemotePath.Combine(RemoteOs.Unix, homeDir, ".dotnet-packages");
packageFileDirectory = RemotePath.Combine(RemoteOs.Unix, remoteDirectory, Path.GetFileName(package)); packageFileDirectory = RemotePath.Combine(RemoteOs.Unix, remoteDirectory,
Path.GetFileName(package));
// Use -p and single quotes to handle spaces safely // Use -p and single quotes to handle spaces safely
var mkdirC = sshClient.RunCommand($"mkdir -p '{remoteDirectory}'"); var mkdirC = sshClient.RunCommand($"mkdir -p '{remoteDirectory}'");
if (mkdirC.ExitStatus != 0) { if (mkdirC.ExitStatus != 0) {
ShowError(Exceptions.failed_to_prepare_server_directory, remoteDirectory, name, os, mkdirC.Result); ShowError(Exceptions.failed_to_prepare_server_directory, remoteDirectory, name,
os, mkdirC.Result);
return; return;
} }
} }
else { else {
ShowError(Exceptions.failed_to_prepare_server_directory, "n/a", name, os, "Unsupported OS"); ShowError(Exceptions.failed_to_prepare_server_directory, "n/a", name, os,
"Unsupported OS");
return; return;
} }
connectionTask.Increment(33); connectionTask.Increment(33);
sshClient.Disconnect(); sshClient.Disconnect();
@@ -398,23 +471,52 @@ public static class Program {
Arguments = $"nuget push {package} --source github", Arguments = $"nuget push {package} --source github",
WorkingDirectory = Environment.CurrentDirectory, WorkingDirectory = Environment.CurrentDirectory,
UseShellExecute = false, UseShellExecute = false,
RedirectStandardOutput = !Verbose, RedirectStandardOutput = true,
RedirectStandardError = !Verbose RedirectStandardError = true
}); });
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
StringBuilder errorLines = new();
p?.ErrorDataReceived += (sender, eventArgs) => {
cts.Cancel();
if (Verbose && eventArgs.Data != null)
AnsiConsole.WriteLine(eventArgs.Data);
errorLines.Append(eventArgs.Data);
};
p?.OutputDataReceived += (sender, eventArgs) => {
if (eventArgs.Data?.ToLower().Contains("press any key") == true)
cts.Cancel();
if (Verbose && eventArgs.Data != null)
AnsiConsole.WriteLine(eventArgs.Data);
};
p?.BeginOutputReadLine();
p?.BeginErrorReadLine();
if (p == null) { if (p == null) {
ShowError(Exceptions.generic_error.EscapeMarkup()); ShowError(Exceptions.generic_error.EscapeMarkup());
} }
task.Increment(size / 2); task.Increment(size / 2);
if (p != null) if (p != null)
await p.WaitForExitAsync(ct); try {
if (p?.ExitCode != 0) { await (p?.WaitForExitAsync(cts.Token) ?? Task.CompletedTask);
ShowError(Exceptions.dotnet_nuget_push_failure, p.ExitCode);
} }
catch (TaskCanceledException) {
p?.Kill();
}
if (p?.ExitCode != 0) {
ShowError(errorLines.ToString().EscapeMarkup());
ShowError(Exceptions.dotnet_nuget_push_failure, p?.ExitCode ?? -1);
task.StopTask();
return;
}
task.Increment(size / 2); task.Increment(size / 2);
} }
Interlocked.Increment(ref destinationsProcessed);
task.StopTask(); task.StopTask();
}); });
}); });
@@ -424,11 +526,27 @@ public static class Program {
Directory.Delete(outDir, true); Directory.Delete(outDir, true);
} }
catch (Exception e) { catch (Exception e) {
ShowError(string.Format(Exceptions.failed_to_clean_up.EscapeMarkup(), outDir.EscapeMarkup(), e.ToString().EscapeMarkup())); ShowError(string.Format(Exceptions.failed_to_clean_up.EscapeMarkup(), outDir.EscapeMarkup(),
e.ToString().EscapeMarkup()));
} }
} }
}
catch(Exception e) {
ShowError(Exceptions.generic_error.EscapeMarkup(), e.ToString().EscapeMarkup());;
RestoreActions.ForEach(x => x());
}
if (destinationsProcessed == 0) {
AnsiConsole.MarkupLine("[bold red]No destinations were processed. Reverting changes to project file.[/]");
RestoreActions.ForEach(x => x());
}
else {
AnsiConsole.MarkupLine("Completed processing of all destinations."); AnsiConsole.MarkupLine("Completed processing of all destinations.");
AnsiConsole.MarkupLine("Example usage:\n\t <PackageReference Include=\"{0}\" Version=\"{1}\" />".EscapeMarkup(), packageId, version); AnsiConsole.MarkupLine(
"Example usage:\n\t <PackageReference Include=\"{0}\" Version=\"{1}\" />".EscapeMarkup(), packageId,
version);
}
} }
/// <summary> /// <summary>
@@ -441,8 +559,7 @@ public static class Program {
/// <param name="operation">A function that defines the adjustment operation to be performed on each version component.</param> /// <param name="operation">A function that defines the adjustment operation to be performed on each version component.</param>
/// <returns>A new version string with the updated major, minor, and patch components, preserving any existing tag.</returns> /// <returns>A new version string with the updated major, minor, and patch components, preserving any existing tag.</returns>
/// <exception cref="Exception">Thrown if the version string is not in the correct format.</exception> /// <exception cref="Exception">Thrown if the version string is not in the correct format.</exception>
private static string ChangeVersion(string version, int patch, int minor, int major, private static string ChangeVersion(string version, int delta, IncrementTarget target) {
Func<int, int, int> operation) {
string[] split = version.Split('.'); string[] split = version.Split('.');
if (split.Length != 3) { if (split.Length != 3) {
throw new Exception(string.Format(Exceptions.version_string_not_formatted_correctly, version)); throw new Exception(string.Format(Exceptions.version_string_not_formatted_correctly, version));
@@ -459,9 +576,23 @@ public static class Program {
throw new Exception(string.Format(Exceptions.version_string_not_formatted_correctly, version)); throw new Exception(string.Format(Exceptions.version_string_not_formatted_correctly, version));
int[] parsedVersion = split.Select(int.Parse).ToArray(); int[] parsedVersion = split.Select(int.Parse).ToArray();
switch (target) {
case IncrementTarget.Major:
parsedVersion[0] += delta;
parsedVersion[1] = 0;
parsedVersion[2] = 0;
break;
case IncrementTarget.Minor:
parsedVersion[1] += delta;
parsedVersion[2] = 0;
break;
case IncrementTarget.Patch:
parsedVersion[2] += delta;
break;
}
return return
$"{operation(parsedVersion[0], major)}.{operation(parsedVersion[1], minor)}.{operation(parsedVersion[2], patch)}{tag}"; $"{parsedVersion[0]}.{parsedVersion[1]}.{parsedVersion[2]}{tag}";
} }
private static void ShowError(string message, params object[] args) { private static void ShowError(string message, params object[] args) {
+5 -1
View File
@@ -106,6 +106,7 @@ internal class ProjectFile {
} }
set("Version", "1.0.0"); set("Version", "1.0.0");
set("PackageVersion", "1.0.0");
set("Title", System.IO.Path.GetFileNameWithoutExtension(Path)); set("Title", System.IO.Path.GetFileNameWithoutExtension(Path));
set("Authors", ""); set("Authors", "");
set("Company", ""); set("Company", "");
@@ -213,5 +214,8 @@ internal class ProjectFile {
public string GetVersion() => MainPropertyGroup.GetProperty("Version"); public string GetVersion() => MainPropertyGroup.GetProperty("Version");
public void SetVersion(string version) => MainPropertyGroup.SetProperty("Version", version); public void SetVersion(string version) {
MainPropertyGroup.SetProperty("Version", version);
MainPropertyGroup.SetProperty("PackageVersion", version);
}
} }
@@ -5,6 +5,7 @@
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>