diff --git a/src/MfGames.ToolBuilder/FakedRootCommand.cs b/src/MfGames.ToolBuilder/FakedRootCommand.cs
new file mode 100644
index 0000000..6cfdac8
--- /dev/null
+++ b/src/MfGames.ToolBuilder/FakedRootCommand.cs
@@ -0,0 +1,28 @@
+using System;
+using System.CommandLine;
+using System.Linq;
+
+namespace MfGames.ToolBuilder
+{
+ ///
+ /// 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.
+ ///
+ 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();
+ }
+ }
+}
diff --git a/src/MfGames.ToolBuilder/Globals/ConfigToolGlobalService.cs b/src/MfGames.ToolBuilder/Globals/ConfigToolGlobalService.cs
index 46013d2..119bf2a 100644
--- a/src/MfGames.ToolBuilder/Globals/ConfigToolGlobalService.cs
+++ b/src/MfGames.ToolBuilder/Globals/ConfigToolGlobalService.cs
@@ -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.
///
- public string? InternalName { get; set; }
+ public ToolNames? Names { get; set; }
///
/// 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.
///
- ///
- /// The internal name of the application.
+ ///
+ /// The names object for the tool.
///
/// The service for chaining operations.
- public ConfigToolGlobalService WithInternalName(string internalName)
+ public ConfigToolGlobalService WithNames(ToolNames? names)
{
- this.InternalName = internalName;
+ this.Names = names;
return this;
}
diff --git a/src/MfGames.ToolBuilder/Tables/TableToolService.cs b/src/MfGames.ToolBuilder/Tables/TableToolService.cs
index e1d572c..f3277aa 100644
--- a/src/MfGames.ToolBuilder/Tables/TableToolService.cs
+++ b/src/MfGames.ToolBuilder/Tables/TableToolService.cs
@@ -416,11 +416,12 @@ namespace MfGames.ToolBuilder.Tables
new Dictionary
{
{ CharMapPositions.DividerY, ' ' },
+ { CharMapPositions.BottomCenter, ' ' },
})
.WithHeaderCharMapDefinition(
new Dictionary
{
- { 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);
}
}
}
diff --git a/src/MfGames.ToolBuilder/ToolBuilder.cs b/src/MfGames.ToolBuilder/ToolBuilder.cs
index fa8f202..b78167d 100644
--- a/src/MfGames.ToolBuilder/ToolBuilder.cs
+++ b/src/MfGames.ToolBuilder/ToolBuilder.cs
@@ -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);
}
- ///
- /// Gets the human-readable name of the application.
- ///
- public string ApplicationName { get; }
-
- ///
- /// Gets the internal name of the application.
- ///
- 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()
+ .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();
}
@@ -180,14 +168,7 @@ namespace MfGames.ToolBuilder
IServiceCollection services)
{
services.AddAutofac();
- }
-
- private ToolService CreateToolService(IComponentContext context)
- {
- var factory = context.Resolve();
- var service = factory(this.InternalName);
-
- return service;
+ services.AddHostedService();
}
}
}
diff --git a/src/MfGames.ToolBuilder/ToolBuilderModule.cs b/src/MfGames.ToolBuilder/ToolBuilderModule.cs
index 3c7cef4..0ac3f7a 100644
--- a/src/MfGames.ToolBuilder/ToolBuilderModule.cs
+++ b/src/MfGames.ToolBuilder/ToolBuilderModule.cs
@@ -15,6 +15,7 @@ namespace MfGames.ToolBuilder
builder
.RegisterAssemblyTypes(this.GetType().Assembly)
.Except()
+ .Except()
.Except()
.Except()
.AsSelf()
diff --git a/src/MfGames.ToolBuilder/ToolException.cs b/src/MfGames.ToolBuilder/ToolException.cs
index 17f7957..de99278 100644
--- a/src/MfGames.ToolBuilder/ToolException.cs
+++ b/src/MfGames.ToolBuilder/ToolException.cs
@@ -60,7 +60,7 @@ namespace MfGames.ToolBuilder
return;
}
- var messages = errors
+ IEnumerable messages = errors
.SelectMany(x => x.Errors)
.Select(x => x.Message);
diff --git a/src/MfGames.ToolBuilder/ToolNames.cs b/src/MfGames.ToolBuilder/ToolNames.cs
new file mode 100644
index 0000000..14787fc
--- /dev/null
+++ b/src/MfGames.ToolBuilder/ToolNames.cs
@@ -0,0 +1,74 @@
+using System;
+using System.IO;
+
+namespace MfGames.ToolBuilder
+{
+ ///
+ /// 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.
+ ///
+ 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]);
+ }
+ }
+
+ ///
+ /// Gets the name of the configuration file. This will fall back to Name
+ /// if not provided.
+ ///
+ public string? ConfigName { get; set; }
+
+ ///
+ /// Gets the human-readable name of the application, falling back to Name
+ /// if not provided.
+ ///
+ public string? DisplayName { get; set; }
+
+ ///
+ /// Gets the name of the executable. This will fall back to ConfigName
+ /// if this propery is null.
+ ///
+ public string? ExecutableName { get; set; }
+
+ ///
+ /// Gets the required name of the application. This can be human-readable
+ /// or not, depending on the developer.
+ ///
+ public string Name { get; set; }
+
+ ///
+ /// Gets the configuration name using any fallbacks.
+ ///
+ public string GetConfigName()
+ {
+ return this.ConfigName ?? this.Name;
+ }
+
+ ///
+ /// Gets the display name using any fallbacks.
+ ///
+ public string GetDisplayName()
+ {
+ return this.DisplayName ?? this.Name;
+ }
+
+ ///
+ /// Gets the executable name using any fallbacks.
+ ///
+ public string GetExecutableName()
+ {
+ return this.ExecutableName ?? this.GetConfigName();
+ }
+ }
+}
diff --git a/src/MfGames.ToolBuilder/ToolService.cs b/src/MfGames.ToolBuilder/ToolService.cs
index 1448fd0..1630ba5 100644
--- a/src/MfGames.ToolBuilder/ToolService.cs
+++ b/src/MfGames.ToolBuilder/ToolService.cs
@@ -25,8 +25,6 @@ namespace MfGames.ToolBuilder
///
public class ToolService : IHostedService
{
- private readonly string cliName;
-
private readonly IList 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 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();
}
- public delegate ToolService Factory(string cliName);
-
///
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
{
diff --git a/tests/MfGames.ToolBuilder.Tests/MfGames.ToolBuilder.Tests.csproj b/tests/MfGames.ToolBuilder.Tests/MfGames.ToolBuilder.Tests.csproj
index 17768b1..2cd09ed 100644
--- a/tests/MfGames.ToolBuilder.Tests/MfGames.ToolBuilder.Tests.csproj
+++ b/tests/MfGames.ToolBuilder.Tests/MfGames.ToolBuilder.Tests.csproj
@@ -6,6 +6,7 @@
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/tests/MfGames.ToolBuilder.Tests/SampleToolTests.cs b/tests/MfGames.ToolBuilder.Tests/SampleToolTests.cs
new file mode 100644
index 0000000..61c7388
--- /dev/null
+++ b/tests/MfGames.ToolBuilder.Tests/SampleToolTests.cs
@@ -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
+{
+ ///
+ /// Tests the SampleTool in the tests directory to make sure the
+ /// basic functionality is correct.
+ ///
+ 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(
+ 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"));
+ }
+
+ ///
+ /// Gets the file object representing the sample tool's project.
+ ///
+ 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;
+ }
+ }
+}
diff --git a/tests/SampleTool/Program.cs b/tests/SampleTool/Program.cs
index 03c313f..9ef9de8 100644
--- a/tests/SampleTool/Program.cs
+++ b/tests/SampleTool/Program.cs
@@ -12,8 +12,7 @@ namespace SampleTool
{
return await ToolBuilder
.Create(
- "Sample Application",
- "SampleApplication",
+ new ToolNames("SampleApplication"),
args)
.ConfigureContainer(ConfigureContainer)
.RunAsync();