bash-interop 0.2.0

Run bash under instrumentation and hear what it says: a session per run, a pipe and a task per shell, words a script speaks and answers it runs.
Documentation

bash-interop

Run a bash program, hear every shell in its process tree, and answer the questions those shells ask — while the program behaves exactly as it does when nothing is listening.

The usual ways of watching a shell script are indirect. set -x produces a trace that no two people parse the same way, exported variables only travel downward, and wrapping the interpreter tells you nothing about what happened inside a function. This crate takes the other route. The script states what it wants observed, and a Rust program on the other end of a pipe hears it and can reply.

What a script does

Once a script has joined a session, it can do two things.

BC_SAY ships a list of words and returns immediately, because nothing is waiting on it:

declare -- BC_SAY__ARG_LABEL=BUILD
BC_SAY STARTED phase compile

BC_ASK ships a list of words and blocks until your Rust code replies:

declare -- BC_ASK__ARG_LABEL=BUILD
declare -a BC_ASK__ARGS=(cache-lookup "$sha")
BC_ASK

The words are yours in both cases. There is no schema and no reserved vocabulary — the wire moves an argument list of any width, and the protocol reads none of its positions.

A rig normally gives scripts a word of its own over these, so a call site is one line and reads like the domain rather than the protocol:

alias STARTED='BC_SAY__ARG_LABEL=BUILD BC_SAY STARTED'

What an answer is

Understand this part first. Everything your Rust code can do to a running shell follows from it.

When your reaction answers an ask, it returns a list of words. Those words travel back to the shell that asked, which parses them with bash's own array syntax and then runs them as a command, in the frame that asked:

declare -ga __BC__ANSWER="$__bc_line"   # the reply, read as a bash array literal
"${__BC__ANSWER[@]}"                    # and invoked, right where you asked

There is no eval anywhere in that. Bash parses an array literal — the same notation declare -p prints — and calls the result.

Because it runs where the call was written, an answer's declare binds in the function that asked and dies with it. Nothing has to reach for -g to be seen, and a reply can shape the caller's own scope.

Handing back a command rather than a value is what makes the channel general. One command, run in the frame that asked, already covers the things you would otherwise design a protocol around:

your reaction returns the asking shell does
Answer::of("echo", [path]) prints it, so x=$(BC_ASK) captures a value
Answer::of("declare", ["target=staging"]) binds a variable in the frame that asked
Answer::status(3) gives the ask status 3, so if BC_ASK branches on the reply
Answer::returning(3) returns 3 from the function that asked, ending it
Answer::of("source", [path]) runs a file of any length that you just wrote
Answer::of("exit", ["9"]) ends the subject

The ask exits with the status of whatever ran, so a reply that says no is an ordinary shell failure the script can test.

The vocabulary is not limited to builtins either. A rig injects bash of its own into every shell that joins, so you can define a helper there and answer by calling it:

// the rig's bash, sourced by every shell in the tree
fn bash(&self, _at: &Layout) -> String {
    "use_toolchain() { export CC=$1 CXX=$2; hash -r; }\n".to_string()
}

// ... and later, deciding what an ask gets back
Ok(Answer::of("use_toolchain", ["clang", "clang++"]))

Your Rust program decides; a function you wrote carries the decision out inside the shell that asked.

The Rust side

You implement two traits. Rig describes the arrangement — what bash to inject and how to build a counterpart for each shell that turns up. Reacting is that counterpart:

impl Reacting for Watching {
    type Kept = Vec<Message>;

    async fn hear(&mut self, said: Message) -> Result<(), Failure>;
    async fn answer(&mut self, asked: Message) -> Result<Answer, Failure>;
    async fn finish(self) -> Result<Self::Kept, Failure>;
}

Every bash process that joins gets its own pipe, its own task and its own Reacting value, so a subshell and its parent never get confused for each other, and a slow reply holds up only the shell waiting on it. The runtime is single-threaded, which is why nothing here asks you for a Send bound.

Two things matter before you put a call site into code you ship.

A call site is a real dependency, and a loud one on purpose. Run the script with no session anywhere and BC_SAY is simply a command that does not exist: status 127, and nothing else happens. Load the prelude but join no session and it reports label … is not joined at your call site and returns 125. Neither case fails quietly, because a script that asked to be observed and silently was not is the worse outcome. If a script has to run both ways, say so in one line at the top:

declare -F __bc_say >/dev/null || alias BC_SAY=:

Second, nothing is timed, counted or inferred on the bash side. A message carries the sending shell's own clock and its own view of the call stack, so what your program reports is what a shell said about itself at the moment it spoke, rather than something reconstructed afterwards from ordering.

Reading on

The overview walks the whole model once. The module documentation for rig carries a worked example that compiles, and the book is the reference: the wire and its message forms, what a shell reports about itself, the frame walk, and the measurements the transport rests on.

Built on bash-strings. bashcap and bashprof are small rigs over this crate, and read as examples of one.

Licensed under the MIT licence.