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
{
// 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;
}

View file

@ -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);
}
}
}

View file

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

View file

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

View file

@ -60,7 +60,7 @@ namespace MfGames.ToolBuilder
return;
}
var messages = errors
IEnumerable<string> messages = errors
.SelectMany(x => x.Errors)
.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>
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
{

View file

@ -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>

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
.Create(
"Sample Application",
"SampleApplication",
new ToolNames("SampleApplication"),
args)
.ConfigureContainer(ConfigureContainer)
.RunAsync();