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
195 changes: 120 additions & 75 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,118 +2,163 @@

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview
## Build Commands

ktsu.RunCommand is a .NET library that provides an easy way to execute shell commands and handle output via delegates. It supports both synchronous and asynchronous execution with customizable output handling.

## Build and Test Commands

### Building
```bash
# Restore, build, and test (standard workflow)
dotnet restore
dotnet build

# Build specific configuration
dotnet build -c Release

# Create the NuGet package
dotnet pack
```

### Running Tests

The test project uses MSTest.Sdk with the Microsoft Testing Platform (MTP). `dotnet test` reports
`Zero tests ran` here even though the tests build and discover correctly — run the produced test
executable directly instead:

```bash
# Run all tests
dotnet test
./RunCommand.Test/bin/Debug/net10.0/ktsu.RunCommand.Test.exe

# Run a single test
dotnet test --filter "FullyQualifiedName~RunCommandTests.ExecuteShouldExecuteCommandAndReturnExitCode"
```
./RunCommand.Test/bin/Debug/net10.0/ktsu.RunCommand.Test.exe --filter "FullyQualifiedName~ExecuteAsyncShouldStartTheProcessInTheGivenWorkingDirectory"

### Creating NuGet Package
```bash
dotnet pack
# List the discovered tests
./RunCommand.Test/bin/Debug/net10.0/ktsu.RunCommand.Test.exe --list-tests
```

### Using the Build Automation
The project uses a custom PowerShell build module (PSBuild) for CI/CD:
```powershell
Import-Module ./scripts/PSBuild.psm1
$buildConfig = Get-BuildConfiguration ...
Invoke-CIPipeline -BuildConfiguration $buildConfig
```
The executable takes VSTest-style `--filter` expressions. The MTP-native `--filter-method` and
`--treenode-filter` options are not accepted by this test host.

Two elevation tests self-skip on Windows (`Assert.Inconclusive`) to avoid raising a UAC prompt, so
a clean run reports skips rather than failures.

## Project Structure

This is a .NET library (`ktsu.RunCommand`) that executes external commands and delivers their
output through delegates. The solution uses:

- **ktsu.Sdk** - Custom SDK providing shared build configuration
- **MSTest.Sdk** - Test project SDK with Microsoft Testing Platform
- Multi-targeting: `net10.0`, `net9.0`, `net8.0`, `net7.0`, `net6.0`, `net5.0`, `netstandard2.0`, `netstandard2.1`

The test project targets `net10.0` only.

### Key Files

- `RunCommand/RunCommand.cs` - Static class holding the whole public execution API and the private `CreateStartInfo`/`RunAsync`/`TryKill` core
- `RunCommand/CommandOptions.cs` - Record carrying process-shaping settings (working directory, environment variables, elevation)
- `RunCommand/OutputHandler.cs` - Base output handler delivering raw chunks
- `RunCommand/LineOutputHandler.cs` - Derived handler that buffers chunks into complete lines
- `RunCommand/AsyncProcessStreamReader.cs` - Internal concurrent reader for stdout and stderr
- `RunCommand/Elevation.cs` - Enum selecting the privilege level

### Dependencies

- **ktsu.Semantics.Paths** - Supplies `AbsoluteDirectoryPath` for `CommandOptions.WorkingDirectory`
- **ktsu.Semantics.Strings** - Referenced explicitly because `WeakString` is declared there; the SDK's `KTSU0006` analyzer rejects using it transitively through Paths
- **Polyfill** (`PrivateAssets="all"`) - Supplies `Ensure.NotNull` and newer-framework APIs on older targets
- **System.Memory**, **System.Threading.Tasks.Extensions** - `netstandard2.0`/`netstandard2.1` only

Note that the Semantics packages pull `System.Text.Json`, `System.IO.Pipelines` and
`System.Text.Encodings.Web` in transitively. On `net5.0`–`net7.0` those emit "doesn't support
<tfm>" MSBuild warnings. They come from targets files rather than the compiler, so
`TreatWarningsAsErrors` does not escalate them and the build stays green.

## Architecture

### Core Components
### Public API shape

Two entry shapes exist, both returning the process exit code:

