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