From cc245e631fdb3b3e43de37e5f47591e3185e74a1 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Thu, 4 Dec 2025 19:07:24 +0100 Subject: [PATCH 1/4] Add unit tests for new text and placeholder extension functionality - Introduce unit tests for `EnumerableStringExtensions.ToDictionary`, `StringExtension.SplitIntoKeyValue`, and `PlaceholderReplacer`. - Improve test coverage for string transformation, placeholder replacement, and dictionary conversion edge cases. - Include the addition of `KeyAndValue` implementation for structured key-value handling. --- .../Placeholders/PlaceholderReplacer.cs | 37 ++++ .../Text/EnumerableStringExtensions.cs | 25 +++ .../CreativeCoders.Core/Text/KeyAndValue.cs | 9 + .../Text/StringExtension.cs | 21 ++ .../CreativeCoders.Core.UnitTests.csproj | 2 +- .../Placeholders/PlaceholderReplacerTests.cs | 179 ++++++++++++++++++ .../Text/EnumerableStringExtensionsTests.cs | 83 ++++++++ .../StringExtensionSplitIntoKeyValueTests.cs | 137 ++++++++++++++ 8 files changed, 492 insertions(+), 1 deletion(-) create mode 100644 source/Core/CreativeCoders.Core/Placeholders/PlaceholderReplacer.cs create mode 100644 source/Core/CreativeCoders.Core/Text/EnumerableStringExtensions.cs create mode 100644 source/Core/CreativeCoders.Core/Text/KeyAndValue.cs create mode 100644 tests/CreativeCoders.Core.UnitTests/Placeholders/PlaceholderReplacerTests.cs create mode 100644 tests/CreativeCoders.Core.UnitTests/Text/EnumerableStringExtensionsTests.cs create mode 100644 tests/CreativeCoders.Core.UnitTests/Text/StringExtensionSplitIntoKeyValueTests.cs diff --git a/source/Core/CreativeCoders.Core/Placeholders/PlaceholderReplacer.cs b/source/Core/CreativeCoders.Core/Placeholders/PlaceholderReplacer.cs new file mode 100644 index 00000000..78abbbe3 --- /dev/null +++ b/source/Core/CreativeCoders.Core/Placeholders/PlaceholderReplacer.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Linq; + +namespace CreativeCoders.Core.Placeholders; + +public class PlaceholderReplacer( + string placeholderPrefix, + string placeholderSuffix, + IDictionary placeholders) +{ + private readonly string _placeholderPrefix = Ensure.IsNotNullOrWhitespace(placeholderPrefix); + + private readonly string _placeholderSuffix = Ensure.IsNotNullOrWhitespace(placeholderSuffix); + + private readonly IDictionary _placeholders = Ensure.NotNull(placeholders); + + public string Replace(string text) + { + if (_placeholders.Count == 0) + { + return text; + } + + return _placeholders + .Aggregate(text, + (current, placeholder) => + current.Replace($"{_placeholderPrefix}{placeholder.Key}{_placeholderSuffix}", + placeholder.Value)); + } + + public IEnumerable Replace(IEnumerable lines) + { + return _placeholders.Count == 0 + ? lines + : lines.Select(Replace); + } +} diff --git a/source/Core/CreativeCoders.Core/Text/EnumerableStringExtensions.cs b/source/Core/CreativeCoders.Core/Text/EnumerableStringExtensions.cs new file mode 100644 index 00000000..017f5531 --- /dev/null +++ b/source/Core/CreativeCoders.Core/Text/EnumerableStringExtensions.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace CreativeCoders.Core.Text; + +public static class EnumerableStringExtensions +{ + public static IDictionary ToDictionary(this IEnumerable items, string separator, + bool ignoreInvalidEntries = true) + { + return items + .Select(x => x.SplitIntoKeyValue(separator)) + .Where(x => + { + if (x == null && !ignoreInvalidEntries) + { + throw new ArgumentException("Invalid key/value entry found"); + } + + return x != null; + }) + .ToDictionary(x => x.Key, x => x.Value); + } +} diff --git a/source/Core/CreativeCoders.Core/Text/KeyAndValue.cs b/source/Core/CreativeCoders.Core/Text/KeyAndValue.cs new file mode 100644 index 00000000..7227088f --- /dev/null +++ b/source/Core/CreativeCoders.Core/Text/KeyAndValue.cs @@ -0,0 +1,9 @@ +#nullable enable +namespace CreativeCoders.Core.Text; + +public class KeyAndValue(string key, string value) +{ + public string Key { get; } = Ensure.NotNull(key); + + public string Value { get; } = Ensure.NotNull(value); +} diff --git a/source/Core/CreativeCoders.Core/Text/StringExtension.cs b/source/Core/CreativeCoders.Core/Text/StringExtension.cs index 1efca63e..b9f4838a 100644 --- a/source/Core/CreativeCoders.Core/Text/StringExtension.cs +++ b/source/Core/CreativeCoders.Core/Text/StringExtension.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Runtime.CompilerServices; @@ -190,4 +191,24 @@ private static string SeparatedToPascalCase(this string? text, char separator) return parts.Aggregate(string.Empty, (current, part) => current + char.ToUpperInvariant(part[0]) + part[1..].ToLowerInvariant()); } + + public static KeyAndValue? SplitIntoKeyValue(this string? text, string separator) + { + if (string.IsNullOrEmpty(text)) + { + return null; + } + + var separatorIndex = text.IndexOf(separator, StringComparison.Ordinal); + + if (separatorIndex == -1) + { + return null; + } + + var key = text[..separatorIndex]; + var value = text[(separatorIndex + 1)..]; + + return new KeyAndValue(key, value); + } } diff --git a/tests/CreativeCoders.Core.UnitTests/CreativeCoders.Core.UnitTests.csproj b/tests/CreativeCoders.Core.UnitTests/CreativeCoders.Core.UnitTests.csproj index a1a4497f..814af972 100644 --- a/tests/CreativeCoders.Core.UnitTests/CreativeCoders.Core.UnitTests.csproj +++ b/tests/CreativeCoders.Core.UnitTests/CreativeCoders.Core.UnitTests.csproj @@ -28,7 +28,7 @@ - + diff --git a/tests/CreativeCoders.Core.UnitTests/Placeholders/PlaceholderReplacerTests.cs b/tests/CreativeCoders.Core.UnitTests/Placeholders/PlaceholderReplacerTests.cs new file mode 100644 index 00000000..597da5b6 --- /dev/null +++ b/tests/CreativeCoders.Core.UnitTests/Placeholders/PlaceholderReplacerTests.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using AwesomeAssertions; +using CreativeCoders.Core.Placeholders; +using Xunit; + +namespace CreativeCoders.Core.UnitTests.Placeholders; + +/// +/// Tests for to verify placeholder replacement for single strings +/// and sequences, as well as guard clauses and edge cases. +/// +[SuppressMessage("ReSharper", "NullableWarningSuppressionIsUsed")] +public class PlaceholderReplacerTests +{ + [Fact] + public void Replace_String_ReplacesAllOccurrencesOfAllPlaceholders() + { + // Arrange + var placeholders = new Dictionary + { + ["Name"] = "Alice", + ["City"] = "Berlin" + }; + + var replacer = new PlaceholderReplacer("${", "}", placeholders); + const string input = "Hello ${Name}, welcome to ${City}. ${Name} likes ${City}."; + + // Act + var result = replacer.Replace(input); + + // Assert + result + .Should() + .Be("Hello Alice, welcome to Berlin. Alice likes Berlin."); + } + + [Fact] + public void Replace_String_WithNoMatchingPlaceholders_ReturnsOriginalString() + { + // Arrange + var placeholders = new Dictionary + { + ["User"] = "Bob" + }; + var replacer = new PlaceholderReplacer("<%", "%>", placeholders); + const string input = "Hello ${User}"; // token style does not match + + // Act + var result = replacer.Replace(input); + + // Assert + result + .Should() + .Be(input); + } + + [Fact] + public void Replace_String_WithEmptyPlaceholderDictionary_ReturnsSameReference() + { + // Arrange + var replacer = new PlaceholderReplacer("${", "}", new Dictionary()); + var input = "Keep me as is"; + + // Act + var result = replacer.Replace(input); + + // Assert + ReferenceEquals(result, input) + .Should() + .BeTrue(); + } + + [Fact] + public void Replace_Lines_ReplacesAcrossAllLines() + { + // Arrange + var placeholders = new Dictionary + { + ["Env"] = "Prod", + ["Ver"] = "1.2.3" + }; + var replacer = new PlaceholderReplacer("${", "}", placeholders); + var lines = new[] + { + "Environment: ${Env}", + "Version: ${Ver}", + "${Env}-${Ver}" + }; + + // Act + var result = replacer.Replace(lines).ToArray(); + + // Assert + result.Length + .Should() + .Be(3); + + result + .Should() + .BeEquivalentTo("Environment: Prod", "Version: 1.2.3", "Prod-1.2.3"); + } + + [Fact] + public void Replace_Lines_WithEmptyPlaceholderDictionary_ReturnsSameEnumerableReference() + { + // Arrange + var lines = new[] { "a", "b" }; + var replacer = new PlaceholderReplacer("${", "}", new Dictionary()); + + // Act + var result = replacer.Replace(lines); + + // Assert + ReferenceEquals(result, lines) + .Should() + .BeTrue(); + } + + [Fact] + public void Constructor_WithNullPlaceholders_Throws() + { + // Act + var act = () => new PlaceholderReplacer("${", "}", null!); + + // Assert + act + .Should() + .ThrowExactly(); + } + + [Fact] + public void Constructor_WithInvalidPrefixOrSuffix_Throws() + { + // Arrange + var placeholders = new Dictionary(); + + // Act + var actPrefixNull = () => new PlaceholderReplacer(null!, "}", placeholders); + var actPrefixEmpty = () => new PlaceholderReplacer(string.Empty, "}", placeholders); + var actPrefixWs = () => new PlaceholderReplacer(" ", "}", placeholders); + + var actSuffixNull = () => new PlaceholderReplacer("${", null!, placeholders); + var actSuffixEmpty = () => new PlaceholderReplacer("${", string.Empty, placeholders); + var actSuffixWs = () => new PlaceholderReplacer("${", " ", placeholders); + + // Assert + actPrefixNull.Should().ThrowExactly(); + actPrefixEmpty.Should().ThrowExactly(); + actPrefixWs.Should().ThrowExactly(); + + actSuffixNull.Should().ThrowExactly(); + actSuffixEmpty.Should().ThrowExactly(); + actSuffixWs.Should().ThrowExactly(); + } + + [Fact] + public void Replace_WithOverlappingPlaceholderNames_ReplacesExactTokensOnly() + { + // Arrange + var placeholders = new Dictionary + { + ["A"] = "x", + ["AB"] = "y" + }; + var replacer = new PlaceholderReplacer("${", "}", placeholders); + const string input = "${A}-${AB}-${A}${AB}"; + + // Act + var result = replacer.Replace(input); + + // Assert + result + .Should() + .Be("x-y-xy"); + } +} diff --git a/tests/CreativeCoders.Core.UnitTests/Text/EnumerableStringExtensionsTests.cs b/tests/CreativeCoders.Core.UnitTests/Text/EnumerableStringExtensionsTests.cs new file mode 100644 index 00000000..3ab4f834 --- /dev/null +++ b/tests/CreativeCoders.Core.UnitTests/Text/EnumerableStringExtensionsTests.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AwesomeAssertions; +using CreativeCoders.Core.Text; +using Xunit; + +namespace CreativeCoders.Core.UnitTests.Text; + +/// +/// Tests for verifying conversion of string sequences +/// into dictionaries using a key/value separator. +/// +public class EnumerableStringExtensionsTests +{ + [Fact] + public void ToDictionary_WithValidItems_ReturnsExpectedDictionary() + { + // Arrange + var items = new[] { "A:1", "B:2", "C:3" }; + + // Act + var result = items.ToDictionary(":"); + + // Assert + result + .Should() + .HaveCount(3); + + result["A"].Should().Be("1"); + result["B"].Should().Be("2"); + result["C"].Should().Be("3"); + } + + [Fact] + public void ToDictionary_WithDuplicateKeys_ThrowsArgumentException() + { + // Arrange + var items = new[] { "Key:1", "Key:2" }; + + // Act + var act = () => items.ToDictionary(":"); + + // Assert + act + .Should() + .ThrowExactly(); + } + + [Fact] + public void ToDictionary_ItemWithoutSeparator_ReturnsOnlyValidItems() + { + // Arrange + var items = new[] { "Key:1", "Invalid" }; + + // Act + var dict = items.ToDictionary(":", true); + + // Assert + dict + .Should() + .HaveCount(1); + + dict["Key"] + .Should() + .Be("1"); + } + + [Fact] + public void ToDictionary_ItemWithoutSeparator_ThrowsArgumentException() + { + // Arrange + var items = new[] { "Key:1", "Invalid" }; + + // Act + var act = () => items.ToDictionary(":", false); + + // Assert + act + .Should() + .ThrowExactly(); + } +} diff --git a/tests/CreativeCoders.Core.UnitTests/Text/StringExtensionSplitIntoKeyValueTests.cs b/tests/CreativeCoders.Core.UnitTests/Text/StringExtensionSplitIntoKeyValueTests.cs new file mode 100644 index 00000000..83aa2f47 --- /dev/null +++ b/tests/CreativeCoders.Core.UnitTests/Text/StringExtensionSplitIntoKeyValueTests.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using AwesomeAssertions; +using CreativeCoders.Core.Text; +using Xunit; + +namespace CreativeCoders.Core.UnitTests.Text; + +/// +/// Tests for . +/// +[SuppressMessage("ReSharper", "NullableWarningSuppressionIsUsed")] +public class StringExtensionSplitIntoKeyValueTests +{ + [Fact] + public void SplitIntoKeyValue_WithSingleCharSeparator_ReturnsKeyAndValue() + { + // Arrange + const string input = "User:Alice"; + + // Act + var kv = input.SplitIntoKeyValue(":"); + + // Assert + kv + .Should() + .NotBeNull(); + + kv!.Key + .Should() + .Be("User"); + + kv.Value + .Should() + .Be("Alice"); + } + + [Fact] + public void SplitIntoKeyValue_WithMultipleSeparators_UsesFirstOccurrence() + { + // Arrange + const string input = "A:B:C"; + + // Act + var kv = input.SplitIntoKeyValue(":"); + + // Assert + kv + .Should() + .NotBeNull(); + + kv!.Key + .Should() + .Be("A"); + + kv.Value + .Should() + .Be("B:C"); + } + + [Fact] + public void SplitIntoKeyValue_WithEmptyKey_ReturnsEmptyKey() + { + // Arrange + const string input = ":value"; + + // Act + var kv = input.SplitIntoKeyValue(":"); + + // Assert + kv + .Should() + .NotBeNull(); + + kv!.Key + .Should() + .Be(string.Empty); + + kv.Value + .Should() + .Be("value"); + } + + [Fact] + public void SplitIntoKeyValue_WithEmptyValue_ReturnsEmptyValue() + { + // Arrange + const string input = "key:"; + + // Act + var kv = input.SplitIntoKeyValue(":"); + + // Assert + kv + .Should() + .NotBeNull(); + + kv!.Key + .Should() + .Be("key"); + + kv.Value + .Should() + .Be(string.Empty); + } + + [Fact] + public void SplitIntoKeyValue_WhenSeparatorMissing_ThrowsArgumentOutOfRangeException() + { + // Arrange + const string input = "NoSeparatorHere"; + + // Act + var kv = input.SplitIntoKeyValue(":"); + + // Assert + kv + .Should() + .BeNull(); + } + + [Fact] + public void SplitIntoKeyValue_InputIsNull_ThrowsArgumentOutOfRangeException() + { + // Arrange + const string input = null; + + // Act + var kv = input.SplitIntoKeyValue(":"); + + // Assert + kv + .Should() + .BeNull(); + } +} From 03ec4eb08d9ea9051fc1ff3621a2d44de11f6ada Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Thu, 4 Dec 2025 19:16:52 +0100 Subject: [PATCH 2/4] Add support for placeholder replacement in process execution and extend `IProcessExecutor` functionality. - Added `usePlaceholderVars` to `ProcessExecutorInfo` for enabling argument placeholder replacement. - Introduced placeholder replacement in `ProcessExecutorBase` using `EnumerableStringExtensions.ReplacePlaceholders`. - Updated `IProcessExecutor` to support overloaded `Execute` and `ExecuteAsync` methods with custom arguments. - Enhanced `IProcessExecutorBuilder` to configure `usePlaceholderVars`. - Updated related unit tests to include `usePlaceholderVars` handling. --- .../Text/EnumerableStringExtensions.cs | 11 +++++++- .../Execution/IProcessExecutor.cs | 8 ++++++ .../Execution/IProcessExecutorBuilder.cs | 4 +-- .../Execution/ProcessExecutor.cs | 20 ++++++++++++++ .../Execution/ProcessExecutorBase.cs | 26 +++++++++++++++++-- .../Execution/ProcessExecutorBuilder.cs | 16 ++++++++---- .../Execution/ProcessExecutorBuilderBase.cs | 4 ++- .../Execution/ProcessExecutorInfo.cs | 12 ++++++--- .../Execution/ProcessExecutorGenericTests.cs | 8 +++--- .../Execution/ProcessExecutorTests.cs | 12 ++++----- 10 files changed, 97 insertions(+), 24 deletions(-) diff --git a/source/Core/CreativeCoders.Core/Text/EnumerableStringExtensions.cs b/source/Core/CreativeCoders.Core/Text/EnumerableStringExtensions.cs index 017f5531..f0ac1014 100644 --- a/source/Core/CreativeCoders.Core/Text/EnumerableStringExtensions.cs +++ b/source/Core/CreativeCoders.Core/Text/EnumerableStringExtensions.cs @@ -1,12 +1,13 @@ using System; using System.Collections.Generic; using System.Linq; +using CreativeCoders.Core.Placeholders; namespace CreativeCoders.Core.Text; public static class EnumerableStringExtensions { - public static IDictionary ToDictionary(this IEnumerable items, string separator, + public static Dictionary ToDictionary(this IEnumerable items, string separator, bool ignoreInvalidEntries = true) { return items @@ -22,4 +23,12 @@ public static IDictionary ToDictionary(this IEnumerable }) .ToDictionary(x => x.Key, x => x.Value); } + + public static IEnumerable ReplacePlaceholders(this IEnumerable items, + string placeholderPrefix, string placeholderSuffix, IDictionary placeholders) + { + var replacer = new PlaceholderReplacer(placeholderPrefix, placeholderSuffix, placeholders); + + return replacer.Replace(items); + } } diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs index 73e535cd..1edf1910 100644 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs @@ -7,11 +7,19 @@ public interface IProcessExecutor { T? Execute(); + T? Execute(string[] args); + Task ExecuteAsync(); + Task ExecuteAsync(string[] args); + ProcessExecutionResult ExecuteEx(); + ProcessExecutionResult ExecuteEx(string[] args); + Task> ExecuteExAsync(); + + Task> ExecuteExAsync(string[] args); } [PublicAPI] diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutorBuilder.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutorBuilder.cs index cd9f1014..e93ede90 100644 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutorBuilder.cs +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutorBuilder.cs @@ -7,7 +7,7 @@ public interface IProcessExecutorBuilder { IProcessExecutorBuilder SetFileName(string fileName); - IProcessExecutorBuilder SetArguments(string[] arguments); + IProcessExecutorBuilder SetArguments(string[] arguments, bool usePlaceholderVars = false); IProcessExecutorBuilder SetOutputParser(IProcessOutputParser parser); @@ -22,7 +22,7 @@ public interface IProcessExecutorBuilder { IProcessExecutorBuilder SetFileName(string fileName); - IProcessExecutorBuilder SetArguments(string[] arguments); + IProcessExecutorBuilder SetArguments(string[] arguments, bool usePlaceholderVars = false); IProcessExecutor Build(); } diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutor.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutor.cs index d828319e..e3e3b05e 100644 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutor.cs +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutor.cs @@ -60,6 +60,11 @@ public class ProcessExecutor(ProcessExecutorInfo processExecutorInfo, IPro return result.Value; } + public T? Execute(string[] args) + { + throw new NotImplementedException(); + } + public async Task ExecuteAsync() { using var result = await ExecuteExAsync().ConfigureAwait(false); @@ -67,6 +72,11 @@ public class ProcessExecutor(ProcessExecutorInfo processExecutorInfo, IPro return result.Value; } + public Task ExecuteAsync(string[] args) + { + throw new NotImplementedException(); + } + public ProcessExecutionResult ExecuteEx() { var process = StartProcess(); @@ -78,6 +88,11 @@ public class ProcessExecutor(ProcessExecutorInfo processExecutorInfo, IPro return new ProcessExecutionResult(process, _outputParser.ParseOutput(output)); } + public ProcessExecutionResult ExecuteEx(string[] args) + { + throw new NotImplementedException(); + } + public async Task> ExecuteExAsync() { var process = StartProcess(); @@ -88,4 +103,9 @@ public class ProcessExecutor(ProcessExecutorInfo processExecutorInfo, IPro return new ProcessExecutionResult(process, _outputParser.ParseOutput(output)); } + + public Task> ExecuteExAsync(string[] args) + { + throw new NotImplementedException(); + } } diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBase.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBase.cs index 5d342120..5d0e8f18 100644 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBase.cs +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBase.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using CreativeCoders.Core; +using CreativeCoders.Core.Text; namespace CreativeCoders.ProcessUtils.Execution; @@ -11,12 +12,12 @@ public abstract class ProcessExecutorBase( private readonly IProcessFactory _processFactory = Ensure.NotNull(processFactory); - private ProcessStartInfo CreateProcessStartInfo() + private ProcessStartInfo CreateProcessStartInfo(string[]? args = null) { var startupInfo = new ProcessStartInfo { FileName = _processExecutorInfo.FileName, - Arguments = string.Join(" ", _processExecutorInfo.Arguments), + Arguments = string.Join(" ", BuildArguments(args)), RedirectStandardOutput = _processExecutorInfo.RedirectStandardOutput, RedirectStandardError = _processExecutorInfo.RedirectStandardError, RedirectStandardInput = _processExecutorInfo.RedirectStandardInput, @@ -27,6 +28,27 @@ private ProcessStartInfo CreateProcessStartInfo() return startupInfo; } + private string[] BuildArguments(string[]? args = null) + { + return _processExecutorInfo.UsePlaceholderVars + ? ReplacePlaceholders(_processExecutorInfo.Arguments, args) + : args ?? _processExecutorInfo.Arguments; + } + + private static string[] ReplacePlaceholders(string[] arguments, string[]? args) + { + if (args == null) + { + return arguments; + } + + var replaceVars = args.ToDictionary(":", false); + + return arguments + .ReplacePlaceholders("{{", "}}", replaceVars) + .ToArray(); + } + protected IProcess StartProcess() { var startupInfo = CreateProcessStartInfo(); diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilder.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilder.cs index f0564655..fef80322 100644 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilder.cs +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilder.cs @@ -15,9 +15,10 @@ public IProcessExecutorBuilder SetFileName(string fileName) return this; } - public IProcessExecutorBuilder SetArguments(string[] arguments) + public IProcessExecutorBuilder SetArguments(string[] arguments, bool usePlaceholderVars = false) { Arguments = arguments; + UsePlaceholderVars = usePlaceholderVars; return this; } @@ -31,7 +32,8 @@ public IProcessExecutor Build() var executorInfo = new ProcessExecutorInfo( FileName, - Arguments ?? []); + Arguments ?? [], + UsePlaceholderVars); return new ProcessExecutor(executorInfo, _processFactory); } @@ -51,9 +53,10 @@ public IProcessExecutorBuilder SetFileName(string fileName) return this; } - public IProcessExecutorBuilder SetArguments(string[] arguments) + public IProcessExecutorBuilder SetArguments(string[] arguments, bool usePlaceholderVars = false) { Arguments = arguments; + UsePlaceholderVars = usePlaceholderVars; return this; } @@ -65,7 +68,8 @@ public IProcessExecutorBuilder SetOutputParser(IProcessOutputParser parser return this; } - public IProcessExecutorBuilder SetOutputParser(Action? configure = null) where TParser : IProcessOutputParser, new() + public IProcessExecutorBuilder SetOutputParser(Action? configure = null) + where TParser : IProcessOutputParser, new() { var parser = new TParser(); @@ -106,7 +110,9 @@ public IProcessExecutor Build() var executorInfo = new ProcessExecutorInfo( FileName, Arguments ?? [], - _outputParser ?? throw new InvalidOperationException("OutputParser must be set before building the executor.")); + UsePlaceholderVars, + _outputParser ?? + throw new InvalidOperationException("OutputParser must be set before building the executor.")); return new ProcessExecutor(executorInfo, _processFactory); } diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilderBase.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilderBase.cs index 51911e13..e00cc3a6 100644 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilderBase.cs +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilderBase.cs @@ -5,4 +5,6 @@ public abstract class ProcessExecutorBuilderBase protected string? FileName { get; set; } protected string[]? Arguments { get; set; } -} \ No newline at end of file + + protected bool UsePlaceholderVars { get; set; } +} diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorInfo.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorInfo.cs index 4cb67098..250cfbfb 100644 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorInfo.cs +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorInfo.cs @@ -1,17 +1,23 @@ namespace CreativeCoders.ProcessUtils.Execution; -public class ProcessExecutorInfo(string fileName, string[] arguments, IProcessOutputParser outputParser) - : ProcessExecutorInfo(fileName, arguments) +public class ProcessExecutorInfo( + string fileName, + string[] arguments, + bool usePlaceholderVars, + IProcessOutputParser outputParser) + : ProcessExecutorInfo(fileName, arguments, usePlaceholderVars) { public IProcessOutputParser OutputParser { get; set; } = outputParser; } -public class ProcessExecutorInfo(string fileName, string[] arguments) +public class ProcessExecutorInfo(string fileName, string[] arguments, bool usePlaceholderVars) { public string FileName { get; } = fileName; public string[] Arguments { get; } = arguments; + public bool UsePlaceholderVars { get; set; } = usePlaceholderVars; + public bool RedirectStandardOutput { get; set; } = true; public bool RedirectStandardError { get; set; } = true; diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorGenericTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorGenericTests.cs index d40a1799..5b6e82a6 100644 --- a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorGenericTests.cs +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorGenericTests.cs @@ -24,7 +24,7 @@ public void Execute_ReadsOutput_AndParsesResult() A.CallTo(() => parser.ParseOutput("42\n")) .Returns(42); - var info = new ProcessExecutorInfo(fileName, args, parser); + var info = new ProcessExecutorInfo(fileName, args, false, parser); // Fake process with output var fakeProcess = A.Fake(); @@ -64,7 +64,7 @@ public void ExecuteEx_ReadsOutput_AndParsesResult() A.CallTo(() => parser.ParseOutput("42\n")) .Returns(42); - var info = new ProcessExecutorInfo(fileName, args, parser); + var info = new ProcessExecutorInfo(fileName, args, false, parser); // Fake process with output var fakeProcess = A.Fake(); @@ -110,7 +110,7 @@ public async Task ExecuteAsync_ReadsOutputAsync_AndParsesResult() A.CallTo(() => parser.ParseOutput("hello world")) .Returns("hello world"); - var info = new ProcessExecutorInfo(fileName, args, parser); + var info = new ProcessExecutorInfo(fileName, args, false, parser); var fakeProcess = A.Fake(); @@ -152,7 +152,7 @@ public async Task ExecuteExAsync_ReadsOutput_AndParsesResult() A.CallTo(() => parser.ParseOutput("42\n")) .Returns(42); - var info = new ProcessExecutorInfo(fileName, args, parser); + var info = new ProcessExecutorInfo(fileName, args, false, parser); // Fake process with output var fakeProcess = A.Fake(); diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorTests.cs index ed978ea4..a099ea01 100644 --- a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorTests.cs +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorTests.cs @@ -20,7 +20,7 @@ public void Execute_StartsProcess_WithConfiguredStartInfo_AndWaitsForExit() const string fileName = "my-tool"; var args = new[] { "arg1", "arg2" }; - var info = new ProcessExecutorInfo(fileName, args); + var info = new ProcessExecutorInfo(fileName, args, false); var fakeProcess = A.Fake(); @@ -80,7 +80,7 @@ public void ExecuteEx_StartsProcess_WithConfiguredStartInfo_AndWaitsForExit() const string fileName = "my-tool"; var args = new[] { "arg1", "arg2" }; - var info = new ProcessExecutorInfo(fileName, args); + var info = new ProcessExecutorInfo(fileName, args, false); var fakeProcess = A.Fake(); @@ -144,7 +144,7 @@ public async Task ExecuteAsync_StartsProcess_WithConfiguredStartInfo_AndWaitsFor var fileName = "my-tool-async"; var args = new[] { "a", "b" }; - var info = new ProcessExecutorInfo(fileName, args); + var info = new ProcessExecutorInfo(fileName, args, false); var fakeProcess = A.Fake(); @@ -208,7 +208,7 @@ public async Task ExecuteExAsync_StartsProcess_WithConfiguredStartInfo_AndWaitsF var fileName = "my-tool-async"; var args = new[] { "a", "b" }; - var info = new ProcessExecutorInfo(fileName, args); + var info = new ProcessExecutorInfo(fileName, args, false); var fakeProcess = A.Fake(); @@ -277,7 +277,7 @@ public void ExecuteAndReturnExitCode_StartsProcess_WithConfiguredStartInfo_Retur var args = new[] { "arg1", "arg2" }; const int expectedExitCode = 1235; - var info = new ProcessExecutorInfo(fileName, args); + var info = new ProcessExecutorInfo(fileName, args, false); var fakeProcess = A.Fake(); @@ -310,7 +310,7 @@ public async Task ExecuteAndReturnExitCodeAsync_StartsProcess_WithConfiguredStar var args = new[] { "a", "b" }; const int expectedExitCode = 1235; - var info = new ProcessExecutorInfo(fileName, args); + var info = new ProcessExecutorInfo(fileName, args, false); var fakeProcess = A.Fake(); From b3b54fc11953de2d248c056571ceb80f204025df Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Fri, 5 Dec 2025 11:20:57 +0100 Subject: [PATCH 3/4] Remove `usePlaceholderVars` property, refactor argument handling, and extend support for placeholder replacements. - Removed `usePlaceholderVars` from `ProcessExecutorInfo` and related methods in `IProcessExecutorBuilder`. - Added overloads for `Execute` and `ExecuteAsync` methods to directly accept placeholder variables. - Refactored internal process execution logic to improve flexibility. - Updated related tests to include direct placeholder variable handling. --- .../CreativeCoders.Core/ObjectExtensions.cs | 20 +- .../Placeholders/PlaceholderReplacer.cs | 15 +- .../Text/EnumerableStringExtensions.cs | 12 +- .../Text/StringExtension.cs | 2 + .../Execution/IProcessExecutor.cs | 31 ++ .../Execution/IProcessExecutorBuilder.cs | 4 +- .../Execution/ProcessExecutor.cs | 137 ++++-- .../Execution/ProcessExecutorBase.cs | 28 +- .../Execution/ProcessExecutorBuilder.cs | 6 +- .../Execution/ProcessExecutorInfo.cs | 2 - .../ObjectExtensionsTests.cs | 44 +- .../Placeholders/PlaceholderReplacerTests.cs | 66 ++- .../Execution/ProcessExecutorGenericTests.cs | 392 ++++++++++++++++ .../Execution/ProcessExecutorTests.cs | 428 ++++++++++++++++++ .../UnitTestDemoObject.cs | 14 + 15 files changed, 1130 insertions(+), 71 deletions(-) create mode 100644 tests/CreativeCoders.Core.UnitTests/UnitTestDemoObject.cs diff --git a/source/Core/CreativeCoders.Core/ObjectExtensions.cs b/source/Core/CreativeCoders.Core/ObjectExtensions.cs index a1f4ba60..014047e4 100644 --- a/source/Core/CreativeCoders.Core/ObjectExtensions.cs +++ b/source/Core/CreativeCoders.Core/ObjectExtensions.cs @@ -1,5 +1,8 @@ using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; using System.Threading.Tasks; using JetBrains.Annotations; @@ -66,7 +69,7 @@ public static async ValueTask TryDisposeAsync(this object instance) throw new MissingMemberException(instance.GetType().Name, propertyName); } - return (T?) propInfo.GetValue(instance); + return (T?)propInfo.GetValue(instance); } public static void SetPropertyValue(this object instance, string propertyName, T? value) @@ -80,4 +83,19 @@ public static void SetPropertyValue(this object instance, string propertyName propInfo.SetValue(instance, value); } + + public static Dictionary ToDictionary(this object obj) + { + Ensure.NotNull(obj); + + return obj + .GetType() + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(propertyInfo => ReadProperty(obj, propertyInfo)) + .ToDictionary(x => x.PropertyName, x => x.PropertyValue); + } + + private static (string PropertyName, object? PropertyValue) ReadProperty(object obj, + PropertyInfo propertyInfo) + => (propertyInfo.Name, propertyInfo.GetValue(obj)); } diff --git a/source/Core/CreativeCoders.Core/Placeholders/PlaceholderReplacer.cs b/source/Core/CreativeCoders.Core/Placeholders/PlaceholderReplacer.cs index 78abbbe3..fa8be59f 100644 --- a/source/Core/CreativeCoders.Core/Placeholders/PlaceholderReplacer.cs +++ b/source/Core/CreativeCoders.Core/Placeholders/PlaceholderReplacer.cs @@ -1,20 +1,23 @@ using System.Collections.Generic; using System.Linq; +using JetBrains.Annotations; namespace CreativeCoders.Core.Placeholders; +#nullable enable + public class PlaceholderReplacer( string placeholderPrefix, string placeholderSuffix, - IDictionary placeholders) + IDictionary placeholders) { private readonly string _placeholderPrefix = Ensure.IsNotNullOrWhitespace(placeholderPrefix); private readonly string _placeholderSuffix = Ensure.IsNotNullOrWhitespace(placeholderSuffix); - private readonly IDictionary _placeholders = Ensure.NotNull(placeholders); + private readonly IDictionary _placeholders = Ensure.NotNull(placeholders); - public string Replace(string text) + public string Replace(string text, bool allowNull = false) { if (_placeholders.Count == 0) { @@ -25,13 +28,13 @@ public string Replace(string text) .Aggregate(text, (current, placeholder) => current.Replace($"{_placeholderPrefix}{placeholder.Key}{_placeholderSuffix}", - placeholder.Value)); + placeholder.Value.ToStringSafe(allowNull ? "null" : string.Empty))); } - public IEnumerable Replace(IEnumerable lines) + public IEnumerable Replace(IEnumerable lines, bool allowNull = false) { return _placeholders.Count == 0 ? lines - : lines.Select(Replace); + : lines.Select(x => Replace(x, allowNull)); } } diff --git a/source/Core/CreativeCoders.Core/Text/EnumerableStringExtensions.cs b/source/Core/CreativeCoders.Core/Text/EnumerableStringExtensions.cs index f0ac1014..f3251fe8 100644 --- a/source/Core/CreativeCoders.Core/Text/EnumerableStringExtensions.cs +++ b/source/Core/CreativeCoders.Core/Text/EnumerableStringExtensions.cs @@ -1,15 +1,21 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using CreativeCoders.Core.Placeholders; namespace CreativeCoders.Core.Text; +#nullable enable + public static class EnumerableStringExtensions { public static Dictionary ToDictionary(this IEnumerable items, string separator, bool ignoreInvalidEntries = true) { + Ensure.NotNull(items); + Ensure.NotNull(separator); + return items .Select(x => x.SplitIntoKeyValue(separator)) .Where(x => @@ -24,9 +30,13 @@ public static Dictionary ToDictionary(this IEnumerable i .ToDictionary(x => x.Key, x => x.Value); } + [ExcludeFromCodeCoverage] public static IEnumerable ReplacePlaceholders(this IEnumerable items, - string placeholderPrefix, string placeholderSuffix, IDictionary placeholders) + string placeholderPrefix, string placeholderSuffix, + IDictionary placeholders) { + Ensure.NotNull(items); + var replacer = new PlaceholderReplacer(placeholderPrefix, placeholderSuffix, placeholders); return replacer.Replace(items); diff --git a/source/Core/CreativeCoders.Core/Text/StringExtension.cs b/source/Core/CreativeCoders.Core/Text/StringExtension.cs index b9f4838a..2e600c2d 100644 --- a/source/Core/CreativeCoders.Core/Text/StringExtension.cs +++ b/source/Core/CreativeCoders.Core/Text/StringExtension.cs @@ -194,6 +194,8 @@ private static string SeparatedToPascalCase(this string? text, char separator) public static KeyAndValue? SplitIntoKeyValue(this string? text, string separator) { + Ensure.NotNull(separator); + if (string.IsNullOrEmpty(text)) { return null; diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs index 1edf1910..8fd88939 100644 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs @@ -9,17 +9,25 @@ public interface IProcessExecutor T? Execute(string[] args); + T? Execute(IDictionary placeholderVars); + Task ExecuteAsync(); Task ExecuteAsync(string[] args); + Task ExecuteAsync(IDictionary placeholderVars); + ProcessExecutionResult ExecuteEx(); ProcessExecutionResult ExecuteEx(string[] args); + ProcessExecutionResult ExecuteEx(IDictionary placeholderVars); + Task> ExecuteExAsync(); Task> ExecuteExAsync(string[] args); + + Task> ExecuteExAsync(IDictionary placeholderVars); } [PublicAPI] @@ -27,13 +35,36 @@ public interface IProcessExecutor { void Execute(); + void Execute(string[] args); + + void Execute(IDictionary placeholderVars); + Task ExecuteAsync(); + Task ExecuteAsync(string[] args); + + Task ExecuteAsync(IDictionary placeholderVars); + IProcess ExecuteEx(); + IProcess ExecuteEx(string[] args); + IProcess ExecuteEx(IDictionary placeholderVars); + Task ExecuteExAsync(); + Task ExecuteExAsync(string[] args); + + Task ExecuteExAsync(IDictionary placeholderVars); + int ExecuteAndReturnExitCode(); + int ExecuteAndReturnExitCode(string[] args); + + int ExecuteAndReturnExitCode(IDictionary placeholderVars); + Task ExecuteAndReturnExitCodeAsync(); + + Task ExecuteAndReturnExitCodeAsync(string[] args); + + Task ExecuteAndReturnExitCodeAsync(IDictionary placeholderVars); } diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutorBuilder.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutorBuilder.cs index e93ede90..cd9f1014 100644 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutorBuilder.cs +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutorBuilder.cs @@ -7,7 +7,7 @@ public interface IProcessExecutorBuilder { IProcessExecutorBuilder SetFileName(string fileName); - IProcessExecutorBuilder SetArguments(string[] arguments, bool usePlaceholderVars = false); + IProcessExecutorBuilder SetArguments(string[] arguments); IProcessExecutorBuilder SetOutputParser(IProcessOutputParser parser); @@ -22,7 +22,7 @@ public interface IProcessExecutorBuilder { IProcessExecutorBuilder SetFileName(string fileName); - IProcessExecutorBuilder SetArguments(string[] arguments, bool usePlaceholderVars = false); + IProcessExecutorBuilder SetArguments(string[] arguments); IProcessExecutor Build(); } diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutor.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutor.cs index e3e3b05e..8a7e575d 100644 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutor.cs +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutor.cs @@ -10,23 +10,61 @@ public void Execute() using var _ = ExecuteEx(); } + public void Execute(string[] args) + { + using var _ = ExecuteEx(args); + } + + public void Execute(IDictionary placeholderVars) + { + using var _ = ExecuteEx(placeholderVars); + } + public async Task ExecuteAsync() { using var _ = await ExecuteExAsync().ConfigureAwait(false); } + public async Task ExecuteAsync(string[] args) + { + using var _ = await ExecuteExAsync(args).ConfigureAwait(false); + } + + public async Task ExecuteAsync(IDictionary placeholderVars) + { + using var _ = await ExecuteExAsync(placeholderVars).ConfigureAwait(false); + } + public IProcess ExecuteEx() + => ExecuteEx(null, null); + + public IProcess ExecuteEx(string[] args) + => ExecuteEx(args, null); + + public IProcess ExecuteEx(IDictionary placeholderVars) + => ExecuteEx(null, placeholderVars); + + private IProcess ExecuteEx(string[]? args, IDictionary? placeholderVars) { - var process = StartProcess(); + var process = StartProcess(args, placeholderVars); process.WaitForExit(); return process; } - public async Task ExecuteExAsync() + public Task ExecuteExAsync() + => ExecuteExAsync(null, null); + + public Task ExecuteExAsync(string[] args) + => ExecuteExAsync(args, null); + + public Task ExecuteExAsync(IDictionary placeholderVars) + => ExecuteExAsync(null, placeholderVars); + + private async Task ExecuteExAsync(string[]? args, IDictionary? placeholderVars) { - var process = StartProcess(); + var process = StartProcess(args, placeholderVars); await process.WaitForExitAsync().ConfigureAwait(false); @@ -40,12 +78,40 @@ public int ExecuteAndReturnExitCode() return process.ExitCode; } + public int ExecuteAndReturnExitCode(string[] args) + { + using var process = ExecuteEx(args); + + return process.ExitCode; + } + + public int ExecuteAndReturnExitCode(IDictionary placeholderVars) + { + using var process = ExecuteEx(placeholderVars); + + return process.ExitCode; + } + public async Task ExecuteAndReturnExitCodeAsync() { using var process = await ExecuteExAsync().ConfigureAwait(false); return process.ExitCode; } + + public async Task ExecuteAndReturnExitCodeAsync(string[] args) + { + using var process = await ExecuteExAsync(args).ConfigureAwait(false); + + return process.ExitCode; + } + + public async Task ExecuteAndReturnExitCodeAsync(IDictionary placeholderVars) + { + using var process = await ExecuteExAsync(placeholderVars).ConfigureAwait(false); + + return process.ExitCode; + } } public class ProcessExecutor(ProcessExecutorInfo processExecutorInfo, IProcessFactory processFactory) @@ -54,32 +120,49 @@ public class ProcessExecutor(ProcessExecutorInfo processExecutorInfo, IPro private readonly IProcessOutputParser _outputParser = Ensure.NotNull(processExecutorInfo.OutputParser); public T? Execute() - { - using var result = ExecuteEx(); - - return result.Value; - } + => Execute(null, null); public T? Execute(string[] args) - { - throw new NotImplementedException(); - } + => Execute(args, null); - public async Task ExecuteAsync() + public T? Execute(IDictionary placeholderVars) + => Execute(null, placeholderVars); + + private T? Execute(string[]? args, IDictionary? placeholderVars) { - using var result = await ExecuteExAsync().ConfigureAwait(false); + using var result = ExecuteEx(args, placeholderVars); return result.Value; } + public Task ExecuteAsync() + => ExecuteAsync(null, null); + public Task ExecuteAsync(string[] args) + => ExecuteAsync(args, null); + + public Task ExecuteAsync(IDictionary placeholderVars) + => ExecuteAsync(null, placeholderVars); + + private async Task ExecuteAsync(string[]? args, IDictionary? placeholderVars) { - throw new NotImplementedException(); + using var result = await ExecuteExAsync(args, placeholderVars).ConfigureAwait(false); + + return result.Value; } - public ProcessExecutionResult ExecuteEx() + public ProcessExecutionResult ExecuteEx() => ExecuteEx(null, null); + + public ProcessExecutionResult ExecuteEx(string[] args) + => ExecuteEx(args, null); + + public ProcessExecutionResult ExecuteEx(IDictionary placeholderVars) + => ExecuteEx(null, placeholderVars); + + private ProcessExecutionResult ExecuteEx(string[]? args, + IDictionary? placeholderVars) { - var process = StartProcess(); + var process = StartProcess(args, placeholderVars); var output = process.StandardOutput.ReadToEnd(); @@ -88,14 +171,19 @@ public class ProcessExecutor(ProcessExecutorInfo processExecutorInfo, IPro return new ProcessExecutionResult(process, _outputParser.ParseOutput(output)); } - public ProcessExecutionResult ExecuteEx(string[] args) - { - throw new NotImplementedException(); - } + public Task> ExecuteExAsync() + => ExecuteExAsync(null, null); + + public Task> ExecuteExAsync(string[] args) + => ExecuteExAsync(args, null); + + public Task> ExecuteExAsync(IDictionary placeholderVars) + => ExecuteExAsync(null, placeholderVars); - public async Task> ExecuteExAsync() + private async Task> ExecuteExAsync(string[]? args, + IDictionary? placeholderVars) { - var process = StartProcess(); + var process = StartProcess(args, placeholderVars); var output = await process.StandardOutput.ReadToEndAsync().ConfigureAwait(false); @@ -103,9 +191,4 @@ public class ProcessExecutor(ProcessExecutorInfo processExecutorInfo, IPro return new ProcessExecutionResult(process, _outputParser.ParseOutput(output)); } - - public Task> ExecuteExAsync(string[] args) - { - throw new NotImplementedException(); - } } diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBase.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBase.cs index 5d0e8f18..50e26cc2 100644 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBase.cs +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBase.cs @@ -12,12 +12,13 @@ public abstract class ProcessExecutorBase( private readonly IProcessFactory _processFactory = Ensure.NotNull(processFactory); - private ProcessStartInfo CreateProcessStartInfo(string[]? args = null) + private ProcessStartInfo CreateProcessStartInfo(string[]? args, + IDictionary? placeholderVars) { var startupInfo = new ProcessStartInfo { FileName = _processExecutorInfo.FileName, - Arguments = string.Join(" ", BuildArguments(args)), + Arguments = string.Join(" ", BuildArguments(args, placeholderVars)), RedirectStandardOutput = _processExecutorInfo.RedirectStandardOutput, RedirectStandardError = _processExecutorInfo.RedirectStandardError, RedirectStandardInput = _processExecutorInfo.RedirectStandardInput, @@ -28,30 +29,25 @@ private ProcessStartInfo CreateProcessStartInfo(string[]? args = null) return startupInfo; } - private string[] BuildArguments(string[]? args = null) + private string[] BuildArguments(string[]? args, IDictionary? placeholderVars) { - return _processExecutorInfo.UsePlaceholderVars - ? ReplacePlaceholders(_processExecutorInfo.Arguments, args) + return placeholderVars != null + ? ReplacePlaceholders(_processExecutorInfo.Arguments, placeholderVars) : args ?? _processExecutorInfo.Arguments; } - private static string[] ReplacePlaceholders(string[] arguments, string[]? args) + private static string[] ReplacePlaceholders(string[] arguments, + IDictionary placeholderVars) { - if (args == null) - { - return arguments; - } - - var replaceVars = args.ToDictionary(":", false); - return arguments - .ReplacePlaceholders("{{", "}}", replaceVars) + .ReplacePlaceholders("{{", "}}", placeholderVars) .ToArray(); } - protected IProcess StartProcess() + protected IProcess StartProcess(string[]? args = null, + IDictionary? placeholderVars = null) { - var startupInfo = CreateProcessStartInfo(); + var startupInfo = CreateProcessStartInfo(args, placeholderVars); var process = _processFactory.StartProcess(startupInfo); diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilder.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilder.cs index fef80322..f5bd9fc3 100644 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilder.cs +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorBuilder.cs @@ -15,10 +15,9 @@ public IProcessExecutorBuilder SetFileName(string fileName) return this; } - public IProcessExecutorBuilder SetArguments(string[] arguments, bool usePlaceholderVars = false) + public IProcessExecutorBuilder SetArguments(string[] arguments) { Arguments = arguments; - UsePlaceholderVars = usePlaceholderVars; return this; } @@ -53,10 +52,9 @@ public IProcessExecutorBuilder SetFileName(string fileName) return this; } - public IProcessExecutorBuilder SetArguments(string[] arguments, bool usePlaceholderVars = false) + public IProcessExecutorBuilder SetArguments(string[] arguments) { Arguments = arguments; - UsePlaceholderVars = usePlaceholderVars; return this; } diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorInfo.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorInfo.cs index 250cfbfb..16bde167 100644 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorInfo.cs +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorInfo.cs @@ -16,8 +16,6 @@ public class ProcessExecutorInfo(string fileName, string[] arguments, bool usePl public string[] Arguments { get; } = arguments; - public bool UsePlaceholderVars { get; set; } = usePlaceholderVars; - public bool RedirectStandardOutput { get; set; } = true; public bool RedirectStandardError { get; set; } = true; diff --git a/tests/CreativeCoders.Core.UnitTests/ObjectExtensionsTests.cs b/tests/CreativeCoders.Core.UnitTests/ObjectExtensionsTests.cs index eed8a272..58fe0ae3 100644 --- a/tests/CreativeCoders.Core.UnitTests/ObjectExtensionsTests.cs +++ b/tests/CreativeCoders.Core.UnitTests/ObjectExtensionsTests.cs @@ -1,17 +1,22 @@ using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; -using CreativeCoders.UnitTests; +using AwesomeAssertions; using FakeItEasy; using Xunit; +#nullable enable + namespace CreativeCoders.Core.UnitTests; +[SuppressMessage("ReSharper", "NullableWarningSuppressionIsUsed")] public class ObjectExtensionsTests { [Fact] public void ToStringSafe_InstanceIsNull_ReturnsEmptyString() { - var result = ((object) null).ToStringSafe(); + var result = ((object)null!).ToStringSafe(); Assert.Equal(string.Empty, result); } @@ -21,7 +26,7 @@ public void ToStringSafe_InstanceIsNullAndDefaultIsGiven_ReturnsDefaultValue() { const string text = "This is a test"; - var result = ((object) null).ToStringSafe(text); + var result = ((object)null!).ToStringSafe(text); Assert.Equal(text, result); } @@ -123,7 +128,7 @@ public async Task TryDisposeAsync_AsyncDisposableAndDisposable_DisposeAsyncIsCal await instance.TryDisposeAsync(); A.CallTo(() => instance.DisposeAsync()).MustHaveHappenedOnceExactly(); - A.CallTo(() => disposable.Dispose()).MustNotHaveHappened(); + A.CallTo(() => disposable!.Dispose()).MustNotHaveHappened(); } [Fact] @@ -131,7 +136,7 @@ public void GetPropertyValue_ReadFromExistingProperty_ReturnsPropertyValue() { const string expectedData = "TestText"; - var instance = new UnitTestDemoObject {Text = expectedData}; + var instance = new UnitTestDemoObject { Text = expectedData }; Assert.Equal(expectedData, instance.GetPropertyValue(nameof(instance.Text))); } @@ -163,4 +168,33 @@ public void SetPropertyValue_WriteToNotExistingProperty_ThrowsMissingMemberExcep Assert.Throws(() => instance.SetPropertyValue("Text", string.Empty)); } + + [Fact] + public void ToDictionary_ObjectWithProperties_ReturnsDictionaryWithPropertiesAsKeyValuePairs() + { + // Arrange + var obj = new UnitTestDemoObject + { + Text = "Test", + IntValue = 1234, + BoolValue = true + }; + + // Act + var dictionary = obj.ToDictionary(); + + // Assert + dictionary + .Should() + .HaveCount(3); + + dictionary + .Should() + .BeEquivalentTo(new Dictionary + { + { "Text", "Test" }, + { "IntValue", 1234 }, + { "BoolValue", true } + }); + } } diff --git a/tests/CreativeCoders.Core.UnitTests/Placeholders/PlaceholderReplacerTests.cs b/tests/CreativeCoders.Core.UnitTests/Placeholders/PlaceholderReplacerTests.cs index 597da5b6..6429956b 100644 --- a/tests/CreativeCoders.Core.UnitTests/Placeholders/PlaceholderReplacerTests.cs +++ b/tests/CreativeCoders.Core.UnitTests/Placeholders/PlaceholderReplacerTests.cs @@ -8,6 +8,8 @@ namespace CreativeCoders.Core.UnitTests.Placeholders; +#nullable enable + /// /// Tests for to verify placeholder replacement for single strings /// and sequences, as well as guard clauses and edge cases. @@ -19,7 +21,7 @@ public class PlaceholderReplacerTests public void Replace_String_ReplacesAllOccurrencesOfAllPlaceholders() { // Arrange - var placeholders = new Dictionary + var placeholders = new Dictionary { ["Name"] = "Alice", ["City"] = "Berlin" @@ -37,11 +39,61 @@ public void Replace_String_ReplacesAllOccurrencesOfAllPlaceholders() .Be("Hello Alice, welcome to Berlin. Alice likes Berlin."); } + [Fact] + public void Replace_String_ReplacesAllOccurrencesOfAllPlaceholdersWithDifferentTypes() + { + // Arrange + var placeholders = new Dictionary + { + ["Name"] = "Alice", + ["City"] = "Berlin", + ["Age"] = 25, + ["BigCity"] = true + }; + + var replacer = new PlaceholderReplacer("${", "}", placeholders); + const string input = + "Hello ${Name}, welcome to ${City}. ${Name} likes ${City}. You are ${Age} years old. ${City} is a big city = ${BigCity}."; + + // Act + var result = replacer.Replace(input); + + // Assert + result + .Should() + .Be( + "Hello Alice, welcome to Berlin. Alice likes Berlin. You are 25 years old. Berlin is a big city = True."); + } + + [Theory] + [InlineData("NullName", null, true, "null")] + [InlineData("EmptyName", null, false, "")] + public void Replace_String_WithNullVar_AllowNullValuesLeadsToCorrectResult(string placeholderName, + object? placeholderValue, bool allowNullValues, string expectedResult) + { + // Arrange + var placeholders = new Dictionary + { + [placeholderName] = placeholderValue + }; + + var replacer = new PlaceholderReplacer("${", "}", placeholders); + var inputText = "${" + placeholderName + "}"; + + // Act + var result = replacer.Replace(inputText, allowNullValues); + + // Assert + result + .Should() + .Be(expectedResult); + } + [Fact] public void Replace_String_WithNoMatchingPlaceholders_ReturnsOriginalString() { // Arrange - var placeholders = new Dictionary + var placeholders = new Dictionary { ["User"] = "Bob" }; @@ -61,7 +113,7 @@ public void Replace_String_WithNoMatchingPlaceholders_ReturnsOriginalString() public void Replace_String_WithEmptyPlaceholderDictionary_ReturnsSameReference() { // Arrange - var replacer = new PlaceholderReplacer("${", "}", new Dictionary()); + var replacer = new PlaceholderReplacer("${", "}", new Dictionary()); var input = "Keep me as is"; // Act @@ -77,7 +129,7 @@ public void Replace_String_WithEmptyPlaceholderDictionary_ReturnsSameReference() public void Replace_Lines_ReplacesAcrossAllLines() { // Arrange - var placeholders = new Dictionary + var placeholders = new Dictionary { ["Env"] = "Prod", ["Ver"] = "1.2.3" @@ -108,7 +160,7 @@ public void Replace_Lines_WithEmptyPlaceholderDictionary_ReturnsSameEnumerableRe { // Arrange var lines = new[] { "a", "b" }; - var replacer = new PlaceholderReplacer("${", "}", new Dictionary()); + var replacer = new PlaceholderReplacer("${", "}", new Dictionary()); // Act var result = replacer.Replace(lines); @@ -135,7 +187,7 @@ public void Constructor_WithNullPlaceholders_Throws() public void Constructor_WithInvalidPrefixOrSuffix_Throws() { // Arrange - var placeholders = new Dictionary(); + var placeholders = new Dictionary(); // Act var actPrefixNull = () => new PlaceholderReplacer(null!, "}", placeholders); @@ -160,7 +212,7 @@ public void Constructor_WithInvalidPrefixOrSuffix_Throws() public void Replace_WithOverlappingPlaceholderNames_ReplacesExactTokensOnly() { // Arrange - var placeholders = new Dictionary + var placeholders = new Dictionary { ["A"] = "x", ["AB"] = "y" diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorGenericTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorGenericTests.cs index 5b6e82a6..a54dc986 100644 --- a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorGenericTests.cs +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorGenericTests.cs @@ -1,5 +1,7 @@ using System.Diagnostics; using System.IO; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; using AwesomeAssertions; @@ -10,6 +12,9 @@ namespace CreativeCoders.Core.UnitTests.ProcessUtils.Execution; +#nullable enable + +[SuppressMessage("ReSharper", "NullableWarningSuppressionIsUsed")] public class ProcessExecutorGenericTests { [Fact] @@ -186,4 +191,391 @@ public async Task ExecuteExAsync_ReadsOutput_AndParsesResult() result.Dispose(); A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); } + + [Fact] + public void Execute_WithArgs_ReadsOutput_ParsesResult_AndUsesProvidedArgs() + { + // Arrange + const string fileName = "calc"; + var defaultArgs = new[] { "1", "+", "1" }; + var providedArgs = new[] { "6", "+", "7" }; + + var parser = A.Fake>(); + A.CallTo(() => parser.ParseOutput("13")) + .Returns(13); + + var info = new ProcessExecutorInfo(fileName, defaultArgs, false, parser); + + var fakeProcess = A.Fake(); + var outputStream = new MemoryStream("13"u8.ToArray()); + var reader = new StreamReader(outputStream); + A.CallTo(() => fakeProcess.StandardOutput).Returns(reader); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var result = executor.Execute(providedArgs); + + // Assert + result + .Should().Be(13); + + capturedStartInfo + .Should().NotBeNull(); + + capturedStartInfo!.Arguments + .Should().Be(string.Join(" ", providedArgs)); + + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => parser.ParseOutput("13")).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public void Execute_WithPlaceholderVars_ReadsOutput_ParsesResult_AndReplacesPlaceholders() + { + // Arrange + const string fileName = "calc"; + var argsWithPlaceholders = new[] { "{{a}}", "+", "{{b}}" }; + + var parser = A.Fake>(); + A.CallTo(() => parser.ParseOutput("11")) + .Returns(11); + + var info = new ProcessExecutorInfo(fileName, argsWithPlaceholders, false, parser); + + var fakeProcess = A.Fake(); + var outputStream = new MemoryStream("11"u8.ToArray()); + var reader = new StreamReader(outputStream); + A.CallTo(() => fakeProcess.StandardOutput).Returns(reader); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var result = executor.Execute(new Dictionary { ["a"] = 5, ["b"] = 6 }); + + // Assert + result + .Should().Be(11); + + capturedStartInfo + .Should().NotBeNull(); + + capturedStartInfo!.Arguments + .Should().Be("5 + 6"); + + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => parser.ParseOutput("11")).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public void ExecuteEx_WithArgs_ReadsOutput_ParsesResult_UsesProvidedArgs_AndReturnsProcess() + { + // Arrange + const string fileName = "echo"; + var defaultArgs = new[] { "x" }; + var providedArgs = new[] { "hello", "world" }; + + var parser = A.Fake>(); + A.CallTo(() => parser.ParseOutput("ok")) + .Returns("ok"); + + var info = new ProcessExecutorInfo(fileName, defaultArgs, false, parser); + + var fakeProcess = A.Fake(); + var outputStream = new MemoryStream("ok"u8.ToArray()); + var reader = new StreamReader(outputStream); + A.CallTo(() => fakeProcess.StandardOutput).Returns(reader); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var result = executor.ExecuteEx(providedArgs); + + // Assert + result.Value + .Should().Be("ok"); + + result.Process + .Should().BeSameAs(fakeProcess); + + capturedStartInfo + .Should().NotBeNull(); + + capturedStartInfo!.Arguments + .Should().Be(string.Join(" ", providedArgs)); + + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => parser.ParseOutput("ok")).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustNotHaveHappened(); + + result.Dispose(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public void + ExecuteEx_WithPlaceholderVars_ReadsOutput_ParsesResult_ReplacesPlaceholders_AndReturnsProcess() + { + // Arrange + const string fileName = "echo"; + var argsWithPlaceholders = new[] { "say", "{{word}}" }; + + var parser = A.Fake>(); + A.CallTo(() => parser.ParseOutput("done")) + .Returns("done"); + + var info = new ProcessExecutorInfo(fileName, argsWithPlaceholders, false, parser); + + var fakeProcess = A.Fake(); + var outputStream = new MemoryStream("done"u8.ToArray()); + var reader = new StreamReader(outputStream); + A.CallTo(() => fakeProcess.StandardOutput).Returns(reader); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var result = executor.ExecuteEx(new Dictionary { ["word"] = "hi" }); + + // Assert + result.Value + .Should().Be("done"); + + result.Process + .Should().BeSameAs(fakeProcess); + + capturedStartInfo + .Should().NotBeNull(); + + capturedStartInfo!.Arguments + .Should().Be("say hi"); + + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => parser.ParseOutput("done")).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustNotHaveHappened(); + + result.Dispose(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task ExecuteAsync_WithArgs_ReadsOutput_ParsesResult_AndUsesProvidedArgs() + { + // Arrange + const string fileName = "echo"; + var defaultArgs = new[] { "a" }; + var providedArgs = new[] { "1", "2" }; + + var parser = A.Fake>(); + A.CallTo(() => parser.ParseOutput("res")) + .Returns("res"); + + var info = new ProcessExecutorInfo(fileName, defaultArgs, false, parser); + + var fakeProcess = A.Fake(); + var outputStream = new MemoryStream("res"u8.ToArray()); + var reader = new StreamReader(outputStream); + A.CallTo(() => fakeProcess.StandardOutput).Returns(reader); + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)) + .Returns(Task.CompletedTask); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var result = await executor.ExecuteAsync(providedArgs); + + // Assert + result + .Should().Be("res"); + + capturedStartInfo + .Should().NotBeNull(); + + capturedStartInfo!.Arguments + .Should().Be(string.Join(" ", providedArgs)); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)).MustHaveHappenedOnceExactly(); + A.CallTo(() => parser.ParseOutput("res")).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task ExecuteAsync_WithPlaceholderVars_ReadsOutput_ParsesResult_AndReplacesPlaceholders() + { + // Arrange + const string fileName = "echo"; + var argsWithPlaceholders = new[] { "{{x}}", "{{y}}" }; + + var parser = A.Fake>(); + A.CallTo(() => parser.ParseOutput("ok")) + .Returns("ok"); + + var info = new ProcessExecutorInfo(fileName, argsWithPlaceholders, false, parser); + + var fakeProcess = A.Fake(); + var outputStream = new MemoryStream("ok"u8.ToArray()); + var reader = new StreamReader(outputStream); + A.CallTo(() => fakeProcess.StandardOutput).Returns(reader); + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)) + .Returns(Task.CompletedTask); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var result = await executor.ExecuteAsync(new Dictionary + { ["x"] = "X", ["y"] = "Y" }); + + // Assert + result + .Should().Be("ok"); + + capturedStartInfo + .Should().NotBeNull(); + + capturedStartInfo!.Arguments + .Should().Be("X Y"); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)).MustHaveHappenedOnceExactly(); + A.CallTo(() => parser.ParseOutput("ok")).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task ExecuteExAsync_WithArgs_ReadsOutput_ParsesResult_UsesProvidedArgs_AndReturnsProcess() + { + // Arrange + const string fileName = "echo"; + var defaultArgs = new[] { "d" }; + var providedArgs = new[] { "hey" }; + + var parser = A.Fake>(); + A.CallTo(() => parser.ParseOutput("value")) + .Returns("value"); + + var info = new ProcessExecutorInfo(fileName, defaultArgs, false, parser); + + var fakeProcess = A.Fake(); + var outputStream = new MemoryStream("value"u8.ToArray()); + var reader = new StreamReader(outputStream); + A.CallTo(() => fakeProcess.StandardOutput).Returns(reader); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var result = await executor.ExecuteExAsync(providedArgs); + + // Assert + result.Value + .Should().Be("value"); + + result.Process + .Should().BeSameAs(fakeProcess); + + capturedStartInfo + .Should().NotBeNull(); + + capturedStartInfo!.Arguments + .Should().Be(string.Join(" ", providedArgs)); + + A.CallTo(() => fakeProcess.WaitForExitAsync()).MustHaveHappenedOnceExactly(); + A.CallTo(() => parser.ParseOutput("value")).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustNotHaveHappened(); + + result.Dispose(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task + ExecuteExAsync_WithPlaceholderVars_ReadsOutput_ParsesResult_ReplacesPlaceholders_AndReturnsProcess() + { + // Arrange + const string fileName = "echo"; + var argsWithPlaceholders = new[] { "say", "{{t}}" }; + + var parser = A.Fake>(); + A.CallTo(() => parser.ParseOutput("rdy")) + .Returns("rdy"); + + var info = new ProcessExecutorInfo(fileName, argsWithPlaceholders, false, parser); + + var fakeProcess = A.Fake(); + var outputStream = new MemoryStream("rdy"u8.ToArray()); + var reader = new StreamReader(outputStream); + A.CallTo(() => fakeProcess.StandardOutput).Returns(reader); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var result = await executor.ExecuteExAsync(new Dictionary { ["t"] = "token" }); + + // Assert + result.Value + .Should().Be("rdy"); + + result.Process + .Should().BeSameAs(fakeProcess); + + capturedStartInfo + .Should().NotBeNull(); + + capturedStartInfo!.Arguments + .Should().Be("say token"); + + A.CallTo(() => fakeProcess.WaitForExitAsync()).MustHaveHappenedOnceExactly(); + A.CallTo(() => parser.ParseOutput("rdy")).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustNotHaveHappened(); + + result.Dispose(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } } diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorTests.cs index a099ea01..0d0a95b4 100644 --- a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorTests.cs +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; @@ -10,6 +11,8 @@ namespace CreativeCoders.Core.UnitTests.ProcessUtils.Execution; +#nullable enable + [SuppressMessage("ReSharper", "NullableWarningSuppressionIsUsed")] public class ProcessExecutorTests { @@ -73,6 +76,85 @@ public void Execute_StartsProcess_WithConfiguredStartInfo_AndWaitsForExit() A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); } + [Fact] + public void Execute_WithArgs_StartsProcess_UsesProvidedArgs_AndWaitsForExit() + { + // Arrange + const string fileName = "my-tool"; + var defaultArgs = new[] { "x", "y" }; + var providedArgs = new[] { "argA", "argB", "argC" }; + + var info = new ProcessExecutorInfo(fileName, defaultArgs, false); + + var fakeProcess = A.Fake(); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + executor.Execute(providedArgs); + + // Assert + capturedStartInfo + .Should() + .NotBeNull(); + + capturedStartInfo!.FileName + .Should() + .Be(fileName); + + capturedStartInfo.Arguments + .Should() + .Be(string.Join(" ", providedArgs)); + + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public void Execute_WithPlaceholderVars_StartsProcess_ReplacesPlaceholders_AndWaitsForExit() + { + // Arrange + const string fileName = "my-tool"; + var argsWithPlaceholders = new[] { "run", "--value", "{{val}}" }; + + var info = new ProcessExecutorInfo(fileName, argsWithPlaceholders, false); + + var fakeProcess = A.Fake(); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + executor.Execute(new Dictionary { ["val"] = 42 }); + + // Assert + capturedStartInfo + .Should() + .NotBeNull(); + + capturedStartInfo!.FileName + .Should() + .Be(fileName); + + capturedStartInfo.Arguments + .Should() + .Be("run --value 42"); + + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + [Fact] public void ExecuteEx_StartsProcess_WithConfiguredStartInfo_AndWaitsForExit() { @@ -137,6 +219,75 @@ public void ExecuteEx_StartsProcess_WithConfiguredStartInfo_AndWaitsForExit() A.CallTo(() => fakeProcess.Dispose()).MustNotHaveHappened(); } + [Fact] + public void ExecuteEx_WithArgs_StartsProcess_UsesProvidedArgs_AndReturnsProcess() + { + // Arrange + const string fileName = "tool-ex"; + var defaultArgs = new[] { "d1" }; + var providedArgs = new[] { "n1", "n2" }; + + var info = new ProcessExecutorInfo(fileName, defaultArgs, false); + + var fakeProcess = A.Fake(); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + using var process = executor.ExecuteEx(providedArgs); + + // Assert + process + .Should() + .BeSameAs(fakeProcess); + + capturedStartInfo + .Should() + .NotBeNull(); + + capturedStartInfo!.Arguments + .Should() + .Be(string.Join(" ", providedArgs)); + + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustNotHaveHappened(); + } + + [Fact] + public void ExecuteEx_WithPlaceholderVars_StartsProcess_ReplacesPlaceholders_AndReturnsProcess() + { + // Arrange + const string fileName = "tool-ex"; + var argsWithPlaceholders = new[] { "run", "{{name}}" }; + + var info = new ProcessExecutorInfo(fileName, argsWithPlaceholders, false); + + var fakeProcess = A.Fake(); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + using var process = executor.ExecuteEx(new Dictionary { ["name"] = "John" }); + + // Assert + process.Should().BeSameAs(fakeProcess); + capturedStartInfo!.Arguments.Should().Be("run John"); + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustNotHaveHappened(); + } + [Fact] public async Task ExecuteAsync_StartsProcess_WithConfiguredStartInfo_AndWaitsForExitAsync() { @@ -269,6 +420,149 @@ public async Task ExecuteExAsync_StartsProcess_WithConfiguredStartInfo_AndWaitsF A.CallTo(() => fakeProcess.Dispose()).MustNotHaveHappened(); } + [Fact] + public async Task ExecuteAsync_WithArgs_StartsProcess_UsesProvidedArgs_AndWaitsForExitAsync() + { + // Arrange + var fileName = "my-tool-async"; + var defaultArgs = new[] { "d" }; + var providedArgs = new[] { "x", "y" }; + + var info = new ProcessExecutorInfo(fileName, defaultArgs, false); + + var fakeProcess = A.Fake(); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)) + .Returns(Task.CompletedTask); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + await executor.ExecuteAsync(providedArgs); + + // Assert + capturedStartInfo!.Arguments + .Should().Be(string.Join(" ", providedArgs)); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task + ExecuteAsync_WithPlaceholderVars_StartsProcess_ReplacesPlaceholders_AndWaitsForExitAsync() + { + // Arrange + var fileName = "my-tool-async"; + var argsWithPlaceholders = new[] { "go", "--n", "{{num}}" }; + + var info = new ProcessExecutorInfo(fileName, argsWithPlaceholders, false); + + var fakeProcess = A.Fake(); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)) + .Returns(Task.CompletedTask); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + await executor.ExecuteAsync(new Dictionary { ["num"] = 7 }); + + // Assert + capturedStartInfo!.Arguments + .Should().Be("go --n 7"); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task ExecuteExAsync_WithArgs_StartsProcess_UsesProvidedArgs_AndReturnsProcess() + { + // Arrange + var fileName = "my-tool-async"; + var defaultArgs = new[] { "d" }; + var providedArgs = new[] { "x", "y" }; + + var info = new ProcessExecutorInfo(fileName, defaultArgs, false); + + var fakeProcess = A.Fake(); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)) + .Returns(Task.CompletedTask); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + using var process = await executor.ExecuteExAsync(providedArgs); + + // Assert + process + .Should().BeSameAs(fakeProcess); + + capturedStartInfo!.Arguments + .Should().Be(string.Join(" ", providedArgs)); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustNotHaveHappened(); + } + + [Fact] + public async Task + ExecuteExAsync_WithPlaceholderVars_StartsProcess_ReplacesPlaceholders_AndReturnsProcess() + { + // Arrange + var fileName = "my-tool-async"; + var argsWithPlaceholders = new[] { "go", "{{word}}" }; + + var info = new ProcessExecutorInfo(fileName, argsWithPlaceholders, false); + + var fakeProcess = A.Fake(); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)) + .Returns(Task.CompletedTask); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + using var process = + await executor.ExecuteExAsync(new Dictionary { ["word"] = "ok" }); + + // Assert + process + .Should().BeSameAs(fakeProcess); + + capturedStartInfo!.Arguments + .Should().Be("go ok"); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustNotHaveHappened(); + } + [Fact] public void ExecuteAndReturnExitCode_StartsProcess_WithConfiguredStartInfo_ReturnsExitCode() { @@ -338,4 +632,138 @@ public async Task ExecuteAndReturnExitCodeAsync_StartsProcess_WithConfiguredStar A.CallTo(() => fakeProcess.WaitForExitAsync(A._)).MustHaveHappenedOnceExactly(); A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); } + + [Fact] + public void ExecuteAndReturnExitCode_WithArgs_UsesProvidedArgs_AndReturnsExitCode() + { + // Arrange + const string fileName = "my-tool"; + var defaultArgs = new[] { "d" }; + var providedArgs = new[] { "a1", "a2" }; + const int expectedExitCode = 55; + + var info = new ProcessExecutorInfo(fileName, defaultArgs, false); + + var fakeProcess = A.Fake(); + A.CallTo(() => fakeProcess.ExitCode).Returns(expectedExitCode); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var exitCode = executor.ExecuteAndReturnExitCode(providedArgs); + + // Assert + exitCode.Should().Be(expectedExitCode); + capturedStartInfo!.Arguments.Should().Be(string.Join(" ", providedArgs)); + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public void ExecuteAndReturnExitCode_WithPlaceholderVars_ReplacesPlaceholders_AndReturnsExitCode() + { + // Arrange + const string fileName = "my-tool"; + var argsWithPlaceholders = new[] { "do", "{{n}}" }; + const int expectedExitCode = 77; + + var info = new ProcessExecutorInfo(fileName, argsWithPlaceholders, false); + + var fakeProcess = A.Fake(); + A.CallTo(() => fakeProcess.ExitCode).Returns(expectedExitCode); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var exitCode = executor.ExecuteAndReturnExitCode(new Dictionary { ["n"] = 9 }); + + // Assert + exitCode.Should().Be(expectedExitCode); + capturedStartInfo!.Arguments.Should().Be("do 9"); + A.CallTo(() => fakeProcess.WaitForExit()).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task ExecuteAndReturnExitCodeAsync_WithArgs_UsesProvidedArgs_AndReturnsExitCode() + { + // Arrange + var fileName = "my-tool-async"; + var defaultArgs = new[] { "d" }; + var providedArgs = new[] { "a1", "a2" }; + const int expectedExitCode = 101; + + var info = new ProcessExecutorInfo(fileName, defaultArgs, false); + + var fakeProcess = A.Fake(); + A.CallTo(() => fakeProcess.ExitCode).Returns(expectedExitCode); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)) + .Returns(Task.CompletedTask); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var exitCode = await executor.ExecuteAndReturnExitCodeAsync(providedArgs); + + // Assert + exitCode.Should().Be(expectedExitCode); + capturedStartInfo!.Arguments.Should().Be(string.Join(" ", providedArgs)); + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task + ExecuteAndReturnExitCodeAsync_WithPlaceholderVars_ReplacesPlaceholders_AndReturnsExitCode() + { + // Arrange + var fileName = "my-tool-async"; + var argsWithPlaceholders = new[] { "do", "{{n}}" }; + const int expectedExitCode = 202; + + var info = new ProcessExecutorInfo(fileName, argsWithPlaceholders, false); + + var fakeProcess = A.Fake(); + A.CallTo(() => fakeProcess.ExitCode).Returns(expectedExitCode); + + var capturedStartInfo = default(ProcessStartInfo); + var processFactory = A.Fake(); + A.CallTo(() => processFactory.StartProcess(A._)) + .Invokes((ProcessStartInfo si) => capturedStartInfo = si) + .Returns(fakeProcess); + + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)) + .Returns(Task.CompletedTask); + + var executor = new ProcessExecutor(info, processFactory); + + // Act + var exitCode = + await executor.ExecuteAndReturnExitCodeAsync(new Dictionary { ["n"] = 3 }); + + // Assert + exitCode.Should().Be(expectedExitCode); + capturedStartInfo!.Arguments.Should().Be("do 3"); + A.CallTo(() => fakeProcess.WaitForExitAsync(A._)).MustHaveHappenedOnceExactly(); + A.CallTo(() => fakeProcess.Dispose()).MustHaveHappenedOnceExactly(); + } } diff --git a/tests/CreativeCoders.Core.UnitTests/UnitTestDemoObject.cs b/tests/CreativeCoders.Core.UnitTests/UnitTestDemoObject.cs new file mode 100644 index 00000000..ed9d6155 --- /dev/null +++ b/tests/CreativeCoders.Core.UnitTests/UnitTestDemoObject.cs @@ -0,0 +1,14 @@ +#nullable enable +using JetBrains.Annotations; + +namespace CreativeCoders.Core.UnitTests; + +[PublicAPI] +public class UnitTestDemoObject +{ + public string? Text { get; set; } + + public int IntValue { get; set; } + + public bool BoolValue { get; set; } +} From 4d483022d6b7dc277eccba4ffcac37db74f53f27 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Fri, 5 Dec 2025 11:54:02 +0100 Subject: [PATCH 4/4] Add unit tests and extensions for placeholder variable support in `ProcessExecutor` - Introduced `ProcessExecutorExtensions` to simplify execution with object-to-dictionary conversion. - Added comprehensive unit tests for generic and non-generic execution methods with placeholders. - Updated `IProcessExecutor` to include `ExecuteEx` and `ExecuteExAsync` overloads for dictionary inputs. --- .../Execution/IProcessExecutor.cs | 1 + .../Execution/ProcessExecutorExtensions.cs | 48 +++ .../ProcessExecutorGenericExtensions.cs | 32 ++ .../ProcessExecutorExtensionsTests.cs | 339 ++++++++++++++++++ 4 files changed, 420 insertions(+) create mode 100644 source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorExtensions.cs create mode 100644 source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorGenericExtensions.cs create mode 100644 tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorExtensionsTests.cs diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs index 8fd88939..37b94c3d 100644 --- a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/IProcessExecutor.cs @@ -48,6 +48,7 @@ public interface IProcessExecutor IProcess ExecuteEx(); IProcess ExecuteEx(string[] args); + IProcess ExecuteEx(IDictionary placeholderVars); Task ExecuteExAsync(); diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorExtensions.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorExtensions.cs new file mode 100644 index 00000000..48a59c50 --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorExtensions.cs @@ -0,0 +1,48 @@ +using CreativeCoders.Core; + +namespace CreativeCoders.ProcessUtils.Execution; + +public static class ProcessExecutorExtensions +{ + public static void Execute(this IProcessExecutor executor, + TVars placeholderVars) + where TVars : class + { + executor.Execute(placeholderVars.ToDictionary()); + } + + public static Task ExecuteAsync(this IProcessExecutor executor, + TVars placeholderVars) + where TVars : class + { + return executor.ExecuteAsync(placeholderVars.ToDictionary()); + } + + public static IProcess ExecuteEx(this IProcessExecutor executor, + TVars placeholderVars) + where TVars : class + { + return executor.ExecuteEx(placeholderVars.ToDictionary()); + } + + public static Task ExecuteExAsync(this IProcessExecutor executor, + TVars placeholderVars) + where TVars : class + { + return executor.ExecuteExAsync(placeholderVars.ToDictionary()); + } + + public static int ExecuteAndReturnExitCode(this IProcessExecutor executor, + TVars placeholderVars) + where TVars : class + { + return executor.ExecuteAndReturnExitCode(placeholderVars.ToDictionary()); + } + + public static Task ExecuteAndReturnExitCodeAsync(this IProcessExecutor executor, + TVars placeholderVars) + where TVars : class + { + return executor.ExecuteAndReturnExitCodeAsync(placeholderVars.ToDictionary()); + } +} diff --git a/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorGenericExtensions.cs b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorGenericExtensions.cs new file mode 100644 index 00000000..e5a3dac9 --- /dev/null +++ b/source/ProcessUtils/CreativeCoders.ProcessUtils/Execution/ProcessExecutorGenericExtensions.cs @@ -0,0 +1,32 @@ +using CreativeCoders.Core; + +namespace CreativeCoders.ProcessUtils.Execution; + +public static class ProcessExecutorGenericExtensions +{ + public static T? Execute(this IProcessExecutor executor, TVars placeholderVars) + where TVars : class + { + return executor.Execute(placeholderVars.ToDictionary()); + } + + public static Task ExecuteAsync(this IProcessExecutor executor, TVars placeholderVars) + where TVars : class + { + return executor.ExecuteAsync(placeholderVars.ToDictionary()); + } + + public static ProcessExecutionResult ExecuteEx(this IProcessExecutor executor, + TVars placeholderVars) + where TVars : class + { + return executor.ExecuteEx(placeholderVars.ToDictionary()); + } + + public static Task> ExecuteExAsync(this IProcessExecutor executor, + TVars placeholderVars) + where TVars : class + { + return executor.ExecuteExAsync(placeholderVars.ToDictionary()); + } +} diff --git a/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorExtensionsTests.cs b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorExtensionsTests.cs new file mode 100644 index 00000000..dad98c7e --- /dev/null +++ b/tests/CreativeCoders.Core.UnitTests/ProcessUtils/Execution/ProcessExecutorExtensionsTests.cs @@ -0,0 +1,339 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using AwesomeAssertions; +using CreativeCoders.ProcessUtils; +using CreativeCoders.ProcessUtils.Execution; +using FakeItEasy; +using JetBrains.Annotations; +using Xunit; + +namespace CreativeCoders.Core.UnitTests.ProcessUtils.Execution; + +#nullable enable + +[SuppressMessage("ReSharper", "NullableWarningSuppressionIsUsed")] +[SuppressMessage("ReSharper", "InvokeAsExtensionMethod")] +public class ProcessExecutorExtensionsTests +{ + [PublicAPI] + private sealed class TestVars + { + // Simple POCO used to verify that the extensions convert an object to a dictionary via ToDictionary() + public string? Name { get; init; } + + public int Number { get; init; } + } + + [Fact] + public void Generic_Execute_WithObjectVars_ForwardsDictionary_AndReturnsValue() + { + // Arrange + var vars = new TestVars { Name = "Alice", Number = 7 }; + + var fakeExecutor = A.Fake>(); + + IDictionary? capturedVars = null; + A.CallTo(() => fakeExecutor.Execute(A>._)) + .Invokes(call => capturedVars = call.GetArgument>(0)) + .Returns("ok"); + + // Act + var result = ProcessExecutorGenericExtensions.Execute(fakeExecutor, vars); + + // Assert + result + .Should().Be("ok"); + + capturedVars + .Should().NotBeNull() + .And.HaveCount(2) + .And.BeEquivalentTo(new Dictionary() + { + { "Name", "Alice" }, + { "Number", 7 } + }); + } + + [Fact] + public async Task Generic_ExecuteAsync_WithObjectVars_ForwardsDictionary_AndReturnsValue() + { + // Arrange + var vars = new TestVars { Name = "Bob", Number = 42 }; + + var fakeExecutor = A.Fake>(); + + IDictionary? capturedVars = null; + A.CallTo(() => fakeExecutor.ExecuteAsync(A>._)) + .Invokes(call => capturedVars = call.GetArgument>(0)) + .Returns(Task.FromResult("123")); + + // Act + var result = await ProcessExecutorGenericExtensions.ExecuteAsync(fakeExecutor, vars); + + // Assert + result + .Should().Be("123"); + + capturedVars + .Should().NotBeNull() + .And.HaveCount(2) + .And.BeEquivalentTo(new Dictionary() + { + { "Name", "Bob" }, + { "Number", 42 } + }); + } + + [Fact] + public void Generic_ExecuteEx_WithObjectVars_ForwardsDictionary_AndReturnsResult() + { + // Arrange + var vars = new TestVars { Name = "X", Number = 1 }; + + var fakeExecutor = A.Fake>(); + var fakeProcess = A.Fake(); + var expected = new ProcessExecutionResult(fakeProcess, "val"); + + IDictionary? capturedVars = null; + A.CallTo(() => fakeExecutor.ExecuteEx(A>._)) + .Invokes(call => capturedVars = call.GetArgument>(0)) + .Returns(expected); + + // Act + var result = ProcessExecutorGenericExtensions.ExecuteEx(fakeExecutor, vars); + + // Assert + result + .Should().BeSameAs(expected); + + result.Value + .Should().Be("val"); + + result.Process + .Should().BeSameAs(fakeProcess); + + capturedVars + .Should().NotBeNull() + .And.HaveCount(2) + .And.BeEquivalentTo(new Dictionary() + { + { "Name", "X" }, + { "Number", 1 } + }); + } + + [Fact] + public async Task Generic_ExecuteExAsync_WithObjectVars_ForwardsDictionary_AndReturnsResult() + { + // Arrange + var vars = new TestVars { Name = "Y", Number = 2 }; + + var fakeExecutor = A.Fake>(); + var fakeProcess = A.Fake(); + var expected = new ProcessExecutionResult(fakeProcess, "data"); + + IDictionary? capturedVars = null; + A.CallTo(() => fakeExecutor.ExecuteExAsync(A>._)) + .Invokes(call => capturedVars = call.GetArgument>(0)) + .Returns(Task.FromResult(expected)); + + // Act + var result = await ProcessExecutorGenericExtensions.ExecuteExAsync(fakeExecutor, vars); + + // Assert + result + .Should().BeSameAs(expected); + + result.Value + .Should().Be("data"); + + result.Process + .Should().BeSameAs(fakeProcess); + + capturedVars + .Should().NotBeNull() + .And.HaveCount(2) + .And.BeEquivalentTo(new Dictionary() + { + { "Name", "Y" }, + { "Number", 2 } + }); + } + + [Fact] + public void NonGeneric_Execute_WithObjectVars_ForwardsDictionary() + { + // Arrange + var vars = new TestVars { Name = "NG", Number = 9 }; + + var fakeExecutor = A.Fake(); + + IDictionary? capturedVars = null; + A.CallTo(() => fakeExecutor.Execute(A>._)) + .Invokes(call => capturedVars = call.GetArgument>(0)); + + // Act + ProcessExecutorExtensions.Execute(fakeExecutor, vars); + + // Assert + capturedVars + .Should().NotBeNull() + .And.HaveCount(2) + .And.BeEquivalentTo(new Dictionary() + { + { "Name", "NG" }, + { "Number", 9 } + }); + } + + [Fact] + public async Task NonGeneric_ExecuteAsync_WithObjectVars_ForwardsDictionary() + { + // Arrange + var vars = new TestVars { Name = "NGA", Number = 10 }; + + var fakeExecutor = A.Fake(); + + IDictionary? capturedVars = null; + A.CallTo(() => fakeExecutor.ExecuteAsync(A>._)) + .Invokes(call => capturedVars = call.GetArgument>(0)) + .Returns(Task.CompletedTask); + + // Act + await ProcessExecutorExtensions.ExecuteAsync(fakeExecutor, vars); + + // Assert + capturedVars + .Should().NotBeNull() + .And.HaveCount(2) + .And.BeEquivalentTo(new Dictionary() + { + { "Name", "NGA" }, + { "Number", 10 } + }); + } + + [Fact] + public void NonGeneric_ExecuteEx_WithObjectVars_ForwardsDictionary_AndReturnsProcess() + { + // Arrange + var vars = new TestVars { Name = "P", Number = 3 }; + + var fakeExecutor = A.Fake(); + var fakeProcess = A.Fake(); + + IDictionary? capturedVars = null; + A.CallTo(() => fakeExecutor.ExecuteEx(A>._)) + .Invokes(call => capturedVars = call.GetArgument>(0)) + .Returns(fakeProcess); + + // Act + var process = ProcessExecutorExtensions.ExecuteEx(fakeExecutor, vars); + + // Assert + process + .Should().BeSameAs(fakeProcess); + + capturedVars + .Should().NotBeNull() + .And.HaveCount(2) + .And.BeEquivalentTo(new Dictionary() + { + { "Name", "P" }, + { "Number", 3 } + }); + } + + [Fact] + public async Task NonGeneric_ExecuteExAsync_WithObjectVars_ForwardsDictionary_AndReturnsProcess() + { + // Arrange + var vars = new TestVars { Name = "PA", Number = 4 }; + + var fakeExecutor = A.Fake(); + var fakeProcess = A.Fake(); + + IDictionary? capturedVars = null; + A.CallTo(() => fakeExecutor.ExecuteExAsync(A>._)) + .Invokes(call => capturedVars = call.GetArgument>(0)) + .Returns(Task.FromResult(fakeProcess)); + + // Act + var process = await ProcessExecutorExtensions.ExecuteExAsync(fakeExecutor, vars); + + // Assert + process + .Should().BeSameAs(fakeProcess); + + capturedVars + .Should().NotBeNull() + .And.HaveCount(2) + .And.BeEquivalentTo(new Dictionary() + { + { "Name", "PA" }, + { "Number", 4 } + }); + } + + [Fact] + public void NonGeneric_ExecuteAndReturnExitCode_WithObjectVars_ForwardsDictionary_AndReturnsExitCode() + { + // Arrange + var vars = new TestVars { Name = "E", Number = 5 }; + + var fakeExecutor = A.Fake(); + + IDictionary? capturedVars = null; + A.CallTo(() => fakeExecutor.ExecuteAndReturnExitCode(A>._)) + .Invokes(call => capturedVars = call.GetArgument>(0)) + .Returns(77); + + // Act + var exitCode = ProcessExecutorExtensions.ExecuteAndReturnExitCode(fakeExecutor, vars); + + // Assert + exitCode + .Should().Be(77); + + capturedVars + .Should().NotBeNull() + .And.HaveCount(2) + .And.BeEquivalentTo(new Dictionary() + { + { "Name", "E" }, + { "Number", 5 } + }); + } + + [Fact] + public async Task + NonGeneric_ExecuteAndReturnExitCodeAsync_WithObjectVars_ForwardsDictionary_AndReturnsExitCode() + { + // Arrange + var vars = new TestVars { Name = "EA", Number = 6 }; + + var fakeExecutor = A.Fake(); + + IDictionary? capturedVars = null; + A.CallTo(() => fakeExecutor.ExecuteAndReturnExitCodeAsync(A>._)) + .Invokes(call => capturedVars = call.GetArgument>(0)) + .Returns(Task.FromResult(99)); + + // Act + var exitCode = await ProcessExecutorExtensions.ExecuteAndReturnExitCodeAsync(fakeExecutor, vars); + + // Assert + exitCode + .Should().Be(99); + + capturedVars + .Should().NotBeNull() + .And.HaveCount(2) + .And.BeEquivalentTo(new Dictionary() + { + { "Name", "EA" }, + { "Number", 6 } + }); + } +}