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
22 changes: 14 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ fail-fast error handling. Keep changes focused and defer to the linked docs for

| Project | Role | Target frameworks |
|---------|------|-------------------|
| `Invex.Process` | The library: `IProcessRunner` / `ProcessRunner`, `ProcessRunOptions`, `ProcessRunResult`, `ProcessHostExtensions` | `net10.0` |
| `Invex.Process` | The library: `IProcessRunner` / `ProcessRunner`, `ProcessRunOptions`, `ProcessRunResult`, `ProcessException`, `ProcessHostExtensions` | `net10.0` |
| `Invex.Process.Tests` | NUnit test suite, including a public API surface snapshot test | `net10.0` |
| `_atom` | Atom build definition (`IBuild.cs`) that generates the GitHub Actions workflows | `net10.0` |

Expand Down Expand Up @@ -40,24 +40,30 @@ docfx docfx.json # add --serve to preview locally

## Architecture overview

The entire public surface is four types in the `Invex.Process` namespace:
The entire public surface is five types in the `Invex.Process` namespace:
Comment thread
DecSmith42 marked this conversation as resolved.
Resolved

- **`IProcessRunner`** — the injectable service: synchronous `Run(options)` and asynchronous
`RunAsync(options, ct)`. Registered as a singleton; stateless and safe for concurrent use.
- **`ProcessRunner`** — the `internal sealed` default implementation wrapping
`System.Diagnostics.Process`. stdout/stderr are redirected and streamed line-by-line to
`ILogger` in real time via `BeginOutputReadLine`/`BeginErrorReadLine`, while also being fully
captured into the result.
- **`ProcessHostExtensions`** — the `AddProcessRunner()` extension on `IServiceCollection`, which
registers `IProcessRunner` as a singleton backed by the default implementation.
- **`ProcessRunOptions`** — immutable record describing one invocation: name, args, working
directory, environment variables, per-stream log levels, per-line transforms
(`TransformOutput`/`TransformError`), and `AllowFailedResult`.
- **`ProcessRunResult`** — immutable record with `ExitCode`, captured `Output`/`Error`, and the
originating `RunOptions`.
- **`ProcessException`** — thrown on a non-zero exit (unless `AllowFailedResult`); derives from
`Exception` and carries `ExitCode` and the full `Result`.

The default implementation, `ProcessRunner`, is `internal sealed` and not part of the public
surface. It wraps `System.Diagnostics.Process`; stdout/stderr are redirected and streamed
line-by-line to `ILogger` in real time via `BeginOutputReadLine`/`BeginErrorReadLine`, while also
being fully captured into the result.

### Behavioral contracts (do not break these)

- **Fail-fast by default**: a non-zero exit code throws a descriptive exception (command line,
exit code, working directory, stderr) unless `AllowFailedResult` is `true`.
- **Fail-fast by default**: a non-zero exit code throws a descriptive `ProcessException` (command
line, exit code, working directory, stderr) unless `AllowFailedResult` is `true`.
- **Environment variable values are never logged** — the invocation line names the variables only.
- **Output is always fully captured** into the result regardless of log levels.
- **Failure promotion**: on any non-zero exit, stdout/stderr configured below `Information` are
re-logged in full at `Information`/`Warning` respectively — regardless of `AllowFailedResult`.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ full output capture, and fail-fast error handling. It wraps `System.Diagnostics.
instead of being buffered until exit.
- **Full output capture.** Complete stdout/stderr are always captured into the result, regardless of log level, so you
can inspect them even when logging is suppressed.
- **Fail-fast by default.** A non-zero exit code throws a descriptive exception (including command, exit code, working
directory, and stderr), or you opt in to inspect failures yourself.
- **Fail-fast by default.** A non-zero exit code throws a descriptive `ProcessException` (including command, exit code,
working directory, and stderr, plus the full result), or you opt in to inspect failures yourself.
- **Configurable log levels.** Control how the invocation, stdout, and stderr are logged.
- **Per-line transforms.** Rewrite or suppress individual output/error lines, for example to redact secrets or filter
noisy progress output.
Expand Down
1 change: 1 addition & 0 deletions api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ match the shipped package; the table below is a map of where to start.
| [`IProcessRunner`](xref:Invex.Process.IProcessRunner) | Interface | Injectable service for executing external processes. Exposes synchronous `Run` and asynchronous `RunAsync`. Registered as a singleton. |
| [`ProcessRunOptions`](xref:Invex.Process.ProcessRunOptions) | Record | Immutable configuration for a single execution: executable name, arguments, working directory, environment variables, log levels, per-line transforms, and failure tolerance. |
| [`ProcessRunResult`](xref:Invex.Process.ProcessRunResult) | Record | The result of a completed execution: exit code, full captured stdout/stderr, and the originating options. |
| [`ProcessException`](xref:Invex.Process.ProcessException) | Exception | Thrown on a non-zero exit (unless `AllowFailedResult` is set); carries the exit code and the full `ProcessRunResult`. |
| [`ProcessHostExtensions`](xref:Invex.Process.ProcessHostExtensions) | Static class | `IServiceCollection` extensions; call `AddProcessRunner()` once during host setup. |

## Typical flow
Expand Down
22 changes: 19 additions & 3 deletions docs/handling-failures.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ to opt out of the default exception when a failure is an expected outcome.

## Fail-fast by default

When a process exits with a **non-zero exit code**, `Run` and `RunAsync` throw an exception rather
than returning a result. This means build steps and tools fail immediately without callers having
When a process exits with a **non-zero exit code**, `Run` and `RunAsync` throw a `ProcessException`
rather than returning a result. This means build steps and tools fail immediately without callers having
to remember to check exit codes:

```csharp
Expand All @@ -31,6 +31,21 @@ It includes:
- the working directory (when one was set),
- the captured stderr (when the process wrote any).