- **Argument vector** — `Execute`/`ExecuteAsync(string fileName, IEnumerable<string> arguments, ...)`. Preferred. The executable is passed separately, so nothing has to be quoted.
- **Command string** — `Execute`/`ExecuteAsync(string command, ...)`. **Obsolete.** Splits on the first space, so an executable path containing a space is mis-split. Kept working for compatibility; do not add new overloads to this shape.

Optional settings arrive through `CommandOptions` rather than through new parameters, so adding a
setting costs no new overloads. The older overloads taking a bare `Elevation` delegate through
`new CommandOptions { Elevation = elevation }`.

### Key design patterns

**[RunCommand.cs](RunCommand/RunCommand.cs)** - Main static class providing the public API:
- `Execute(string command)` - Synchronous command execution
- `Execute(string command, OutputHandler outputHandler)` - Synchronous with output handling
- `ExecuteAsync(string command)` - Asynchronous command execution
- `ExecuteAsync(string command, OutputHandler outputHandler)` - Asynchronous with output handling
- All methods return process exit codes
- Commands are parsed by splitting on first space: filename and arguments
1. **Async over sync**: The synchronous `Execute` methods call `ExecuteAsync().Result`, making the async implementation the single source of truth. Note this means argument-null exceptions surface wrapped in `AggregateException` from the synchronous overloads.

2. **Strategy pattern**: `OutputHandler` and `LineOutputHandler` plug different output processing strategies into the same execution core.

3. **Template method**: `OutputHandler` exposes virtual `HandleStandardOutputData`/`HandleStandardErrorData` that `LineOutputHandler` overrides.

4. **Buffering strategy**: `LineOutputHandler` keeps separate `outputBuffer`/`errorBuffer` fields so an incomplete line spanning two chunk reads is reassembled rather than raised twice.

## Important Implementation Notes

**[OutputHandler.cs](RunCommand/OutputHandler.cs)** - Base class for handling command output:
- Processes output in raw, undelimited chunks as they arrive from the process
- Provides `OnStandardOutput` and `OnStandardError` delegates
- Supports custom encoding (defaults to UTF-8)
- Virtual methods `HandleStandardOutputData` and `HandleStandardErrorData` for extensibility
### Cancellation

**[LineOutputHandler.cs](RunCommand/LineOutputHandler.cs)** - Derived output handler for line-by-line processing:
- Inherits from OutputHandler
- Buffers incoming chunks and splits by newlines
- Maintains separate buffers (`outputBuffer`, `errorBuffer`) for incomplete lines
- Uses `Environment.NewLine` after normalizing line endings with `ReplaceLineEndings()`
- Invokes delegates for each complete line
`RunAsync` delivers cancellation two ways at once: a `CancellationTokenRegistration` kills the
process, and `WaitForExitAsync(cancellationToken)` separately observes the token. Killing the
process makes it exit fast enough that the normal-exit path can win that race, which would return
the killed process's exit code (`-1` on Windows) and throw nothing.

**[AsyncProcessStreamReader.cs](RunCommand/AsyncProcessStreamReader.cs)** - Internal async stream reader:
- Reads from both stdout and stderr concurrently using 4096-character buffers
- Continuously reads while process is running, then performs final read after exit
- Uses `Task.WhenAny` to poll streams efficiently
- Invokes OutputHandler methods for each chunk of data received
`RunAsync` therefore calls `cancellationToken.ThrowIfCancellationRequested()` after the await and
before returning. **Do not remove this** — without it a cancelled command is indistinguishable from
a genuine failure of the underlying tool. The regression test repeats a 1 ms cancellation 50 times,
because a single attempt still throws most of the time even when the bug is present.

### Key Design Patterns
Process-tree termination requires .NET Core 3.0 or later; the `netstandard2.0`/`netstandard2.1`
builds can only kill the process itself.

1. **Async Over Sync**: The synchronous `Execute` methods call `ExecuteAsync().Result`, making the async implementation the source of truth.
### Elevation constraints

2. **Strategy Pattern**: OutputHandler and LineOutputHandler allow different output processing strategies to be plugged in.
Elevation forces `UseShellExecute = true`, which is incompatible with both output redirection and
setting an environment. Consequently an `OutputHandler` is silently not invoked under elevation
(documented behaviour), while combining `EnvironmentVariables` with elevation throws
`ArgumentException` up front rather than failing opaquely inside `Process.Start`.

