refactor!: reworked the setup methods to handle stripped executables
This commit is contained in:
parent
a150a22022
commit
71c0d4a217
11 changed files with 270 additions and 60 deletions
28
src/MfGames.ToolBuilder/FakedRootCommand.cs
Normal file
28
src/MfGames.ToolBuilder/FakedRootCommand.cs
Normal file
|
@ -0,0 +1,28 @@
|
|||
using System;
|
||||
using System.CommandLine;
|
||||
using System.Linq;
|
||||
|
||||
namespace MfGames.ToolBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Fakes the functionality of the RootCommand because of the given issue:
|
||||
/// https://github.com/dotnet/command-line-api/issues/1471
|
||||
/// In short, RootCommand doesn't resolve properly when working in certain
|
||||
/// situations, mainly having the command line parser in a library that is
|
||||
/// called by another executable.
|
||||
/// </summary>
|
||||
public class FakedRootCommand : Command
|
||||
{
|
||||
public FakedRootCommand(ToolNames names)
|
||||
: base(names.GetExecutableName(), string.Empty)
|
||||
{
|
||||
}
|
||||
|
||||
public string[] GetArguments()
|
||||
{
|
||||
string[] args = Environment.GetCommandLineArgs();
|
||||
|
||||
return args.Length == 0 ? args : args.Skip(1).ToArray();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -46,12 +46,12 @@ namespace MfGames.ToolBuilder.Globals
|
|||
get
|
||||
{
|
||||
// If we don't have an internal name, blow up.
|
||||
string? internalName = this.InternalName;
|
||||
string? configName = this.Names?.GetConfigName();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(internalName))
|
||||
if (string.IsNullOrWhiteSpace(configName))
|
||||
{
|
||||
throw new ApplicationException(
|
||||
"Cannot determine the default configuration path unless internal name has been set.");
|
||||
"Cannot determine the default configuration path unless the configuration name has been set.");
|
||||
}
|
||||
|
||||
// Figure out the path to the default configuration. This is
|
||||
|
@ -61,7 +61,7 @@ namespace MfGames.ToolBuilder.Globals
|
|||
.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||
string appDirectory = Path.Combine(
|
||||
configDirectory,
|
||||
internalName);
|
||||
configName);
|
||||
string configPath = Path.Combine(appDirectory, "Settings.json");
|
||||
|
||||
return configPath;
|
||||
|
@ -72,7 +72,7 @@ namespace MfGames.ToolBuilder.Globals
|
|||
/// Gets or sets the name of the application. This is used to figure out
|
||||
/// the name of the configuration file and what is shown on the screen.
|
||||
/// </summary>
|
||||
public string? InternalName { get; set; }
|
||||
public ToolNames? Names { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Adds the common options to the command.
|
||||
|
@ -138,13 +138,13 @@ namespace MfGames.ToolBuilder.Globals
|
|||
/// Sets the internal name of the application, used for the
|
||||
/// configuration path.
|
||||
/// </summary>
|
||||
/// <param name="internalName">
|
||||
/// The internal name of the application.
|
||||
/// <param name="names">
|
||||
/// The names object for the tool.
|
||||
/// </param>
|
||||
/// <returns>The service for chaining operations.</returns>
|
||||
public ConfigToolGlobalService WithInternalName(string internalName)
|
||||
public ConfigToolGlobalService WithNames(ToolNames? names)
|
||||
{
|
||||
this.InternalName = internalName;
|
||||
this.Names = names;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
|
|
@ -416,11 +416,12 @@ namespace MfGames.ToolBuilder.Tables
|
|||
new Dictionary<CharMapPositions, char>
|
||||
{
|
||||
{ CharMapPositions.DividerY, ' ' },
|
||||
{ CharMapPositions.BottomCenter, ' ' },
|
||||
})
|
||||
.WithHeaderCharMapDefinition(
|
||||
new Dictionary<HeaderCharMapPositions, char>
|
||||
{
|
||||
{ HeaderCharMapPositions.BottomCenter, ' ' },
|
||||
{ HeaderCharMapPositions.BottomCenter, '+' },
|
||||
{ HeaderCharMapPositions.Divider, ' ' },
|
||||
{ HeaderCharMapPositions.BorderBottom, '-' },
|
||||
});
|
||||
|
@ -455,7 +456,11 @@ namespace MfGames.ToolBuilder.Tables
|
|||
}
|
||||
|
||||
// Write out the results.
|
||||
context.Console.Out.Write(builder.Export().ToString());
|
||||
string rendered = builder.Export()
|
||||
.ToString()
|
||||
.TrimEnd(' ', '\n', '\r', '\0');
|
||||
|
||||
context.Console.Out.WriteLine(rendered);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,17 +30,17 @@ namespace MfGames.ToolBuilder
|
|||
|
||||
private readonly LoggingToolGlobalService loggingService;
|
||||
|
||||
private readonly ToolNames names;
|
||||
|
||||
public ToolBuilder(
|
||||
string applicationName,
|
||||
string internalName,
|
||||
ToolNames names,
|
||||
string[] arguments)
|
||||
{
|
||||
// Create our various services.
|
||||
this.names = names;
|
||||
this.arguments = arguments;
|
||||
this.ApplicationName = applicationName;
|
||||
this.InternalName = internalName;
|
||||
this.configService = new ConfigToolGlobalService()
|
||||
.WithInternalName(this.InternalName);
|
||||
.WithNames(this.names);
|
||||
this.loggingService = new LoggingToolGlobalService();
|
||||
|
||||
// Set up logging first so we can report the loading process. This
|
||||
|
@ -76,22 +76,11 @@ namespace MfGames.ToolBuilder
|
|||
this.ConfigureContainer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the human-readable name of the application.
|
||||
/// </summary>
|
||||
public string ApplicationName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the internal name of the application.
|
||||
/// </summary>
|
||||
public string InternalName { get; }
|
||||
|
||||
public static ToolBuilder Create(
|
||||
string applicationName,
|
||||
string internalName,
|
||||
ToolNames names,
|
||||
string[] arguments)
|
||||
{
|
||||
return new ToolBuilder(applicationName, internalName, arguments);
|
||||
return new ToolBuilder(names, arguments);
|
||||
}
|
||||
|
||||
public ToolBuilder ConfigureContainer(
|
||||
|
@ -155,6 +144,11 @@ namespace MfGames.ToolBuilder
|
|||
AppDomain.CurrentDomain.ProcessExit +=
|
||||
(_, _) => Log.CloseAndFlush();
|
||||
|
||||
// Register the names as a singleton instance.
|
||||
builder.RegisterInstance(this.names)
|
||||
.As<ToolNames>()
|
||||
.SingleInstance();
|
||||
|
||||
// Register the global services as singletons.
|
||||
builder
|
||||
.RegisterInstance(this.configService)
|
||||
|
@ -165,12 +159,6 @@ namespace MfGames.ToolBuilder
|
|||
.AsSelf()
|
||||
.SingleInstance();
|
||||
|
||||
// Register the tool service since we have to use the factory to
|
||||
// use it.
|
||||
builder
|
||||
.Register(this.CreateToolService)
|
||||
.SingleInstance();
|
||||
|
||||
// Register the components required to make the CLI work.
|
||||
builder.RegisterModule<ToolBuilderModule>();
|
||||
}
|
||||
|
@ -180,14 +168,7 @@ namespace MfGames.ToolBuilder
|
|||
IServiceCollection services)
|
||||
{
|
||||
services.AddAutofac();
|
||||
}
|
||||
|
||||
private ToolService CreateToolService(IComponentContext context)
|
||||
{
|
||||
var factory = context.Resolve<ToolService.Factory>();
|
||||
var service = factory(this.InternalName);
|
||||
|
||||
return service;
|
||||
services.AddHostedService<ToolService>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ namespace MfGames.ToolBuilder
|
|||
builder
|
||||
.RegisterAssemblyTypes(this.GetType().Assembly)
|
||||
.Except<ToolService>()
|
||||
.Except<ToolNames>()
|
||||
.Except<ConfigToolGlobalService>()
|
||||
.Except<LoggingToolGlobalService>()
|
||||
.AsSelf()
|
||||
|
|
|
@ -60,7 +60,7 @@ namespace MfGames.ToolBuilder
|
|||
return;
|
||||
}
|
||||
|
||||
var messages = errors
|
||||
IEnumerable<string> messages = errors
|
||||
.SelectMany(x => x.Errors)
|
||||
.Select(x => x.Message);
|
||||
|
||||
|
|
74
src/MfGames.ToolBuilder/ToolNames.cs
Normal file
74
src/MfGames.ToolBuilder/ToolNames.cs
Normal file
|
@ -0,0 +1,74 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace MfGames.ToolBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides the name of the tool in its various formats. This is used to
|
||||
/// identify the executable name and other elements used throughout the
|
||||
/// system.
|
||||
/// </summary>
|
||||
public class ToolNames
|
||||
{
|
||||
public ToolNames(string name)
|
||||
{
|
||||
this.Name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
|
||||
// The name of the executing program comes in as the first parameter.
|
||||
string[] args = Environment.GetCommandLineArgs();
|
||||
|
||||
if (args.Length > 0)
|
||||
{
|
||||
this.ExecutableName = Path.GetFileNameWithoutExtension(args[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the configuration file. This will fall back to Name
|
||||
/// if not provided.
|
||||
/// </summary>
|
||||
public string? ConfigName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the human-readable name of the application, falling back to Name
|
||||
/// if not provided.
|
||||
/// </summary>
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the executable. This will fall back to ConfigName
|
||||
/// if this propery is null.
|
||||
/// </summary>
|
||||
public string? ExecutableName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the required name of the application. This can be human-readable
|
||||
/// or not, depending on the developer.
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configuration name using any fallbacks.
|
||||
/// </summary>
|
||||
public string GetConfigName()
|
||||
{
|
||||
return this.ConfigName ?? this.Name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the display name using any fallbacks.
|
||||
/// </summary>
|
||||
public string GetDisplayName()
|
||||
{
|
||||
return this.DisplayName ?? this.Name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the executable name using any fallbacks.
|
||||
/// </summary>
|
||||
public string GetExecutableName()
|
||||
{
|
||||
return this.ExecutableName ?? this.GetConfigName();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -25,8 +25,6 @@ namespace MfGames.ToolBuilder
|
|||
/// </summary>
|
||||
public class ToolService : IHostedService
|
||||
{
|
||||
private readonly string cliName;
|
||||
|
||||
private readonly IList<ITopCommand> commands;
|
||||
|
||||
private readonly ConfigToolGlobalService configService;
|
||||
|
@ -37,16 +35,17 @@ namespace MfGames.ToolBuilder
|
|||
|
||||
private readonly LoggingToolGlobalService loggingService;
|
||||
|
||||
private readonly ToolNames names;
|
||||
|
||||
public ToolService(
|
||||
string cliName,
|
||||
ToolNames names,
|
||||
ILogger logger,
|
||||
IHostApplicationLifetime lifetime,
|
||||
IList<ITopCommand> commands,
|
||||
ConfigToolGlobalService configService,
|
||||
LoggingToolGlobalService loggingService)
|
||||
{
|
||||
this.cliName = cliName
|
||||
?? throw new ArgumentNullException(nameof(cliName));
|
||||
this.names = names;
|
||||
this.lifetime = lifetime;
|
||||
this.commands = commands;
|
||||
this.configService = configService;
|
||||
|
@ -54,8 +53,6 @@ namespace MfGames.ToolBuilder
|
|||
this.logger = logger.ForContext<ToolService>();
|
||||
}
|
||||
|
||||
public delegate ToolService Factory(string cliName);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
|
@ -78,13 +75,13 @@ namespace MfGames.ToolBuilder
|
|||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Command CreateRootCommand()
|
||||
private FakedRootCommand CreateRootCommand()
|
||||
{
|
||||
// Create the root command and add in the top-level commands
|
||||
// underneath it. We can't use the "real" `RootCommand` here because
|
||||
// it doesn't work in stripped executables (because this is a
|
||||
// library) so we fake it with a "normal" command.
|
||||
var root = new Command(this.cliName, string.Empty);
|
||||
var root = new FakedRootCommand(this.names);
|
||||
|
||||
foreach (var command in this.commands)
|
||||
{
|
||||
|
@ -117,6 +114,10 @@ namespace MfGames.ToolBuilder
|
|||
this.logger.Fatal(
|
||||
exception,
|
||||
"Unhandled exception!");
|
||||
|
||||
Environment.ExitCode = Environment.ExitCode == 0
|
||||
? 1
|
||||
: Environment.ExitCode;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -125,20 +126,27 @@ namespace MfGames.ToolBuilder
|
|||
try
|
||||
{
|
||||
// Build the command tree.
|
||||
Command root = this.CreateRootCommand();
|
||||
string[] args = Environment.GetCommandLineArgs();
|
||||
FakedRootCommand root = this.CreateRootCommand();
|
||||
string[] args = root.GetArguments();
|
||||
|
||||
// Execute the command.
|
||||
this.logger.Verbose(
|
||||
"Running the command-line arguments: {Arguments}",
|
||||
args);
|
||||
|
||||
Environment.ExitCode = await new CommandLineBuilder(root)
|
||||
CommandLineBuilder builder = new CommandLineBuilder(root)
|
||||
.UseDefaults()
|
||||
.UseExceptionHandler(this.OnException)
|
||||
.Build()
|
||||
.UseExceptionHandler(this.OnException);
|
||||
|
||||
Parser cli = builder.Build();
|
||||
int exitCode = await cli
|
||||
.InvokeAsync(args)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (exitCode != 0)
|
||||
{
|
||||
Environment.ExitCode = exitCode;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliWrap" Version="3.3.3" />
|
||||
<PackageReference Include="coverlet.collector" Version="3.0.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
|
113
tests/MfGames.ToolBuilder.Tests/SampleToolTests.cs
Normal file
113
tests/MfGames.ToolBuilder.Tests/SampleToolTests.cs
Normal file
|
@ -0,0 +1,113 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using CliWrap;
|
||||
using CliWrap.Exceptions;
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace MfGames.ToolBuilder.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests the SampleTool in the tests directory to make sure the
|
||||
/// basic functionality is correct.
|
||||
/// </summary>
|
||||
public class SampleToolTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CrashCommandFails()
|
||||
{
|
||||
// Run the executable using CliWrap.
|
||||
FileInfo projectFile = GetProjectFile();
|
||||
StringBuilder output = new();
|
||||
CancellationToken cancellationToken =
|
||||
new CancellationTokenSource(TimeSpan.FromSeconds(20))
|
||||
.Token;
|
||||
|
||||
var exception = Assert.ThrowsAsync<CommandExecutionException>(
|
||||
async () => await Cli.Wrap("dotnet")
|
||||
.WithArguments(
|
||||
new[]
|
||||
{
|
||||
"run", "--project", projectFile.FullName, "--",
|
||||
"crash",
|
||||
})
|
||||
.WithWorkingDirectory(projectFile.DirectoryName!)
|
||||
.WithStandardOutputPipe(PipeTarget.ToStringBuilder(output))
|
||||
.ExecuteAsync(cancellationToken)
|
||||
.ConfigureAwait(false));
|
||||
|
||||
// Verify the return code.
|
||||
Assert.NotNull(exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TableCommandWorks()
|
||||
{
|
||||
// Run the executable using CliWrap.
|
||||
FileInfo projectFile = GetProjectFile();
|
||||
StringBuilder output = new();
|
||||
CancellationToken cancellationToken =
|
||||
new CancellationTokenSource(TimeSpan.FromSeconds(20))
|
||||
.Token;
|
||||
|
||||
CommandResult result = await Cli.Wrap("dotnet")
|
||||
.WithArguments(
|
||||
new[]
|
||||
{
|
||||
"run", "--project", projectFile.FullName, "--", "table",
|
||||
})
|
||||
.WithWorkingDirectory(projectFile.DirectoryName!)
|
||||
.WithStandardOutputPipe(PipeTarget.ToStringBuilder(output))
|
||||
.ExecuteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Verify the return code.
|
||||
Assert.Equal(0, result.ExitCode);
|
||||
|
||||
// Check the output.
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"DefaultString DefaultInt32",
|
||||
"-------------+------------",
|
||||
"Row 1 1",
|
||||
"Row 2 10",
|
||||
"Row 3 100",
|
||||
"",
|
||||
},
|
||||
output.ToString().Split("\n"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the file object representing the sample tool's project.
|
||||
/// </summary>
|
||||
private static FileInfo GetProjectFile()
|
||||
{
|
||||
// Loop up until we find the directory that contains it.
|
||||
var parent = new DirectoryInfo(Environment.CurrentDirectory);
|
||||
|
||||
while (parent?.GetDirectories("SampleTool").Length == 0)
|
||||
{
|
||||
parent = parent.Parent;
|
||||
}
|
||||
|
||||
// If we got a null, we can't find it.
|
||||
if (parent == null)
|
||||
{
|
||||
throw new DirectoryNotFoundException(
|
||||
"Cannot find sample tool directory from "
|
||||
+ Environment.CurrentDirectory);
|
||||
}
|
||||
|
||||
// Get the project file inside there.
|
||||
DirectoryInfo directory = parent.GetDirectories("SampleTool")[0];
|
||||
FileInfo file = directory.GetFiles("SampleTool.csproj")[0];
|
||||
|
||||
return file;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -12,8 +12,7 @@ namespace SampleTool
|
|||
{
|
||||
return await ToolBuilder
|
||||
.Create(
|
||||
"Sample Application",
|
||||
"SampleApplication",
|
||||
new ToolNames("SampleApplication"),
|
||||
args)
|
||||
.ConfigureContainer(ConfigureContainer)
|
||||
.RunAsync();
|
||||
|
|
Reference in a new issue