diff --git a/src/Cake.Common.Tests/Fixtures/Tools/Command/CommandRunnerFixture.cs b/src/Cake.Common.Tests/Fixtures/Tools/Command/CommandRunnerFixture.cs new file mode 100644 index 0000000000..39dbe064cb --- /dev/null +++ b/src/Cake.Common.Tests/Fixtures/Tools/Command/CommandRunnerFixture.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Cake.Common.Tools.Command; +using Cake.Core.IO; +using Cake.Testing.Fixtures; + +namespace Cake.Common.Tests.Fixtures.Tools.Command +{ + internal class CommandRunnerFixture : ToolFixture + { + public ProcessArgumentBuilder Arguments { get; set; } + + public string ToolName + { + get => Settings.ToolName; + set => Settings.ToolName = value; + } + + public ICollection ToolExecutableNames + { + get => Settings.ToolExecutableNames; + set => Settings.ToolExecutableNames = value; + } + + public CommandRunnerFixture() + : base("dotnet.exe") + { + Arguments = new ProcessArgumentBuilder(); + Settings.ToolName = "dotnet"; + Settings.ToolExecutableNames = new[] { "dotnet.exe", "dotnet" }; + } + + protected override void RunTool() + { + GetRunner().RunCommand(Arguments); + } + + protected CommandRunner GetRunner() + => new CommandRunner( + Settings, + FileSystem, + Environment, + ProcessRunner, + Tools); + } +} diff --git a/src/Cake.Common.Tests/Fixtures/Tools/Command/CommandRunnerStandardErrorFixture.cs b/src/Cake.Common.Tests/Fixtures/Tools/Command/CommandRunnerStandardErrorFixture.cs new file mode 100644 index 0000000000..15944337cd --- /dev/null +++ b/src/Cake.Common.Tests/Fixtures/Tools/Command/CommandRunnerStandardErrorFixture.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Cake.Common.Tests.Fixtures.Tools.Command +{ + internal class CommandRunnerStandardErrorFixture : CommandRunnerStandardOutputFixture + { + public string StandardError { get; private set; } + + protected override void RunTool() + { + ExitCode = GetRunner().RunCommand(Arguments, out var standardOutput, out var standardError); + StandardOutput = standardOutput; + StandardError = standardError; + } + } +} diff --git a/src/Cake.Common.Tests/Fixtures/Tools/Command/CommandRunnerStandardOutFixture.cs b/src/Cake.Common.Tests/Fixtures/Tools/Command/CommandRunnerStandardOutFixture.cs new file mode 100644 index 0000000000..048501dbbd --- /dev/null +++ b/src/Cake.Common.Tests/Fixtures/Tools/Command/CommandRunnerStandardOutFixture.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Cake.Common.Tests.Fixtures.Tools.Command +{ + internal class CommandRunnerStandardOutputFixture : CommandRunnerFixture + { + public int ExitCode { get; protected set; } + public string StandardOutput { get; protected set; } + + protected override void RunTool() + { + ExitCode = GetRunner().RunCommand(Arguments, out var standardOutput); + StandardOutput = standardOutput; + } + } +} diff --git a/src/Cake.Common.Tests/Fixtures/Tools/Command/CommandRunnerStandardOutputFixtureExtentions.cs b/src/Cake.Common.Tests/Fixtures/Tools/Command/CommandRunnerStandardOutputFixtureExtentions.cs new file mode 100644 index 0000000000..edf6cdb1f4 --- /dev/null +++ b/src/Cake.Common.Tests/Fixtures/Tools/Command/CommandRunnerStandardOutputFixtureExtentions.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Cake.Common.Tests.Fixtures.Tools.Command +{ + internal static class CommandRunnerStandardOutputFixtureExtentions + { + public static T GivenStandardOutput(this T fixture, params string[] standardOutput) + where T : CommandRunnerStandardOutputFixture + { + fixture.ProcessRunner.Process.SetStandardOutput(standardOutput); + return fixture; + } + + public static T GivenStandardError(this T fixture, params string[] standardError) + where T : CommandRunnerStandardOutputFixture + { + fixture.ProcessRunner.Process.SetStandardError(standardError); + return fixture; + } + } +} diff --git a/src/Cake.Common.Tests/Unit/Tools/Command/CommandRunnerTests.cs b/src/Cake.Common.Tests/Unit/Tools/Command/CommandRunnerTests.cs new file mode 100644 index 0000000000..58c423a434 --- /dev/null +++ b/src/Cake.Common.Tests/Unit/Tools/Command/CommandRunnerTests.cs @@ -0,0 +1,207 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Cake.Common.Tests.Fixtures.Tools.Command; +using Xunit; + +namespace Cake.Common.Tests.Unit.Tools.Command +{ + public sealed class CommandRunnerTests + { + public sealed class TheRunCommandMethod + { + [Fact] + public void Should_Throw_If_Arguments_Was_Null() + { + // Given + var fixture = new CommandRunnerFixture + { + Arguments = null + }; + + // When + var result = Record.Exception(() => fixture.Run()); + + // Then + AssertEx.IsArgumentNullException(result, "arguments"); + } + + [Fact] + public void Should_Throw_If_Settings_Was_Null() + { + // Given + var fixture = new CommandRunnerFixture + { + Settings = null + }; + + // When + var result = Record.Exception(() => fixture.Run()); + + // Then + AssertEx.IsArgumentNullException(result, "settings"); + } + + [Fact] + public void Should_Throw_If_ToolName_Was_Null() + { + // Given + var fixture = new CommandRunnerFixture + { + ToolName = null + }; + + // When + var result = Record.Exception(() => fixture.Run()); + + // Then + AssertEx.IsArgumentNullException(result, "ToolName"); + } + } + + [Fact] + public void Should_Throw_If_ToolExecutableNames_Was_Null() + { + // Given + var fixture = new CommandRunnerFixture + { + ToolExecutableNames = null + }; + + // When + var result = Record.Exception(() => fixture.Run()); + + // Then + AssertEx.IsArgumentNullException(result, "ToolExecutableNames"); + } + + [Fact] + public void Should_Throw_If_ToolExecutableNames_Was_Empty() + { + // Given + var fixture = new CommandRunnerFixture + { + ToolExecutableNames = Array.Empty() + }; + + // When + var result = Record.Exception(() => fixture.Run()); + + // Then + AssertEx.IsArgumentNullException(result, "ToolExecutableNames"); + } + + [Fact] + public void Should_Call_Settings_PostAction() + { + // Given + var called = false; + var fixture = new CommandRunnerFixture + { + Settings = { PostAction = _ => called = true } + }; + + // When + var result = fixture.Run(); + + // Then + Assert.True(called, "Settings PostAction not called"); + } + + [Fact] + public void Should_Return_StandardOutput() + { + // Given + const string expectedStandardOutput = "LINE1"; + const int expectedExitCode = 0; + + var fixture = new CommandRunnerStandardOutputFixture() + .GivenStandardOutput(expectedStandardOutput); + + // When + fixture.Run(); + + // Then + Assert.Equal(expectedStandardOutput, fixture.StandardOutput); + Assert.Equal(expectedExitCode, fixture.ExitCode); + } + + [Fact] + public void Should_Return_StandardOutput_ExitCode() + { + // Given + const string expectedStandardOutput = "LINE1"; + const int expectedExitCode = 1337; + + var fixture = new CommandRunnerStandardOutputFixture + { + Settings = + { + HandleExitCode = exitCode => exitCode == expectedExitCode + } + } + .GivenStandardOutput(expectedStandardOutput); + + fixture.ProcessRunner.Process.SetExitCode(expectedExitCode); + + // When + fixture.Run(); + + // Then + Assert.Equal(expectedStandardOutput, fixture.StandardOutput); + Assert.Equal(expectedExitCode, fixture.ExitCode); + } + + [Fact] + public void Should_Return_StandardError() + { + // Given + const string expectedStandardOutput = "LINE1"; + const string expectedStandardError = "ERRORLINE1"; + const int expectedExitCode = 0; + + var fixture = new CommandRunnerStandardErrorFixture() + .GivenStandardError(expectedStandardError) + .GivenStandardOutput(expectedStandardOutput); + + // When + fixture.Run(); + + // Then + Assert.Equal(expectedStandardOutput, fixture.StandardOutput); + Assert.Equal(expectedStandardError, fixture.StandardError); + Assert.Equal(expectedExitCode, fixture.ExitCode); + } + + [Fact] + public void Should_Return_StandardError_ExitCode() + { + // Given + const string expectedStandardOutput = "LINE1"; + const string expectedStandardError = "ERRORLINE1"; + const int expectedExitCode = 1337; + + var fixture = new CommandRunnerStandardErrorFixture + { + Settings = + { + HandleExitCode = exitCode => exitCode == expectedExitCode + } + } + .GivenStandardError(expectedStandardError) + .GivenStandardOutput(expectedStandardOutput); + + fixture.ProcessRunner.Process.SetExitCode(expectedExitCode); + + // When + fixture.Run(); + + // Then + Assert.Equal(expectedStandardOutput, fixture.StandardOutput); + Assert.Equal(expectedStandardError, fixture.StandardError); + Assert.Equal(expectedExitCode, fixture.ExitCode); + } + } +} diff --git a/src/Cake.Common/Tools/Command/CommandAliases.cs b/src/Cake.Common/Tools/Command/CommandAliases.cs new file mode 100644 index 0000000000..7f1f3fb069 --- /dev/null +++ b/src/Cake.Common/Tools/Command/CommandAliases.cs @@ -0,0 +1,458 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Cake.Core; +using Cake.Core.Annotations; +using Cake.Core.IO; + +namespace Cake.Common.Tools.Command +{ + /// + /// Contains generic functionality for simplifying the execution tools with no dedicated alias available yet. + /// + [CakeAliasCategory("Command")] + public static class CommandAliases + { + /// + /// Executes a generic tool/process based on arguments and settings. + /// + /// The context. + /// The tool executable names. + /// The optional arguments. + /// The expected exit code (default 0). + /// The optional settings customization (default null). + /// Thrown if or is null or empty. + /// + /// + /// // Example with ProcessArgumentBuilder + /// #tool dotnet:?package=DPI&version=2022.8.21.54 + /// Command( + /// new []{ "dpi", "dpi.exe"}, + /// new ProcessArgumentBuilder() + /// .Append("nuget") + /// .AppendQuoted(Context.Environment.WorkingDirectory.FullPath) + /// .AppendSwitch("--output", " ", "TABLE") + /// .Append("analyze") + /// ); + /// + /// + /// // Example with implicit ProcessArgumentBuilder + /// Command( + /// new []{ "dotnet", "dotnet.exe"}, + /// "--version" + /// ); + /// + /// + /// // Example specify expected exit code + /// Command( + /// new []{ "dotnet", "dotnet.exe"}, + /// expectedExitCode: -2147450751 + /// ); + /// + /// + /// // Example settings customization + /// Command( + /// new []{ "dotnet", "dotnet.exe"}, + /// settingsCustomization: settings => settings + /// .WithToolName(".NET tool") + /// .WithExpectedExitCode(1) + /// .WithArgumentCustomization(args => args.Append("tool")) + /// ); + /// + /// + [CakeMethodAlias] + [CakeAliasCategory("Command")] + public static void Command( + this ICakeContext context, + ICollection toolExecutableNames, + ProcessArgumentBuilder arguments = null, + int expectedExitCode = 0, + Func settingsCustomization = null) + => context.Command( + GetSettings(toolExecutableNames, expectedExitCode, settingsCustomization), + arguments); + + /// + /// Executes a generic command based on arguments and settings. + /// + /// The context. + /// The settings. + /// The optional arguments. + /// Thrown if or is null. + /// + /// + /// #tool dotnet:?package=DPI&version=2022.8.21.54 + /// // Reusable tools settings i.e. created in setup. + /// var settings = new CommandSettings { + /// ToolName = "DPI", + /// ToolExecutableNames = new []{ "dpi", "dpi.exe"}, + /// }; + /// + /// // Example with ProcessArgumentBuilder + /// Command( + /// settings, + /// new ProcessArgumentBuilder() + /// .Append("nuget") + /// .AppendQuoted(Context.Environment.WorkingDirectory.FullPath) + /// .AppendSwitch("--output", " ", "TABLE") + /// .Append("analyze") + /// ); + /// + /// // Example with implicit ProcessArgumentBuilder + /// Command( + /// settings, + /// $"nuget --output TABLE analyze" + /// ); + /// + /// + [CakeMethodAlias] + [CakeAliasCategory("Command")] + public static void Command( + this ICakeContext context, + CommandSettings settings, + ProcessArgumentBuilder arguments = null) + { + var runner = GetRunner(context, settings, ref arguments); + + runner.RunCommand(arguments); + } + + /// + /// Executes a generic tool/process based on arguments, tool executable names and redirects standard output. + /// + /// The context. + /// The tool executable names. + /// The standard output. + /// The optional arguments. + /// The expected exit code (default 0). + /// The optional settings customization. + /// Thrown if , or is null. + /// The exit code. + /// + /// + /// using System.Text.Json.Serialization; + /// using System.Text.Json; + /// #tool dotnet:?package=DPI&version=2022.8.21.54 + /// + /// // Example with ProcessArgumentBuilder + /// var exitCode = Command( + /// new []{ "dpi", "dpi.exe"}, + /// out var standardOutput, + /// new ProcessArgumentBuilder() + /// .Append("nuget") + /// .AppendQuoted(Context.Environment.WorkingDirectory.FullPath) + /// .AppendSwitch("--output", " ", "JSON") + /// .Append("analyze") + /// ); + /// + /// var packageReferences = JsonSerializer.Deserialize<DPIPackageReference[]>( + /// standardOutput + /// ); + /// + /// // Example with implicit ProcessArgumentBuilder + /// var implicitExitCode = Command( + /// new []{ "dpi", "dpi.exe"}, + /// out var implicitStandardOutput, + /// $"nuget --output JSON analyze" + /// ); + /// + /// var implicitPackageReferences = JsonSerializer.Deserialize<DPIPackageReference[]>( + /// implicitStandardOutput + /// ); + /// + /// // Example settings customization + /// var settingsCustomizationExitCode = Command( + /// new []{ "dpi", "dpi.exe"}, + /// out var settingsCustomizationStandardOutput, + /// $"nuget --output JSON analyze", + /// settingsCustomization: settings => settings + /// .WithToolName("DPI") + /// .WithArgumentCustomization(args => args.AppendSwitchQuoted("--buildversion", " ", "1.0.0")) + /// ); + /// + /// var settingsCustomizationPackageReferences = JsonSerializer.Deserialize<DPIPackageReference[]>( + /// settingsCustomizationStandardOutput + /// ); + /// + /// // Record used in example above + /// public record DPIPackageReference( + /// [property: JsonPropertyName("source")] + /// string Source, + /// [property: JsonPropertyName("sourceType")] + /// string SourceType, + /// [property: JsonPropertyName("packageId")] + /// string PackageId, + /// [property: JsonPropertyName("version")] + /// string Version + /// ); + /// + /// + [CakeMethodAlias] + [CakeAliasCategory("Command")] + public static int Command( + this ICakeContext context, + ICollection toolExecutableNames, + out string standardOutput, + ProcessArgumentBuilder arguments = null, + int expectedExitCode = 0, + Func settingsCustomization = null) + => context.Command( + GetSettings(toolExecutableNames, expectedExitCode, settingsCustomization), + out standardOutput, + arguments); + + /// + /// Executes a generic tool/process based on arguments, settings and redirects standard output. + /// + /// The context. + /// The settings. + /// The standard output. + /// The optional arguments. + /// Thrown if or is null. + /// The exit code. + /// + /// + /// using System.Text.Json.Serialization; + /// using System.Text.Json; + /// #tool dotnet:?package=DPI&version=2022.8.21.54 + /// // Reusable tools settings i.e. created in setup. + /// var settings = new CommandSettings { + /// ToolName = "DPI", + /// ToolExecutableNames = new []{ "dpi", "dpi.exe" }, + /// }; + /// + /// // Example with ProcessArgumentBuilder + /// var exitCode = Command( + /// settings, + /// out var standardOutput, + /// new ProcessArgumentBuilder() + /// .Append("nuget") + /// .AppendQuoted(Context.Environment.WorkingDirectory.FullPath) + /// .AppendSwitch("--output", " ", "JSON") + /// .Append("analyze") + /// ); + /// + /// var packageReferences = JsonSerializer.Deserialize<DPIPackageReference[]>( + /// standardOutput + /// ); + /// + /// // Example with implicit ProcessArgumentBuilder + /// var implicitExitCode = Command( + /// settings, + /// out var implicitStandardOutput, + /// $"nuget --output JSON analyze" + /// ); + /// + /// var implicitPackageReferences = JsonSerializer.Deserialize<DPIPackageReference[]>( + /// implicitStandardOutput + /// ); + /// + /// // Record used in example above + /// public record DPIPackageReference( + /// [property: JsonPropertyName("source")] + /// string Source, + /// [property: JsonPropertyName("sourceType")] + /// string SourceType, + /// [property: JsonPropertyName("packageId")] + /// string PackageId, + /// [property: JsonPropertyName("version")] + /// string Version + /// ); + /// + /// + [CakeMethodAlias] + [CakeAliasCategory("Command")] + public static int Command( + this ICakeContext context, + CommandSettings settings, + out string standardOutput, + ProcessArgumentBuilder arguments = null) + { + var runner = GetRunner(context, settings, ref arguments); + + return runner.RunCommand(arguments, out standardOutput); + } + + /// + /// Executes a generic tool/process based on arguments, settings, redirects standard output and standard error. + /// + /// The context. + /// The tool executable names. + /// The standard output. + /// The standard error. + /// The optional arguments. + /// The expected exit code (default 0). + /// The optional settings customization (default null). + /// Thrown if or is null. + /// The exit code. + /// + /// + /// // Example with ProcessArgumentBuilder + /// var exitCode = Command( + /// new []{ "dotnet", "dotnet.exe" }, + /// out var standardOutput, + /// out var standardError, + /// new ProcessArgumentBuilder() + /// .Append("tool"), + /// expectedExitCode:1 + /// ); + /// + /// Verbose("Exit code: {0}", exitCode); + /// Information("Output: {0}", standardOutput); + /// Error("Error: {0}", standardError); + /// + /// + /// // Example with implicit ProcessArgumentBuilder + /// var implicitExitCode = Command( + /// new []{ "dotnet", "dotnet.exe" }, + /// out var implicitStandardOutput, + /// out var implicitStandardError, + /// "tool", + /// expectedExitCode:1 + /// ); + /// + /// Verbose("Exit code: {0}", implicitExitCode); + /// Information("Output: {0}", implicitStandardOutput); + /// Error("Error: {0}", implicitStandardError); + /// + /// + /// // Example settings customization + /// var settingsCustomizationExitCode = Command( + /// new []{ "dotnet", "dotnet.exe" }, + /// out var settingsCustomizationStandardOutput, + /// out var settingsCustomizationStandardError, + /// settingsCustomization: settings => settings + /// .WithToolName(".NET Tool") + /// .WithArgumentCustomization(args => args.Append("tool")) + /// .WithExpectedExitCode(1) + /// ); + /// + /// Verbose("Exit code: {0}", settingsCustomizationExitCode); + /// Information("Output: {0}", settingsCustomizationStandardOutput); + /// Error("Error: {0}", settingsCustomizationStandardError); + /// + /// + [CakeMethodAlias] + [CakeAliasCategory("Command")] + public static int Command( + this ICakeContext context, + ICollection toolExecutableNames, + out string standardOutput, + out string standardError, + ProcessArgumentBuilder arguments = null, + int expectedExitCode = 0, + Func settingsCustomization = null) + => context.Command( + GetSettings(toolExecutableNames, expectedExitCode, settingsCustomization), + out standardOutput, + out standardError, + arguments); + + /// + /// Executes a generic tool/process based on arguments and settings. + /// + /// The context. + /// The settings. + /// The standard output. + /// The standard error. + /// The optional arguments. + /// Thrown if or is null. + /// The exit code. + /// + /// + /// // Reusable tools settings i.e. created in setup. + /// var settings = new CommandSettings { + /// ToolName = ".NET CLI", + /// ToolExecutableNames = new []{ "dotnet", "dotnet.exe" }, + /// }.WithExpectedExitCode(1); + /// + /// // Example with ProcessArgumentBuilder + /// var exitCode = Command( + /// settings, + /// out var standardOutput, + /// out var standardError, + /// new ProcessArgumentBuilder() + /// .Append("tool") + /// ); + /// + /// Verbose("Exit code: {0}", exitCode); + /// Information("Output: {0}", standardOutput); + /// Error("Error: {0}", standardError); + /// + /// + /// // Example with implicit ProcessArgumentBuilder + /// var implicitExitCode = Command( + /// settings, + /// out var implicitStandardOutput, + /// out var implicitStandardError, + /// "tool" + /// ); + /// + /// Verbose("Exit code: {0}", implicitExitCode); + /// Information("Output: {0}", implicitStandardOutput); + /// Error("Error: {0}", implicitStandardError); + /// + /// + [CakeMethodAlias] + [CakeAliasCategory("Command")] + public static int Command( + this ICakeContext context, + CommandSettings settings, + out string standardOutput, + out string standardError, + ProcessArgumentBuilder arguments = null) + { + var runner = GetRunner(context, settings, ref arguments); + + return runner.RunCommand(arguments, out standardOutput, out standardError); + } + + private static CommandSettings GetSettings( + ICollection toolExecutableNames, + int expectedExitCode, + Func settingsCustomization) + { + if (toolExecutableNames is null || toolExecutableNames.Count < 1) + { + throw new ArgumentNullException(nameof(toolExecutableNames)); + } + + var settings = new CommandSettings + { + ToolName = toolExecutableNames.First(), + ToolExecutableNames = toolExecutableNames, + HandleExitCode = exitCode => exitCode == expectedExitCode + }; + + return settingsCustomization?.Invoke(settings) ?? settings; + } + + private static CommandRunner GetRunner(ICakeContext context, CommandSettings settings, ref ProcessArgumentBuilder arguments) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (settings == null) + { + throw new ArgumentNullException(nameof(settings)); + } + + arguments ??= new ProcessArgumentBuilder(); + + var runner = new CommandRunner( + settings, + context.FileSystem, + context.Environment, + context.ProcessRunner, + context.Tools); + + return runner; + } + } +} diff --git a/src/Cake.Common/Tools/Command/CommandRunner.cs b/src/Cake.Common/Tools/Command/CommandRunner.cs new file mode 100644 index 0000000000..0bb562ad6c --- /dev/null +++ b/src/Cake.Common/Tools/Command/CommandRunner.cs @@ -0,0 +1,134 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Cake.Core; +using Cake.Core.IO; +using Cake.Core.Tooling; + +namespace Cake.Common.Tools.Command +{ + /// + /// The generic command runner. + /// + public sealed class CommandRunner : Tool + { + private CommandSettings Settings { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The settings. + /// The file system. + /// The environment. + /// The process runner. + /// The globber. + public CommandRunner( + CommandSettings settings, + IFileSystem fileSystem, + ICakeEnvironment environment, + IProcessRunner processRunner, + IToolLocator tools) : base(fileSystem, environment, processRunner, tools) + { + Settings = settings ?? throw new ArgumentNullException(nameof(settings)); + } + + /// + protected override IEnumerable GetToolExecutableNames() + => Settings.ToolExecutableNames; + + /// + protected override string GetToolName() + => Settings.ToolName; + + /// + /// Runs the command using the specified settings. + /// + /// The arguments. + public void RunCommand(ProcessArgumentBuilder arguments) + => RunCommand(arguments, null, null); + + /// + /// Runs the command using the specified settings. + /// + /// The arguments. + /// The standard output. + /// The exit code. + public int RunCommand(ProcessArgumentBuilder arguments, out string standardOutput) + => RunCommand( + arguments, + out standardOutput, + out _, + new ProcessSettings + { + RedirectStandardOutput = true + }); + + /// + /// Runs the command using the specified settings. + /// + /// The arguments. + /// The standard output. + /// The standard error output. + /// The exit code. + public int RunCommand(ProcessArgumentBuilder arguments, out string standardOutput, out string standardError) + => RunCommand( + arguments, + out standardOutput, + out standardError, + new ProcessSettings + { + RedirectStandardOutput = true, + RedirectStandardError = true + }); + + private int RunCommand(ProcessArgumentBuilder arguments, out string standardOutput, out string standardError, ProcessSettings processSettings) + { + IEnumerable + standardOutputResult = null, standardErrorResult = null; + int returnCode = -1; + + RunCommand(arguments, processSettings, + process => + { + standardOutputResult = processSettings.RedirectStandardOutput ? process.GetStandardOutput() : Array.Empty(); + standardErrorResult = processSettings.RedirectStandardError ? process.GetStandardError() : Array.Empty(); + returnCode = process.GetExitCode(); + }); + + standardOutput = string.Join(Environment.NewLine, standardOutputResult); + standardError = string.Join(Environment.NewLine, standardErrorResult); + + return returnCode; + } + + /// + /// Runs the command using the specified settings. + /// + /// The arguments. + /// The process settings. + /// If specified called after process exit. + private void RunCommand(ProcessArgumentBuilder arguments, ProcessSettings processSettings, Action postAction) + { + if (arguments is null) + { + throw new ArgumentNullException(nameof(arguments)); + } + + if (string.IsNullOrWhiteSpace(Settings.ToolName)) + { + throw new ArgumentNullException(nameof(Settings.ToolName)); + } + + if (Settings.ToolExecutableNames == null || !Settings.ToolExecutableNames.Any()) + { + throw new ArgumentNullException(nameof(Settings.ToolExecutableNames)); + } + + Run(Settings, arguments, processSettings, postAction); + } + } +} diff --git a/src/Cake.Common/Tools/Command/CommandSettings.cs b/src/Cake.Common/Tools/Command/CommandSettings.cs new file mode 100644 index 0000000000..5d9129a06f --- /dev/null +++ b/src/Cake.Common/Tools/Command/CommandSettings.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using Cake.Core.Tooling; + +namespace Cake.Common.Tools.Command +{ + /// + /// Contains settings used by . + /// + public class CommandSettings : ToolSettings + { + /// + /// Gets or sets the name of the tool. + /// + public virtual string ToolName { get; set; } + + /// + /// Gets or sets the tool executable names. + /// + public virtual ICollection ToolExecutableNames { get; set; } = Array.Empty(); + } +} diff --git a/src/Cake.Common/Tools/Command/CommandSettingsExtensions.cs b/src/Cake.Common/Tools/Command/CommandSettingsExtensions.cs new file mode 100644 index 0000000000..f28046bdb2 --- /dev/null +++ b/src/Cake.Common/Tools/Command/CommandSettingsExtensions.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Cake.Core.Tooling; + +namespace Cake.Common.Tools.Command +{ + /// + /// Contains functionality related to . + /// + public static class CommandSettingsExtensions + { + /// + /// Sets the tool executable names. + /// + /// The tools settings. + /// The tool executable names. + /// The ToolSettings type. + /// The tools settings. + public static T WithExecutableNames(this T toolSettings, params string[] toolExecutableNames) + where T : CommandSettings + => toolSettings.WithToolSettings(toolSettings => toolSettings.ToolExecutableNames = toolExecutableNames); + + /// + /// Sets the tool name. + /// + /// The tools settings. + /// The tool name. + /// The ToolSettings type. + /// The tools settings. + public static T WithToolName(this T toolSettings, string toolName) + where T : CommandSettings + => toolSettings.WithToolSettings(toolSettings => toolSettings.ToolName = toolName); + } +} diff --git a/src/Cake.Core.Tests/Unit/Tooling/ToolSettingsExtensionsTests.cs b/src/Cake.Core.Tests/Unit/Tooling/ToolSettingsExtensionsTests.cs new file mode 100644 index 0000000000..558f6326ec --- /dev/null +++ b/src/Cake.Core.Tests/Unit/Tooling/ToolSettingsExtensionsTests.cs @@ -0,0 +1,187 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Cake.Core.IO; +using Cake.Core.Tests.Fixtures; +using Cake.Core.Tooling; +using Xunit; + +namespace Cake.Core.Tests.Unit.Tooling +{ + public sealed class ToolSettingsExtensionsTests + { + [Fact] + public void Should_Call_WithToolSettings() + { + // Given + var fixture = new DummyToolFixture(); + FilePath expect = "/temp/WithToolSettings.exe"; + + // When + fixture.Settings.WithToolSettings(settings => settings.ToolPath = expect); + + // Then + Assert.Equal(expect.FullPath, fixture.Settings.ToolPath.FullPath); + } + + [Fact] + public void Should_Set_ToolPath() + { + // Given + var fixture = new DummyToolFixture(); + FilePath expect = "/temp/WithToolSettings.exe"; + + // When + fixture.Settings.WithToolPath(expect); + + // Then + Assert.Equal(expect.FullPath, fixture.Settings.ToolPath.FullPath); + } + + [Fact] + public void Should_Set_Timeout() + { + // Given + var fixture = new DummyToolFixture(); + var expect = TimeSpan.FromSeconds(42); + + // When + fixture.Settings.WithToolTimeout(expect); + + // Then + Assert.Equal(expect.TotalSeconds, fixture.Settings.ToolTimeout?.TotalSeconds); + } + + [Fact] + public void Should_Set_WorkingDirectory() + { + // Given + var fixture = new DummyToolFixture(); + DirectoryPath expect = "/temp/dir"; + + // When + fixture.Settings.WithWorkingDirectory(expect); + + // Then + Assert.Equal(expect.FullPath, fixture.Settings.WorkingDirectory.FullPath); + } + + [Fact] + public void Should_Set_NoWorkingDirectory() + { + // Given + var fixture = new DummyToolFixture(); + + // When + fixture.Settings.WithNoWorkingDirectory(); + + // Then + Assert.True(fixture.Settings.NoWorkingDirectory); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Should_Set_NoWorkingDirectory_Arg(bool noWorkingDirectory) + { + // Given + var fixture = new DummyToolFixture(); + + // When + fixture.Settings.WithNoWorkingDirectory(noWorkingDirectory); + + // Then + Assert.Equal(noWorkingDirectory, fixture.Settings.NoWorkingDirectory); + } + + [Fact] + public void Should_Set_ArgumentCustomization() + { + // Given + var fixture = new DummyToolFixture(); + Func expect = _ => _; + + // When + fixture.Settings.WithArgumentCustomization(expect); + + // Then + Assert.Same(expect, fixture.Settings.ArgumentCustomization); + } + + [Fact] + public void Should_Set_EnvironmentVariable() + { + // Given + var fixture = new DummyToolFixture(); + const string key = nameof(key); + const string value = nameof(value); + + // When + fixture.Settings.WithEnvironmentVariable(key, value); + + // Then + Assert.Equal(value, fixture.Settings.EnvironmentVariables[key]); + } + + [Fact] + public void Should_Set_HandleExitCode() + { + // Given + var fixture = new DummyToolFixture(); + Func expect = _ => false; + + // When + fixture.Settings.WithHandleExitCode(expect); + + // Then + Assert.Same(expect, fixture.Settings.HandleExitCode); + } + + [Fact] + public void Should_Set_PostAction() + { + // Given + var fixture = new DummyToolFixture(); + Action expect = _ => { }; + + // When + fixture.Settings.WithPostAction(expect); + + // Then + Assert.Same(expect, fixture.Settings.PostAction); + } + + [Fact] + public void Should_Set_SetupProcessSettings() + { + // Given + var fixture = new DummyToolFixture(); + Action expect = _ => { }; + + // When + fixture.Settings.WithSetupProcessSettings(expect); + + // Then + Assert.Same(expect, fixture.Settings.SetupProcessSettings); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void Should_Set_ReturnTrueForExpectedExitCode(int expectedExitCode) + { + // Given + var fixture = new DummyToolFixture(); + fixture.Settings.WithExpectedExitCode(expectedExitCode); + + // When + var result = fixture.Settings.HandleExitCode(expectedExitCode); + + // Then + Assert.True(result); + } + } +} \ No newline at end of file diff --git a/src/Cake.Core/Scripting/ScriptConventions.cs b/src/Cake.Core/Scripting/ScriptConventions.cs index 55e1eb36eb..dea181f53f 100644 --- a/src/Cake.Core/Scripting/ScriptConventions.cs +++ b/src/Cake.Core/Scripting/ScriptConventions.cs @@ -49,8 +49,9 @@ public IReadOnlyList GetDefaultNamespaces() "System.IO", "Cake.Core", "Cake.Core.IO", + "Cake.Core.Diagnostics", "Cake.Core.Scripting", - "Cake.Core.Diagnostics" + "Cake.Core.Tooling" }; } diff --git a/src/Cake.Core/Tooling/ToolSettingsExtensions.cs b/src/Cake.Core/Tooling/ToolSettingsExtensions.cs new file mode 100644 index 0000000000..4011095fd8 --- /dev/null +++ b/src/Cake.Core/Tooling/ToolSettingsExtensions.cs @@ -0,0 +1,152 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Cake.Core.IO; + +namespace Cake.Core.Tooling +{ + /// + /// Contains functionality related to . + /// + public static class ToolSettingsExtensions + { + /// + /// Provides fluent null guarded tool settings action. + /// + /// The ToolSettings type. + /// The tools settings. + /// The tools settings action. + /// The tools settings. + /// Thrown if or is null. + public static T WithToolSettings(this T toolSettings, Action toolSettingsAction) + where T : ToolSettings + { + if (toolSettings is null) + { + throw new ArgumentNullException(nameof(toolSettings)); + } + + if (toolSettingsAction is null) + { + throw new ArgumentNullException(nameof(toolSettingsAction)); + } + + toolSettingsAction.Invoke(toolSettings); + + return toolSettings; + } + + /// + /// Sets the tool path. + /// + /// The ToolSettings type. + /// The tools settings. + /// The tool path. + /// The tools settings. + public static T WithToolPath(this T toolSettings, FilePath toolPath) + where T : ToolSettings + => toolSettings.WithToolSettings(toolSettings => toolSettings.ToolPath = toolPath); + + /// + /// Sets the tool timeout. + /// + /// The ToolSettings type. + /// The tools settings. + /// The tool timeout. + /// The tools settings. + public static T WithToolTimeout(this T toolSettings, TimeSpan toolTimeout) + where T : ToolSettings + => toolSettings.WithToolSettings(toolSettings => toolSettings.ToolTimeout = toolTimeout); + + /// + /// Sets the tool working directory. + /// + /// The ToolSettings type. + /// The tools settings. + /// The tool working directory. + /// The tools settings. + public static T WithWorkingDirectory(this T toolSettings, DirectoryPath workingDirectory) + where T : ToolSettings + => toolSettings.WithToolSettings(toolSettings => toolSettings.WorkingDirectory = workingDirectory); + + /// + /// Sets whether the tool should use a working directory or not. + /// + /// The ToolSettings type. + /// The tools settings. + /// Flag for no working directory (default true). + /// The tools settings. + public static T WithNoWorkingDirectory(this T toolSettings, bool noWorkingDirectory = true) + where T : ToolSettings + => toolSettings.WithToolSettings(toolSettings => toolSettings.NoWorkingDirectory = noWorkingDirectory); + + /// + /// Sets the tool argument customization delegate. + /// + /// The ToolSettings type. + /// The tools settings. + /// The tool argument customization delegate. + /// The tools settings. + public static T WithArgumentCustomization(this T toolSettings, Func argumentCustomization) + where T : ToolSettings + => toolSettings.WithToolSettings(toolSettings => toolSettings.ArgumentCustomization = argumentCustomization); + + /// + /// Sets or adds tool environment variable. + /// + /// The ToolSettings type. + /// The tools settings. + /// The tool environment variable key. + /// The tool environment variable value. + /// The tools settings. + public static T WithEnvironmentVariable(this T toolSettings, string key, string value) + where T : ToolSettings + => toolSettings.WithToolSettings(toolSettings => toolSettings.EnvironmentVariables[key] = value); + + /// + /// Sets delegate whether the exit code from the tool process causes an exception to be thrown. + /// + /// The ToolSettings type. + /// The tools settings. + /// The tool handle exit code delegate. + /// The tools settings. + public static T WithHandleExitCode(this T toolSettings, Func handleExitCode) + where T : ToolSettings + => toolSettings.WithToolSettings(toolSettings => toolSettings.HandleExitCode = handleExitCode); + + /// + /// Sets a delegate which is executed after the tool process was started. + /// + /// The ToolSettings type. + /// The tools settings. + /// The tool argument customization delegate. + /// The tools settings. + public static T WithPostAction(this T toolSettings, Action postAction) + where T : ToolSettings + => toolSettings.WithToolSettings(toolSettings => toolSettings.PostAction = postAction); + + /// + /// Sets a delegate to configure the process settings. + /// + /// The ToolSettings type. + /// The tools settings. + /// The setup process settings delegate. + /// The tools settings. + public static T WithSetupProcessSettings(this T toolSettings, Action setupProcessSettings) + where T : ToolSettings + => toolSettings.WithToolSettings(toolSettings => toolSettings.SetupProcessSettings = setupProcessSettings); + + /// + /// Sets expected exit code using . + /// + /// The ToolSettings type. + /// The tools settings. + /// The tool expected exit code. + /// The tools settings. + public static T WithExpectedExitCode(this T toolSettings, int expectExitCode) + where T : ToolSettings + => toolSettings.WithToolSettings(toolSettings => toolSettings.HandleExitCode = exitCode => exitCode == expectExitCode); + } +} \ No newline at end of file diff --git a/tests/integration/Cake.Common/Tools/Command/CommandAliases.cake b/tests/integration/Cake.Common/Tools/Command/CommandAliases.cake new file mode 100644 index 0000000000..835911d09d --- /dev/null +++ b/tests/integration/Cake.Common/Tools/Command/CommandAliases.cake @@ -0,0 +1,174 @@ +#load "./../utilities/xunit.cake" + +////////////////////////////////////////////////////////////////////////////// + +Setup( + context => new CommandSettings { + ToolName = "dotnet", + ToolExecutableNames = new []{ "dotnet", "dotnet.exe" }, + } +); + +Task("Cake.Common.Tools.Command.CommandAliases.Command") + .Does(static (ctx, settings) => +{ + // Given, When, Then + ctx.Command(settings.ToolExecutableNames, "--version"); +}); + +Task("Cake.Common.Tools.Command.CommandAliases.Command.Settings") + .Does(static (ctx, settings) => +{ + // Given, When, Then + ctx.Command(settings, "--version"); +}); + +Task("Cake.Common.Tools.Command.CommandAliases.CommandStandardOutput") + .Does(static (ctx, settings) => +{ + // Given + const string expectStandardOutput = @"Description: + List tools installed globally or locally. + +Usage: + dotnet tool list [options] + +Options:"; + + // When + var exitCode = ctx.Command(settings.ToolExecutableNames, out var standardOutput, "tool list -h"); + + // Then + Assert.Equal(0, exitCode); + Assert.StartsWith( + expectStandardOutput.NormalizeLineEndings(), + standardOutput.NormalizeLineEndings()); +}); + +Task("Cake.Common.Tools.Command.CommandAliases.CommandStandardOutput.Settings") + .Does(static (ctx, settings) => +{ + // Given + const string expectStandardOutput = @"Description: + List tools installed globally or locally. + +Usage: + dotnet tool list [options] + +Options:"; + + // When + var exitCode = ctx.Command(settings, out var standardOutput, "tool list -h"); + + // Then + Assert.Equal(0, exitCode); + Assert.StartsWith( + expectStandardOutput.NormalizeLineEndings(), + standardOutput.NormalizeLineEndings()); +}); + +Task("Cake.Common.Tools.Command.CommandAliases.CommandStandardOutput.SettingsCustomization") + .Does(static (ctx, settings) => +{ + // Given + const string expectStandardOutput = @"Description: + List tools installed globally or locally. + +Usage: + dotnet tool list [options] + +Options:"; + + // When + var exitCode = ctx.Command( + settings.ToolExecutableNames, + out var standardOutput, + settingsCustomization: settings => settings.WithArgumentCustomization(args => "tool list -h") + ); + + // Then + Assert.Equal(0, exitCode); + Assert.StartsWith( + expectStandardOutput.NormalizeLineEndings(), + standardOutput.NormalizeLineEndings()); +}); + +Task("Cake.Common.Tools.Command.CommandAliases.CommandStandardError") + .Does(static (ctx, settings) => +{ + // Given + const string expectStandardOutput = @"Description: + Install or work with tools that extend the .NET experience. + +Usage: + dotnet tool [command] [options] + +Options: + -?, -h, --help Show command line help. + +Commands:"; + const string expectStandardError = "Required command was not provided."; + const int expectExitCode = 1; + + // When + var result = ctx.Command( + settings.ToolExecutableNames, + out var standardOutput, + out var standardError, + "tool", + expectExitCode); + + // Then + Assert.Equal(expectExitCode, result); + Assert.StartsWith( + expectStandardOutput.NormalizeLineEndings(), + standardOutput.NormalizeLineEndings()); + Assert.Equal( + expectStandardError.NormalizeLineEndings(), + standardError.NormalizeLineEndings()); +}); + +Task("Cake.Common.Tools.Command.CommandAliases.CommandStandardError.Settings") + .Does(static (ctx, settings) => +{ + // Given + const string expectStandardOutput = @"Description: + Install or work with tools that extend the .NET experience. + +Usage: + dotnet tool [command] [options] + +Options: + -?, -h, --help Show command line help. + +Commands:"; + const string expectStandardError = "Required command was not provided."; + const int expectExitCode = 1; + var errorSettings = new CommandSettings { + ToolName = settings.ToolName, + ToolExecutableNames = settings.ToolExecutableNames, + }.WithExpectedExitCode(expectExitCode); + + // When + var result = ctx.Command(errorSettings, out var standardOutput, out var standardError, "tool"); + + // Then + Assert.Equal(expectExitCode, result); + Assert.StartsWith( + expectStandardOutput.NormalizeLineEndings(), + standardOutput.NormalizeLineEndings()); + Assert.Equal( + expectStandardError.NormalizeLineEndings(), + standardError.NormalizeLineEndings()); +}); + +////////////////////////////////////////////////////////////////////////////// + +Task("Cake.Common.Tools.Command.CommandAliases") + .IsDependentOn("Cake.Common.Tools.Command.CommandAliases.Command") + .IsDependentOn("Cake.Common.Tools.Command.CommandAliases.Command.Settings") + .IsDependentOn("Cake.Common.Tools.Command.CommandAliases.CommandStandardOutput") + .IsDependentOn("Cake.Common.Tools.Command.CommandAliases.CommandStandardOutput.Settings") + .IsDependentOn("Cake.Common.Tools.Command.CommandAliases.CommandStandardOutput.SettingsCustomization") + .IsDependentOn("Cake.Common.Tools.Command.CommandAliases.CommandStandardError") + .IsDependentOn("Cake.Common.Tools.Command.CommandAliases.CommandStandardError.Settings"); \ No newline at end of file diff --git a/tests/integration/build.cake b/tests/integration/build.cake index 3f7a2c536a..d923d6455d 100644 --- a/tests/integration/build.cake +++ b/tests/integration/build.cake @@ -25,6 +25,7 @@ #load "./Cake.Common/Solution/Project/XmlDoc/XmlDocAliases.cake" #load "./Cake.Common/Text/TextTransformationAliases.cake" #load "./Cake.Common/Tools/Cake/CakeAliases.cake" +#load "./Cake.Common/Tools/Command/CommandAliases.cake" #load "./Cake.Common/Tools/DotNet/DotNetAliases.cake" #load "./Cake.Common/Tools/DotNetCore/DotNetCoreAliases.cake" #load "./Cake.Common/Tools/NuGet/NuGetAliases.cake" @@ -91,6 +92,7 @@ Task("Cake.Common") .IsDependentOn("Cake.Common.Solution.Project.XmlDoc.XmlDocAliases") .IsDependentOn("Cake.Common.Text.TextTransformationAliases") .IsDependentOn("Cake.Common.Tools.Cake.CakeAliases") + .IsDependentOn("Cake.Common.Tools.Command.CommandAliases") .IsDependentOn("Cake.Common.Tools.DotNet.DotNetAliases") .IsDependentOn("Cake.Common.Tools.DotNetCore.DotNetCoreAliases") .IsDependentOn("Cake.Common.Tools.NuGet.NuGetAliases")