3. **Template Method**: OutputHandler provides virtual methods that LineOutputHandler overrides to customize behavior.
### Argument escaping

4. **Buffering Strategy**: LineOutputHandler demonstrates how to buffer incomplete data across multiple chunk reads to reconstruct complete lines.
`ProcessStartInfo.ArgumentList` is unavailable on `netstandard2.0`, so that target alone falls back
to a hand-written `EscapeArgument` implementing the `CommandLineToArgvW` quoting rules. This is one
of the few places conditional compilation is warranted.

### Multi-Targeting
### Stream reading

The library targets multiple .NET versions:
- .NET 9.0
- .NET 8.0
- .NET 7.0
- .NET 6.0
- .NET 5.0
- .NET Standard 2.1
- .NET Standard 2.0
`AsyncProcessStreamReader` reads stdout and stderr concurrently with 4096-character buffers and
performs a final read after process exit, which is what ensures short-lived processes do not lose
buffered output.

Uses `ktsu.Sdk` for standardized project configuration.
### Process configuration

On Windows, `LoadUserProfile` is set to true for proper environment variable expansion.

## Testing

Tests are located in [RunCommand.Test/](RunCommand.Test/) using MSTest framework:
- [RunCommandTests.cs](RunCommand.Test/RunCommandTests.cs) - Tests for main execution methods
- [LineOutputHandlerTests.cs](RunCommand.Test/LineOutputHandlerTests.cs) - Tests for line buffering logic
- Tests target .NET 9.0 only
- Uses MSTest.Sdk for test execution
Tests live in `RunCommand.Test/` and use MSTest:

- `RunCommandTests.cs` - Execution, output capture, elevation, working directory, environment variables, and cancellation
- `LineOutputHandlerTests.cs` - Line buffering across chunk boundaries

Patterns worth preserving:

- Commands are chosen per-platform through helpers (`GetSleepCommand`, `GetPrintWorkingDirectoryCommand`, `GetPrintEnvironmentVariableCommand`) rather than hard-coded, so the suite runs on Windows and Unix.
- Tests run in parallel at method level, so anything touching process-global or filesystem state derives a unique name from `[CallerMemberName]`.
- The tests covering the obsolete command-string overloads sit inside a single `#pragma warning disable CS0618` region that ends after the last of them. Keep the region tight; do not promote it to file scope.

## Version Management

The project uses semantic versioning with git-based version calculation:
Semantic versioning with git-based version calculation:

- Version tags in commit messages: `[major]`, `[minor]`, `[patch]`, `[pre]`
- Public API changes are automatically detected and trigger minor version bumps
- VERSION.md, CHANGELOG.md, and other metadata files are auto-generated by PSBuild module

## Important Implementation Notes
- VERSION.md, CHANGELOG.md, and LICENSE.md are auto-generated — never edit them manually

### Command Parsing
Commands are split on the first space character. The first part becomes the filename, the rest becomes arguments. Be aware this simple parsing doesn't handle quoted strings specially.
## CI/CD

### Process Configuration
On Windows, `LoadUserProfile` is set to true for proper environment variable expansion.
Uses `scripts/PSBuild.psm1` PowerShell module for CI pipeline. Version increments are controlled by commit message tags: `[major]`, `[minor]`, `[patch]`, `[pre]`.

### Stream Reading
The AsyncProcessStreamReader performs a final read after process exit to ensure all buffered data is captured. This is crucial for short-lived processes.
## Code Quality

### Encoding
All input/output streams use UTF-8 by default but can be customized via OutputHandler constructor.
Do not add global suppressions for warnings. Use explicit suppression attributes with justifications when needed, with preprocessor defines only as fallback. Make the smallest, most targeted suppressions possible.
2 changes: 1 addition & 1 deletion DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
A library that provides an easy way to execute shell commands and handle the output via delegates with both synchronous and asynchronous support.
A .NET library for executing external commands and handling their output through delegates, with both synchronous and asynchronous APIs. Delivers standard output and standard error as raw chunks or complete lines as the process produces them, passes arguments as a vector so paths with spaces need no quoting, and shapes the spawned process with a working directory, an environment variable overlay, Windows elevation, and cancellation that terminates the process tree.
Loading