`ProcessException` derives from `Exception` and also exposes the failure programmatically:
`ExitCode` and `Result` (the full `ProcessRunResult`, with captured stdout/stderr and the original
`ProcessRunOptions`):

```csharp
try
{
processRunner.Run(new ProcessRunOptions("git", "rev-parse HEAD"));
}
catch (ProcessException ex) when (ex.ExitCode == 128)
{
Console.WriteLine($"Not a git repository: {ex.Result.Error}");
}
```

## Tolerating failures with `AllowFailedResult`

Sometimes a non-zero exit code is an *expected* outcome you want to inspect — linters, format
Expand Down Expand Up @@ -87,4 +102,5 @@ catch (Win32Exception ex)

- [Logging](logging.md): failure promotion in detail.
- [Testing Your Code](testing.md): simulating failures in unit tests.
- <xref:Invex.Process.IProcessRunner>, <xref:Invex.Process.ProcessRunOptions> and <xref:Invex.Process.ProcessRunResult>
- <xref:Invex.Process.IProcessRunner>, <xref:Invex.Process.ProcessRunOptions>, <xref:Invex.Process.ProcessRunResult> and
<xref:Invex.Process.ProcessException>
2 changes: 1 addition & 1 deletion docs/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ It is probably not the right tool when:

## Design principles

- **Fail fast by default.** A non-zero exit code throws a descriptive exception that includes the
- **Fail fast by default.** A non-zero exit code throws a descriptive `ProcessException` that includes the
command line, exit code, working directory, and captured stderr. Opt out per-invocation with
`AllowFailedResult` when you want to handle failures yourself.
- **Never lose output.** Full stdout and stderr are always captured into the result, regardless of
Expand Down
11 changes: 7 additions & 4 deletions docs/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ Each invocation produces three kinds of log entries:
1. **The invocation line** — logged once when the process starts:

```text
Run: dotnet build --configuration Release in /repo/src with env CI=true
Run: dotnet build --configuration Release in /repo/src with env CI, NUGET_API_KEY
```

The working directory and environment variables are included only when set.
The working directory and environment variables are included only when set. Only the
environment variable *names* are logged, never their values.

2. **Output lines** — each stdout line, streamed in real time as the process runs.

Expand Down Expand Up @@ -63,8 +64,10 @@ failure). The common pattern is therefore:

Two things to watch:

