Skip to content
You are reading v0.1, which is not the current release. Go to v0.4.

std::process

Running another program.

The last thing on the “not yet” list that a program needs before it can be the thing that drives other things: a build script, a test runner, a deployment step. Until this existed a Khora program could read a file, read the clock and talk to a socket, and the one thing it could not do was ask the machine it was already running on to do something.

A program and a list of arguments, and a shell only if you ask.

run and output name a program and hand it a List<String>. No shell sees them, so a space, a quote, a ; or a && inside an argument is one argument with punctuation in it — there is nothing left to reinterpret it. An argument that came from a request, a file name or a database is safe to pass, which is the whole reason this shape exists.

shell is the other one, and it is spelled that way so nobody reaches for it by accident. It hands a command line to cmd.exe or /bin/sh and everything true of a line of shell is true of it — a pipeline works, a glob expands, a redirect redirects, and a file name that came from somewhere untrusted can end the command and start another one. Sometimes that is exactly what a caller wants, and hiding it behind a name that does not say so would be worse than not having it.

The rule: if any part of the command came from outside the program, it is run or output. If the whole line is a literal and wants a pipeline, it is shell.

This is what the earlier version of this module said was missing and could not have: system and popen are all ISO C offers, and CreateProcess takes a STARTUPINFO while posix_spawn wants a posix_spawn_file_actions_t — neither reachable from Khora, because no struct crosses the C ABI. They are reachable from the runtime, where Rust’s std::process::Command is one interface over both, so khora_spawn_status and khora_spawn_capture are what the two safe operations call.

Still no quoting helper. An escaped(argument) for the shell form would be the obvious kindness and it would be a lie: cmd.exe and sh disagree about backslashes, about carets, about what a quote inside a quote means, and a helper that is subtly wrong on one host is worse than none because it reads as though the problem is handled. A caller with untrusted text has run.

Types: ProcessError · Completed

Effects: Process

Methods: Process

Functions: checked_output

pub type ProcessError =
| NotStarted(command: String)
| NotText(command: String)
| Failed(command: String, status: Int);

Why running something did not work.

Coarse, for the reason std::fs’s IoError is: system and popen report failure as one number with no story attached, and inventing detail that the C library never supplied would be inventing it.

NotStarted is the shell itself refusing to run — not “the command was not found”, which is a shell that started perfectly well and then exited 127. NotText is a command that ran and printed bytes that are not UTF-8, so there is no String to hand back; only the two operations that keep the output – [Process::output] and [Process::shell] – can raise it, and [Process::run], which keeps none, cannot. Failed is raised by [checked_output] alone.

Note what is not here: a command that ran and exited non-zero. It ran. Its status is the answer, and a caller who wanted that to be a failure says so by calling [checked_output].

pub type Completed = {
status: Int,
text: String,
};

A finished command.

Both halves together because popen gives both for the price of one, and the alternative — a capture that returns text and a caller who then wants the status — is a caller running the command twice.

status: Int

What the command exited with. Zero is the universal “it worked”.

text: String

Everything it wrote to its standard output. Its standard error is not captured; it goes wherever this process’s does, and a caller who wants it folded in writes 2>&1 on the end of the command themselves.

pub effect Process {
run: (String, List<String>) -> Int raises ProcessError,
output: (String, List<String>) -> Completed raises ProcessError,
shell: (String) -> Completed raises ProcessError,
}

Permission to start another program.

The reason this is an effect and not two free functions is the reason every other capability in std is one, and it is sharper here than most: “this library may run arbitrary commands on your machine” is close to the largest thing a dependency can ask for, and a with { process: Process } in a signature is that sentence written where a reviewer will see it. A dependency that does not carry the row cannot start anything, and nothing it imports can either.

And it is a seam. A test that wanted to exercise a build driver used to have the choice of really invoking a compiler or not testing the thing; here it installs its own handler and the code under test cannot tell:

with { process: handler for Process {
run: fn (program, arguments) => 0,
output: fn (program, arguments) => { status: 0, text: "v1.2.3" },
shell: fn command => { status: 0, text: "" },
} } {
check(version_reported_by("git"));
}

Every operation, because a handler supplies the whole effect. The one this example is about is output; run and shell are there because leaving either out is not a smaller handler, it is not a handler.

That double is also where a test asserts what was asked for — capture the command into a Shared<List<String>> and the test can say that the driver built the command line it was supposed to, which is the assertion that matters and the one a real subprocess makes impossible to write.

run: (String, List) -> Int raises ProcessError

Runs program with arguments and waits, giving what it exited with.

No shell. Each argument crosses whole, so one containing a space, a quote or a ; is one argument with punctuation in it. Whatever the program prints goes to this process’s own output, unredirected.

output: (String, List) -> Completed raises ProcessError

The same, keeping what the program wrote to its standard output.

shell: (String) -> Completed raises ProcessError

Runs a line of shell, and everything true of a line of shell is true of it.

Named for what it does rather than for what it is used for, because a caller reaching for it should have to write the word. run is the one for a command built out of anything that came from outside the program.

impl Process
pub fn real() -> Process

The real machine.

pub fn checked_output(program: String, arguments: List<String>) -> String with { process: Process } raises ProcessError

What command printed, provided it succeeded.

The shape most callers actually mean. Reading a version number or a commit hash out of a tool, the branch on the status is not interesting — either it worked and there is text, or it did not and there is nothing useful to do with whatever came out. Written once here rather than as the same three lines at every call site, the way std::env’s variable_or is.

Khora 0.1.0built frombd964738afb6on 2026-10-17 20:41ZVersioningSecurityContributingReleasesSource