Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 52 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Or you can use the NuGet Package Manager in Visual Studio to search for and inst

### Basic Execution

The simplest way to execute a command is to use the `Execute` method. All methods return the process exit code:
The simplest way to execute a command is to use the `Execute` method, passing the executable and its arguments separately. All methods return the process exit code:

```csharp
using ktsu.RunCommand;
Expand All @@ -33,7 +33,7 @@ class Program
{
static void Main()
{
int exitCode = RunCommand.Execute("echo Hello World!");
int exitCode = RunCommand.Execute("dotnet", ["--version"]);

if (exitCode == 0)
{
Expand All @@ -47,6 +47,32 @@ class Program
}
```

### Deprecated: single command strings

The overloads taking one `command` string are obsolete. They separate the executable from its arguments by splitting on the **first space**, which cannot represent an executable path that itself contains a space — on Windows that includes anything under `C:\Program Files\`:

```csharp
// Obsolete, and broken: splits into "C:\Program" plus "Files\Git\bin\git.exe --version"
await RunCommand.ExecuteAsync(@"C:\Program Files\Git\bin\git.exe --version");

// Correct
await RunCommand.ExecuteAsync(@"C:\Program Files\Git\bin\git.exe", ["--version"]);
```

Quoting does not rescue it, because the split happens before any quote handling. The string form is inherently ambiguous — no parse handles every combination of spaces and quotes without adopting a shell's full grammar — so rather than grow a half-grammar that moves the surprise elsewhere, these overloads are deprecated in favour of the argument-list ones, which have no such ambiguity because the executable is passed separately.

Migration is mechanical: split the string yourself at the boundaries you meant.

| Obsolete | Replacement |
| --- | --- |
| `Execute(command)` | `Execute(fileName, arguments)` |
| `Execute(command, outputHandler)` | `Execute(fileName, arguments, outputHandler)` |
| `Execute(command, elevation)` | `Execute(fileName, arguments, outputHandler, options)` |
| `ExecuteAsync(command)` | `ExecuteAsync(fileName, arguments)` |
| `ExecuteAsync(command, outputHandler)` | `ExecuteAsync(fileName, arguments, outputHandler)` |
| `ExecuteAsync(command, cancellationToken)` | `ExecuteAsync(fileName, arguments, outputHandler, cancellationToken)` |
| `ExecuteAsync(command, outputHandler, elevation, cancellationToken)` | `ExecuteAsync(fileName, arguments, outputHandler, options, cancellationToken)` |

### Custom Output Handling

To handle the output of the command, you can provide delegates to the `OutputHandler` class:
Expand All @@ -59,7 +85,8 @@ class Program
static void Main()
{
int exitCode = RunCommand.Execute(
command: "echo Hello World!",
fileName: "dotnet",
arguments: ["--version"],
outputHandler: new(
onStandardOutput: Console.Write,
onStandardError: Console.Write
Expand All @@ -85,7 +112,8 @@ class Program
static void Main()
{
int exitCode = RunCommand.Execute(
command: "echo Hello World!",
fileName: "dotnet",
arguments: ["--version"],
outputHandler: new LineOutputHandler(
onStandardOutput: line => Console.WriteLine($"Output: {line}"),
onStandardError: line => Console.WriteLine($"Error: {line}")
Expand All @@ -108,7 +136,7 @@ class Program
{
static async Task Main()
{
int exitCode = await RunCommand.ExecuteAsync("echo Hello World!");
int exitCode = await RunCommand.ExecuteAsync("dotnet", ["--version"]);

if (exitCode == 0)
{
Expand All @@ -133,7 +161,11 @@ class Program
{
static void Main()
{
int exitCode = RunCommand.Execute("powershell -Command \"Get-Service\"", Elevation.Elevated);
int exitCode = RunCommand.Execute(
fileName: "powershell",
arguments: ["-Command", "Get-Service"],
outputHandler: new(),
options: new() { Elevation = Elevation.Elevated });

Console.WriteLine($"Process exited with code: {exitCode}");
}
Expand Down Expand Up @@ -207,7 +239,8 @@ class Program
static void Main()
{
int exitCode = RunCommand.Execute(
command: "echo Hello World!",
fileName: "dotnet",
arguments: ["--version"],
outputHandler: new(
onStandardOutput: Console.Write,
onStandardError: Console.Write,
Expand All @@ -222,14 +255,18 @@ class Program

### RunCommand Class

- `Execute(string command)`: Executes a command synchronously and returns the process exit code.
- `Execute(string command, OutputHandler outputHandler)`: Executes a command synchronously with custom output handling and returns the process exit code.
- `Execute(string command, Elevation elevation)`: Executes a command synchronously at the given elevation level.
- `Execute(string command, OutputHandler outputHandler, Elevation elevation)`: Executes a command synchronously with custom output handling at the given elevation level.
- `ExecuteAsync(string command)`: Executes a command asynchronously and returns a task with the process exit code.
- `ExecuteAsync(string command, OutputHandler outputHandler)`: Executes a command asynchronously with custom output handling and returns a task with the process exit code.
- `ExecuteAsync(string command, Elevation elevation)`: Executes a command asynchronously at the given elevation level.
- `ExecuteAsync(string command, OutputHandler outputHandler, Elevation elevation)`: Executes a command asynchronously with custom output handling at the given elevation level.
Passing the executable and its arguments separately:

- `Execute(string fileName, IEnumerable<string> arguments)`: Executes a command synchronously and returns the process exit code.
- `Execute(string fileName, IEnumerable<string> arguments, OutputHandler outputHandler)`: Executes a command synchronously with custom output handling.
- `ExecuteAsync(string fileName, IEnumerable<string> arguments)`: The asynchronous equivalent.
- `ExecuteAsync(string fileName, IEnumerable<string> arguments, OutputHandler outputHandler)`: The asynchronous equivalent with custom output handling.
- `ExecuteAsync(string fileName, IEnumerable<string> arguments, OutputHandler outputHandler, CancellationToken cancellationToken)`: As above, terminating the process and its children if the token is signalled.

**Obsolete** — see [Deprecated: single command strings](#deprecated-single-command-strings):

- `Execute(string command)`, `Execute(string command, OutputHandler outputHandler)`, `Execute(string command, Elevation elevation)`, `Execute(string command, OutputHandler outputHandler, Elevation elevation)`
- `ExecuteAsync(string command)`, `ExecuteAsync(string command, OutputHandler outputHandler)`, `ExecuteAsync(string command, Elevation elevation)`, `ExecuteAsync(string command, OutputHandler outputHandler, Elevation elevation)`, `ExecuteAsync(string command, CancellationToken cancellationToken)`, `ExecuteAsync(string command, OutputHandler outputHandler, CancellationToken cancellationToken)`, `ExecuteAsync(string command, OutputHandler outputHandler, Elevation elevation, CancellationToken cancellationToken)`
- `Execute(string fileName, IEnumerable<string> arguments, OutputHandler outputHandler, CommandOptions options)`: Executes a command synchronously with the given process options, passing arguments individually so no manual quoting is required.
- `ExecuteAsync(string fileName, IEnumerable<string> arguments, OutputHandler outputHandler, CommandOptions options)`: The asynchronous equivalent.
- `ExecuteAsync(string fileName, IEnumerable<string> arguments, OutputHandler outputHandler, CommandOptions options, CancellationToken cancellationToken)`: As above, terminating the process and its children if the token is signalled.
Expand Down
12 changes: 11 additions & 1 deletion RunCommand.Test/RunCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ private static string GetCopyCommand(string source, string destination) =>
? $"cmd /c copy \"{source}\" \"{destination}\""
: $"cp {source} {destination}";

// These tests cover the command-string overloads themselves, which are obsolete but still
// supported, so they have to keep calling them until those overloads are removed. The region
// ends after the last such test rather than covering the file.
#pragma warning disable CS0618 // Type or member is obsolete

[TestMethod]
public void ExecuteShouldExecuteCommandAndReturnExitCode()
{
Expand Down Expand Up @@ -301,6 +306,8 @@ public void ExecuteShouldThrowArgumentNullExceptionWhenCommandIsNull()
Assert.IsTrue(didThrow, "Expected an ArgumentNullException to be thrown.");
}

#pragma warning restore CS0618 // Type or member is obsolete

/// <summary>
/// Returns a command that reads a single file, as an executable plus separate arguments. Both
/// tools exit 0 only when they can open the file, so a path that was wrongly split on its
Expand Down Expand Up @@ -393,7 +400,10 @@ public async Task ExecuteAsyncShouldFailWhenArgumentWithSpacesIsPassedAsOneStrin

// The unquoted single-string overload splits the path on its spaces, which is precisely the
// failure the argument-list overload exists to avoid. This pins that difference down.
#pragma warning disable CS0618 // Type or member is obsolete -- this test exists to pin the very
// behaviour that made the command-string overloads obsolete, so it must call one.
int exitCode = await RunCommand.ExecuteAsync($"{fileName} {string.Join(" ", arguments)}").ConfigureAwait(false);
#pragma warning restore CS0618 // Type or member is obsolete

Assert.AreNotEqual(0, exitCode, "Expected the unquoted command string to mis-split the path.");
}
Expand All @@ -417,7 +427,7 @@ public async Task ExecuteAsyncShouldThrowWhenTokenIsAlreadyCancelled()
await cancellationTokenSource.CancelAsync().ConfigureAwait(false);

await Assert.ThrowsAsync<OperationCanceledException>(
() => RunCommand.ExecuteAsync("dotnet --version", cancellationTokenSource.Token)).ConfigureAwait(false);
() => RunCommand.ExecuteAsync("dotnet", ["--version"], new OutputHandler(), cancellationTokenSource.Token)).ConfigureAwait(false);
}

[TestMethod]
Expand Down
23 changes: 23 additions & 0 deletions RunCommand/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace ktsu.RunCommand;

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.InteropServices;
Expand All @@ -22,6 +23,8 @@
/// </summary>
/// <param name="command">The command to execute.</param>
/// <returns>The exit code of the executed process.</returns>
[Obsolete("A command string is split on its first space, which cannot handle an executable path "

Check warning on line 26 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Do not forget to remove this deprecated code someday.
+ "containing spaces. Use the overload taking a file name and an argument list instead.")]

Check warning on line 27 in RunCommand/RunCommand.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not forget to remove this deprecated code someday.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_RunCommand&issues=AaAZl9BcpQf9fzfj9JKW&open=AaAZl9BcpQf9fzfj9JKW&pullRequest=45
public static int Execute(string command) =>
ExecuteAsync(command).Result;

Expand All @@ -31,6 +34,8 @@
/// <param name="command">The command to execute.</param>
/// <param name="outputHandler">The handler for processing command output.</param>
/// <returns>The exit code of the executed process.</returns>
[Obsolete("A command string is split on its first space, which cannot handle an executable path "

Check warning on line 37 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Do not forget to remove this deprecated code someday.
+ "containing spaces. Use the overload taking a file name and an argument list instead.")]

Check warning on line 38 in RunCommand/RunCommand.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not forget to remove this deprecated code someday.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_RunCommand&issues=AaAZl9BcpQf9fzfj9JKY&open=AaAZl9BcpQf9fzfj9JKY&pullRequest=45
public static int Execute(string command, OutputHandler outputHandler) =>
ExecuteAsync(command, outputHandler).Result;

Expand All @@ -40,6 +45,8 @@
/// <param name="command">The command to execute.</param>
/// <param name="elevation">The privilege level under which to run the command.</param>
/// <returns>The exit code of the executed process.</returns>
[Obsolete("A command string is split on its first space, which cannot handle an executable path "

Check warning on line 48 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Do not forget to remove this deprecated code someday.
+ "containing spaces. Use the overload taking a file name and an argument list instead.")]

Check warning on line 49 in RunCommand/RunCommand.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not forget to remove this deprecated code someday.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_RunCommand&issues=AaAZl9BcpQf9fzfj9JKO&open=AaAZl9BcpQf9fzfj9JKO&pullRequest=45
public static int Execute(string command, Elevation elevation) =>
ExecuteAsync(command, elevation).Result;

Expand All @@ -54,6 +61,8 @@
/// </param>
/// <param name="elevation">The privilege level under which to run the command.</param>
/// <returns>The exit code of the executed process.</returns>
[Obsolete("A command string is split on its first space, which cannot handle an executable path "

Check warning on line 64 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Do not forget to remove this deprecated code someday.
+ "containing spaces. Use the overload taking a file name and an argument list instead.")]

Check warning on line 65 in RunCommand/RunCommand.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not forget to remove this deprecated code someday.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_RunCommand&issues=AaAZl9BcpQf9fzfj9JKP&open=AaAZl9BcpQf9fzfj9JKP&pullRequest=45
public static int Execute(string command, OutputHandler outputHandler, Elevation elevation) =>
ExecuteAsync(command, outputHandler, elevation).Result;

Expand Down Expand Up @@ -99,6 +108,8 @@
/// </summary>
/// <param name="command">The command to execute.</param>
/// <returns>A task representing the asynchronous operation with the process exit code.</returns>
[Obsolete("A command string is split on its first space, which cannot handle an executable path "

Check warning on line 111 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Do not forget to remove this deprecated code someday.
+ "containing spaces. Use the overload taking a file name and an argument list instead.")]

Check warning on line 112 in RunCommand/RunCommand.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not forget to remove this deprecated code someday.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_RunCommand&issues=AaAZl9BcpQf9fzfj9JKQ&open=AaAZl9BcpQf9fzfj9JKQ&pullRequest=45
public static async Task<int> ExecuteAsync(string command)
=> await ExecuteAsync(command, new OutputHandler()).ConfigureAwait(false);

Expand All @@ -108,6 +119,8 @@
/// <param name="command">The command to execute.</param>
/// <param name="outputHandler">The handler for processing command output.</param>
/// <returns>A task representing the asynchronous operation with the process exit code.</returns>
[Obsolete("A command string is split on its first space, which cannot handle an executable path "

Check warning on line 122 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Do not forget to remove this deprecated code someday.
+ "containing spaces. Use the overload taking a file name and an argument list instead.")]

Check warning on line 123 in RunCommand/RunCommand.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not forget to remove this deprecated code someday.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_RunCommand&issues=AaAZl9BcpQf9fzfj9JKR&open=AaAZl9BcpQf9fzfj9JKR&pullRequest=45
public static async Task<int> ExecuteAsync(string command, OutputHandler outputHandler)
=> await ExecuteAsync(command, outputHandler, Elevation.Default).ConfigureAwait(false);

Expand All @@ -117,6 +130,8 @@
/// <param name="command">The command to execute.</param>
/// <param name="elevation">The privilege level under which to run the command.</param>
/// <returns>A task representing the asynchronous operation with the process exit code.</returns>
[Obsolete("A command string is split on its first space, which cannot handle an executable path "

Check warning on line 133 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Do not forget to remove this deprecated code someday.
+ "containing spaces. Use the overload taking a file name and an argument list instead.")]

Check warning on line 134 in RunCommand/RunCommand.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not forget to remove this deprecated code someday.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_RunCommand&issues=AaAZl9BcpQf9fzfj9JKS&open=AaAZl9BcpQf9fzfj9JKS&pullRequest=45
public static async Task<int> ExecuteAsync(string command, Elevation elevation)
=> await ExecuteAsync(command, new(), elevation).ConfigureAwait(false);

Expand All @@ -131,6 +146,8 @@
/// </param>
/// <param name="elevation">The privilege level under which to run the command.</param>
/// <returns>A task representing the asynchronous operation with the process exit code.</returns>
[Obsolete("A command string is split on its first space, which cannot handle an executable path "

Check warning on line 149 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Do not forget to remove this deprecated code someday.
+ "containing spaces. Use the overload taking a file name and an argument list instead.")]

Check warning on line 150 in RunCommand/RunCommand.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not forget to remove this deprecated code someday.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_RunCommand&issues=AaAZl9BcpQf9fzfj9JKT&open=AaAZl9BcpQf9fzfj9JKT&pullRequest=45
public static async Task<int> ExecuteAsync(string command, OutputHandler outputHandler, Elevation elevation)
=> await ExecuteAsync(command, outputHandler, elevation, CancellationToken.None).ConfigureAwait(false);

Expand All @@ -142,6 +159,8 @@
/// A token that, when cancelled, terminates the running process and its children.
/// </param>
/// <returns>A task representing the asynchronous operation with the process exit code.</returns>
[Obsolete("A command string is split on its first space, which cannot handle an executable path "

Check warning on line 162 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Do not forget to remove this deprecated code someday.
+ "containing spaces. Use the overload taking a file name and an argument list instead.")]

Check warning on line 163 in RunCommand/RunCommand.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not forget to remove this deprecated code someday.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_RunCommand&issues=AaAZl9BcpQf9fzfj9JKU&open=AaAZl9BcpQf9fzfj9JKU&pullRequest=45
public static async Task<int> ExecuteAsync(string command, CancellationToken cancellationToken)
=> await ExecuteAsync(command, new OutputHandler(), cancellationToken).ConfigureAwait(false);

Expand All @@ -155,6 +174,8 @@
/// A token that, when cancelled, terminates the running process and its children.
/// </param>
/// <returns>A task representing the asynchronous operation with the process exit code.</returns>
[Obsolete("A command string is split on its first space, which cannot handle an executable path "

Check warning on line 177 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Do not forget to remove this deprecated code someday.
+ "containing spaces. Use the overload taking a file name and an argument list instead.")]

Check warning on line 178 in RunCommand/RunCommand.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not forget to remove this deprecated code someday.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_RunCommand&issues=AaAZl9BcpQf9fzfj9JKV&open=AaAZl9BcpQf9fzfj9JKV&pullRequest=45
public static async Task<int> ExecuteAsync(string command, OutputHandler outputHandler, CancellationToken cancellationToken)
=> await ExecuteAsync(command, outputHandler, Elevation.Default, cancellationToken).ConfigureAwait(false);

Expand All @@ -174,6 +195,8 @@
/// </param>
/// <returns>A task representing the asynchronous operation with the process exit code.</returns>
/// <exception cref="OperationCanceledException">The token was cancelled before the process exited.</exception>
[Obsolete("A command string is split on its first space, which cannot handle an executable path "
+ "containing spaces. Use the overload taking a file name and an argument list instead.")]

Check warning on line 199 in RunCommand/RunCommand.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not forget to remove this deprecated code someday.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_RunCommand&issues=AaAZl9BcpQf9fzfj9JKX&open=AaAZl9BcpQf9fzfj9JKX&pullRequest=45
public static async Task<int> ExecuteAsync(string command, OutputHandler outputHandler, Elevation elevation, CancellationToken cancellationToken)
{
Ensure.NotNull(command);
Expand Down