intertwingly

It’s just data

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.


Railcar is open source. Ruby2JS is its sibling project.