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
- hello Smallest possible pipeline src
- http Small HTTP server through jigs src
- async Async pipeline on tokio src
- events Async event listener pipeline src
- checkout Nested feature jigs in a checkout flow src
- cf-rag RAG pipeline on Cloudflare Workers src
-
todo-api
Multi-depth
fork!routing src - library Pipeline in a cdylib with a C API src
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
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
- basic functionality
- time tracing
- tree + NDJSON logging
- interactive map generation
- complex examples (todo-api)
Maybe
- IDE extension to view jigs in the editor
- terminal jigs-map
- runtime tracing rendered onto the map
- trace remote jigs services as one system