- The **invocation line** includes the command arguments and any environment variables you set.
Avoid passing secrets as plain arguments/variables, or lower `InvocationLogLevel`.
- The **invocation line** includes the full command arguments and the *names* (not values) of any
environment variables you set. Prefer passing secrets through `EnvironmentVariables`; if a
secret must go on the command line, lower `InvocationLogLevel` (or set it to `LogLevel.None`).
The exception thrown on a non-zero exit also includes the full command line.
- **Output lines** may echo secrets printed by the tool. Use `TransformOutput` /
`TransformError` to redact or suppress them — see
[Transforming Output](transforming-output.md).
Expand Down
5 changes: 4 additions & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ A.CallTo(() => processRunner.RunAsync(A<ProcessRunOptions>._, A<CancellationToke

// Code under test relies on the default throwing behavior:
A.CallTo(() => processRunner.RunAsync(A<ProcessRunOptions>._, A<CancellationToken>._))
.Throws(new Exception("Process 'git' failed with exit code 128"));
.Throws((ProcessRunOptions options, CancellationToken _) =>
new ProcessException(
"Process 'git' failed with exit code 128",
new ProcessRunResult(options, 128, "", "fatal: not a git repository")));
```

## Tips
Expand Down
4 changes: 2 additions & 2 deletions docs/transforming-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ The redaction applies to both the live log stream *and* the captured `result.Out

> [!WARNING]
> Transforms only see the process's output lines. The invocation line (`Run: deploy --token …`) is logged before the
> process starts and includes the full arguments and any environment variables you set, so a secret passed on the
> command line still reaches the log at `InvocationLogLevel`. See [Logging](logging.md#secrets-in-logs).
> process starts and includes the full arguments (environment variables are logged by name only), so a secret passed
> on the command line still reaches the log at `InvocationLogLevel`. See [Logging](logging.md#secrets-in-logs).

## Filtering noise

Expand Down
61 changes: 61 additions & 0 deletions src/Invex.Process/ProcessException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
namespace Invex.Process;

/// <summary>
/// The exception thrown by <see cref="IProcessRunner" /> when a process exits with a non-zero
/// exit code and <see cref="ProcessRunOptions.AllowFailedResult" /> is <c>false</c>.
/// </summary>
/// <remarks>
/// <para>
/// The <see cref="Exception.Message" /> describes the failure (command line, exit code,
/// working directory when set, and any captured stderr), and <see cref="Result" /> exposes
/// the full result of the failed run — including the captured stdout and stderr and the
/// originating <see cref="ProcessRunOptions" /> — for programmatic inspection.
/// </para>
/// <para>
/// Derives directly from <see cref="Exception" />, so existing <c>catch (Exception)</c>
/// blocks continue to handle it.
/// </para>
/// </remarks>
/// <example>
/// Inspecting a failed run:
/// <code>
/// try
/// {
/// processRunner.Run(new ProcessRunOptions("git", "rev-parse HEAD"));
/// }
/// catch (ProcessException ex) when (ex.ExitCode == 128)
/// {
/// logger.LogWarning("Not a git repository: {Error}", ex.Result.Error);
/// }
/// </code>
/// </example>
[PublicAPI]
[SuppressMessage("Roslynator",
"RCS1194:Implement exception constructors",
Justification = "A ProcessException always describes a failed run, so every constructor requires its result.")]
public sealed class ProcessException : Exception
{
/// <summary>
/// Initializes a new instance of <see cref="ProcessException" /> for a failed process run.
/// </summary>
/// <param name="message">The message that describes the failure.</param>
/// <param name="result">The result of the failed process run.</param>
public ProcessException(string message, ProcessRunResult result) : base(message)
{
Result = result;
}

/// <summary>
/// Gets the result of the failed process run, including the captured stdout and stderr and
/// the options the process was run with.
/// </summary>
public ProcessRunResult Result { get; }

/// <summary>
/// Gets the non-zero exit code the process returned.
/// </summary>
/// <remarks>
/// Shorthand for <c>Result.ExitCode</c>.
/// </remarks>
public int ExitCode => Result.ExitCode;
}
31 changes: 24 additions & 7 deletions src/Invex.Process/ProcessRunOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
/// <para>
/// All properties have defaults suited to typical build-automation use: invocation is
/// logged at <see cref="LogLevel.Information" />, stdout at <see cref="LogLevel.Debug" />,
/// and stderr at <see cref="LogLevel.Warning" />. A non-zero exit code throws by default
/// so build steps fail fast without requiring callers to check exit codes manually.
/// and stderr at <see cref="LogLevel.Warning" />. A non-zero exit code throws a
/// <see cref="ProcessException" /> by default so build steps fail fast without requiring
/// callers to check exit codes manually.
/// </para>
/// <para>
/// Both stdout and stderr are fully captured into <see cref="ProcessRunResult" /> regardless
Expand Down Expand Up @@ -60,8 +61,18 @@ public ProcessRunOptions(string Name, string[] Args) : this(Name,
/// (e.g. <c>Run: dotnet build --configuration Release</c>).
/// </summary>
/// <remarks>
/// Defaults to <see cref="LogLevel.Information" />. Lower this to
/// <see cref="LogLevel.Debug" /> for noisy or frequently-called sub-processes.
/// <para>
/// Defaults to <see cref="LogLevel.Information" />. Lower this to
/// <see cref="LogLevel.Debug" /> for noisy or frequently-called sub-processes.
/// </para>
/// <para>
/// The invocation line includes the full <see cref="Args" />, the
/// <see cref="WorkingDirectory" /> when set, and the <em>names</em> of any
/// <see cref="EnvironmentVariables" />. Environment variable values are never logged,
/// so they are a safer way to pass secrets than command-line arguments. When a secret
/// must be passed as an argument, lower this level (or set it to
/// <see cref="LogLevel.None" />) to keep it out of the log.
/// </para>
/// </remarks>
public LogLevel InvocationLogLevel { get; init; } = LogLevel.Information;

Expand Down Expand Up @@ -122,9 +133,15 @@ public ProcessRunOptions(string Name, string[] Args) : this(Name,
/// Gets the environment variables to inject into the process's environment.
/// </summary>
/// <remarks>
/// Keys are variable names and values are their string values. A <c>null</c> value removes
/// the inherited variable from the child process's environment. Variables not listed here
/// are inherited from the current process unchanged.
/// <para>
/// Keys are variable names and values are their string values. A <c>null</c> value
/// removes the inherited variable from the child process's environment. Variables not
/// listed here are inherited from the current process unchanged.
/// </para>
/// <para>
/// Only the variable names appear in the invocation log line (see
/// <see cref="InvocationLogLevel" />); their values are never logged.
/// </para>
/// </remarks>
public Dictionary<string, string?> EnvironmentVariables { get; init; } = [];

Expand Down
Loading
Loading