jigs

Pipelines you can see through

Heads up. Exploratory project. APIs will change. Play with it, don't ship it.

HTTP handlers, RPC services, agent workflows, data pipelines: they all have the same shape. Something comes in, steps run, something goes out. The more steps, the harder it gets to follow what happens where.

jigs makes each step a named, typed function held in a known position. You can read the pipeline top to bottom. The type system prevents invalid chains. And the whole graph is known at compile time, so you get an interactive map without running a build script.

#[jig]
fn handle(request: Request) -> Response {
    request
        .then(log_incoming)
        .then(set_auth_state)
        .then(route_to_feature)
        .then(log_response)
}

jigs!(handle);

Examples

Quickstart

Install

cargo add jigs

For per-step timing: cargo add jigs --features trace

Write a pipeline

Every jig is a plain function. The type system enforces ordering: once you hold a Response, you can't chain a jig that expects a Request. Errored responses and Branch::Done short-circuit the rest.

use jigs::{jig, Request, Response};

#[jig]
fn validate(r: Request<u32>) -> Request<u32> { r }

#[jig]
fn handle(r: Request<u32>) -> Response<String> {
    Response::ok(format!("got {}", r.0))
}

fn main() {
    let response = Request(42u32)
        .then(validate)
        .then(handle);
    assert_eq!(response.inner.unwrap(), "got 42");
}

Route with fork!

First matching predicate wins. _ is the default:

#[jig]
fn route(req: Request<HttpRequest>) -> Response<String> {
    fork!(req,
        |r: &HttpRequest| r.path == "/"                  => root,
        |r: &HttpRequest| r.path.starts_with("/hello/")  => hello,
        _                                                => not_found,
    )
}

Generate a map

Each #[jig] gets a marker struct that implements JigDef. The jigs!(entry_fn) macro walks the trait recursively and produces all_jigs() / find_jig(). Place it in the same module as the entry function:

// pipeline.rs
use jigs::{jig, Request, Response};

#[jig]
fn handle(r: Request<u32>) -> Response<String> { /* ... */ }

jigs!(handle);  // generates all_jigs() and find_jig()
// main.rs
use my_crate::pipeline::{all_jigs, handle};

let dir = env!("CARGO_MANIFEST_DIR");
std::fs::write(
    format!("{dir}/map.html"),
    jigs::map::to_html(all_jigs(), "my service", None),
)?;
std::fs::write(
    format!("{dir}/map.md"),
    jigs::map::to_markdown(all_jigs(), "my service"),
)?;

Every node in the map links to its source. The third argument to to_html is an editor URL template: Some("vscode://file/{path}:{line}") for VS Code, Some("idea://open?file={path}&line={line}") for JetBrains, or Some("https://github.com/ValeriaVG/jigs/blob/main/{rel_path}#L{line}") for GitHub.

Trace at runtime

With the trace feature, every step records timing. Drain and render:

let entries = jigs::trace::take();
print!("{}", jigs::log::render_tree(&entries));   // human-readable
print!("{}", jigs::log::render_ndjson(&entries)); // one JSON object per line
log_incoming ok 0.1ms
set_auth_state ok 2.3ms
route_to_feature err 500
  validate_route ok 0.4ms
  check_permissions err DB Timeout

NDJSON carries name, depth, duration_ns, ok, and optional error per line.

Caveats

.then() compiles to plain function calls. No boxing, no dynamic dispatch, no hidden allocations. The #[jig] macro generates a marker struct that compiles away.

// This compiles to direct function calls,
// not a vtable or trait object dispatch:
let response = Request(42u32)
    .then(validate)
    .then(handle);

// The macro produces a marker struct
// used only at compile time:
struct __Jig_validate;
impl JigDef for __Jig_validate { /* ... */ }

Request and Response are generic wrappers you put your own data into. Use jigs with axum, actix-web, warp, tokio channels, Lambda handlers, CLI args, or plain function calls. Anywhere you have input that flows through steps to output.

When a #[jig] function references a jig from another module, use the module-qualified path (features::auth::authenticate) instead of importing the function directly. Same-module jigs can be referenced by name.

Roadmap

Maybe