diff --git a/MedallionShell.Tests/MedallionShell.Tests.csproj b/MedallionShell.Tests/MedallionShell.Tests.csproj index e449524..43b8a45 100644 --- a/MedallionShell.Tests/MedallionShell.Tests.csproj +++ b/MedallionShell.Tests/MedallionShell.Tests.csproj @@ -6,9 +6,9 @@ false Latest enable - True - ..\stylecop.analyzers.ruleset - true + True + ..\stylecop.analyzers.ruleset + true 1591 Medallion.Shell.Tests @@ -17,15 +17,16 @@ - - + + + All - + diff --git a/MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs b/MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs new file mode 100644 index 0000000..2721441 --- /dev/null +++ b/MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Medallion.Shell.Tests.Streams; + +public class AsyncEnumerableAdapter : IAsyncEnumerable +{ + private readonly IEnumerable strings; + + public AsyncEnumerableAdapter(IEnumerable strings) + { + this.strings = strings; + } + + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) => + // this does not allow consuming the same IEnumerable twice + new AsyncEnumeratorAdapter(this.strings.GetEnumerator()); + + private class AsyncEnumeratorAdapter : IAsyncEnumerator + { + private readonly IEnumerator enumerator; + + public AsyncEnumeratorAdapter(IEnumerator enumerator) + { + this.enumerator = enumerator; + } + + public string Current => this.enumerator.Current; + + public ValueTask DisposeAsync() + { + this.enumerator.Dispose(); + return default; + } + + public ValueTask MoveNextAsync() => new(this.enumerator.MoveNext()); + } +} diff --git a/MedallionShell.Tests/Streams/MergedLinesEnumerableTest.cs b/MedallionShell.Tests/Streams/MergedLinesEnumerableTest.cs deleted file mode 100644 index c62a0ae..0000000 --- a/MedallionShell.Tests/Streams/MergedLinesEnumerableTest.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Medallion.Shell.Streams; -using Moq; -using NUnit.Framework; - -namespace Medallion.Shell.Tests.Streams -{ - public class MergedLinesEnumerableTest - { - [Test] - public void TestOneIsEmpty() - { - var empty1 = new StringReader(string.Empty); - var nonEmpty1 = new StringReader("abc\r\ndef\r\nghi\r\njkl"); - - var enumerable1 = new MergedLinesEnumerable(empty1, nonEmpty1); - var list1 = enumerable1.ToList(); - list1.SequenceEqual(["abc", "def", "ghi", "jkl"]) - .ShouldEqual(true, string.Join(", ", list1)); - - var empty2 = new StringReader(string.Empty); - var nonEmpty2 = new StringReader("a\nbb\nccc\n"); - var enumerable2 = new MergedLinesEnumerable(nonEmpty2, empty2); - var list2 = enumerable2.ToList(); - list2.SequenceEqual(["a", "bb", "ccc"]) - .ShouldEqual(true, string.Join(", ", list2)); - } - - [Test] - public void TestBothAreEmpty() - { - var list = new MergedLinesEnumerable(new StringReader(string.Empty), new StringReader(string.Empty)).ToList(); - list.Count.ShouldEqual(0, string.Join(", ", list)); - } - - [Test] - public void TestBothArePopulatedEqualSizes() - { - var list = new MergedLinesEnumerable( - new StringReader("a\nbb\nccc"), - new StringReader("1\r\n22\r\n333") - ) - .ToList(); - string.Join(", ", list).ShouldEqual("a, 1, bb, 22, ccc, 333"); - } - - [Test] - public void TestBothArePopulatedDifferenceSizes() - { - var lines1 = string.Join("\n", ["x", "y", "z"]); - var lines2 = string.Join("\n", ["1", "2", "3", "4", "5"]); - - var list1 = new MergedLinesEnumerable(new StringReader(lines1), new StringReader(lines2)) - .ToList(); - string.Join(", ", list1).ShouldEqual("x, 1, y, 2, z, 3, 4, 5"); - - var list2 = new MergedLinesEnumerable(new StringReader(lines2), new StringReader(lines1)) - .ToList(); - string.Join(", ", list2).ShouldEqual("1, x, 2, y, 3, z, 4, 5"); - } - - [Test] - public void TestConsumeTwice() - { - var enumerable = new MergedLinesEnumerable(new StringReader("a"), new StringReader("b")); - enumerable.GetEnumerator(); - Assert.Throws(() => enumerable.GetEnumerator()); - } - - [Test] - public void TestOneThrows() - { - void TestOneThrows(bool reverse) - { - var reader1 = new StringReader("a\nb\nc"); - var count = 0; - var mockReader = new Mock(MockBehavior.Strict); - mockReader.Setup(r => r.ReadLineAsync()) - .ReturnsAsync(() => ++count < 3 ? "LINE" : throw new TimeZoneNotFoundException()); - - Assert.Throws( - () => new MergedLinesEnumerable( - reverse ? mockReader.Object : reader1, - reverse ? reader1 : mockReader.Object - ) - .ToList() - ); - } - - TestOneThrows(reverse: false); - TestOneThrows(reverse: true); - } - - [Test] - public void FuzzTest() - { - var pipe1 = new Pipe(); - var pipe2 = new Pipe(); - - var enumerable = new MergedLinesEnumerable(new StreamReader(pipe1.OutputStream), new StreamReader(pipe2.OutputStream)); - - var strings1 = Enumerable.Range(0, 2000).Select(_ => Guid.NewGuid().ToString()).ToArray(); - var strings2 = Enumerable.Range(0, 2300).Select(_ => Guid.NewGuid().ToString()).ToArray(); - - void WriteStrings(IReadOnlyList strings, Pipe pipe) - { - var spinWait = default(SpinWait); - var random = new Random(Guid.NewGuid().GetHashCode()); - using (var writer = new StreamWriter(pipe.InputStream)) - { - foreach (var line in strings) - { - if (random.Next(4) == 1) - { - spinWait.SpinOnce(); - } - - writer.WriteLine(line); - } - } - } - - var task1 = Task.Run(() => WriteStrings(strings1, pipe1)); - var task2 = Task.Run(() => WriteStrings(strings2, pipe2)); - var consumeTask = Task.Run(() => enumerable.ToList()); - Task.WaitAll(task1, task2, consumeTask); - - CollectionAssert.AreEquivalent(strings1.Concat(strings2).ToList(), consumeTask.Result); - } - } -} diff --git a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestAsync.cs b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestAsync.cs new file mode 100644 index 0000000..d0d9b55 --- /dev/null +++ b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestAsync.cs @@ -0,0 +1,13 @@ +#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_0_OR_GREATER +using System.Collections.Generic; +using System.IO; +using Medallion.Shell.Streams; + +namespace Medallion.Shell.Tests.Streams; + +public class MergedLinesEnumerableTestAsync : MergedLinesEnumerableTestBase +{ + protected override IAsyncEnumerable Create(TextReader reader1, TextReader reader2) => + new MergedLinesEnumerable(reader1, reader2); +} +#endif \ No newline at end of file diff --git a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs new file mode 100644 index 0000000..7ba3216 --- /dev/null +++ b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Medallion.Shell.Streams; +using Moq; +using NUnit.Framework; + +namespace Medallion.Shell.Tests.Streams; + +public abstract class MergedLinesEnumerableTestBase +{ + protected abstract IAsyncEnumerable Create(TextReader reader1, TextReader reader2); + + [Test] + public async Task TestOneIsEmpty() + { + var empty1 = new StringReader(string.Empty); + var nonEmpty1 = new StringReader("abc\r\ndef\r\nghi\r\njkl"); + + var enumerable1 = this.Create(empty1, nonEmpty1); + var list1 = await enumerable1.ToListAsync(); + list1.SequenceEqual(["abc", "def", "ghi", "jkl"]) + .ShouldEqual(true, string.Join(", ", list1)); + + var empty2 = new StringReader(string.Empty); + var nonEmpty2 = new StringReader("a\nbb\nccc\n"); + var enumerable2 = this.Create(nonEmpty2, empty2); + var list2 = await enumerable2.ToListAsync(); + list2.SequenceEqual(["a", "bb", "ccc"]) + .ShouldEqual(true, string.Join(", ", list2)); + } + + [Test] + public async Task TestBothAreEmpty() + { + var list = await this.Create(new StringReader(string.Empty), new StringReader(string.Empty)).ToListAsync(); + list.Count.ShouldEqual(0, string.Join(", ", list)); + } + + [Test] + public async Task TestBothArePopulatedEqualSizes() + { + var list = await this.Create( + new StringReader("a\nbb\nccc"), + new StringReader("1\r\n22\r\n333") + ) + .ToListAsync(); + string.Join(", ", list).ShouldEqual("a, 1, bb, 22, ccc, 333"); + } + + [Test] + public async Task TestBothArePopulatedDifferenceSizes() + { + var lines1 = string.Join("\n", ["x", "y", "z"]); + var lines2 = string.Join("\n", ["1", "2", "3", "4", "5"]); + + var list1 = await this.Create(new StringReader(lines1), new StringReader(lines2)) + .ToListAsync(); + string.Join(", ", list1).ShouldEqual("x, 1, y, 2, z, 3, 4, 5"); + + var list2 = await this.Create(new StringReader(lines2), new StringReader(lines1)) + .ToListAsync(); + string.Join(", ", list2).ShouldEqual("1, x, 2, y, 3, z, 4, 5"); + } + + [Test] + public void TestConsumeTwice() + { + var asyncEnumerable = this.Create(new StringReader("a"), new StringReader("b")); + asyncEnumerable.GetAsyncEnumerator(); + Assert.Throws(() => asyncEnumerable.GetAsyncEnumerator()); + } + + [Test] + public void TestOneThrows() + { + void TestOneThrows(bool reverse) + { + var reader1 = new StringReader("a\nb\nc"); + var count = 0; + var mockReader = new Mock(MockBehavior.Strict); + mockReader.Setup(r => r.ReadLineAsync()) + .ReturnsAsync(() => ++count < 3 ? "LINE" : throw new TimeZoneNotFoundException()); + + Assert.ThrowsAsync( + async () => await this.Create( + reverse ? mockReader.Object : reader1, + reverse ? reader1 : mockReader.Object + ).ToListAsync() + ); + } + + TestOneThrows(reverse: false); + TestOneThrows(reverse: true); + } + + [Test, Timeout(10_000)] // something's wrong if it's taking more than 10 seconds + public async Task FuzzTest() + { + Pipe pipe1 = new(), pipe2 = new(); + + var asyncEnumerable = this.Create(new StreamReader(pipe1.OutputStream), new StreamReader(pipe2.OutputStream)); + + var strings1 = Enumerable.Range(0, 2000).Select(_ => Guid.NewGuid().ToString()).ToArray(); + var strings2 = Enumerable.Range(0, 2300).Select(_ => Guid.NewGuid().ToString()).ToArray(); + + static async Task WriteStringsAsync(IReadOnlyList strings, Pipe pipe) + { + await Task.CompletedTask; // to make compiler happy + + SpinWait spinWait = default; + Random random = new(Guid.NewGuid().GetHashCode()); + using StreamWriter writer = new(pipe.InputStream); + foreach (var line in strings) + { + if (random.Next(10) == 1) + { + spinWait.SpinOnce(); + } + + writer.WriteLine(line); + } + } + + var task1 = WriteStringsAsync(strings1, pipe1); + var task2 = WriteStringsAsync(strings2, pipe2); + var consumeTask = asyncEnumerable.ToListAsync(); + await Task.WhenAll(task1, task2, consumeTask); // need to dispose the writer to end the stream + + CollectionAssert.AreEquivalent(strings1.Concat(strings2), consumeTask.Result); + } +} + +public static class AsyncEnumerableExtensions +{ + public static async Task> ToListAsync(this IAsyncEnumerable strings) + { + List result = []; + await foreach (var item in strings) { result.Add(item); } + return result; + } +} diff --git a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestSync.cs b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestSync.cs new file mode 100644 index 0000000..c1d24b5 --- /dev/null +++ b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestSync.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.IO; +using Medallion.Shell.Streams; + +namespace Medallion.Shell.Tests.Streams; + +public class MergedLinesEnumerableTestSync : MergedLinesEnumerableTestBase +{ + protected override IAsyncEnumerable Create(TextReader reader1, TextReader reader2) => + new AsyncEnumerableAdapter(new MergedLinesEnumerable(reader1, reader2)); +} \ No newline at end of file diff --git a/MedallionShell/MedallionShell.csproj b/MedallionShell/MedallionShell.csproj index 3efaeac..8d6498b 100644 --- a/MedallionShell/MedallionShell.csproj +++ b/MedallionShell/MedallionShell.csproj @@ -45,7 +45,7 @@ - + @@ -80,4 +80,7 @@ MedallionShell.ProcessSignaler.exe + + + \ No newline at end of file diff --git a/MedallionShell/Streams/MergedLinesEnumerable.cs b/MedallionShell/Streams/MergedLinesEnumerable.cs index 93aa736..75d2375 100644 --- a/MedallionShell/Streams/MergedLinesEnumerable.cs +++ b/MedallionShell/Streams/MergedLinesEnumerable.cs @@ -1,94 +1,149 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; -using System.Text; using System.Threading; using System.Threading.Tasks; namespace Medallion.Shell.Streams { - internal sealed class MergedLinesEnumerable(TextReader standardOutput, TextReader standardError) : IEnumerable + internal sealed class MergedLinesEnumerable : +#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_0_OR_GREATER + IEnumerable, IAsyncEnumerable +#else + IEnumerable +#endif { + private readonly TextReader standardOutput, standardError; private int consumed; + public MergedLinesEnumerable(TextReader standardOutput, TextReader standardError) + { + this.standardOutput = standardOutput; + this.standardError = standardError; + } + public IEnumerator GetEnumerator() { + this.AssertNoMultipleEnumeration(); + + return this.GetEnumeratorInternal(); + } + + private void AssertNoMultipleEnumeration() => Throw.If( Interlocked.Exchange(ref this.consumed, 1) != 0, "The enumerable returned by GetOutputAndErrorLines() can only be enumerated once" ); - return this.GetEnumeratorInternal(); - } - IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); private IEnumerator GetEnumeratorInternal() { - var tasks = new List(capacity: 2); - tasks.Add(new ReaderAndTask(standardOutput)); - tasks.Add(new ReaderAndTask(standardError)); + List tasks = [new(this.standardOutput), new(this.standardError)]; - // phase 1: read both streams simultaneously, alternating between which is given priority. - // Stop when one (or both) streams is exhausted + do + { + var nextLine = this.GetNextLineOrDefaultAsync(tasks).GetAwaiter().GetResult(); + if (nextLine is null) { yield break; } + yield return nextLine; + } + while (tasks.Count != 1); - TextReader remaining; - while (true) + var remaining = tasks[0].Reader; + while (remaining.ReadLine() is { } line) { - ReaderAndTask next; - if (tasks[0].Task.IsCompleted) - { - next = tasks[0]; - } - else if (tasks[1].Task.IsCompleted) - { - next = tasks[1]; - } - else - { - var nextCompleted = Task.WhenAny(tasks.Select(t => t.Task)).GetAwaiter().GetResult(); - next = tasks[0].Task == nextCompleted ? tasks[0] : tasks[1]; - } - - var nextLine = next.Task.GetAwaiter().GetResult(); - tasks.Remove(next); - - if (nextLine != null) - { - yield return nextLine; - tasks.Add(new ReaderAndTask(next.Reader)); - } - else - { - var otherAsyncLine = tasks[0].Task.GetAwaiter().GetResult(); - if (otherAsyncLine != null) - { - yield return otherAsyncLine; - remaining = tasks[0].Reader; - break; - } - else - { - yield break; - } - } + yield return line; } + } + + private async Task GetNextLineOrDefaultAsync(List tasks, CancellationToken cancellationToken = default) + { + Debug.Assert(tasks.Count is 0 or 2, "There should be EITHER nothing OR both stdout and stderr."); - // phase 2: finish reading the remaining stream + if (tasks.Count == 0) + { + tasks = [new(this.standardOutput, cancellationToken), new(this.standardError, cancellationToken)]; + } - string? line; - while ((line = remaining.ReadLine()) != null) + ReaderAndTask next; + if (tasks[0].Task.IsCompleted) + { + next = tasks[0]; + } + else if (tasks[1].Task.IsCompleted) + { + next = tasks[1]; + } + else + { + var nextCompleted = await Task.WhenAny(tasks.Select(t => t.Task)).ConfigureAwait(false); + next = tasks[0].Task == nextCompleted ? tasks[0] : tasks[1]; + } + + var nextLine = await next.Task.ConfigureAwait(false); + tasks.Remove(next); + + if (nextLine is { }) + { + tasks.Add(new ReaderAndTask(next.Reader, cancellationToken)); + return nextLine; + } + else if (await tasks[0].Task.ConfigureAwait(false) is { } otherAsyncLine) + { + return otherAsyncLine; + } + return null; + } + +#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_0_OR_GREATER + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + this.AssertNoMultipleEnumeration(); + + return this.GetAsyncEnumeratorInternal(cancellationToken); + } + + private async IAsyncEnumerator GetAsyncEnumeratorInternal(CancellationToken cancellationToken) + { + List tasks = [new(this.standardOutput, cancellationToken), new(this.standardError, cancellationToken)]; + + do + { + var nextLine = await this.GetNextLineOrDefaultAsync(tasks, cancellationToken).ConfigureAwait(false); + if (nextLine is null) { yield break; } + yield return nextLine; + } + while (tasks.Count != 1); + + var remaining = tasks[0].Reader; +#if NET7_0_OR_GREATER + while (await remaining.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line) +#else + while (remaining.ReadLine() is { } line) +#endif { yield return line; } } +#endif - private struct ReaderAndTask(TextReader reader) : IEquatable + private readonly struct ReaderAndTask : IEquatable { - public TextReader Reader { get; } = reader; - public Task Task { get; } = reader.ReadLineAsync(); + public ReaderAndTask(TextReader reader, CancellationToken cancellationToken = default) + { + this.Reader = reader; +#if NET7_0_OR_GREATER + this.Task = reader.ReadLineAsync(cancellationToken); +#else + this.Task = reader.ReadLineAsync(); +#endif + } + + public TextReader Reader { get; } + public Task Task { get; } public bool Equals(ReaderAndTask that) => this.Reader == that.Reader && this.Task == that.Task; diff --git a/README.md b/README.md index 15bfb20..55a33c7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -MedallionShell -============== +# MedallionShell MedallionShell vastly simplifies working with processes in .NET. @@ -47,9 +46,30 @@ Most APIs create a `Command` instance by starting a new process. However, you ca ### Standard IO -One of the main ways to interact with a process is via its [standard IO streams](https://en.wikipedia.org/wiki/Standard_streams) (in, out and error). By default, MedallionShell configures the process to enable these streams and captures standard error and standard output in the `Command`'s result. +One of the main ways to interact with a process is via its [standard IO streams](https://en.wikipedia.org/wiki/Standard_streams) (in, out and error). By default, MedallionShell configures the process to enable these streams and captures standard error and standard output in the `Command`'s result: +```C# +var command = Command.Run(...); +var result = await command.Task; +Console.WriteLine($"{result.StandardOutput}, {result.StandardError}"); +``` + +If you want to consume the output (stdout and stderr) as a _merged_ stream of lines like you would see in the console, you can use the `GetOutputAndErrorLines()` method: +```C# +var command = Command.Run(...); +foreach (var line in command.GetOutputAndErrorLines()) +{ + Console.WriteLine(line); +} +``` -Additionally/alternatively, you can interact with these streams directly via the `Command.StandardInput`, `Command.StandardOutput`, and `Command.StandardError` properties. As with `Process`, these are `TextWriter`/`TextReader` objects that also expose the underlying `Stream`, giving you the option of writing/reading either text or raw bytes. +Additionally/alternatively, you can interact with these streams directly via the `Command.StandardInput`, `Command.StandardOutput`, and `Command.StandardError` properties. As with `Process`, these are `TextWriter`/`TextReader` objects that also expose the underlying `Stream`, giving you the option of writing/reading either text or raw bytes: +```C# +var command = Command.Run(...); +command.StandardInput.Write("some text"); // e.g. write as text +command.StandardInput.BaseStream.Write(new byte[100]); // e.g. write as bytes +command.StandardOutput.ReadLine(); // e.g. read as text +command.StandardError.BaseStream.Read(new byte[100]); // e.g. read as bytes +``` The standard IO streams also contain methods for piping to and from common sinks and sources, including `Stream`s, `TextReader/Writer`s, files, and collections. For example: ```C# @@ -70,6 +90,8 @@ await Command.Run("ProcssingStep1.exe") < new FileInfo("input.txt") | Command.Run("processingStep2.exe") > new FileInfo("output.text"); ``` +Finally, note that **any content you read directly will _not_ end up in the result; the `result.StandardOutput` and `result.StandardError` properties store only content that you have not already consumed via some other mechanism. + ### Stopping a Command You can immediately terminate a command with the `Kill()` API. You can also use the `TrySignalAsync` API to send other types of signals which can allow for graceful shutdown if the target process handles them. `CommandSignal.ControlC` works across platforms, while other signals are OS-specific. @@ -120,7 +142,7 @@ Linux: [![Build Status](https://travis-ci.com/madelson/MedallionShell.svg?branch ## Release Notes - 1.6.2 - - Add net471 build as workaround for [#75](https://github.com/madelson/MedallionShell/issues/75) + - Add net471 build as workaround for [#75](https://github.com/madelson/MedallionShell/issues/75). Thanks [Cloudmersive](https://github.com/Cloudmersive) for reporting the issue and testing the fix! - 1.6.1 - Strong-named release [MedallionShell.StrongName](https://www.nuget.org/packages/medallionshell.strongname) ([#65](https://github.com/madelson/MedallionShell/issues/65)). Thanks [ldennington](https://github.com/ldennington)! - Fixes transient error in signaling on Windows machines with slow disks ([#61](https://github.com/madelson/MedallionShell/issues/61)) @@ -135,7 +157,7 @@ Linux: [![Build Status](https://travis-ci.com/madelson/MedallionShell.svg?branch - Add .NET Standard 2.0 and .NET 4.6 build targets so that users of more modern frameworks can take advantage of more modern APIs. The .NET Standard 1.3 and .NET 4.5 targets will likely be retired in the event of a 2.0 release. - Allow for setting piping and redirection via a `Shell` option with the new `Command(Func)` option ([#39](https://github.com/madelson/MedallionShell/issues/39)) - Add CI testing for Mono and .NET Core on Linux -- 1.5.1 Improves Mono.Android compatibility ([#22](https://github.com/madelson/MedallionShell/issues/22)). Thanks [sushihangover](https://github.com/sushihangover) for reporting and testing the fix! +- 1.5.1 Improves Mono.Android compatibility ([#22](https://github.com/madelson/MedallionShell/issues/22)). Thanks [sushihangover](https://github.com/sushihangover) for reporting the issue and testing the fix! - 1.5.0 - Command overrides `ToString()` to simplify debugging ([#19](https://github.com/madelson/MedallionShell/issues/19)). Thanks [Stephanvs](https://github.com/Stephanvs)! - WindowsCommandLineSyntax no longer quotes arguments that don't require it diff --git a/stylecop.analyzers.ruleset b/stylecop.analyzers.ruleset index fe9b573..3b8ef37 100644 --- a/stylecop.analyzers.ruleset +++ b/stylecop.analyzers.ruleset @@ -14,6 +14,7 @@ +