Build a Scaffolding CLI for Axum
Build a Rust CLI that scaffolds Axum projects with templates and a plugin system.
Introduction
30 Second Summary
Every backend project starts with the same tedious setup. You create folder structures, wire up configuration files, and copy-paste boilerplate code that looks identical across projects.
In this project, you will build axum-forge, a Rust CLI tool that scaffolds complete Axum backend projects from templates. The tool generates entities with type-safe SQL migrations, injects code into existing files, and supports a plugin system for custom generators.
What You'll Build
A production-style scaffolding CLI that uses Tera templates and marker-based code injection to generate Axum project structures, entity modules, and route files.
By the end of this project, you'll have:
- A Rust binary with clap-based CLI supporting new, generate entity, generate route, and plugin install subcommands.
- A Tera-powered template engine with a type mapping system that converts CLI field definitions into Rust structs and SQL migrations.
- A marker-based injection engine that safely inserts generated code into existing files at sentinel comments.
- A plugin system where users can drop in their own template directories with a manifest to extend the CLI with custom generators.
- Secret Mission: Add a middleware generator that creates a Tower middleware layer, injects it into the router, and packages it as a standalone plugin.
Are there any prerequisites?
This project assumes intermediate Rust knowledge including traits, enums, error handling, and modules. Familiarity with Axum web framework concepts is helpful but not strictly required since you are building the generator, not the generated project.
Before We Start
Before We Start...
Initialize the Project and Configure Dependencies
Every scaffolding tool starts as an empty project with the right foundations. Before you can teach axum-forge to generate Axum projects, you need a binary crate with the libraries that power CLI parsing, template rendering, and configuration loading.
In this step, you'll create the axum-forge crate, declare its five core dependencies, and lay down the module skeleton that every future step will build on.
In this step, get ready to:
- Create the axum-forge binary crate.
- Configure all required dependencies in Cargo.toml.
- Set up the module file structure for the project.
Create the binary crate
A binary crate is a Rust project that compiles into an executable you can run from the terminal. cargo init sets one up with a Cargo.toml manifest and a src/main.rs entry point.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the axum-forge crate by running:
cargo init axum-forge
- Move into the new project folder by running:
cd axum-forge
- Confirm the project was created by running:
ls src
You should see main.rs listed in the output.
Seeing 'command not found'?
Make sure your Rust toolchain is installed. Run rustup --version to check. If that also fails, revisit the Rust installation at https://rustup.rs.
help me fix a 'command not found' error when running cargo init
- Open the axum-forge folder in your code editor.
Open from the terminal
If you use VS Code, run code . from inside the axum-forge folder to open the project directly.
Configure your dependencies
The generated Cargo.toml only has the package metadata. You need to declare the five crates that power the CLI.
- Open Cargo.toml in your editor and replace its entire contents with the following:
[package]
name = "axum-forge"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4.6", features = ["derive"] }
tera = "1.20"
serde = { version = "1.0", features = ["derive"] }
toml = "1.1"
anyhow = "1.0"
What do these dependencies do?
- clap with the derive feature lets you define CLI arguments and subcommands using Rust structs and enums with derive macros.
- tera is a Jinja2-inspired template engine that supports loops, conditionals, and filters for generating code files.
- serde with derive provides automatic serialization and deserialization for your Rust structs.
- toml parses TOML files (like the plugin.toml manifests your plugin system will read).
- anyhow simplifies error handling by letting you return any error type from functions with a single Result<()>.
- Save Cargo.toml.
Editor showing TOML warnings?
Some editors flag version strings like "4.6" if they expect an exact version. These are semver-compatible ranges and are correct. The warnings are safe to ignore.
help me understand why my editor shows warnings in Cargo.toml
Create the module structure
Rust organizes code into modules. Each module lives in its own file and gets declared in main.rs. You'll create six empty stub files now and fill them in over the next steps.
- Create the six module files by running:
macOS
touch src/cli.rs src/generator.rs src/injector.rs src/plugin.rs src/templates.rs src/types.rs
Windows
New-Item -ItemType File src/cli.rs, src/generator.rs, src/injector.rs, src/plugin.rs, src/templates.rs, src/types.rs
- Confirm the files exist by running:
ls src
You should see cli.rs, generator.rs, injector.rs, main.rs, plugin.rs, templates.rs, and types.rs listed.
Now you need to tell Rust about these modules. The compiler only looks at files that are declared with mod statements in the crate root.
- Open src/main.rs and replace its contents with the following:
mod cli;
mod generator;
mod injector;
mod plugin;
mod templates;
mod types;
fn main() {}
What does this code do?
Each mod line tells the Rust compiler to include the corresponding file as a module. mod cli maps to src/cli.rs, mod generator maps to src/generator.rs, and so on.
The fn main() {} is a placeholder. You'll replace it with the full CLI dispatch logic in a later step.
- Save src/main.rs.
- Verify everything compiles by running:
cargo check
You should see a message ending with something like Finished and no errors. Warnings about unused modules are expected at this stage.
Seeing compilation errors?
Make sure every module file exists in the src/ directory. A missing file causes a "file not found" error for the corresponding mod declaration.
Also check that your Cargo.toml has no typos in the dependency names or version strings. A malformed TOML file causes cargo check to fail before it even tries to compile.
help me fix compilation errors in my axum-forge project
✔️ Awesome, I've got everything!
Great. Double check you've saved both Cargo.toml and src/main.rs.
ⓧ I'd like to double check the full code
Here is the complete Cargo.toml:
[package]
name = "axum-forge"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4.6", features = ["derive"] }
tera = "1.20"
serde = { version = "1.0", features = ["derive"] }
toml = "1.1"
anyhow = "1.0"
Here is the complete src/main.rs:
mod cli;
mod generator;
mod injector;
mod plugin;
mod templates;
mod types;
fn main() {}
The module files (src/cli.rs, src/generator.rs, src/injector.rs, src/plugin.rs, src/templates.rs, src/types.rs) should all be empty files.
Your project skeleton is ready. Next up, you'll define the CLI interface with clap derive macros to give axum-forge its new, generate, and plugin subcommands.
Define the CLI with Clap Derive Macros
Your project skeleton is in place with all dependencies configured. Now you need the interface that users interact with.
In this step, you will define the entire CLI surface using clap derive macros. These macros let you declare your commands, subcommands, and arguments as plain Rust structs and enums. Clap generates the argument parsing, help text, and error messages automatically from your type definitions.
In this step, get ready to:
- Define the top-level Cli struct and Commands enum with clap derive macros.
- Add sub-subcommands for entity generation, route generation, and plugin management.
- Wire up main.rs to parse CLI input and dispatch to handler functions.
Define the Cli struct and Commands enum
Clap's derive approach works by annotating structs and enums with macros like #[derive(Parser)] and #[derive(Subcommand)]. Clap reads these annotations at compile time and generates all the parsing logic for you.
- Open src/cli.rs and add the following code:
use clap::{Parser, Subcommand};
// Top-level CLI struct - clap uses this as the entry point for parsing
#[derive(Parser, Debug)]
#[command(name = "axum-forge", version, about = "Scaffolding CLI for Axum projects")]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
}
// Each variant becomes a subcommand (e.g., `axum-forge new`, `axum-forge generate`)
#[derive(Subcommand, Debug)]
pub enum Commands {
/// Create a new Axum project
New {
/// Project name
name: String,
},
/// Generate a component
Generate {
#[command(subcommand)]
command: GenerateCommands,
},
/// Manage plugins
Plugin {
#[command(subcommand)]
command: PluginCommands,
},
}
What does this code do?
The Cli struct is the root of your CLI. The #[command(name = "axum-forge")] attribute sets the binary name that appears in help text.
The Commands enum has three variants. Each variant maps to a top-level subcommand. The New variant takes a positional name argument, while Generate and Plugin each hold nested subcommands (sub-subcommands).
Implement the generate and plugin sub-subcommands
The Generate subcommand needs its own sub-subcommands for different generator types. The --fields flag on the entity generator is what makes the CLI genuinely useful. It lets users define database columns right from the command line.
- In src/cli.rs, add the following code below the Commands enum:
#[derive(Subcommand, Debug)]
pub enum GenerateCommands {
/// Generate an entity with model, repository, and migration
Entity {
/// Entity name (e.g., "user")
name: String,
/// Fields in format "name:type,name:type"
#[arg(short, long)]
fields: String,
},
/// Generate a route module
Route {
/// Route name (e.g., "users")
name: String,
},
}
#[derive(Subcommand, Debug)]
pub enum PluginCommands {
/// Install a plugin from a local directory
Install {
/// Path to plugin directory
path: String,
},
}
What does this code do?
GenerateCommands defines the two generators the CLI ships with. The Entity variant takes a positional name and a --fields flag annotated with #[arg(short, long)]. This means users can pass fields as -f "id:uuid,name:string" or --fields "id:uuid,name:string".
PluginCommands only has Install for now. The plugin system stays minimal in v1.
- Save src/cli.rs.
- Confirm the module compiles by running:
cargo check
- You should see a successful check with no errors. You may see warnings about unused imports, which is expected since nothing uses the CLI types yet.
Seeing a compile error?
- Check that your Cargo.toml has features = ["derive"] on the clap dependency. Without this feature flag, the Parser and Subcommand derive macros are not available.
- Make sure the GenerateCommands and PluginCommands enums are defined in the same file. The Commands enum references them by name, so they must exist in scope.
- help me debug a clap derive compile error in my axum-forge CLI
Wire up the CLI dispatch in main.rs
The CLI types define what input the tool accepts. Now you need the dispatch logic that matches each parsed command to the right handler function.
- Open src/main.rs and replace the entire file contents with this code:
mod cli;
mod generator;
mod injector;
mod plugin;
mod templates;
mod types;
use anyhow::Result;
use clap::Parser;
use cli::{Cli, Commands, GenerateCommands};
fn main() -> Result<()> {
// Parse CLI arguments into our typed Cli struct
let cli = Cli::parse();
// Dispatch to the appropriate handler based on the parsed command
match cli.command {
Commands::New { name } => {
generator::scaffold_project(&name)?;
println!("Project '{}' created successfully!", name);
}
Commands::Generate { command } => match command {
GenerateCommands::Entity { name, fields } => {
generator::generate_entity(&name, &fields)?;
println!("Entity '{}' generated successfully!", name);
}
GenerateCommands::Route { name } => {
generator::generate_route(&name)?;
println!("Route '{}' generated successfully!", name);
}
},
Commands::Plugin { command } => match command {
cli::PluginCommands::Install { path } => {
plugin::install_plugin(&path)?;
println!("Plugin installed from '{}'!", path);
}
},
}
Ok(())
}
What does this code do?
Cli::parse() reads the process arguments and constructs a fully-typed Cli value. If the user passes invalid input, clap prints an error and exits before your code runs.
The nested match arms destructure each command variant and delegate to module functions like generator::scaffold_project and plugin::install_plugin. The ? operator propagates errors up through the Result return type.
- Save src/main.rs.
You won't be able to run cargo check successfully yet. The dispatch calls functions like generator::scaffold_project that don't exist in the empty module stubs. You will implement those in the upcoming steps.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files.
ⓧ I'd like to double check the full code
Here is the full src/cli.rs:
use clap::{Parser, Subcommand};
#[derive(Parser, Debug)]
#[command(name = "axum-forge", version, about = "Scaffolding CLI for Axum projects")]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
/// Create a new Axum project
New {
/// Project name
name: String,
},
/// Generate a component
Generate {
#[command(subcommand)]
command: GenerateCommands,
},
/// Manage plugins
Plugin {
#[command(subcommand)]
command: PluginCommands,
},
}
#[derive(Subcommand, Debug)]
pub enum GenerateCommands {
/// Generate an entity with model, repository, and migration
Entity {
/// Entity name (e.g., "user")
name: String,
/// Fields in format "name:type,name:type"
#[arg(short, long)]
fields: String,
},
/// Generate a route module
Route {
/// Route name (e.g., "users")
name: String,
},
}
#[derive(Subcommand, Debug)]
pub enum PluginCommands {
/// Install a plugin from a local directory
Install {
/// Path to plugin directory
path: String,
},
}
And here is the full src/main.rs:
mod cli;
mod generator;
mod injector;
mod plugin;
mod templates;
mod types;
use anyhow::Result;
use clap::Parser;
use cli::{Cli, Commands, GenerateCommands};
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::New { name } => {
generator::scaffold_project(&name)?;
println!("Project '{}' created successfully!", name);
}
Commands::Generate { command } => match command {
GenerateCommands::Entity { name, fields } => {
generator::generate_entity(&name, &fields)?;
println!("Entity '{}' generated successfully!", name);
}
GenerateCommands::Route { name } => {
generator::generate_route(&name)?;
println!("Route '{}' generated successfully!", name);
}
},
Commands::Plugin { command } => match command {
cli::PluginCommands::Install { path } => {
plugin::install_plugin(&path)?;
println!("Plugin installed from '{}'!", path);
}
},
}
Ok(())
}
Your CLI surface is fully defined. Next up, you will build the template engine that powers all code generation.
Build the Template Engine Core
Your CLI knows what commands to expect. Now it needs a way to turn those commands into actual files.
In this step, you will build the template engine that powers all code generation in axum-forge. You will use Tera (a Jinja2-inspired template engine) to define reusable code templates that get rendered with dynamic context variables like project names and field definitions.
In this step, get ready to:
- Create the Tera template loader that embeds templates into the binary at compile time.
- Write the project, entity, and route templates that define context variables.
- Implement the render function that combines templates with a context to produce output strings.
Create the Tera template loader
The template loader uses Rust's include_str! macro to embed template files directly into the compiled binary. This means the CLI works as a standalone executable with no external template files to ship alongside it.
- Create the template directory structure by running these commands:
mkdir -p templates/project templates/entity templates/route
What does this command do?
The -p flag creates parent directories as needed. You end up with three folders under templates/ for the three generator categories: project scaffolding, entity generation, and route generation.
- Confirm the directories exist by running:
ls templates/
You should see entity, project, and route listed in the output.
- Open src/templates.rs and add the full template engine implementation:
use anyhow::Result;
use tera::{Context, Tera};
pub fn create_engine() -> Result<Tera> {
let mut tera = Tera::default();
// Disable autoescaping since we're generating code, not HTML
tera.autoescape_on(vec![]);
// Register built-in templates from embedded files
tera.add_raw_templates(vec![
("project/main.rs", include_str!("../templates/project/main.rs.tera")),
("project/app.rs", include_str!("../templates/project/app.rs.tera")),
("project/cargo.toml", include_str!("../templates/project/cargo.toml.tera")),
("project/config.rs", include_str!("../templates/project/config.rs.tera")),
("project/db.rs", include_str!("../templates/project/db.rs.tera")),
("project/error.rs", include_str!("../templates/project/error.rs.tera")),
("entity/model.rs", include_str!("../templates/entity/model.rs.tera")),
("entity/handler.rs", include_str!("../templates/entity/handler.rs.tera")),
("entity/repository.rs", include_str!("../templates/entity/repository.rs.tera")),
("entity/routes.rs", include_str!("../templates/entity/routes.rs.tera")),
("entity/mod.rs", include_str!("../templates/entity/mod.rs.tera")),
("entity/migration.sql", include_str!("../templates/entity/migration.sql.tera")),
("route/mod.rs", include_str!("../templates/route/mod.rs.tera")),
("route/handler.rs", include_str!("../templates/route/handler.rs.tera")),
("route/routes.rs", include_str!("../templates/route/routes.rs.tera")),
])?;
Ok(tera)
}
pub fn render(tera: &Tera, template_name: &str, context: &Context) -> Result<String> {
let rendered = tera.render(template_name, context)?;
Ok(rendered)
}
What does this code do?
Tera::default() creates an empty engine with no templates loaded. autoescape_on(vec![]) disables HTML autoescaping for all files. This is critical because you are generating Rust code, not HTML. Without this, characters like < and > in generic types would get escaped into < and >.
add_raw_templates() registers all templates at once from string literals. Each include_str! macro reads a file at compile time and bakes its content into the binary. The first element of each tuple is the template name you will reference when rendering.
The render() function is a thin wrapper that takes a pre-built Context (populated by the generator module in a later step) and returns the rendered string.
- Save src/templates.rs.
You will not see a change yet. The project will not compile until all 15 template files exist because include_str! resolves paths at compile time. The next two substeps create those files.
Create the project scaffold templates
The project templates define the file structure that the new command generates. They use Tera's {{ variable }} syntax for dynamic values and embed marker comments where future generators will inject code.
- Create templates/project/main.rs.tera with this content:
use anyhow::Result;
mod app;
mod config;
mod db;
mod error;
mod features;
#[tokio::main]
async fn main() -> Result<()> {
let app = app::create_router();
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
println!("Server running on http://localhost:3000");
axum::serve(listener, app).await?;
Ok(())
}
- Create templates/project/app.rs.tera with this content:
use axum::Router;
// @axum-forge:inject(modules)
pub fn create_router() -> Router {
Router::new()
// @axum-forge:inject(routes)
// @axum-forge:inject(middleware)
}
What are these marker comments?
Lines like // @axum-forge:inject(routes) are sentinel markers. When the entity or route generators run later, they will scan for these markers and insert new code directly below them. This is the same pattern production scaffolders like Rails generators and Angular CLI use.
- Create templates/project/cargo.toml.tera with this content:
[package]
name = "{{ project_name }}"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "migrate"] }
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
anyhow = "1.0"
tracing = "0.1"
tracing-subscriber = "0.3"
# @axum-forge:inject(dependencies)
What is {{ project_name }}?
This is a Tera variable placeholder. When the new command runs, it builds a Context with project_name set to whatever the user passes as an argument. Tera replaces {{ project_name }} with that value at render time.
- Create templates/project/config.rs.tera with this content:
use std::env;
pub struct Config {
pub database_url: String,
pub host: String,
pub port: u16,
}
impl Config {
pub fn from_env() -> Self {
Self {
database_url: env::var("DATABASE_URL").expect("DATABASE_URL must be set"),
host: env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()),
port: env::var("PORT")
.unwrap_or_else(|_| "3000".to_string())
.parse()
.expect("PORT must be a number"),
}
}
}
- Create templates/project/db.rs.tera with this content:
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
pub async fn create_pool(database_url: &str) -> PgPool {
PgPoolOptions::new()
.max_connections(5)
.connect(database_url)
.await
.expect("Failed to create database pool")
}
- Create templates/project/error.rs.tera with this content:
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
pub struct AppError(pub anyhow::Error);
impl IntoResponse for AppError {
fn into_response(self) -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong: {}", self.0),
)
.into_response()
}
}
impl<E> From<E> for AppError
where
E: Into<anyhow::Error>,
{
fn from(err: E) -> Self {
Self(err.into())
}
}
Why are some templates static?
Templates like config.rs.tera, db.rs.tera, and error.rs.tera contain no Tera variables. They produce the same output every time. This is intentional. Not every generated file needs dynamic content. These provide a consistent project baseline while still being customizable later (either by editing the template or via plugin overrides).
- Save all six files in the templates/project/ directory.
Create the entity and route generator templates
These templates use Tera's control flow syntax ({% for %} loops and {% if %} conditionals) to dynamically generate code based on the fields the user passes in. This is where the template engine earns its keep.
- Create templates/entity/model.rs.tera with this content:
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct {{ entity_name_pascal }} {
{% for field in fields %} pub {{ field.name }}: {{ field.rust_type }},
{% endfor %}}
#[derive(Debug, Deserialize)]
pub struct Create{{ entity_name_pascal }}Request {
{% for field in fields %}{% if field.name != "id" and field.name != "created_at" and field.name != "updated_at" %} pub {{ field.name }}: {{ field.rust_type }},
{% endif %}{% endfor %}}
What does the Tera loop syntax do here?
{% for field in fields %} iterates over each field definition the user provided via the --fields flag. For each field, it outputs a struct field with the mapped Rust type. The {% if %} block in the request struct excludes auto-generated fields like id and created_at that the server sets itself.
- Create templates/entity/repository.rs.tera with this content:
use sqlx::PgPool;
use uuid::Uuid;
use super::model::{{ entity_name_pascal }};
pub async fn find_all(pool: &PgPool) -> Result<Vec<{{ entity_name_pascal }}>, sqlx::Error> {
sqlx::query_as::<_, {{ entity_name_pascal }}>("SELECT * FROM {{ entity_name }}s")
.fetch_all(pool)
.await
}
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<{{ entity_name_pascal }}>, sqlx::Error> {
sqlx::query_as::<_, {{ entity_name_pascal }}>("SELECT * FROM {{ entity_name }}s WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await
}
pub async fn create(pool: &PgPool, {{ entity_name }}: &{{ entity_name_pascal }}) -> Result<{{ entity_name_pascal }}, sqlx::Error> {
sqlx::query_as::<_, {{ entity_name_pascal }}>(
"INSERT INTO {{ entity_name }}s ({% for field in fields %}{{ field.name }}{% if not loop.last %}, {% endif %}{% endfor %}) VALUES ({% for field in fields %}${{ loop.index }}{% if not loop.last %}, {% endif %}{% endfor %}) RETURNING *"
)
{% for field in fields %} .bind(&{{ entity_name }}.{{ field.name }})
{% endfor %} .fetch_one(pool)
.await
}
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM {{ entity_name }}s WHERE id = $1")
.bind(id)
.execute(pool)
.await?;
Ok(())
}
How does the SQL query get built dynamically?
The create function uses nested Tera loops inside the SQL string. One loop generates the column list (id, name, email) and another generates the positional placeholders ($1, $2, $3). The loop.last variable prevents a trailing comma after the final item.
- Create templates/entity/handler.rs.tera with this content:
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use sqlx::PgPool;
use uuid::Uuid;
use super::model::{Create{{ entity_name_pascal }}Request, {{ entity_name_pascal }}};
use super::repository;
pub async fn list(State(pool): State<PgPool>) -> Result<Json<Vec<{{ entity_name_pascal }}>>, StatusCode> {
repository::find_all(&pool)
.await
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
pub async fn get_by_id(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<{{ entity_name_pascal }}>, StatusCode> {
repository::find_by_id(&pool, id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
pub async fn create(
State(pool): State<PgPool>,
Json(payload): Json<Create{{ entity_name_pascal }}Request>,
) -> Result<(StatusCode, Json<{{ entity_name_pascal }}>), StatusCode> {
let {{ entity_name }} = {{ entity_name_pascal }} {
id: Uuid::new_v4(),
{% for field in fields %}{% if field.name != "id" and field.name != "created_at" and field.name != "updated_at" %} {{ field.name }}: payload.{{ field.name }},
{% endif %}{% if field.name == "created_at" %} created_at: chrono::Utc::now(),
{% endif %}{% if field.name == "updated_at" %} updated_at: chrono::Utc::now(),
{% endif %}{% endfor %} };
repository::create(&pool, &{{ entity_name }})
.await
.map(|e| (StatusCode::CREATED, Json(e)))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
pub async fn delete(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, StatusCode> {
repository::delete(&pool, id)
.await
.map(|_| StatusCode::NO_CONTENT)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
- Create templates/entity/routes.rs.tera with this content:
use axum::routing::{delete, get, post};
use axum::Router;
use sqlx::PgPool;
use super::handler;
pub fn router() -> Router<PgPool> {
Router::new()
.route("/", get(handler::list).post(handler::create))
.route("/:id", get(handler::get_by_id).delete(handler::delete))
}
- Create templates/entity/mod.rs.tera with this content:
pub mod handler;
pub mod model;
pub mod repository;
pub mod routes;
- Create templates/entity/migration.sql.tera with this content:
CREATE TABLE IF NOT EXISTS {{ entity_name }}s (
{% for field in fields %} {{ field.name }} {{ field.sql_type }}{% if field.name == "id" %} PRIMARY KEY{% endif %}{% if field.name == "created_at" %} DEFAULT NOW(){% endif %}{% if not loop.last %},{% endif %}
{% endfor %});
What does the migration template produce?
This template generates a CREATE TABLE SQL statement. Each field from the CLI input becomes a column with its mapped SQL type. The template automatically adds PRIMARY KEY to the id column and DEFAULT NOW() to created_at.
Now create the three route templates. These are simpler because routes do not involve field parsing.
- Create templates/route/mod.rs.tera with this content:
pub mod handler;
pub mod routes;
- Create templates/route/handler.rs.tera with this content:
use axum::Json;
use serde_json::{json, Value};
pub async fn index() -> Json<Value> {
Json(json!({ "message": "{{ route_name }} endpoint" }))
}
- Create templates/route/routes.rs.tera with this content:
use axum::routing::get;
use axum::Router;
use super::handler;
pub fn router() -> Router {
Router::new()
.route("/", get(handler::index))
}
- Save all nine template files in the templates/entity/ and templates/route/ directories.
- Verify the template engine compiles by running:
cargo build
You should see a successful compilation with no errors. You will see warnings about unused functions in templates.rs because nothing calls them yet. That is expected.
Seeing a file not found error?
If the compiler reports that include_str! cannot find a file, double-check that all 15 template files exist at the exact paths listed in src/templates.rs. The paths are relative to the source file, so "../templates/project/main.rs.tera" means the templates/ directory must be at the crate root (next to src/).
Run find templates -name "*.tera" to list all template files and compare against the 15 entries in add_raw_templates.
help me debug a file-not-found error from include_str! in my Tera template loader
✔️ Awesome, I've got everything!
Great. Double check you have saved all files before moving on.
ⓧ I'd like to double check the full code
Here is the complete src/templates.rs file:
use anyhow::Result;
use tera::{Context, Tera};
pub fn create_engine() -> Result<Tera> {
let mut tera = Tera::default();
tera.autoescape_on(vec![]);
tera.add_raw_templates(vec![
("project/main.rs", include_str!("../templates/project/main.rs.tera")),
("project/app.rs", include_str!("../templates/project/app.rs.tera")),
("project/cargo.toml", include_str!("../templates/project/cargo.toml.tera")),
("project/config.rs", include_str!("../templates/project/config.rs.tera")),
("project/db.rs", include_str!("../templates/project/db.rs.tera")),
("project/error.rs", include_str!("../templates/project/error.rs.tera")),
("entity/model.rs", include_str!("../templates/entity/model.rs.tera")),
("entity/handler.rs", include_str!("../templates/entity/handler.rs.tera")),
("entity/repository.rs", include_str!("../templates/entity/repository.rs.tera")),
("entity/routes.rs", include_str!("../templates/entity/routes.rs.tera")),
("entity/mod.rs", include_str!("../templates/entity/mod.rs.tera")),
("entity/migration.sql", include_str!("../templates/entity/migration.sql.tera")),
("route/mod.rs", include_str!("../templates/route/mod.rs.tera")),
("route/handler.rs", include_str!("../templates/route/handler.rs.tera")),
("route/routes.rs", include_str!("../templates/route/routes.rs.tera")),
])?;
Ok(tera)
}
pub fn render(tera: &Tera, template_name: &str, context: &Context) -> Result<String> {
let rendered = tera.render(template_name, context)?;
Ok(rendered)
}
Your template engine is ready. It can load all 15 templates at startup, disable escaping for code generation, and render any template with a given context. Next up, you will wire the new command to call this engine and produce a full Axum project on disk.
Implement the Project Scaffolder
Your template engine can load and render Tera templates. Your CLI can parse the new subcommand and extract the project name. Now you need to connect them.
The project scaffolder is the function that takes a name, renders every project template with that name as context, and writes the output files into a new directory. This is the core "code generation" loop that all CLI scaffolders use.
In this step, get ready to:
- Review the project templates and their marker injection points.
- Implement the scaffold_project function that generates a full Axum project from templates.
- Test the axum-forge new command end-to-end.
Review the project templates
When a user runs axum-forge new my-api, the CLI needs to generate six files that form a working Axum project skeleton. You already created these templates in the previous step.
- Open the templates/project/ directory in your editor and review the six .tera files: main.rs.tera, app.rs.tera, config.rs.tera, db.rs.tera, error.rs.tera, and cargo.toml.tera.
What do these templates produce?
Each .tera file is a Tera template that produces one file in the generated project. The {{ project_name }} placeholder gets replaced with whatever name the user provides.
For example, cargo.toml.tera contains name = "{{ project_name }}". When rendered with project_name = "my-api", Tera outputs name = "my-api".
Understand the marker injection points
Production scaffolders like Rails generators use sentinel comments as injection targets. When you later generate an entity or route, the CLI finds these markers and inserts new code after them.
- Open templates/project/app.rs.tera and note the three marker comments.
use axum::Router;
// @axum-forge:inject(modules)
pub fn create_router() -> Router {
Router::new()
// @axum-forge:inject(routes)
// @axum-forge:inject(middleware)
}
Why marker comments instead of AST manipulation?
Parsing Rust source code with syn to find insertion points would be fragile. It breaks on non-standard formatting and adds massive complexity.
Marker comments are explicit, predictable, and work even when users edit the surrounding code. Every production scaffolder uses this pattern.
The // @axum-forge:inject(modules) marker is where module declarations get injected. The // @axum-forge:inject(routes) marker is where route mounts go. You will build the injection engine in a later step.
Implement the scaffold_project function
Now you will write the function that ties everything together. It creates directories, renders each template with the project name as context, and writes the output files.
- Open src/generator.rs (currently empty) and add the imports and scaffold_project function by pasting this code:
use std::fs;
use std::path::Path;
use anyhow::Result;
use tera::Context;
use crate::injector;
use crate::templates;
use crate::types;
pub fn scaffold_project(name: &str) -> Result<()> {
// Initialize the Tera engine with all registered templates
let tera = templates::create_engine()?;
let mut context = Context::new();
context.insert("project_name", name);
// Create the project directory structure
let base = Path::new(name);
fs::create_dir_all(base.join("src/features"))?;
fs::create_dir_all(base.join("migrations"))?;
// Map each template to its output path inside the new project
let files = vec![
("project/main.rs", "src/main.rs"),
("project/app.rs", "src/app.rs"),
("project/config.rs", "src/config.rs"),
("project/db.rs", "src/db.rs"),
("project/error.rs", "src/error.rs"),
("project/cargo.toml", "Cargo.toml"),
];
// Render each template and write to the output path
for (template, output) in files {
let rendered = templates::render(&tera, template, &context)?;
fs::write(base.join(output), rendered)?;
}
println!(" Created project structure at ./{}/", name);
Ok(())
}
What does this code do?
- templates::create_engine() loads all the .tera files you registered in the previous step.
- Context::new() creates a Tera context and inserts the project name so templates can reference {{ project_name }}.
- fs::create_dir_all creates nested directories recursively, including the src/features folder where entities and routes will live.
- The files vec maps template names (as registered in src/templates.rs) to their output locations inside the generated project.
- Below scaffold_project, add the stub functions and the to_pascal_case helper by pasting this code:
pub fn generate_entity(_name: &str, _fields_input: &str) -> Result<()> {
todo!()
}
pub fn generate_route(_name: &str) -> Result<()> {
todo!()
}
// Converts snake_case to PascalCase for struct names
fn to_pascal_case(s: &str) -> String {
s.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
}
})
.collect()
}
What does this code do?
- generate_entity and generate_route are stubs using todo!(). They satisfy the compiler since main.rs already calls them. You will implement them in the next steps.
- to_pascal_case converts snake_case input (like user_profile) into PascalCase (like UserProfile) for Rust struct names in generated code.
- Save src/generator.rs.
- Confirm the project compiles by running:
cargo build
You should see Compiling axum-forge followed by Finished with no errors.
Seeing a compile error?
- If you see "cannot find function" errors for install_plugin, make sure src/plugin.rs exists (it can be empty for now since main.rs references it). Add a placeholder: pub fn install_plugin(_path: &str) -> anyhow::Result<()> { todo!() }.
- Check that the use crate::injector and use crate::types lines match module files that exist in src/ (even if those files are empty).
Help me debug this cargo build error for axum-forge
Now test the new command to verify it generates a complete project structure.
- Run the CLI with a test project name:
cargo run -- new test-api
You should see output like:
Created project structure at ./test-api/
Project 'test-api' created successfully!
- Verify the generated project structure by running:
ls test-api/src/
You should see app.rs, config.rs, db.rs, error.rs, features, and main.rs listed.
- Confirm the marker comments were rendered correctly by checking the generated app.rs:
cat test-api/src/app.rs
You should see the // @axum-forge:inject(modules) and // @axum-forge:inject(routes) comments in the output. These are the injection points you will use in the next step.
- Clean up the test output by running:
rm -rf test-api
Don't see the generated files?
- If you see a Tera rendering error, check that the template names in the files vec (like "project/main.rs") match exactly what you registered in src/templates.rs.
- If you see a permissions error, make sure you are running the command from the axum-forge project root, not from inside another directory.
Help me debug why axum-forge new is not generating files
✔️ Awesome, I've got everything!
Great. Double check you have saved src/generator.rs and that cargo build compiles without errors.
ⓧ I'd like to double check the full code
use std::fs;
use std::path::Path;
use anyhow::Result;
use tera::Context;
use crate::injector;
use crate::templates;
use crate::types;
pub fn scaffold_project(name: &str) -> Result<()> {
let tera = templates::create_engine()?;
let mut context = Context::new();
context.insert("project_name", name);
let base = Path::new(name);
fs::create_dir_all(base.join("src/features"))?;
fs::create_dir_all(base.join("migrations"))?;
let files = vec![
("project/main.rs", "src/main.rs"),
("project/app.rs", "src/app.rs"),
("project/config.rs", "src/config.rs"),
("project/db.rs", "src/db.rs"),
("project/error.rs", "src/error.rs"),
("project/cargo.toml", "Cargo.toml"),
];
for (template, output) in files {
let rendered = templates::render(&tera, template, &context)?;
fs::write(base.join(output), rendered)?;
}
println!(" Created project structure at ./{}/", name);
Ok(())
}
pub fn generate_entity(_name: &str, _fields_input: &str) -> Result<()> {
todo!()
}
pub fn generate_route(_name: &str) -> Result<()> {
todo!()
}
fn to_pascal_case(s: &str) -> String {
s.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
}
})
.collect()
}
Your scaffolder is working. The new command generates a complete Axum project skeleton with injection markers ready for future code generation. Next up, you will build the type mapping system that translates CLI field definitions into Rust types and SQL columns for the entity generator.
Build the Type Mapping System and Entity Generator
Your project scaffolder creates the skeleton. Now you need a way to generate feature-specific code from a single CLI command.
The entity generator takes a field definition string like id:uuid,name:string,created_at:datetime and produces fully typed Rust structs, SQL migrations, handlers, and route files. To pull this off, you need a type mapping system that converts shorthand CLI types into three representations: Rust types, SQL column types, and SQLx attributes.
In this step, get ready to:
- Build a field definition parser that splits CLI input into structured data.
- Create a three-way type mapping system (CLI type to Rust, SQL, and SQLx).
- Wire the generate entity and route commands to render templates and write files.
Define the FieldDefinition parser
When a user runs the generate entity command, they pass a --fields flag with a comma-separated string of field definitions. Each field uses a name:type format. You need a parser that splits this raw string into structured data your templates can consume.
- Open src/types.rs and add the FieldDefinition struct along with the parse_fields function:
use anyhow::{anyhow, Result};
#[derive(Debug, Clone)]
pub struct FieldDefinition {
pub name: String,
pub field_type: String,
pub rust_type: String,
pub sql_type: String,
}
pub fn parse_fields(input: &str) -> Result<Vec<FieldDefinition>> {
input
.split(',')
.map(|field| {
let parts: Vec<&str> = field.trim().split(':').collect();
if parts.len() != 2 {
return Err(anyhow!("Invalid field format: '{}'. Expected 'name:type'", field));
}
let name = parts[0].trim().to_string();
let field_type = parts[1].trim().to_string();
let rust_type = map_rust_type(&field_type)?;
let sql_type = map_sql_type(&field_type)?;
Ok(FieldDefinition { name, field_type, rust_type, sql_type })
})
.collect()
}
What does this code do?
The FieldDefinition struct holds all three representations of a field: the original CLI type, the mapped Rust type, and the mapped SQL type.
The parse_fields function splits on commas to get individual fields, then splits each field on the colon to separate name from type. It calls map_rust_type and map_sql_type (which you will add next) to resolve the final types. The .collect() call gathers all results, short-circuiting on the first error.
This code will not compile yet because map_rust_type and map_sql_type do not exist. You will add them in the next substep.
Create the type mapping functions
The type map is what makes the entity generator production-useful. A single CLI type like datetime needs to resolve to chrono::DateTime<chrono::Utc> in Rust structs and TIMESTAMPTZ in SQL migrations.
- In src/types.rs, add both mapping functions below the parse_fields function:
fn map_rust_type(field_type: &str) -> Result<String> {
match field_type {
"uuid" => Ok("uuid::Uuid".to_string()),
"string" => Ok("String".to_string()),
"i32" => Ok("i32".to_string()),
"i64" => Ok("i64".to_string()),
"f64" => Ok("f64".to_string()),
"bool" => Ok("bool".to_string()),
"datetime" => Ok("chrono::DateTime<chrono::Utc>".to_string()),
"date" => Ok("chrono::NaiveDate".to_string()),
"text" => Ok("String".to_string()),
_ => Err(anyhow!("Unknown field type: '{}'. Supported: uuid, string, i32, i64, f64, bool, datetime, date, text", field_type)),
}
}
fn map_sql_type(field_type: &str) -> Result<String> {
match field_type {
"uuid" => Ok("UUID".to_string()),
"string" => Ok("VARCHAR(255)".to_string()),
"i32" => Ok("INTEGER".to_string()),
"i64" => Ok("BIGINT".to_string()),
"f64" => Ok("DOUBLE PRECISION".to_string()),
"bool" => Ok("BOOLEAN".to_string()),
"datetime" => Ok("TIMESTAMPTZ".to_string()),
"date" => Ok("DATE".to_string()),
"text" => Ok("TEXT".to_string()),
_ => Err(anyhow!("Unknown field type: '{}'", field_type)),
}
}
What does this code do?
Both functions use a match expression to translate the CLI shorthand into the appropriate target type. The map_rust_type function produces fully qualified Rust type paths (e.g. uuid::Uuid) that go directly into struct field annotations.
The map_sql_type function produces PostgreSQL column types for the migration DDL. Both return an error on unknown types, which surfaces a clear message to the CLI user.
- Save src/types.rs.
- Verify the file compiles by running:
cargo build 2>&1 | head -5
You should see a few warnings about unused imports or functions, but no errors. The warnings will disappear once you wire up the generator in the next substep.
Seeing a compile error?
- If you see an error about anyhow not being found, make sure your use anyhow::{anyhow, Result}; line is at the top of the file.
- If you get a mismatched type error, check that every match arm returns Ok("...".to_string()) with the closing parenthesis in the right place.
Help me debug a compile error in my types.rs type mapping functions.
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/types.rs before moving on.
ⓧ I'd like to double check the full code
use anyhow::{anyhow, Result};
#[derive(Debug, Clone)]
pub struct FieldDefinition {
pub name: String,
pub field_type: String,
pub rust_type: String,
pub sql_type: String,
}
pub fn parse_fields(input: &str) -> Result<Vec<FieldDefinition>> {
input
.split(',')
.map(|field| {
let parts: Vec<&str> = field.trim().split(':').collect();
if parts.len() != 2 {
return Err(anyhow!("Invalid field format: '{}'. Expected 'name:type'", field));
}
let name = parts[0].trim().to_string();
let field_type = parts[1].trim().to_string();
let rust_type = map_rust_type(&field_type)?;
let sql_type = map_sql_type(&field_type)?;
Ok(FieldDefinition { name, field_type, rust_type, sql_type })
})
.collect()
}
fn map_rust_type(field_type: &str) -> Result<String> {
match field_type {
"uuid" => Ok("uuid::Uuid".to_string()),
"string" => Ok("String".to_string()),
"i32" => Ok("i32".to_string()),
"i64" => Ok("i64".to_string()),
"f64" => Ok("f64".to_string()),
"bool" => Ok("bool".to_string()),
"datetime" => Ok("chrono::DateTime<chrono::Utc>".to_string()),
"date" => Ok("chrono::NaiveDate".to_string()),
"text" => Ok("String".to_string()),
_ => Err(anyhow!("Unknown field type: '{}'. Supported: uuid, string, i32, i64, f64, bool, datetime, date, text", field_type)),
}
}
fn map_sql_type(field_type: &str) -> Result<String> {
match field_type {
"uuid" => Ok("UUID".to_string()),
"string" => Ok("VARCHAR(255)".to_string()),
"i32" => Ok("INTEGER".to_string()),
"i64" => Ok("BIGINT".to_string()),
"f64" => Ok("DOUBLE PRECISION".to_string()),
"bool" => Ok("BOOLEAN".to_string()),
"datetime" => Ok("TIMESTAMPTZ".to_string()),
"date" => Ok("DATE".to_string()),
"text" => Ok("TEXT".to_string()),
_ => Err(anyhow!("Unknown field type: '{}'", field_type)),
}
}
Wire the generate entity and route commands
The templates and type system are ready. Now you need to replace the todo!() stubs in src/generator.rs with real implementations that parse fields, build a Tera context, render templates, and write files to disk.
- In src/generator.rs, find the generate_entity stub function:
pub fn generate_entity(_name: &str, _fields_input: &str) -> Result<()> {
todo!()
}
- Replace the entire generate_entity function with the full implementation:
pub fn generate_entity(name: &str, fields_input: &str) -> Result<()> {
let tera = templates::create_engine()?;
let fields = types::parse_fields(fields_input)?;
let mut context = Context::new();
context.insert("entity_name", name);
context.insert("entity_name_pascal", &to_pascal_case(name));
context.insert("fields", &fields.iter().map(|f| {
let mut map = std::collections::HashMap::new();
map.insert("name", f.name.clone());
map.insert("rust_type", f.rust_type.clone());
map.insert("sql_type", f.sql_type.clone());
map
}).collect::<Vec<_>>());
let feature_dir = Path::new("src/features").join(name);
fs::create_dir_all(&feature_dir)?;
let files = vec![
("entity/model.rs", "model.rs"),
("entity/handler.rs", "handler.rs"),
("entity/repository.rs", "repository.rs"),
("entity/routes.rs", "routes.rs"),
("entity/mod.rs", "mod.rs"),
];
for (template, output) in files {
let rendered = templates::render(&tera, template, &context)?;
fs::write(feature_dir.join(output), rendered)?;
}
// Generate the SQL migration file
let migration_content = templates::render(&tera, "entity/migration.sql", &context)?;
let migration_dir = Path::new("migrations");
fs::create_dir_all(migration_dir)?;
let migration_file = migration_dir.join(format!("001_create_{}.sql", name));
fs::write(migration_file, migration_content)?;
// Inject module declaration and route mount into app.rs
injector::inject_at_marker("src/app.rs", "modules", &format!("mod {};", name))?;
injector::inject_at_marker("src/app.rs", "routes", &format!(" .nest(\"/{}\", features::{}::routes::router())", name, name))?;
Ok(())
}
What does this code do?
- It calls types::parse_fields to convert the raw --fields string into structured FieldDefinition data.
- It builds a Tera Context with the entity name, its PascalCase version, and a Vec<HashMap> of fields (Tera needs serializable data, so the fields are converted to hash maps).
- It creates the feature directory under src/features/<name>/, then renders and writes each entity template (model, handler, repository, routes, mod).
- It generates a SQL migration file at migrations/001_create_<name>.sql.
- Finally, it calls the injector to insert module declarations and route mounts into app.rs. The injector is still empty, so these calls will be no-ops until the next step.
- Now find the generate_route stub:
pub fn generate_route(_name: &str) -> Result<()> {
todo!()
}
- Replace the entire generate_route function with:
pub fn generate_route(name: &str) -> Result<()> {
let tera = templates::create_engine()?;
let mut context = Context::new();
context.insert("route_name", name);
context.insert("route_name_pascal", &to_pascal_case(name));
let feature_dir = Path::new("src/features").join(name);
fs::create_dir_all(&feature_dir)?;
let files = vec![
("route/mod.rs", "mod.rs"),
("route/handler.rs", "handler.rs"),
("route/routes.rs", "routes.rs"),
];
for (template, output) in files {
let rendered = templates::render(&tera, template, &context)?;
fs::write(feature_dir.join(output), rendered)?;
}
injector::inject_at_marker("src/app.rs", "routes", &format!(" .nest(\"/{}\", features::{}::routes::router())", name, name))?;
Ok(())
}
What does this code do?
The route generator follows the same pattern as the entity generator but is simpler. It only needs the route name (no field parsing), renders three template files (mod, handler, routes), and injects a route mount into app.rs.
- Save src/generator.rs.
- Verify the project compiles by running:
cargo build
You should see a successful build with some warnings about the empty injector and plugin modules.
- Test the entity generator end-to-end by running:
cargo run -- generate entity user --fields "id:uuid,name:string,created_at:datetime"
You should see the output Entity 'user' generated successfully! and a new src/features/user/ directory with five files plus a migrations/001_create_user.sql file.
- Confirm the generated files exist by running:
ls src/features/user/
You should see handler.rs, mod.rs, model.rs, repository.rs, and routes.rs in the output.
Seeing a panic or error?
- If you see a template rendering error, check that the field names in context.insert match what the Tera templates expect: entity_name, entity_name_pascal, and fields.
- If you get a "No such file or directory" error, make sure you are running the command from your project root (the axum-forge directory).
- If you get "Unknown field type", double check the fields string uses supported types: uuid, string, i32, i64, f64, bool, datetime, date, text.
Help me debug an error when running the axum-forge entity generator.
- Clean up the generated test files so they do not interfere with future steps by running:
rm -rf src/features/user migrations
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/generator.rs before moving on.
ⓧ I'd like to double check the full code
use std::fs;
use std::path::Path;
use anyhow::Result;
use tera::Context;
use crate::injector;
use crate::templates;
use crate::types;
pub fn scaffold_project(name: &str) -> Result<()> {
let tera = templates::create_engine()?;
let mut context = Context::new();
context.insert("project_name", name);
let base = Path::new(name);
fs::create_dir_all(base.join("src/features"))?;
fs::create_dir_all(base.join("migrations"))?;
let files = vec![
("project/main.rs", "src/main.rs"),
("project/app.rs", "src/app.rs"),
("project/config.rs", "src/config.rs"),
("project/db.rs", "src/db.rs"),
("project/error.rs", "src/error.rs"),
("project/cargo.toml", "Cargo.toml"),
];
for (template, output) in files {
let rendered = templates::render(&tera, template, &context)?;
fs::write(base.join(output), rendered)?;
}
println!(" Created project structure at ./{}/", name);
Ok(())
}
pub fn generate_entity(name: &str, fields_input: &str) -> Result<()> {
let tera = templates::create_engine()?;
let fields = types::parse_fields(fields_input)?;
let mut context = Context::new();
context.insert("entity_name", name);
context.insert("entity_name_pascal", &to_pascal_case(name));
context.insert("fields", &fields.iter().map(|f| {
let mut map = std::collections::HashMap::new();
map.insert("name", f.name.clone());
map.insert("rust_type", f.rust_type.clone());
map.insert("sql_type", f.sql_type.clone());
map
}).collect::<Vec<_>>());
let feature_dir = Path::new("src/features").join(name);
fs::create_dir_all(&feature_dir)?;
let files = vec![
("entity/model.rs", "model.rs"),
("entity/handler.rs", "handler.rs"),
("entity/repository.rs", "repository.rs"),
("entity/routes.rs", "routes.rs"),
("entity/mod.rs", "mod.rs"),
];
for (template, output) in files {
let rendered = templates::render(&tera, template, &context)?;
fs::write(feature_dir.join(output), rendered)?;
}
let migration_content = templates::render(&tera, "entity/migration.sql", &context)?;
let migration_dir = Path::new("migrations");
fs::create_dir_all(migration_dir)?;
let migration_file = migration_dir.join(format!("001_create_{}.sql", name));
fs::write(migration_file, migration_content)?;
injector::inject_at_marker("src/app.rs", "modules", &format!("mod {};", name))?;
injector::inject_at_marker("src/app.rs", "routes", &format!(" .nest(\"/{}\", features::{}::routes::router())", name, name))?;
Ok(())
}
pub fn generate_route(name: &str) -> Result<()> {
let tera = templates::create_engine()?;
let mut context = Context::new();
context.insert("route_name", name);
context.insert("route_name_pascal", &to_pascal_case(name));
let feature_dir = Path::new("src/features").join(name);
fs::create_dir_all(&feature_dir)?;
let files = vec![
("route/mod.rs", "mod.rs"),
("route/handler.rs", "handler.rs"),
("route/routes.rs", "routes.rs"),
];
for (template, output) in files {
let rendered = templates::render(&tera, template, &context)?;
fs::write(feature_dir.join(output), rendered)?;
}
injector::inject_at_marker("src/app.rs", "routes", &format!(" .nest(\"/{}\", features::{}::routes::router())", name, name))?;
Ok(())
}
fn to_pascal_case(s: &str) -> String {
s.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
}
})
.collect()
}
Your type system and entity generator are wired up. Next, you will build the marker-based injection engine so that generated modules and routes get automatically inserted into existing files.
Implement the Marker-Based Code Injection Engine
Your generators already call injector::inject_at_marker to insert module declarations and route mounts into existing files. The problem is that src/injector.rs is still empty, so nothing compiles.
Marker-based injection is the technique production scaffolders use to modify existing source files safely. Instead of parsing an AST (which breaks on non-standard formatting), you place sentinel comments in generated files and insert new code directly after them.
In this step, get ready to:
- Build the inject_at_marker function that finds sentinel comments in files and inserts content after them.
- Handle edge cases including missing files, missing markers, idempotent re-runs, and indentation preservation.
- Verify the integration with the entity and route generators.
Build the inject_at_marker function
The injector needs to do one job: find a marker comment like // @axum-forge:inject(routes) in an existing file and insert new code directly below it. Production CLIs like Rails generators and Angular CLI use this exact pattern.
- In src/injector.rs, paste the full implementation of the injection engine:
use std::fs;
use std::path::Path;
use anyhow::Result;
/// Finds a marker comment in a file and injects content after it.
/// Marker format: `// @axum-forge:inject(<marker_name>)`
pub fn inject_at_marker(file_path: &str, marker_name: &str, content: &str) -> Result<()> {
let path = Path::new(file_path);
if !path.exists() {
eprintln!("Warning: File '{}' not found, skipping injection for marker '{}'", file_path, marker_name);
return Ok(());
}
let file_content = fs::read_to_string(path)?;
let marker = format!("// @axum-forge:inject({})", marker_name);
if !file_content.contains(&marker) {
eprintln!("Warning: Marker '{}' not found in '{}', skipping injection", marker_name, file_path);
return Ok(());
}
// Check if content already exists (idempotency)
if file_content.contains(content.trim()) {
return Ok(());
}
let mut result = String::new();
for line in file_content.lines() {
result.push_str(line);
result.push('\n');
if line.contains(&marker) {
// Preserve indentation from the marker line
let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect();
result.push_str(&indent);
result.push_str(content.trim());
result.push('\n');
}
}
fs::write(path, result)?;
Ok(())
}
What does this code do?
The function takes three arguments: the file to modify, the marker name to search for, and the content to inject.
- It builds a marker string in the format // @axum-forge:inject(<name>) and searches the file for it.
- If the file or marker is missing, it prints a warning and returns Ok(()) instead of panicking.
- If the content already exists in the file, it skips the injection entirely (idempotency).
- When injecting, it iterates each line, copies it into a result string, and inserts the new content right after the marker line with matching indentation.
- Save src/injector.rs.
Seeing a syntax error?
- Check that the use anyhow::Result; import is at the top. Without it, the Result return type won't resolve.
- Make sure the function is marked pub. The generator module calls this function from another module, so it must be public.
- help me debug a syntax error in my injector.rs
Understand the edge case handling
The injector handles three defensive scenarios that production scaffolders must account for. Without these, running a generator twice or running it before scaffolding would crash the CLI.
Why these edge cases matter
Missing file: If a learner runs generate entity before running new, the target src/app.rs does not exist yet. The function warns and skips instead of panicking.
Missing marker: If a user edits app.rs and accidentally removes a sentinel comment, the function warns rather than corrupting the file.
Idempotency: Running generate entity user twice must not inject duplicate mod user; lines. The file_content.contains(content.trim()) check handles this.
Indentation preservation: The function reads the leading whitespace from the marker line and applies the same indent to the injected content. This keeps generated code formatted correctly regardless of nesting depth.
Why markers instead of AST manipulation?
An alternative approach would be to parse the Rust source with syn and inject code by modifying the syntax tree. Marker comments are far simpler and more resilient. They work even after the user reformats their code, adds custom imports, or rearranges functions.
Rails generators, Angular CLI, and NestJS CLI all use this exact approach. Markers are explicit about where generated code goes, and they survive across refactors.
Verify the integration with the generators
The entity and route generators already call inject_at_marker. Now that you have implemented it, those calls will resolve. Take a look at how the integration works.
- Open src/generator.rs and scroll to the bottom of the generate_entity function.
- Find these two lines:
injector::inject_at_marker("src/app.rs", "modules", &format!("mod {};", name))?;
injector::inject_at_marker("src/app.rs", "routes", &format!(" .nest(\"/{}\", features::{}::routes::router())", name, name))?;
What do these calls do?
When someone runs axum-forge generate entity user, the first call finds the // @axum-forge:inject(modules) marker in src/app.rs and inserts mod user; below it.
The second call finds the // @axum-forge:inject(routes) marker and inserts the .nest() call to mount the new entity's router. Together, they automatically wire new features into the application.
- Open templates/project/app.rs.tera to see the marker comments that the generators target:
use axum::Router;
// @axum-forge:inject(modules)
pub fn create_router() -> Router {
Router::new()
// @axum-forge:inject(routes)
// @axum-forge:inject(middleware)
}
Each // @axum-forge:inject(...) comment is a hook point. When the injector finds one, it inserts the new content on the next line with matching indentation.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/injector.rs.
ⓧ I'd like to double check the full code
Here is the complete src/injector.rs file:
use std::fs;
use std::path::Path;
use anyhow::Result;
/// Finds a marker comment in a file and injects content after it.
/// Marker format: `// @axum-forge:inject(<marker_name>)`
pub fn inject_at_marker(file_path: &str, marker_name: &str, content: &str) -> Result<()> {
let path = Path::new(file_path);
if !path.exists() {
eprintln!("Warning: File '{}' not found, skipping injection for marker '{}'", file_path, marker_name);
return Ok(());
}
let file_content = fs::read_to_string(path)?;
let marker = format!("// @axum-forge:inject({})", marker_name);
if !file_content.contains(&marker) {
eprintln!("Warning: Marker '{}' not found in '{}', skipping injection", marker_name, file_path);
return Ok(());
}
// Check if content already exists (idempotency)
if file_content.contains(content.trim()) {
return Ok(());
}
let mut result = String::new();
for line in file_content.lines() {
result.push_str(line);
result.push('\n');
if line.contains(&marker) {
// Preserve indentation from the marker line
let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect();
result.push_str(&indent);
result.push_str(content.trim());
result.push('\n');
}
}
fs::write(path, result)?;
Ok(())
}
Your injection engine is complete. In the next step, you will build the plugin system that lets users extend the CLI with their own template directories and generators.
Build the Plugin System
Your injector can now surgically insert generated code into existing files at marker comments. The core generation pipeline is complete, but it only handles built-in generators.
A truly useful scaffolding tool lets users extend it with their own generators. In this step, you will build a plugin system where users drop in a template directory with a plugin.toml manifest, and the CLI automatically discovers and runs their custom generators.
In this step, get ready to:
- Define a serde-deserializable manifest schema for plugins.
- Build the plugin install command that copies plugin directories into a central location.
- Connect plugin generators to the Tera engine with injection support.
Define the plugin manifest schema
Every plugin needs a contract that tells the CLI what generators it provides, which templates to render, and where to inject code. A plugin.toml file serves as this manifest.
You will define Rust structs that mirror this TOML structure, using serde's Deserialize derive macro to automatically parse the file into typed data.
- Open src/plugin.rs and add the following imports, struct definitions, and plugin directory helper:
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Result};
use serde::Deserialize;
use tera::{Context, Tera};
use crate::injector;
// The top-level plugin.toml structure
#[derive(Deserialize, Debug)]
pub struct PluginManifest {
pub plugin: PluginInfo,
pub generators: Option<Vec<Generator>>,
}
// Basic plugin metadata
#[derive(Deserialize, Debug)]
pub struct PluginInfo {
pub name: String,
pub description: Option<String>,
}
// A single generator that the plugin provides
#[derive(Deserialize, Debug)]
pub struct Generator {
pub name: String,
pub templates: Vec<String>,
pub output_dir: Option<String>,
pub injections: Option<Vec<Injection>>,
}
// An injection rule: which file and marker to inject rendered content into
#[derive(Deserialize, Debug)]
pub struct Injection {
pub target: String,
pub marker: String,
pub template: String,
}
// Returns the path to the central plugins directory (~/.axum-forge/plugins/)
fn plugins_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".axum-forge").join("plugins")
}
What does this code do?
The four structs map directly to the TOML file structure a plugin author writes. PluginManifest is the top-level container with a plugin table and an optional list of generators.
Each Generator declares which Tera template files it uses, where to write the output, and which injection rules to apply. The plugins_dir() helper resolves ~/.axum-forge/plugins/ as the central install location.
- Save src/plugin.rs.
You won't see a change yet. The next chunk adds the install logic that uses these structs.
Build the plugin install command
The install command needs to validate that a source directory exists, read and parse its plugin.toml, then copy the entire directory tree into the central plugins folder.
- In src/plugin.rs, add the install_plugin function below the plugins_dir() function:
pub fn install_plugin(source_path: &str) -> Result<()> {
let source = Path::new(source_path);
if !source.exists() {
return Err(anyhow!("Plugin directory '{}' does not exist", source_path));
}
// Validate that the source has a manifest
let manifest_path = source.join("plugin.toml");
if !manifest_path.exists() {
return Err(anyhow!("No plugin.toml found in '{}'", source_path));
}
// Parse the manifest to get the plugin name
let manifest_content = fs::read_to_string(&manifest_path)?;
let manifest: PluginManifest = toml::from_str(&manifest_content)?;
// Copy to the central plugins directory, replacing if already installed
let dest = plugins_dir().join(&manifest.plugin.name);
if dest.exists() {
fs::remove_dir_all(&dest)?;
}
copy_dir_recursive(source, &dest)?;
println!(" Installed plugin '{}' to {:?}", manifest.plugin.name, dest);
Ok(())
}
What does this code do?
The function performs three validation steps before installing. It checks that the source path exists, that it contains a plugin.toml, and that the TOML is parseable into the PluginManifest struct.
If the plugin was already installed previously, it removes the old copy before writing the new one. This makes reinstalls idempotent.
The install_plugin function calls copy_dir_recursive, which does not exist yet. You need to add this helper so the entire plugin directory tree is copied faithfully.
- Add the copy_dir_recursive function at the bottom of src/plugin.rs:
// Recursively copies a directory and all its contents to a new location
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let entry_path = entry.path();
let dest_path = dst.join(entry.file_name());
if entry_path.is_dir() {
copy_dir_recursive(&entry_path, &dest_path)?;
} else {
fs::copy(&entry_path, &dest_path)?;
}
}
Ok(())
}
Why a custom copy function?
Rust's standard library provides fs::copy for individual files but has no built-in recursive directory copy. This helper walks the source tree entry by entry, creating subdirectories as needed and copying each file.
- Save src/plugin.rs.
- Verify the project compiles by running:
cargo build
You should see a successful compilation with no errors. You may see a warning about the unused run_plugin_generator import or function, which you will add next.
Seeing a compilation error?
If you see an error about copy_dir_recursive not being found, make sure it is defined in the same file (src/plugin.rs). It can appear anywhere in the file since Rust resolves functions within a module regardless of order.
If you see an error about missing imports, double check that the use statements at the top of the file include anyhow::{anyhow, Result} and serde::Deserialize.
help me debug a compilation error in my plugin.rs file
Connect plugin generators to the Tera engine
Installing a plugin is only half the story. The CLI also needs a function that loads an installed plugin's templates into a fresh Tera engine, renders them with the provided context, and applies any injection rules. This is the bridge between the plugin manifest and actual code generation.
- In src/plugin.rs, add the run_plugin_generator function between install_plugin and copy_dir_recursive:
pub fn run_plugin_generator(plugin_name: &str, generator_name: &str, context: &Context) -> Result<()> {
// Locate the installed plugin directory
let plugin_path = plugins_dir().join(plugin_name);
if !plugin_path.exists() {
return Err(anyhow!("Plugin '{}' not found. Run `axum-forge plugin install` first.", plugin_name));
}
// Read and parse the plugin manifest
let manifest_content = fs::read_to_string(plugin_path.join("plugin.toml"))?;
let manifest: PluginManifest = toml::from_str(&manifest_content)?;
// Find the requested generator in the manifest
let generators = manifest.generators.unwrap_or_default();
let generator = generators
.iter()
.find(|g| g.name == generator_name)
.ok_or_else(|| anyhow!("Generator '{}' not found in plugin '{}'", generator_name, plugin_name))?;
// Build a Tera engine from the plugin's template files
let mut tera = Tera::default();
tera.autoescape_on(vec![]);
for template_file in &generator.templates {
let template_path = plugin_path.join(template_file);
let template_content = fs::read_to_string(&template_path)?;
tera.add_raw_template(template_file, &template_content)?;
}
// Render templates and write output files
let output_dir = generator.output_dir.as_deref().unwrap_or("src/features");
fs::create_dir_all(output_dir)?;
for template_file in &generator.templates {
let rendered = tera.render(template_file, context)?;
let output_name = template_file.replace(".tera", "");
fs::write(Path::new(output_dir).join(&output_name), rendered)?;
}
// Process injection rules if the generator defines any
if let Some(injections) = &generator.injections {
for injection in injections {
let template_path = plugin_path.join(&injection.template);
let template_content = fs::read_to_string(&template_path)?;
tera.add_raw_template(&injection.template, &template_content)?;
let rendered = tera.render(&injection.template, context)?;
injector::inject_at_marker(&injection.target, &injection.marker, &rendered)?;
}
}
Ok(())
}
What does this code do?
This function is the runtime engine for plugins. It reads the installed plugin's manifest, finds the requested generator by name, then loads the plugin's Tera templates from disk into a fresh engine instance.
After rendering the templates with the provided context, it writes the output files. If the generator defines injection rules, it also renders those templates and uses your existing inject_at_marker function to insert the rendered content into target files at the specified markers.
- Save src/plugin.rs.
- Run a final build to confirm everything compiles cleanly:
cargo build
You should see a clean build. You may see a dead-code warning for run_plugin_generator since nothing calls it yet in the main binary. That is expected and will be resolved in the Secret Mission.
Seeing type errors around Context or Tera?
Make sure your imports at the top of src/plugin.rs include tera::{Context, Tera}. The Context type is needed in the function signature even though it is passed in from the caller.
help me fix type errors in my plugin.rs run_plugin_generator function
- Test the plugin install command by running:
cargo run -- plugin install ./nonexistent-path
You should see an error message: Plugin directory './nonexistent-path' does not exist. This confirms the validation logic is working correctly.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/plugin.rs and that cargo build succeeds.
ⓧ I'd like to double check the full code
Here is the complete src/plugin.rs file:
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Result};
use serde::Deserialize;
use tera::{Context, Tera};
use crate::injector;
#[derive(Deserialize, Debug)]
pub struct PluginManifest {
pub plugin: PluginInfo,
pub generators: Option<Vec<Generator>>,
}
#[derive(Deserialize, Debug)]
pub struct PluginInfo {
pub name: String,
pub description: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct Generator {
pub name: String,
pub templates: Vec<String>,
pub output_dir: Option<String>,
pub injections: Option<Vec<Injection>>,
}
#[derive(Deserialize, Debug)]
pub struct Injection {
pub target: String,
pub marker: String,
pub template: String,
}
fn plugins_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".axum-forge").join("plugins")
}
pub fn install_plugin(source_path: &str) -> Result<()> {
let source = Path::new(source_path);
if !source.exists() {
return Err(anyhow!("Plugin directory '{}' does not exist", source_path));
}
let manifest_path = source.join("plugin.toml");
if !manifest_path.exists() {
return Err(anyhow!("No plugin.toml found in '{}'", source_path));
}
let manifest_content = fs::read_to_string(&manifest_path)?;
let manifest: PluginManifest = toml::from_str(&manifest_content)?;
let dest = plugins_dir().join(&manifest.plugin.name);
if dest.exists() {
fs::remove_dir_all(&dest)?;
}
copy_dir_recursive(source, &dest)?;
println!(" Installed plugin '{}' to {:?}", manifest.plugin.name, dest);
Ok(())
}
pub fn run_plugin_generator(plugin_name: &str, generator_name: &str, context: &Context) -> Result<()> {
let plugin_path = plugins_dir().join(plugin_name);
if !plugin_path.exists() {
return Err(anyhow!("Plugin '{}' not found. Run `axum-forge plugin install` first.", plugin_name));
}
let manifest_content = fs::read_to_string(plugin_path.join("plugin.toml"))?;
let manifest: PluginManifest = toml::from_str(&manifest_content)?;
let generators = manifest.generators.unwrap_or_default();
let generator = generators
.iter()
.find(|g| g.name == generator_name)
.ok_or_else(|| anyhow!("Generator '{}' not found in plugin '{}'", generator_name, plugin_name))?;
let mut tera = Tera::default();
tera.autoescape_on(vec![]);
for template_file in &generator.templates {
let template_path = plugin_path.join(template_file);
let template_content = fs::read_to_string(&template_path)?;
tera.add_raw_template(template_file, &template_content)?;
}
let output_dir = generator.output_dir.as_deref().unwrap_or("src/features");
fs::create_dir_all(output_dir)?;
for template_file in &generator.templates {
let rendered = tera.render(template_file, context)?;
let output_name = template_file.replace(".tera", "");
fs::write(Path::new(output_dir).join(&output_name), rendered)?;
}
if let Some(injections) = &generator.injections {
for injection in injections {
let template_path = plugin_path.join(&injection.template);
let template_content = fs::read_to_string(&template_path)?;
tera.add_raw_template(&injection.template, &template_content)?;
let rendered = tera.render(&injection.template, context)?;
injector::inject_at_marker(&injection.target, &injection.marker, &rendered)?;
}
}
Ok(())
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let entry_path = entry.path();
let dest_path = dst.join(entry.file_name());
if entry_path.is_dir() {
copy_dir_recursive(&entry_path, &dest_path)?;
} else {
fs::copy(&entry_path, &dest_path)?;
}
}
Ok(())
}
Your plugin system is fully wired. Users can now create a directory with a plugin.toml manifest and Tera templates, install it with axum-forge plugin install, and run custom generators that produce files and inject code. In the Secret Mission, you will build a middleware generator that proves this system works end-to-end.
Secret mission
Add a Middleware Generator Plugin
Your CLI can scaffold projects and generate entities. But what about middleware? In this secret mission, you'll build a middleware generator that creates Tower middleware layers and injects them into the router. Then you'll package it as a standalone plugin so others can install and use it.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The axum-forge project directory on your Desktop (source code, templates, compiled binary in target/).
- The ~/.axum-forge/plugins/ directory (created during the plugin system step).
Keep everything running
No action needed. Choose this if you're still actively building or plan to extend the CLI with more generators and plugins.
- Your project files stay on your Desktop.
- The ~/.axum-forge/plugins/ directory stays in your home folder with any installed plugins.
- The compiled binary remains in target/debug/axum-forge and can be run at any time.
Pause - I'll come back to this later
Free up disk space from the compiled artifacts while keeping your source code intact.
- Remove the target/ directory to reclaim disk space. Navigate to your project folder and run:
cargo clean
- Your source code, templates, and plugin directory remain untouched.
- When you return, run cargo build to recompile.
Delete - I don't want to use this again
Remove all project files and the plugins directory completely.
- Delete the project directory by running:
rm -rf ~/Desktop/axum-forge
- Delete the plugins directory by running:
rm -rf ~/.axum-forge
What about Windows?
On Windows, use these commands instead:
rmdir /s /q %USERPROFILE%\Desktop\axum-forge
rmdir /s /q %USERPROFILE%\.axum-forge
That's a Wrap!
That's a Wrap!
Nice work. You just built axum-forge, a production-level scaffolding CLI that generates entire Axum project structures from templates, parses field definitions into typed code, and supports user-extensible plugins.
You've learned how to:
- Build a multi-level CLI with clap derive macros, including nested subcommands and flag parsing for structured field definitions.
- Architect a Tera-powered template engine with a type mapping system that translates CLI input into Rust structs, SQL migrations, and repository code.
- Implement a marker-based code injection engine and a plugin architecture that lets users extend the CLI with their own generators and templates.
- Secret Mission: Create a middleware generator that doubles as a reference plugin implementation, complete with a plugin.toml manifest and injection rules.
Ready to quiz yourself?