refactor!: reworked the setup methods to handle stripped executables

This commit is contained in:
Dylan R. E. Moonfire 2021-11-29 18:45:26 -06:00
parent a150a22022
commit 71c0d4a217
11 changed files with 270 additions and 60 deletions

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

View file

@ -46,12 +46,12 @@ namespace MfGames.ToolBuilder.Globals
get get
{ {
// If we don't have an internal name, blow up. // 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( 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 // Figure out the path to the default configuration. This is
@ -61,7 +61,7 @@ namespace MfGames.ToolBuilder.Globals
.GetFolderPath(Environment.SpecialFolder.ApplicationData); .GetFolderPath(Environment.SpecialFolder.ApplicationData);
string appDirectory = Path.Combine( string appDirectory = Path.Combine(
configDirectory, configDirectory,
internalName); configName);
string configPath = Path.Combine(appDirectory, "Settings.json"); string configPath = Path.Combine(appDirectory, "Settings.json");
return configPath; return configPath;
@ -72,7 +72,7 @@ namespace MfGames.ToolBuilder.Globals
/// Gets or sets the name of the application. This is used to figure out /// 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. /// the name of the configuration file and what is shown on the screen.
/// </summary> /// </summary>
public string? InternalName { get; set; } public ToolNames? Names { get; set; }
/// <summary> /// <summary>
/// Adds the common options to the command. /// 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 /// Sets the internal name of the application, used for the
/// configuration path. /// configuration path.
/// </summary> /// </summary>
/// <param name="internalName"> /// <param name="names">
/// The internal name of the application. /// The names object for the tool.
/// </param> /// </param>
/// <returns>The service for chaining operations.</returns> /// <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; return this;
} }

View file

@ -416,11 +416,12 @@ namespace MfGames.ToolBuilder.Tables
new Dictionary<CharMapPositions, char> new Dictionary<CharMapPositions, char>
{ {
{ CharMapPositions.DividerY, ' ' }, { CharMapPositions.DividerY, ' ' },
{ CharMapPositions.BottomCenter, ' ' },
}) })
.WithHeaderCharMapDefinition( .WithHeaderCharMapDefinition(
new Dictionary<HeaderCharMapPositions, char> new Dictionary<HeaderCharMapPositions, char>
{ {
{ HeaderCharMapPositions.BottomCenter, ' ' }, { HeaderCharMapPositions.BottomCenter, '+' },
{ HeaderCharMapPositions.Divider, ' ' }, { HeaderCharMapPositions.Divider, ' ' },
{ HeaderCharMapPositions.BorderBottom, '-' }, { HeaderCharMapPositions.BorderBottom, '-' },
}); });
@ -455,7 +456,11 @@ namespace MfGames.ToolBuilder.Tables
} }
// Write out the results. // 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);
} }
} }
} }

View file

@ -30,17 +30,17 @@ namespace MfGames.ToolBuilder
private readonly LoggingToolGlobalService loggingService; private readonly LoggingToolGlobalService loggingService;
private readonly ToolNames names;
public ToolBuilder( public ToolBuilder(
string applicationName, ToolNames names,
string internalName,
string[] arguments) string[] arguments)
{ {
// Create our various services. // Create our various services.
this.names = names;
this.arguments = arguments; this.arguments = arguments;
this.ApplicationName = applicationName;
this.InternalName = internalName;
this.configService = new ConfigToolGlobalService() this.configService = new ConfigToolGlobalService()
.WithInternalName(this.InternalName); .WithNames(this.names);
this.loggingService = new LoggingToolGlobalService(); this.loggingService = new LoggingToolGlobalService();
// Set up logging first so we can report the loading process. This // Set up logging first so we can report the loading process. This
@ -76,22 +76,11 @@ namespace MfGames.ToolBuilder
this.ConfigureContainer); 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( public static ToolBuilder Create(
string applicationName, ToolNames names,
string internalName,
string[] arguments) string[] arguments)
{ {
return new ToolBuilder(applicationName, internalName, arguments); return new ToolBuilder(names, arguments);
} }
public ToolBuilder ConfigureContainer( public ToolBuilder ConfigureContainer(
@ -155,6 +144,11 @@ namespace MfGames.ToolBuilder
AppDomain.CurrentDomain.ProcessExit += AppDomain.CurrentDomain.ProcessExit +=
(_, _) => Log.CloseAndFlush(); (_, _) => Log.CloseAndFlush();
// Register the names as a singleton instance.
builder.RegisterInstance(this.names)
.As<ToolNames>()
.SingleInstance();
// Register the global services as singletons. // Register the global services as singletons.
builder builder
.RegisterInstance(this.configService) .RegisterInstance(this.configService)
@ -165,12 +159,6 @@ namespace MfGames.ToolBuilder
.AsSelf() .AsSelf()
.SingleInstance(); .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. // Register the components required to make the CLI work.
builder.RegisterModule<ToolBuilderModule>(); builder.RegisterModule<ToolBuilderModule>();
} }
@ -180,14 +168,7 @@ namespace MfGames.ToolBuilder
IServiceCollection services) IServiceCollection services)
{ {
services.AddAutofac(); services.AddAutofac();
} services.AddHostedService<ToolService>();
private ToolService CreateToolService(IComponentContext context)
{
var factory = context.Resolve<ToolService.Factory>();
var service = factory(this.InternalName);
return service;
} }
} }
} }

