Rust on Rails
Yesterday, Railcar could transpile a Rails app to Crystal, Python, TypeScript, Elixir, and Go. Now it can also transpile to Rust.
This is the sixth target. The same demo blog — articles, comments, nested resources, validations, Turbo Streams — generates a working Rust web application. 21 tests pass. Zero compiler warnings. The app serves styled pages with Tailwind CSS, handles real-time updates via ActionCable WebSockets, and runs on Axum with Tokio.
The Reputation vs. The Reality
Rust has a reputation for being hard. The borrow checker, lifetimes, trait bounds — the language that fights you at every turn. Adding Rust as a target was expected to be the hardest of the six.
It wasn't. The 9 model tests passed on the first run. The full 21 tests took about the same effort as Go. The borrow checker caught 5 real bugs during compilation (private fields, missing trait imports, closure borrow issues, type mismatches) that would have been runtime failures in Python or test failures in Go.
Three reasons it went smoothly:
Owned data, not references. Models use String fields, not &str. Views take &Article (borrowed) and return owned String. No lifetime annotations needed anywhere. The ownership patterns are simple because the architecture is simple.
Clone before closure. The one tricky spot: save() needs &mut self but the with_db closure borrows the database. Fix: clone field values into locals before the closure. Five extra lines per model, but mechanical.
Sixth time through the pattern. The pipeline, filters, emitter structure, test patterns — all proven across five prior targets. The Rust-specific parts (trait impls, Result<T, E>, impl Broadcaster) were new syntax for known semantics.
The Same Architecture, Different Idioms
The pipeline is identical to Go:
Rails source
|
v
Prism parser -> Crystal AST
|
v
Shared filters (9 transformers)
|
+--> Rust filters -> Cr2Rs -> .rs models
| -> View functions -> .rs views
| -> Axum handlers -> .rs controllers
Views use the same string-building approach as Go and Python — functions that return String via push_str(). No template engine. This works because Rust's compiler catches typos at compile time, and html::EscapeString isn't needed (we handle escaping in the view emitter).
The key Rust-specific patterns:
Model trait + concrete methods:
pub trait Model: Sized {
fn table_name() -> &'static str;
fn id(&self) -> i64;
fn from_row(row: &Row) -> Result<Self, rusqlite::Error>;
fn run_validations(&self) -> Vec<ValidationError>;
// ...
}
impl Article {
pub fn save(&mut self) -> Result<(), String> {
let errors = self.validate();
if !errors.is_empty() {
self.errors = errors;
return Err("validation failed".to_string());
}
// Clone values for the closure (borrow checker)
let title_val = self.title.clone();
let body_val = self.body.clone();
railcar::with_db(|conn| {
conn.execute("INSERT INTO articles ...", params![title_val, body_val])?;
Ok(conn.last_insert_rowid())
})
}
}
Generic queries use Rust generics (like Go):
pub fn find<T: Model>(id: i64) -> Result<T, String> {
with_db(|conn| {
conn.query_row(
&format!("SELECT * FROM {} WHERE id = ?", T::table_name()),
params![id],
|row| T::from_row(row),
)
})
}
Axum async handlers with proper error handling:
pub async fn show_article(Path(id): Path<i64>) -> Response {
match article::find_article(id) {
Ok(article) => Html(helpers::render_page(&views::render_show(&article))).into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
Broadcaster trait for model callbacks:
impl railcar::Broadcaster for Comment {
fn after_save(&self) {
railcar::broadcast_replace_to("comments", self.id, "Comment",
&format!("article_{}_comments", self.article_id), "comments");
if let Ok(article) = self.article() {
railcar::broadcast_replace_to("articles", article.id, "Article", "articles", "");
}
}
}
Design Decisions
Axum, not Actix. Axum is built on Tower and Tokio — the same ecosystem most Rust web services use. Its extractor pattern (Path(id): Path<i64>, Form(form): Form<HashMap<...>>) produces clean handler signatures. The built-in WebSocket support handles Action Cable's subprotocol negotiation.
rusqlite with bundled SQLite. No system dependency needed. features = ["bundled"] compiles SQLite from source, making the generated project self-contained. Cross-compilation works.
Concrete save/delete, not generic. Go uses interface{} for generic column values. Rust's Box<dyn ToSql> equivalent is verbose and fights the borrow checker. Instead, each model generates its own save() and delete() with concrete SQL — the runtime provides find, all, where_eq as generics, but mutation stays concrete.
#[derive(Default)] for unwrap_or_default(). Association calls return Result<Model, String>. In views, we use .unwrap_or_default() to gracefully handle errors. This requires Default on model structs — a one-line derive that the emitter adds automatically.
View functions, not templates. Same reasoning as Go: no template engine to fight. buf.push_str() calls with html::EscapeString for user data. rshtml (compile-time Rust templates with full type checking) is a compelling future option — it would give us template-file separation with Rust's compile-time safety.
--test-threads=1 for tests. All tests share a global Mutex<Option<Connection>> database. Rust runs tests in parallel by default, causing interference. Serial execution is the pragmatic fix; a connection-per-test pattern would be the production solution.
The Numbers
| Crystal | Elixir | Go | Python | Rust | TypeScript | |
|---|---|---|---|---|---|---|
| Tests | 313 | 21 | 21 | 21 | 21 | 21 |
| Model tests | yes | 9 passing | 9 passing | 9 passing | 9 passing | 9 passing |
| Controller tests | yes | 12 passing | 12 passing | 12 passing | 12 passing | 12 passing |
| HTTP server | Crystal HTTP | Plug + Bandit | net/http | aiohttp | Axum + Tokio | Express |
| ORM | Macro-based | Railcar.Record | Model interface | COLUMNS + setattr | Model trait + generics | COLUMNS + index sig |
| Views | ECR templates | EEx templates | Go functions | String functions | Rust functions | EJS templates |
| Turbo Streams | ActionCable | WebSock | nhooyr | aiohttp WS | Axum WS | ws |
| CSS | Tailwind CLI | Tailwind CLI | Tailwind CLI | Tailwind CLI | Tailwind CLI | Tailwind CLI |
| Test framework | Crystal Spec | ExUnit | go test | pytest | cargo test | node:test |
| Compiler warnings | — | 0 | 0 | — | 0 | — |
The Honest Assessment
All 21 tests pass. The app compiles with zero warnings. Broadcasting works. Here's what that doesn't tell you.
Memory management didn't bite. The blog demo exercises borrowing (views take &Article), ownership transfer (controllers create models), and interior mutability (Mutex<Option<Connection>>). But it doesn't exercise the hard cases: self-referential structs, borrowed iterators returned from functions, or lifetime-parameterized traits. A more complex app would hit those.
Async is shallow. Controllers are async fn (required by Axum), but all database calls are synchronous (rusqlite wrapped in Mutex). A production app would use sqlx for native async or spawn_blocking for the rusqlite calls. The generated code works because SQLite serializes writes anyway.
The _method dispatch is a workaround. HTML forms can only POST. Axum routes PATCH/DELETE natively but forms need a POST dispatcher that checks _method. The generated router combines both: .route("/articles/{id}", get(show).patch(update).delete(destroy).post(method_dispatch)). It works but a middleware approach would be cleaner.
One app. Same caveat as every target.
Try It
Browse the generated code — compare the original Ruby source side-by-side with Crystal, Elixir, Go, Python, Rust, and TypeScript output.
To generate and run the Rust blog locally:
git clone https://github.com/rubys/railcar
cd railcar
make
npx github:ruby2js/juntos --demo blog
./build/railcar --rust blog rust-blog
cd rust-blog
cargo test -- --test-threads=1
cargo run
Open multiple browser tabs at http://localhost:3000 to see real-time Turbo Streams updates. Set LOG_LEVEL=debug for verbose WebSocket logging.
All six targets are available. See the README for details.
Source code. Architecture guide. MIT licensed.