Coming from ProcessKit-rs

Previous: Comparison and migration guide

ProcessKit for .NET and ProcessKit-rs share the same contract: async process execution, typed failures, honest non-zero outcomes, whole-tree containment, and an injectable runner seam. The spelling follows each ecosystem. This guide maps the concepts without pretending that Rust ownership and .NET disposal are the same mechanism.

Verb map

ProcessKit-rsProcessKit for .NETResult
Command::run().awaitCommand.RunAsync()accepted exit required; trimmed stdout
Command::output_string().awaitCommand.OutputStringAsync()full ProcessResult<string>; non-zero exit remains data
Command::output_bytes().awaitCommand.OutputBytesAsync()full ProcessResult<byte[]>
Command::exit_code().awaitCommand.ExitCodeAsync()exit code; a timeout or signal is an error
Command::probe().awaitCommand.ProbeAsync()0 → true, 1 → false, any other outcome is an error
Command::start().awaitCommand.StartAsync()live process for streaming, stdin, and readiness waits

The most important distinction survives the language change: output_string / OutputStringAsync captures a non-zero exit in the result, while run / RunAsync promotes an unaccepted exit to a typed error.

The Rust blocks on this page are non-compiled illustrations; the repository's snippet harness compiles every paired F# and C# block against the current .NET API.

#![allow(unused)]
fn main() {
let output = Command::new("git")
    .arg("status")
    .output_string()
    .await?;
println!("code={:?} {}", output.code(), output.stdout());
}

F#

task {
    let command = Command.create "git" |> Command.arg "status"

    match! command.OutputStringAsync() with
    | Ok output -> printfn $"code={output.Code} {output.Stdout}"
    | Error err -> eprintfn $"{err.Message}"
}

C#

var command = new Command("git").Arg("status");

Console.WriteLine(await command.OutputStringAsync() switch
{
    { IsOk: true, ResultValue: var output } => $"code={output.Code} {output.Stdout}",
    { IsOk: false, ErrorValue: var err }    => $"error: {err.Message}",
});

Ownership and teardown

Rust makes teardown visible through ownership: dropping a RunningProcess or ProcessGroup reaps its contained tree. .NET cannot attach correctness to the GC lifetime, so the same deterministic boundary is IDisposable / IAsyncDisposable. Bind the live handle with use in F# or using / await using in C#. F# use! is the corresponding spelling when an async factory returns the disposable directly; ProcessKit's built-in StartAsync returns an honest Result, so match it first and then bind the successful handle with use, as below. Do not leave containment to a finalizer.

#![allow(unused)]
fn main() {
let process = Command::new("server").start().await?;
// Dropping `process` tears down its private contained tree.
}

F#

task {
    match! (Command.create "server").StartAsync() with
    | Error err -> eprintfn $"{err.Message}"
    | Ok process ->
        use process = process
        match! process.WaitForLineAsync((fun line -> line.Contains "ready"), TimeSpan.FromSeconds 10.0) with
        | Ok _ -> printfn "ready"
        | Error err -> eprintfn $"{err.Message}"
}

Errors and honest outcomes

Rust vocabulary.NET vocabulary
Result<T, processkit::Error>Task<Result<'T, ProcessError>> in F#; Task<FSharpResult<T, ProcessError>> in C#
Error::reason() / ErrorReasonpattern-match the ProcessError discriminated union
ProcessResult<T>ProcessResult<'T> with Outcome, Code, Stdout, and Stderr
ErrorReason::UnsupportedProcessError.Unsupported
ErrorReason::NotFoundProcessError.NotFound
ErrorReason::CancelledProcessError.Cancelled

Both libraries keep a non-zero exit as data for capture verbs and make cancellation an error. Match the structured case; do not parse Message.

Cancellation

Rust's Command::cancel_on(CancellationToken) maps directly to Command.CancelOn(CancellationToken) / Command.cancelOn. Every consuming .NET verb also accepts a call-scoped CancellationToken; CancelOn is useful when a preconfigured command or CliClient carries its own lifetime.

#![allow(unused)]
fn main() {
let output = Command::new("worker")
    .cancel_on(shutdown.child_token())
    .output_string()
    .await?;
}

F#

task {
    use shutdown = new CancellationTokenSource(TimeSpan.FromSeconds 30.0)

    let command =
        Command.create "worker"
        |> Command.cancelOn shutdown.Token

    let! result = command.OutputStringAsync()
    return result
}

Features become packages or always-available modules

Rust uses Cargo features to keep optional dependencies and platform surfaces out of a build. NuGet has no equivalent compile-time feature gate, so the core .NET package exposes production capabilities directly and isolates optional concerns in side packages.

ProcessKit-rs feature/conceptProcessKit for .NET
default process control, stats, limits, record, ptycore modules are available without feature flags; record/replay lives in ProcessKit.Testing
tracingoptional ILogger; ProcessKitDiagnostics.ActivitySource for traces
metricsProcessKitDiagnostics.Meter
mock / ProcessRunnerIProcessRunner plus ScriptedRunner, FakeProcess, and RecordReplayRunner in ProcessKit.Testing
application DI wiringProcessKit.Extensions.DependencyInjection
hosted supervisionProcessKit.Extensions.Hosting

The core remains DI-friendly rather than DI-coupled: production code can accept the plain IProcessRunner interface without referencing a container package.

Encoding, PTY, and supervision

ConcernRust.NET
Text encodingper-stream encoding configuration, with raw byte capture when text is the wrong abstractionCommand.Encoding, StdoutEncoding, StderrEncoding, StdinEncoding, or OutputBytesAsync
PTYCargo pty feature plus Command::use_ptyCommand.Pty(PtyConfig) and PtySession; unsupported platform details are typed
StreamingRunningProcess streams and waitsRunningProcess exposes IAsyncEnumerable lines/events and readiness waits
SupervisionSupervisor restart/backoff policySupervisor in the core package; hosted lifetime wiring in ProcessKit.Extensions.Hosting
Observabilitytracing and metrics feature adaptersILogger, ActivitySource, and Meter, all secret-safe by contract

The names differ, but the design test remains the same: select a capability explicitly, inspect a typed unsupported result when the platform cannot provide it, and keep the process tree owned by a deterministic lifetime.

Testing seam

Code that accepts Rust's &dyn ProcessRunner should usually accept .NET's IProcessRunner. In tests, ScriptedRunner is the stable default on both sides; the .NET version additionally provides FakeProcess for live streaming and RecordReplayRunner for cassettes in the same ProcessKit.Testing package.

C#

IProcessRunner runner =
    new ScriptedRunner().On(["git", "status"], Reply.Ok("clean"));

var result = await runner.RunAsync(new Command("git").Arg("status"));
Console.WriteLine(result);

See Testing your code for structural invocation journals, streaming doubles, and cassette matching.


Next: Cookbook