View file

@ -15,6 +15,7 @@ namespace MfGames.ToolBuilder
builder builder
.RegisterAssemblyTypes(this.GetType().Assembly) .RegisterAssemblyTypes(this.GetType().Assembly)
.Except<ToolService>() .Except<ToolService>()
.Except<ToolNames>()
.Except<ConfigToolGlobalService>() .Except<ConfigToolGlobalService>()
.Except<LoggingToolGlobalService>() .Except<LoggingToolGlobalService>()
.AsSelf() .AsSelf()

View file

@ -60,7 +60,7 @@ namespace MfGames.ToolBuilder
return; return;
} }
var messages = errors IEnumerable<string> messages = errors
.SelectMany(x => x.Errors) .SelectMany(x => x.Errors)
.Select(x => x.Message); .Select(x => x.Message);

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

View file

@ -25,8 +25,6 @@ namespace MfGames.ToolBuilder
/// </summary> /// </summary>
public class ToolService : IHostedService public class ToolService : IHostedService
{ {
private readonly string cliName;
private readonly IList<ITopCommand> commands; private readonly IList<ITopCommand> commands;
private readonly ConfigToolGlobalService configService; private readonly ConfigToolGlobalService configService;
@ -37,16 +35,17 @@ namespace MfGames.ToolBuilder
private readonly LoggingToolGlobalService loggingService; private readonly LoggingToolGlobalService loggingService;
private readonly ToolNames names;
public ToolService( public ToolService(
string cliName, ToolNames names,
ILogger logger, ILogger logger,
IHostApplicationLifetime lifetime, IHostApplicationLifetime lifetime,
IList<ITopCommand> commands, IList<ITopCommand> commands,
ConfigToolGlobalService configService, ConfigToolGlobalService configService,
LoggingToolGlobalService loggingService) LoggingToolGlobalService loggingService)
{ {
this.cliName = cliName this.names = names;
?? throw new ArgumentNullException(nameof(cliName));
this.lifetime = lifetime; this.lifetime = lifetime;
this.commands = commands; this.commands = commands;
this.configService = configService; this.configService = configService;
@ -54,8 +53,6 @@ namespace MfGames.ToolBuilder
this.logger = logger.ForContext<ToolService>(); this.logger = logger.ForContext<ToolService>();
} }
public delegate ToolService Factory(string cliName);
/// <inheritdoc /> /// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken) public Task StartAsync(CancellationToken cancellationToken)
{ {
@ -78,13 +75,13 @@ namespace MfGames.ToolBuilder
return Task.CompletedTask; return Task.CompletedTask;
} }
private Command CreateRootCommand() private FakedRootCommand CreateRootCommand()
{ {
// Create the root command and add in the top-level commands // Create the root command and add in the top-level commands
// underneath it. We can't use the "real" `RootCommand` here because // underneath it. We can't use the "real" `RootCommand` here because
// it doesn't work in stripped executables (because this is a // it doesn't work in stripped executables (because this is a
// library) so we fake it with a "normal" command. // 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) foreach (var command in this.commands)
{ {
@ -117,6 +114,10 @@ namespace MfGames.ToolBuilder
this.logger.Fatal( this.logger.Fatal(
exception, exception,
"Unhandled exception!"); "Unhandled exception!");
Environment.ExitCode = Environment.ExitCode == 0
? 1
: Environment.ExitCode;
} }
} }
@ -125,20 +126,27 @@ namespace MfGames.ToolBuilder
try try
{ {
// Build the command tree. // Build the command tree.
Command root = this.CreateRootCommand(); FakedRootCommand root = this.CreateRootCommand();
string[] args = Environment.GetCommandLineArgs(); string[] args = root.GetArguments();
// Execute the command. // Execute the command.
this.logger.Verbose( this.logger.Verbose(
"Running the command-line arguments: {Arguments}", "Running the command-line arguments: {Arguments}",
args); args);
Environment.ExitCode = await new CommandLineBuilder(root) CommandLineBuilder builder = new CommandLineBuilder(root)
.UseDefaults() .UseDefaults()
.UseExceptionHandler(this.OnException) .UseExceptionHandler(this.OnException);
.Build()
Parser cli = builder.Build();
int exitCode = await cli
.InvokeAsync(args) .InvokeAsync(args)
.ConfigureAwait(false); .ConfigureAwait(false);
if (exitCode != 0)
{
Environment.ExitCode = exitCode;
}
} }
finally finally
{ {

View file

@ -6,6 +6,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CliWrap" Version="3.3.3" />
<PackageReference Include="coverlet.collector" Version="3.0.1"> <PackageReference Include="coverlet.collector" Version="3.0.1">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

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

View file

@ -12,8 +12,7 @@ namespace SampleTool
{ {
return await ToolBuilder return await ToolBuilder
.Create( .Create(
"Sample Application", new ToolNames("SampleApplication"),
"SampleApplication",
args) args)
.ConfigureContainer(ConfigureContainer) .ConfigureContainer(ConfigureContainer)
.RunAsync(); .RunAsync();