A microframework for declarative code generation and injection.
Templates use Tera as a templating language (similar to liquid), and use a special metadata/body separation with frontmatter.
The first part of the template instructs what the template should do, and which injections it should perform.
The second part is the actual target file that's being generated.
Example template controller.t:
---
to: tests/fixtures/realistic/generated/controllers/{{name | snake_case }}.rs
injections:
- into: tests/fixtures/realistic/generated/controllers/mod.rs
append: true
content: "pub mod {{ name | snake_case }};"
- into: tests/fixtures/realistic/generated/app.rs
after: "AppRoutes::"
content: " .add_route(controllers::{{ name | snake_case }}::routes())"
---
#![allow(clippy::unused_async)]
use axum::{extract::State, routing::get};
use rustyrails::{
app::AppContext,
controller::{format, Routes},
Result,
};
pub async fn echo(req_body: String) -> String {
req_body
}
pub async fn hello(State(ctx): State<AppContext>) -> Result<String> {
// do something with context (database, etc)
format::text("hello")
}
pub fn routes() -> Routes {
Routes::new()
.prefix("{{ name | snake_case }}")
.add("/", get(hello))
.add("/echo", get(echo))
}Rendering a template will create one or more files, potentially inject into files, and is done like so:
use std::fs;
use rrgen::Rgen;
use serde_json::json;
let rrgen = RRgen::default();
let vars = json!({"name": "post"});
rrgen.generate(
&fs::read_to_string("tests/fixtures/test1/template.t").unwrap(),
&vars,
)
.unwrap();vars will be variables that are exposed both for the frontmatter part and the body part.
An injection edits a file that already exists. Every injection needs an into:
(the file), a content: (what to add), and exactly one placement:
| Placement | Effect |
|---|---|
prepend: true |
Put the content at the top of the file. |
append: true |
Put the content at the bottom of the file. |
before: |
Put it above the first line matching the regex. |
before_last: |
Put it above the last line matching the regex. |
after: |
Put it below the first line matching the regex. |
after_last: |
Put it below the last line matching the regex. |
remove_lines: |
Delete every line matching the regex. |
Two optional keys shape when an injection runs:
skip_if:— a regex; if it matches anywhere in the target file, the injection is skipped. This is how you make generation idempotent.into:a file that does not exist is an error. Create it first.
An anchor regex (before, before_last, after, after_last) that matches no
line is an error. So is an injection that declares content but no
placement.
This matters more than it sounds. A generator's job is to wire generated code into the rest of the app — register a migration, mount a route, export a module. When an anchor silently misses, the file is still written and still compiles; it is simply never reached. The failure then surfaces days later as behaviour that makes no sense, with nothing in the generator's output to point at.
When any injection fails, nothing is written at all — not the target file, not the earlier injections. Fix the anchor and run the generator again.
remove_lines is the one exception: matching nothing means the file is already
in the state the template asked for, so it succeeds and leaves the file alone.