You are reading the docs for rws 18.31 The latest release is 18.34. Read this page for the latest release.

Project layout and rws.app.toml

The conventional layout of an rws application — where routes, models, jobs, migrations, templates and tests go — and the rws.app.toml manifest that names the app and any path that differs.

Since 18.28.0 an rws application has one conventional layout, so a person or a coding agent can find (and add) a route, a model or a migration without reading the project first. The starters use it; the dev MCP endpoint's layout tool reports it, and rws gen (18.29.0) writes models, routes, migrations and tests into it:

rws gen model Post title:String       # src/models/post.rs + migrations/0001_create_posts.sql
rws gen route posts                   # src/routes/posts.rs, registered in app()
rws gen test posts                    # tests/posts.rs
rws db migrate                        # apply migrations/ (rws db status|rollback|seed)
my-app/
├── rws.app.toml          the manifest: the app's name, paths that differ
├── Cargo.toml
├── src/
│   ├── main.rs           wiring only: settings, dev_mcp, Server::run_tls
│   ├── lib.rs            app(): the routes and middleware, built in a function
│   ├── routes/           typed handlers (#[get] …), one module per resource
│   │   ├── mod.rs
│   │   └── posts.rs
│   ├── models/           #[derive(Model)] types, one module per table
│   └── jobs/             background jobs and scheduled tasks
├── migrations/           0001_create_posts.sql, 0001_create_posts.down.sql, …
├── templates/            Tera templates
├── tests/                integration tests against app() (TestClient)
└── static/               files served as they are
Role Path What goes there
main src/main.rs Wiring only: Server::setup(), app().dev_mcp_with(…), Server::run_tls. Nothing a test needs.
lib src/lib.rs pub fn app() -> … building every route and layer, so tests/ can call it; the state type; dev_tools()
routes src/routes/ One module per resource: its typed handlers and a register(app) -> app that adds them (app.route(list).route(show))
models src/models/ #[derive(Model)] types, one module per table
jobs src/jobs/ Jobs and scheduled tasks
migrations migrations/ NNNN_name.sql, applied in order, each with an optional NNNN_name.down.sql for rollback (migrations)
templates templates/ Tera templates
tests tests/ Integration tests: TestClient::new(my_app::app())
static static/ Static files

A binary-only crate can't be tested from tests/, hence src/lib.rs: the binary is a few lines that serve what the library builds.

// src/main.rs
use rust_web_server::dev_mcp::DevMcpExt;
use rust_web_server::server::Server;

#[tokio::main]
async fn main() {
    let (listener, pool) = Server::setup().expect("server setup");
    let app = my_app::app().dev_mcp_with(my_app::dev_tools());
    Server::run_tls(listener, pool, app).await;
}
// src/lib.rs
pub mod routes;

use rust_web_server::dev_mcp::DevTools;
use rust_web_server::prelude::*;
use rust_web_server::state::AppWithState;

#[derive(Clone, Default)] // the server clones the app per worker
pub struct Db {
    pub posts: std::collections::HashMap<u64, String>, // or a DbPool
}

pub fn app() -> AppWithState<Db> {
    routes::posts::register(App::with_state(Db::default()))
}

pub fn dev_tools() -> DevTools {
    DevTools::new().app_dir(env!("CARGO_MANIFEST_DIR"))
}
// src/routes/posts.rs
use rust_web_server::error::AppError;
use rust_web_server::handler::{Json, Path};
use rust_web_server::state::AppWithState;
use rust_web_server::get;

use crate::Db;

#[get("/posts/{id}")]
pub fn show(Path(id): Path<u64>, db: &Db) -> Result<Json<String>, AppError> {
    db.posts.get(&id).cloned().map(Json).ok_or_else(|| AppError::NotFound(format!("no post {id}")))
}

pub fn register(app: AppWithState<Db>) -> AppWithState<Db> {
    app.route(show)
}

rws.app.toml

The manifest sits at the project root. It names the app and says where the project differs from the layout; every key is optional, so an empty file — or none — means "conventional":

[app]
name = "blog"              # default: Cargo.toml's [package] name, else the directory's

[paths]
static = "frontend/dist"   # only what differs; the rest keep their defaults
Key Default
[app] name [package] name from Cargo.toml, else the directory's name
[paths] main src/main.rs
[paths] lib src/lib.rs
[paths] routes src/routes
[paths] models src/models
[paths] jobs src/jobs
[paths] migrations migrations
[paths] templates templates
[paths] tests tests
[paths] static static
  • Values are TOML strings ("…" or '…'); # comments are allowed; paths.static = "…" at the top level is the same as the key in [paths].
  • Paths are relative to the project root and stay inside it: a leading /, a drive letter or a .. is an error, as is a key set twice. Errors name the line: rws.app.toml line 3: `routes` = "/srv/x" is absolute; paths are relative to the project root.
  • Unknown keys and tables are warnings, not errors, so a manifest written for a newer release still loads.
  • The manifest describes the project; rws doesn't move files or change routing because of it. rws gen writes where it says. The mcp starter, whose "routes" are MCP tools, says routes = "src/tools".

Read it in code with app_manifest::AppManifest:

use rust_web_server::app_manifest::AppManifest;

let manifest = AppManifest::load(env!("CARGO_MANIFEST_DIR"))?; // conventional when absent
println!("{} keeps its models in {}", manifest.name, manifest.paths.models);
for entry in manifest.layout(env!("CARGO_MANIFEST_DIR")) {
    println!("{:<10} {:<14} {}", entry.role, entry.path, if entry.exists { "ok" } else { "missing" });
}

AppManifest::parse(text) (or text.parse::<AppManifest>()) reads a manifest from a string; files over 64 KiB are refused.

Edit this page on GitHub