# Generators (rws gen)

> rws gen model, route, migration and test — code for a project in the conventional layout, written against the exact API of the rws release you run, never overwriting a file.

Source: https://rws8.tech/docs/building-apps/generators/ · Last updated 2026-11-04

Since 18.29.0 `rws gen` writes the four things an app gets more of — a
model, a resource's routes, a migration, a test — into a project in the
[conventional layout](/building-apps/project-layout/). What it writes is
pinned to the API of the rws release you run (CI generates into every
[starter](/getting-started/ai-agents/#start-from-a-starter) and runs the
generated tests), and it never overwrites a file: running a generator twice
changes nothing the second time, and a file that is in the way is a conflict,
shown as a diff. There is no `rws new` — new projects start from a starter.

```sh
rws gen model Post title:String views:i64 published:bool 'note:Option<String>'
rws gen route posts            # the model's fields; or: rws gen route posts title:String
rws gen test posts
rws gen migration add_index_to_posts
cargo test
```

Use the `rws` binary of the release the project depends on
(`cargo install rust-web-server --version <x.y.z>`, or any of the
[installation](/getting-started/installation/) packages): its output names
that release in each file's header and is written against its API.

The same generators are tools of the [dev MCP endpoint](/mcp/dev-tools/#generators)
(`gen_model`, `gen_route`, `gen_migration`, `gen_test`), and a library
module, `rust_web_server::generator`.

## What each generator writes

| Command | Creates | Adds a line to |
|---|---|---|
| `rws gen model <Name> field:type…` | `src/models/<name>.rs`, `migrations/NNNN_create_<table>.sql` and `.down.sql` | `src/models/mod.rs` (`pub mod post;`, `pub use post::Post;`; created when missing), `src/lib.rs` (`pub mod models;`), `Cargo.toml` (the `model-<db>` feature) |
| `rws gen route <resource> [field:type…]` | `src/routes/<resource>.rs` | `src/routes/mod.rs`, `app()` in `src/lib.rs` (the routes are registered) |
| `rws gen migration <name>` | `migrations/NNNN_<name>.sql` and `.down.sql` | — |
| `rws gen test <resource>` | `tests/<resource>.rs` | — |

The paths are those of `rws.app.toml` (`[paths] models`, `routes`,
`migrations`, `tests`, `lib`), so a project that keeps its routes elsewhere
gets them there. A line is added only where it can be placed safely — beside
the other `mod` lines, inside the existing `register(…)` call of `app()`, in a
one-line `rust-web-server = { … }` dependency; otherwise the plan ends with a
`note:` saying what to add by hand.

### Models

```sh
rws gen model Post title:String views:i64 published:bool 'note:Option<String>'
```

```rust
// src/models/post.rs
//! `Post`: the `posts` table (migrations/0001_create_posts.sql).
//!
//! Generated by `rws gen model Post title:String views:i64 published:bool note:Option<String>` (rws 18.29.0).

use rust_web_server::Model;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Model)]
#[table(name = "posts")]
pub struct Post {
    #[primary_key(auto_increment)]
    pub id: i64,
    pub title: String,
    pub views: i64,
    pub published: bool,
    pub note: Option<String>,
}

#[cfg(test)]
mod tests {
    // maps_to_the_posts_table: the table, columns and primary key;
    // round_trips_through_sqlite (SQLite only): the migrations applied to an
    // in-memory database, then save, find_by_id, delete_by_id.
}
```

- The name may be `Post`, `BlogPost` or `blog_post`: the module is
  snake_case (`blog_post.rs`), the type CamelCase, the table the snake_case
  plural (`blog_posts`). Every model gets `id: i64`, auto-increment.
- Field types are a fixed list: `i64`, `i32`, `f64`, `bool`, `String`, and
  `Option<T>` of those (a nullable column). Anything else is refused with that
  list.
- The database is `--db sqlite|postgres|mysql`, else the `model-*` feature
  the project's `Cargo.toml` already enables, else SQLite; the feature is added
  when missing. The migration's column types follow it:

| Rust | SQLite | PostgreSQL | MySQL |
|---|---|---|---|
| `id` | `INTEGER PRIMARY KEY AUTOINCREMENT` | `BIGSERIAL PRIMARY KEY` | `BIGINT AUTO_INCREMENT PRIMARY KEY` |
| `i64` | `INTEGER` | `BIGINT` | `BIGINT` |
| `i32` | `INTEGER` | `INTEGER` | `INT` |
| `f64` | `REAL` | `DOUBLE PRECISION` | `DOUBLE` |
| `bool` | `INTEGER` | `BOOLEAN` | `BOOLEAN` |
| `String` | `TEXT` | `TEXT` | `TEXT` |

  Columns are `NOT NULL` unless the field is an `Option`.
- The migration is numbered after the highest `NNNN_` in `migrations/`; the
  down file drops the table.
- To list the model in the dev MCP endpoint's `models` tool, add
  `.model::<models::Post>()` to your `DevTools` (the plan's note says so).
- `--timestamps` and `--soft-delete` (18.30.0) add the repository's
  [conventions](/database/conventions/): `#[timestamps]` with
  `created_at: Timestamp` and `updated_at: Timestamp` (columns `NOT NULL`),
  `#[soft_delete]` with `deleted_at: Option<Timestamp>` (nullable). The
  columns are `TEXT` on SQLite, `TIMESTAMPTZ` on PostgreSQL, `DATETIME(6)` on
  MySQL; the `serde` feature is added for `Timestamp`'s `Serialize`. A field
  of the same name is refused — `save` and `delete_by_id` write these columns.

  ```sh
  rws gen model Comment body:String post_id:i64 --timestamps --soft-delete
  ```

  ```rust
  #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Model)]
  #[table(name = "comments")]
  #[timestamps]
  #[soft_delete]
  pub struct Comment {
      #[primary_key(auto_increment)]
      pub id: i64,
      pub body: String,
      pub post_id: i64,
      pub created_at: Timestamp,
      pub updated_at: Timestamp,
      pub deleted_at: Option<Timestamp>,
  }
  ```

### Routes

```sh
rws gen route posts title:String views:i64 [--methods list,show,create,update,remove] [--prefix /api]
```

writes typed handlers — `#[get("/posts")] list`, `#[get("/posts/{id}")] show`,
`#[post("/posts")] create` (201), `#[put("/posts/{id}")] update`,
`#[delete("/posts/{id}")] remove` — a `Post` and a `NewPost` (the request
body) type, and

```rust
/// Add these routes to the app.
pub fn register(app: AppWithState<crate::Store>) -> AppWithState<crate::Store> {
    app.route(list).route(show).route(create).route(update).route(remove)
}
```

The app type and its state come from `pub fn app(…) -> AppWithState<S>` (or
`AsyncAppWithState<S>`) in `src/lib.rs`, and `app()`'s existing
`routes::…::register(…)` call is wrapped:
`routes::posts::register(routes::todos::register(App::with_state(…)))`. A
project whose `lib.rs` has no such `app()` — the `mcp` starter builds an
`McpServer` — is refused.

Without fields, the routes take the fields of `src/models/<singular>.rs` when
there is one, else `name: String`. The handlers keep the items in memory, in
the module (`static STORE`): they work and are tested as they are; replace the
store with the app's state or the model's repository when the data must outlast
the process. Unknown ids answer `404`, ids that aren't numbers `400`, bodies that
aren't JSON of the right shape `400` — problem details, as every rws error.

### Migrations

```sh
rws gen migration add_index_to_posts
```

writes `migrations/0002_add_index_to_posts.sql` and `.down.sql`, each holding
a statement that changes nothing (`SELECT 1;`) under a comment, so the files
apply as they are until you replace it. `DbPool::migrate` splits a file at
semicolons — keep them out of comments ([migrations](/database/migrations/)).

### Tests

```sh
rws gen test posts [--app-expr 'app(Store::memory())']
```

reads the routes `src/routes/posts.rs` declares (`#[get("…")]` and the like)
and the fields of its `NewPost`, and writes `tests/posts.rs`: a round trip
through every route there (create, show, list, update, remove, then `404`),
and the errors (`404` for an unknown id, `400` for a non-numeric id and for a
body that isn't JSON) — through `TestClient`, no socket. The test builds the
app with `app()`; when `app` takes arguments, with what the existing tests in
`tests/` pass to `TestClient::new`, or `--app-expr`.

## Conflicts, dry runs, JSON

```text
$ rws gen model Post title:String          # after the one above
conflict   src/models/post.rs
unchanged  src/models/mod.rs
…
--- a/src/models/post.rs
+++ b/src/models/post.rs
@@ -11,9 +11,6 @@
     #[primary_key(auto_increment)]
     pub id: i64,
     pub title: String,
-    pub views: i64,
…
rws gen: not written — a file above exists with other content (the diff is what the generator would write). Move it aside or merge by hand.
```

- A file the generator creates is never replaced. If it exists with exactly
  what the generator would write, it is `unchanged`; with anything else it is
  a `conflict`, the diff goes from the file to what would be written, and
  **nothing** of the plan is written. Re-running the same command is safe.
- `--dry-run` prints the plan with every diff and writes nothing.
- `--json` prints the plan for agents:
  `{"command":"rws gen model Post title:String","status":"written","written":true,"changes":[{"path":"src/models/post.rs","action":"create","diff":"--- /dev/null\n+++ b/src/models/post.rs\n…"}],"notes":[…]}`
  — `status` is `written`, `planned` (dry run), `unchanged` or `conflict`;
  errors are `{"status":"usage"|"refused","error":"…"}`.
- `--dir <project>` runs against another directory (default: the working
  directory, which must hold the project's `Cargo.toml`).

Exit codes: `0` written (or nothing to do), `1` a conflict or a project the
generator can't work with (no `app()`, no route module for `gen test`), `2` a
usage error (a bad name or type, an unknown option).

Names are identifiers — letters, digits and single `_`, starting with a letter,
at most 64 characters, not a Rust keyword — so no generated path leaves the
project; a resource whose type would clash with a name the generated code
imports (`paths` → `Path`, `values` → `Value`) is refused.

## From Rust

```rust
use rust_web_server::generator::{plan, Field, Generate};

let request = Generate::Model {
    name: "Post".into(),
    fields: vec![Field::parse("title:String")?],
    db: None,
};
let plan = plan(".", &request)?;    // reads only
print!("{}", plan.render(true));    // one line per file, then the diffs
if !plan.has_conflicts() {
    plan.apply(".")?;                // writes; refuses if a file changed since
}
```

`Plan` has the `changes` (`FileChange { path, kind, content, original, diff }`,
`kind` one of `Create`, `Edit`, `Unchanged`, `Conflict`), the `notes`, and
`to_json(written)`; `generator::run(args, out, err)` is the command line.
