{"language":"rust","files":[{"path":"Cargo.toml","content":"[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[lib]\npath = \"src/lib.rs\"\n\n[[bin]]\nname = \"app\"\npath = \"src/main.rs\"\n\n[dependencies]\naxum = { version = \"0.8\", features = [\"ws\"] }\ntokio = { version = \"1\", features = [\"rt-multi-thread\", \"macros\", \"net\", \"sync\", \"time\"] }\ntower-http = { version = \"0.6\", features = [\"fs\"] }\nfutures-util = \"0.3\"\nrusqlite = { version = \"0.33\", features = [\"bundled\"] }\nserde = { version = \"1\", features = [\"derive\"] }\nserde_json = \"1\"\nbase64 = \"0.22\"\nregex = \"1\"\nchrono = { version = \"0.4\", features = [\"serde\"] }\n\n[dev-dependencies]\naxum-test = \"18\"\n"},{"path":"src/action_controller_base.rs","content":"// Generated from runtime/ruby/action_controller/base.rb at app emit time.\n// Do not edit by hand — edit the source `.rb` and re-run emit.\n\nuse crate::flash::Flash;\nuse crate::session::Session;\nuse crate::param_value::ParamValue;\nuse crate::errors_ext::raise;\nuse crate::errors_ext::NotImplementedError;\n\nstatic STATUS_CODES: std::sync::LazyLock<std::collections::HashMap<&'static str, i64>> = std::sync::LazyLock::new(|| std::collections::HashMap::from([(\"ok\", 200_i64), (\"created\", 201_i64), (\"accepted\", 202_i64), (\"no_content\", 204_i64), (\"moved_permanently\", 301_i64), (\"found\", 302_i64), (\"see_other\", 303_i64), (\"not_modified\", 304_i64), (\"bad_request\", 400_i64), (\"unauthorized\", 401_i64), (\"forbidden\", 403_i64), (\"not_found\", 404_i64), (\"unprocessable_entity\", 422_i64), (\"unprocessable_content\", 422_i64), (\"internal_server_error\", 500_i64)]));\n\n#[derive(Clone, Default)]\npub struct Base {\n    pub params: std::collections::HashMap<String, ParamValue>,\n    pub session: Session,\n    pub flash: Flash,\n    pub request_method: String,\n    pub request_path: String,\n    pub request_format: String,\n    pub status: i64,\n    pub body: String,\n    pub location: Option<String>,\n    pub content_type: String,\n}\n\nimpl Base {\n    pub fn params(&self) -> std::collections::HashMap<String, ParamValue> {\n        self.params.clone()\n    }\n\n    pub fn set_params(&mut self, value: std::collections::HashMap<String, ParamValue>) {\n        self.params = value\n    }\n\n    pub fn session(&self) -> Session {\n        self.session.clone()\n    }\n\n    pub fn set_session(&mut self, value: Session) {\n        self.session = value\n    }\n\n    pub fn flash(&self) -> Flash {\n        self.flash.clone()\n    }\n\n    pub fn set_flash(&mut self, value: Flash) {\n        self.flash = value\n    }\n\n    pub fn request_method(&self) -> String {\n        self.request_method.clone()\n    }\n\n    pub fn set_request_method(&mut self, value: &str) {\n        self.request_method = (value).to_string()\n    }\n\n    pub fn request_path(&self) -> String {\n        self.request_path.clone()\n    }\n\n    pub fn set_request_path(&mut self, value: &str) {\n        self.request_path = (value).to_string()\n    }\n\n    pub fn request_format(&self) -> String {\n        self.request_format.clone()\n    }\n\n    pub fn set_request_format(&mut self, value: &str) {\n        self.request_format = (value).to_string()\n    }\n\n    pub fn status(&self) -> i64 {\n        self.status.clone()\n    }\n\n    pub fn body(&self) -> String {\n        self.body.clone()\n    }\n\n    pub fn location(&self) -> Option<String> {\n        self.location.clone()\n    }\n\n    pub fn content_type(&self) -> String {\n        self.content_type.clone()\n    }\n\n    pub fn new() -> Self {\n        let mut params: std::collections::HashMap<String, ParamValue> = std::collections::HashMap::new();\n        let mut session: Session = Session::new();\n        let mut flash: Flash = Flash::new();\n        let mut status: i64 = 200_i64;\n        let mut body: String = (\"\").to_string();\n        let mut location: Option<String> = None;\n        let mut request_format: String = \"html\".to_string();\n        let mut content_type: String = (\"text/html; charset=utf-8\").to_string();\n        let request_method: String = String::new();\n        let request_path: String = String::new();\n        Self { params, session, flash, request_method, request_path, request_format, status, body, location, content_type }\n    }\n\n    pub fn process_action(&self, _action_name: &str) {\n        raise(NotImplementedError, \"process_action must be overridden by subclass\");\n    }\n\n    pub fn render(&mut self, body: &str, status: &str, content_type: Option<String>, location: Option<String>) {\n        self.body = (body).to_string();\n        self.status = Self::resolve_status(&(status));\n        if !(content_type.is_none()) { self.content_type = (content_type.clone().unwrap()).to_string() };\n        if !(location.is_none()) { self.location = Some((location.clone().unwrap()).to_string()) };\n    }\n\n    pub fn redirect_to(&mut self, path: &str, notice: Option<String>, alert: Option<String>, status: &str) {\n        self.location = Some((path).to_string());\n        self.status = Self::resolve_status(&(status));\n        if !(notice.is_none()) { self.flash.set(\"notice\", Some(notice.clone().unwrap())) };\n        if !(alert.is_none()) { self.flash.set(\"alert\", Some(alert.clone().unwrap())) };\n    }\n\n    pub fn head(&mut self, status: &str, content_type: Option<String>) {\n        self.status = Self::resolve_status(&(status));\n        self.body = (\"\").to_string();\n        if !(content_type.is_none()) { self.content_type = (content_type.clone().unwrap()).to_string() };\n    }\n\n    pub fn resolve_status(s: &str) -> i64 {\n        STATUS_CODES.get(s).cloned().unwrap_or(200_i64)\n    }\n}\n"},{"path":"src/active_record_adapter.rs","content":"//! Abstract `ActiveRecordAdapter` trait — the rust analog of\n//! crystal's `abstract class ActiveRecordAdapter` and TS's\n//! `interface ActiveRecordAdapter`. Hand-written for Phase 3.\n//!\n//! The 9-method contract `runtime/ruby/active_record/base.rb` calls\n//! against `ActiveRecord.adapter`. Every concrete adapter (production\n//! sqlite, in-memory framework-test, future libsql/D1) implements it.\n//!\n//! Return shapes are `serde_json::Value` because the abstract slot is\n//! polymorphic — concrete adapters produce concrete row types\n//! (`HashMap<String, rusqlite::Value>` for sqlite, an in-memory\n//! `TestRow` for the framework-test adapter), and the only common\n//! surface is the untyped JSON tree. The transpiled `Base` methods\n//! that call into the adapter feed the result through\n//! `instantiate(row)` which subclasses override with concrete-typed\n//! per-column extraction.\n\nuse serde_json::Value;\nuse std::collections::HashMap;\n\n// Row shape: `HashMap<String, Value>` (not `Value`). The transpiled\n// `runtime/ruby/active_record/base.rb` types rows as\n// `Hash[String, untyped]` and feeds them to `Self::instantiate(row)`\n// whose RBS signature is `(Hash[String, untyped]) -> Base`. Returning\n// a bare `Value` would force the transpile to insert an `as_object`-\n// style coercion at every call site — handing the trait the\n// pre-shaped HashMap keeps the body-typer's view aligned with what\n// the runtime delivers.\n//\n// Parameters use owned `String` (not `&str`) so transpiled call sites\n// can pass the result of `Self::table_name() -> String` directly.\n// Rust idiom would prefer `&str`, but emit-side auto-borrow at every\n// call site is a much wider change than the one-time alloc cost here.\npub type Row = HashMap<String, Value>;\n\npub trait ActiveRecordAdapter: Send + Sync {\n    fn all(&self, table_name: String) -> Vec<Row>;\n    fn find(&self, table_name: String, id: i64) -> Option<Row>;\n    fn r#where(&self, table_name: String, conditions: HashMap<String, Value>) -> Vec<Row>;\n    fn count(&self, table_name: String) -> i64;\n    fn exists(&self, table_name: String, id: i64) -> bool;\n    fn insert(&self, table_name: String, attributes: HashMap<String, Value>) -> i64;\n    fn update(&self, table_name: String, id: i64, attributes: HashMap<String, Value>);\n    fn delete(&self, table_name: String, id: i64);\n    fn truncate(&self, table_name: String);\n}\n"},{"path":"src/active_record_base.rs","content":"// Generated from runtime/ruby/active_record/base.rb at app emit time.\n// Do not edit by hand — edit the source `.rb` and re-run emit.\n\nuse crate::active_record_adapter::ActiveRecordAdapter;\nuse crate::adapter_interface::AdapterInterface;\nuse crate::errors_ext::raise;\nuse crate::errors_ext::name;\nuse crate::errors_ext::NotImplementedError;\nuse crate::errors_ext::RecordNotFound;\nuse crate::errors_ext::RecordInvalid;\n\n#[derive(Clone, Default)]\npub struct Base {\n    pub id: i64,\n    pub errors: Vec<String>,\n    pub persisted: bool,\n    pub destroyed: bool,\n}\n\nimpl Base {\n    pub fn id(&self) -> i64 {\n        self.id.clone()\n    }\n\n    pub fn set_id(&mut self, value: i64) {\n        self.id = value\n    }\n\n    pub fn errors(&self) -> Vec<String> {\n        self.errors.clone()\n    }\n\n    pub fn new(_attrs: std::collections::HashMap<String, serde_json::Value>) -> Self {\n        let mut id: i64 = 0_i64;\n        let mut errors: Vec<String> = vec![];\n        let mut persisted: bool = false;\n        let mut destroyed: bool = false;\n        Self { id, errors, persisted, destroyed }\n    }\n\n    pub fn table_name() -> String {\n        raise(NotImplementedError, format!(\"{}.table_name must be overridden\", name()))\n    }\n\n    pub fn schema_columns() -> Vec<String> {\n        raise(NotImplementedError, format!(\"{}.schema_columns must be overridden\", name()))\n    }\n\n    pub fn instantiate(_row: std::collections::HashMap<String, serde_json::Value>) -> Base {\n        raise(NotImplementedError, format!(\"{}.instantiate must be overridden\", name()))\n    }\n\n    pub fn _adapter_find_by_id(id: i64) -> Option<Base> {\n        let Some(row) = ActiveRecord::adapter().find(Self::table_name(), id) else { return None };\n        Some(Self::instantiate(row.clone().clone()))\n    }\n\n    pub fn _adapter_all() -> Vec<Base> {\n        ActiveRecord::adapter().all(Self::table_name()).into_iter().map(|row| { Self::instantiate(row.clone()) }).collect::<Vec<_>>()\n    }\n\n    pub fn _adapter_insert() -> i64 {\n        0_i64\n    }\n\n    pub fn _adapter_update() {\n    }\n\n    pub fn _adapter_delete() {\n    }\n\n    pub fn _adapter_count() -> i64 {\n        ActiveRecord::adapter().count(Self::table_name())\n    }\n\n    pub fn _adapter_exists_by_id(id: i64) -> bool {\n        ActiveRecord::adapter().exists(Self::table_name(), id)\n    }\n\n    pub fn _adapter_truncate() {\n        ActiveRecord::adapter().truncate(Self::table_name())\n    }\n\n    pub fn _adapter_reload() -> Option<Base> {\n        None\n    }\n\n    pub fn attributes() -> std::collections::HashMap<String, serde_json::Value> {\n        std::collections::HashMap::new()\n    }\n\n    pub fn get_index(&self, _name: &str) -> serde_json::Value {\n        raise(NotImplementedError, \"[] must be overridden by subclass\")\n    }\n\n    pub fn set_index(&self, _name: &str, _value: serde_json::Value) {\n        raise(NotImplementedError, \"[]= must be overridden by subclass\")\n    }\n\n    pub fn assign_from_row(_row: std::collections::HashMap<String, serde_json::Value>) {\n    }\n\n    pub fn dom_prefix(&self) -> String {\n        raise(NotImplementedError, \"dom_prefix must be overridden by subclass\")\n    }\n\n    pub fn persisted(&self) -> bool {\n        self.persisted.clone()\n    }\n\n    pub fn new_record(&self) -> bool {\n        !(self.persisted)\n    }\n\n    pub fn destroyed(&self) -> bool {\n        self.destroyed.clone()\n    }\n\n    pub fn mark_persisted_bang(&mut self) {\n        self.persisted = true;\n        self.destroyed = false;\n    }\n\n    pub fn all() -> Vec<Base> {\n        Self::_adapter_all()\n    }\n\n    pub fn find(id: i64) -> Base {\n        let Some(result) = Self::_adapter_find_by_id(id) else { raise(RecordNotFound, format!(\"Couldn't find {} with id={}\", name(), id)) };\n        result.clone()\n    }\n\n    pub fn find_by(conditions: std::collections::HashMap<String, serde_json::Value>) -> Option<Base> {\n        let mut rows = ActiveRecord::adapter().r#where(Self::table_name(), conditions.clone());\n        if (rows.len() as i64) == 0_i64 { return None };\n        Some(Self::instantiate(rows.clone()[(0_i64) as usize].clone()))\n    }\n\n    pub fn r#where(conditions: std::collections::HashMap<String, serde_json::Value>) -> Vec<Base> {\n        ActiveRecord::adapter().r#where(Self::table_name(), conditions.clone()).into_iter().map(|row| { Self::instantiate(row.clone()) }).collect::<Vec<_>>()\n    }\n\n    pub fn count() -> i64 {\n        Self::_adapter_count()\n    }\n\n    pub fn exists(id: i64) -> bool {\n        Self::_adapter_exists_by_id(id)\n    }\n\n    pub fn destroy_all() -> Vec<Base> {\n        let mut records = Self::all();\n        records.clone().iter_mut().for_each(|r| { r.destroy(); });\n        records.clone()\n    }\n\n    pub fn create(attrs: std::collections::HashMap<String, serde_json::Value>) -> Base {\n        let mut instance = Self::new(attrs);\n        instance.save();\n        instance.clone()\n    }\n\n    pub fn create_bang(attrs: std::collections::HashMap<String, serde_json::Value>) -> Base {\n        let mut instance = Self::new(attrs);\n        if !(instance.save()) { raise(RecordInvalid, instance.clone()) };\n        instance.clone()\n    }\n\n    pub fn last() -> Option<Base> {\n        let mut records = Self::all();\n        if !(records.is_empty()) { Some(records.clone()[records.clone().len() - 1_usize].clone()) } else { None }\n    }\n\n    pub fn save(&mut self) -> bool {\n        Self::before_validation();\n        let ok = self.valid();\n        Self::after_validation();\n        if !(ok) { return false };\n        Self::before_save();\n        if self.new_record() { Self::before_create();\n        self.fill_timestamps(true);\n        self.id = Self::_adapter_insert();\n        self.persisted = true;\n        Self::after_create();\n        Self::after_create_commit() } else { Self::before_update();\n        self.fill_timestamps(false);\n        Self::_adapter_update();\n        Self::after_update();\n        Self::after_update_commit() };\n        Self::after_save();\n        Self::after_save_commit();\n        Self::after_commit();\n        true\n    }\n\n    pub fn save_bang(&mut self) -> Base {\n        if !(self.save()) { raise(RecordInvalid, self) };\n        self.clone()\n    }\n\n    pub fn destroy(&mut self) -> Base {\n        if !(self.persisted()) { return self.clone() };\n        Self::before_destroy();\n        Self::_adapter_delete();\n        self.persisted = false;\n        self.destroyed = true;\n        Self::after_destroy();\n        Self::after_destroy_commit();\n        Self::after_commit();\n        self.clone()\n    }\n\n    pub fn reload(&self) -> Base {\n        Self::_adapter_reload();\n        self.clone()\n    }\n\n    pub fn before_validation() {\n    }\n\n    pub fn after_validation() {\n    }\n\n    pub fn before_save() {\n    }\n\n    pub fn after_save() {\n    }\n\n    pub fn before_create() {\n    }\n\n    pub fn after_create() {\n    }\n\n    pub fn before_update() {\n    }\n\n    pub fn after_update() {\n    }\n\n    pub fn before_destroy() {\n    }\n\n    pub fn after_destroy() {\n    }\n\n    pub fn after_commit() {\n    }\n\n    pub fn after_create_commit() {\n    }\n\n    pub fn after_update_commit() {\n    }\n\n    pub fn after_destroy_commit() {\n    }\n\n    pub fn after_save_commit() {\n    }\n\n    pub fn after_touch() {\n    }\n\n    pub fn validate() {\n    }\n\n    pub fn fill_timestamps(&mut self, creating: bool) {\n        let mut cols = Self::schema_columns();\n        let now = chrono::Utc::now().to_rfc3339();\n        if cols.iter().any(|__c| __c == \"updated_at\") { self.set_index(\"updated_at\", serde_json::Value::from(now.clone())) };\n        if creating && cols.iter().any(|__c| __c == \"created_at\") && self.get_index(\"created_at\").is_null() { self.set_index(\"created_at\", serde_json::Value::from(now.clone())) };\n    }\n\n    pub fn valid(&mut self) -> bool {\n        self.errors = vec![];\n        Self::validate();\n        self.errors.is_empty()\n    }\n}\n\npub struct ActiveRecord;\n\nstatic ADAPTER: std::sync::Mutex<Option<AdapterInterface>> = std::sync::Mutex::new(None);\n\nimpl ActiveRecord {\n    pub fn adapter() -> AdapterInterface {\n        ADAPTER.lock().unwrap().clone().unwrap_or_default()\n    }\n\n    pub fn set_adapter(value: AdapterInterface) {\n        *ADAPTER.lock().unwrap() = Some(value)\n    }\n}\n"},{"path":"src/adapter_interface.rs","content":"//! `AdapterInterface` — the concrete type the transpiled\n//! `ActiveRecord.adapter` slot uses. Wraps an `Arc<dyn ActiveRecordAdapter>`\n//! so the module-singleton emit's slot template\n//! (`Mutex<Option<AdapterInterface>>` + `.clone().unwrap_or_default()`)\n//! works without per-target rust2 emit branching.\n//!\n//! Why a wrapper: the `runtime/ruby/active_record/base.rbs` types\n//! `ActiveRecord.adapter` as `AdapterInterface` (the analyzer registers\n//! that class with the 9-method contract — `all/find/where/count/exists?/\n//! insert/update/delete/truncate`). Transpiled call sites\n//! (`ActiveRecord::adapter().find(...)`) need a *single* concrete type\n//! that:\n//!   - Is `Clone` (the slot template does `.clone()` on the mutex guard).\n//!   - Has a `Default` (the template falls back to `Default::default()`\n//!     when the slot is `None`).\n//!   - Forwards every adapter method to whatever concrete impl was\n//!     installed at boot (sqlite, framework-test, libsql, ...).\n//!\n//! A bare `Arc<dyn ActiveRecordAdapter>` lacks `Default`. Wrapping it\n//! lets us provide a panicking-on-call \"not configured\" default\n//! (matches the call-time error you'd get if the boot path forgot to\n//! install an adapter — earlier than e.g. a SQL error).\n//!\n//! Install at boot:\n//!     ActiveRecord::set_adapter(AdapterInterface::new(SqliteAdapter::open(\"./db.sqlite\")));\n\nuse std::collections::HashMap;\nuse std::sync::Arc;\n\nuse serde_json::Value;\n\nuse crate::active_record_adapter::{ActiveRecordAdapter, Row};\n\nstruct NotConfigured;\nimpl ActiveRecordAdapter for NotConfigured {\n    fn all(&self, _t: String) -> Vec<Row> {\n        panic!(\"ActiveRecord.adapter was not set before use\")\n    }\n    fn find(&self, _t: String, _id: i64) -> Option<Row> {\n        panic!(\"ActiveRecord.adapter was not set before use\")\n    }\n    fn r#where(&self, _t: String, _c: HashMap<String, Value>) -> Vec<Row> {\n        panic!(\"ActiveRecord.adapter was not set before use\")\n    }\n    fn count(&self, _t: String) -> i64 {\n        panic!(\"ActiveRecord.adapter was not set before use\")\n    }\n    fn exists(&self, _t: String, _id: i64) -> bool {\n        panic!(\"ActiveRecord.adapter was not set before use\")\n    }\n    fn insert(&self, _t: String, _a: HashMap<String, Value>) -> i64 {\n        panic!(\"ActiveRecord.adapter was not set before use\")\n    }\n    fn update(&self, _t: String, _id: i64, _a: HashMap<String, Value>) {\n        panic!(\"ActiveRecord.adapter was not set before use\")\n    }\n    fn delete(&self, _t: String, _id: i64) {\n        panic!(\"ActiveRecord.adapter was not set before use\")\n    }\n    fn truncate(&self, _t: String) {\n        panic!(\"ActiveRecord.adapter was not set before use\")\n    }\n}\n\n#[derive(Clone)]\npub struct AdapterInterface(Arc<dyn ActiveRecordAdapter + Send + Sync>);\n\nimpl Default for AdapterInterface {\n    fn default() -> Self {\n        Self(Arc::new(NotConfigured))\n    }\n}\n\nimpl AdapterInterface {\n    pub fn new<A>(adapter: A) -> Self\n    where\n        A: ActiveRecordAdapter + Send + Sync + 'static,\n    {\n        Self(Arc::new(adapter))\n    }\n}\n\nimpl ActiveRecordAdapter for AdapterInterface {\n    fn all(&self, t: String) -> Vec<Row> {\n        self.0.all(t)\n    }\n    fn find(&self, t: String, id: i64) -> Option<Row> {\n        self.0.find(t, id)\n    }\n    fn r#where(&self, t: String, c: HashMap<String, Value>) -> Vec<Row> {\n        self.0.r#where(t, c)\n    }\n    fn count(&self, t: String) -> i64 {\n        self.0.count(t)\n    }\n    fn exists(&self, t: String, id: i64) -> bool {\n        self.0.exists(t, id)\n    }\n    fn insert(&self, t: String, a: HashMap<String, Value>) -> i64 {\n        self.0.insert(t, a)\n    }\n    fn update(&self, t: String, id: i64, a: HashMap<String, Value>) {\n        self.0.update(t, id, a)\n    }\n    fn delete(&self, t: String, id: i64) {\n        self.0.delete(t, id)\n    }\n    fn truncate(&self, t: String) {\n        self.0.truncate(t)\n    }\n}\n"},{"path":"src/broadcasts.rs","content":"//! Turbo Streams broadcasts shim.\n//!\n//! The model lowerer's `broadcasts_to` expansion (see\n//! `src/lower/broadcasts.rs`) produces calls like\n//! `Broadcasts.prepend(stream: \"x\", target: \"y\", html: \"...\")`\n//! from inside model callback methods (`after_create`, etc.).\n//! rust2 emits the kwargs as a `HashMap<String, Value>`, so each\n//! shim method here accepts the unified hash shape and pulls\n//! the named fields out.\n//!\n//! State lives in a thread-local log so framework tests can assert\n//! on what got emitted; production installs a broadcaster via\n//! `install_broadcaster` that fans fragments out to the cable\n//! websocket. Mirrors `runtime/crystal/broadcasts.cr` member-for-\n//! member at the shim level.\n//!\n//! For Phase 5 the production path is a no-op stub — Cable wiring\n//! arrives in a later phase.\n\nuse std::cell::RefCell;\nuse std::collections::HashMap;\n\nuse serde_json::Value;\n\nthread_local! {\n    /// In-memory broadcast log: `(action, stream, target, html)`\n    /// tuples in emission order. Tests inspect this after running\n    /// model callbacks; production reads it through `log()` if a\n    /// fan-out plugin needs the trail.\n    static LOG: RefCell<Vec<(String, String, String, String)>> =\n        const { RefCell::new(Vec::new()) };\n}\n\n/// `Broadcasts` namespace — `pub struct Broadcasts;` + impl gives\n/// the same `Broadcasts::method(...)` call shape the lowered model\n/// callbacks emit.\npub struct Broadcasts;\n\nimpl Broadcasts {\n    /// Reset the in-memory log. Framework tests call this between\n    /// assertions; production typically doesn't.\n    pub fn reset_log_bang() {\n        LOG.with(|c| c.borrow_mut().clear());\n    }\n\n    /// Snapshot the log as a fresh Vec.\n    pub fn log() -> Vec<(String, String, String, String)> {\n        LOG.with(|c| c.borrow().clone())\n    }\n\n    pub fn append(attrs: HashMap<String, Value>) {\n        Self::record(\"append\", &attrs);\n    }\n\n    pub fn prepend(attrs: HashMap<String, Value>) {\n        Self::record(\"prepend\", &attrs);\n    }\n\n    pub fn replace(attrs: HashMap<String, Value>) {\n        Self::record(\"replace\", &attrs);\n    }\n\n    pub fn remove(attrs: HashMap<String, Value>) {\n        Self::record(\"remove\", &attrs);\n    }\n\n    fn record(action: &str, attrs: &HashMap<String, Value>) {\n        let stream = attrs.get(\"stream\").and_then(|v| v.as_str()).unwrap_or(\"\").to_string();\n        let target = attrs.get(\"target\").and_then(|v| v.as_str()).unwrap_or(\"\").to_string();\n        let html = attrs.get(\"html\").and_then(|v| v.as_str()).unwrap_or(\"\").to_string();\n        LOG.with(|c| c.borrow_mut().push((action.to_string(), stream, target, html)));\n    }\n}\n"},{"path":"src/cable.rs","content":"//! Action Cable server + Turbo Streams broadcaster.\n//!\n//! Mirrors the TS runtime's `CableServer` + `/cable` handler.\n//! Ported from railcar's proven implementation (pings + the\n//! `actioncable-v1-json` subprotocol are both known-good there).\n//!\n//! Two halves:\n//!   1. Broadcasting — models call `broadcast_prepend_to` /\n//!      `broadcast_replace_to` / `broadcast_remove_to`, which render\n//!      the appropriate `<turbo-stream>` element and push it to\n//!      every subscriber of the given channel. A partial-renderer\n//!      registry (`register_partial`) lets the runtime reach back\n//!      into the generated `views::*` functions without this file\n//!      having to know model-specific types.\n//!   2. WebSocket — `cable_handler` upgrades incoming requests,\n//!      sends a welcome frame, pings every 3s, and on `subscribe`\n//!      commands decodes Turbo's signed-stream-name blob to recover\n//!      the channel name, then registers the socket's outbound mpsc\n//!      sender with the global `CABLE` registry.\n//!\n//! Generated code (Phase B of the cable work) calls these via\n//! `crate::cable::...` from `impl Broadcaster for <Model>` blocks\n//! produced by the emitter's `broadcasts_to` translation.\n//!\n//! The `actioncable-v1-json` subprotocol spec:\n//!   https://github.com/rails/rails/blob/main/actioncable/lib/action_cable/server/worker.rb\n//! Frames used here: `welcome`, `ping`, `confirm_subscription`,\n//! `message`. Rejections + `unsubscribe` commands aren't needed\n//! for the current broadcast paths.\n\nuse std::collections::HashMap;\nuse std::sync::{LazyLock, RwLock};\nuse std::time::{SystemTime, UNIX_EPOCH};\n\nuse axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};\nuse axum::response::IntoResponse;\nuse base64::Engine;\nuse futures_util::{SinkExt, StreamExt};\nuse serde_json::{json, Value};\nuse tokio::sync::mpsc;\n\n// ── Partial-renderer registry ──────────────────────────────────\n//\n// Models register a closure that renders an instance identified by\n// id into its Turbo Stream partial HTML. Kept as a runtime lookup\n// (rather than parameterising Broadcaster on the model type) so\n// broadcasts called on associations — e.g., `comment.article`'s\n// replace broadcast — can find the parent's partial without the\n// child model needing to know the parent's view module.\n\npub type RenderPartialFn = Box<dyn Fn(i64) -> String + Send + Sync>;\n\nstatic PARTIAL_RENDERERS: LazyLock<RwLock<HashMap<String, RenderPartialFn>>> =\n    LazyLock::new(|| RwLock::new(HashMap::new()));\n\n/// Register a partial renderer for `type_name` (the model class\n/// name, e.g. `\"Article\"`). The closure receives a record id and\n/// returns the rendered partial HTML, or empty string on miss.\npub fn register_partial(type_name: &str, f: impl Fn(i64) -> String + Send + Sync + 'static) {\n    PARTIAL_RENDERERS\n        .write()\n        .expect(\"cable partial renderers poisoned\")\n        .insert(type_name.to_string(), Box::new(f));\n}\n\n/// Look up and invoke a registered partial renderer. Returns a\n/// placeholder div when no renderer is registered — tests can\n/// assert on the fallback rather than panicking.\npub fn render_partial(type_name: &str, id: i64) -> String {\n    let table = PARTIAL_RENDERERS\n        .read()\n        .expect(\"cable partial renderers poisoned\");\n    match table.get(type_name) {\n        Some(f) => f(id),\n        None => format!(\"<div>{} #{}</div>\", type_name, id),\n    }\n}\n\n// ── Turbo Streams rendering ────────────────────────────────────\n\n/// Render a single `<turbo-stream>` element. Empty content collapses\n/// to a self-closing template (used by `remove` actions).\npub fn turbo_stream_html(action: &str, target: &str, content: &str) -> String {\n    if content.is_empty() {\n        format!(\n            r#\"<turbo-stream action=\"{}\" target=\"{}\"></turbo-stream>\"#,\n            action, target\n        )\n    } else {\n        format!(\n            r#\"<turbo-stream action=\"{}\" target=\"{}\"><template>{}</template></turbo-stream>\"#,\n            action, target, content,\n        )\n    }\n}\n\n/// Rails convention: `<singular>_<id>`. Naive depluralise — strips\n/// a trailing `s` if present. Matches railcar's `dom_id_for`.\nfn dom_id_for(table_name: &str, id: i64) -> String {\n    let singular = table_name.strip_suffix('s').unwrap_or(table_name);\n    format!(\"{}_{}\", singular, id)\n}\n\n// ── Broadcast helpers ──────────────────────────────────────────\n\n/// Replace the target element with the record's partial. Defaults\n/// `target` to `<singular>_<id>` when caller passes an empty\n/// string (matches Rails' `broadcast_replace_to` with no explicit\n/// target).\npub fn broadcast_replace_to(\n    table_name: &str,\n    id: i64,\n    type_name: &str,\n    channel: &str,\n    target: &str,\n) {\n    let target = if target.is_empty() {\n        dom_id_for(table_name, id)\n    } else {\n        target.to_string()\n    };\n    let html = render_partial(type_name, id);\n    let stream = turbo_stream_html(\"replace\", &target, &html);\n    CABLE.broadcast(channel, &stream);\n}\n\n/// Prepend the record's partial into the target container.\n/// Defaults `target` to the table name (the scaffold's `<ul\n/// id=\"articles\">` convention).\npub fn broadcast_prepend_to(\n    table_name: &str,\n    id: i64,\n    type_name: &str,\n    channel: &str,\n    target: &str,\n) {\n    let target = if target.is_empty() {\n        table_name.to_string()\n    } else {\n        target.to_string()\n    };\n    let html = render_partial(type_name, id);\n    let stream = turbo_stream_html(\"prepend\", &target, &html);\n    CABLE.broadcast(channel, &stream);\n}\n\n/// Append the record's partial into the target container. Same\n/// default-target rule as prepend.\npub fn broadcast_append_to(\n    table_name: &str,\n    id: i64,\n    type_name: &str,\n    channel: &str,\n    target: &str,\n) {\n    let target = if target.is_empty() {\n        table_name.to_string()\n    } else {\n        target.to_string()\n    };\n    let html = render_partial(type_name, id);\n    let stream = turbo_stream_html(\"append\", &target, &html);\n    CABLE.broadcast(channel, &stream);\n}\n\n/// Remove the target element. Target defaults to `<singular>_<id>`\n/// so `broadcast_remove_to(channel)` on a record deletes its own\n/// DOM node.\npub fn broadcast_remove_to(table_name: &str, id: i64, channel: &str, target: &str) {\n    let target = if target.is_empty() {\n        dom_id_for(table_name, id)\n    } else {\n        target.to_string()\n    };\n    let stream = turbo_stream_html(\"remove\", &target, \"\");\n    CABLE.broadcast(channel, &stream);\n}\n\n// ── CableServer ────────────────────────────────────────────────\n\nstruct Subscriber {\n    /// Outbound channel to this socket's send task. Cloned into the\n    /// registry; the socket task owns the receiver half.\n    tx: mpsc::UnboundedSender<String>,\n    /// The raw identifier string the client sent on subscribe. We\n    /// echo it back in every broadcast so Turbo can route the\n    /// message to the right `<turbo-cable-stream-source>` element.\n    identifier: String,\n}\n\npub struct CableServer {\n    channels: RwLock<HashMap<String, Vec<Subscriber>>>,\n}\n\n/// Process-wide registry. One per server; fine as a static because\n/// the server runs one app per process (same assumption as\n/// `server::LAYOUT_FN`).\npub static CABLE: LazyLock<CableServer> = LazyLock::new(|| CableServer {\n    channels: RwLock::new(HashMap::new()),\n});\n\nimpl CableServer {\n    fn subscribe(&self, channel: &str, tx: mpsc::UnboundedSender<String>, identifier: &str) {\n        self.channels\n            .write()\n            .expect(\"cable channels poisoned\")\n            .entry(channel.to_string())\n            .or_default()\n            .push(Subscriber {\n                tx,\n                identifier: identifier.to_string(),\n            });\n    }\n\n    /// Drop any subscribers whose `tx` matches the given pointer.\n    /// Called on socket close. We compare by pointer rather than\n    /// PartialEq because `UnboundedSender` doesn't implement it;\n    /// each subscriber holds a distinct sender, so pointer identity\n    /// is sufficient and avoids threading a subscriber id through\n    /// the WebSocket task.\n    fn unsubscribe(&self, tx_ptr: usize) {\n        let mut channels = self.channels.write().expect(\"cable channels poisoned\");\n        for subs in channels.values_mut() {\n            subs.retain(|s| &s.tx as *const _ as usize != tx_ptr);\n        }\n        channels.retain(|_, subs| !subs.is_empty());\n    }\n\n    /// Push `html` as a Turbo Stream `message` frame to every\n    /// subscriber on `channel`. Dropped senders are ignored — the\n    /// subscriber will be cleaned up on the close-driven\n    /// `unsubscribe` path.\n    pub fn broadcast(&self, channel: &str, html: &str) {\n        let channels = self.channels.read().expect(\"cable channels poisoned\");\n        if let Some(subs) = channels.get(channel) {\n            for sub in subs {\n                let frame = json!({\n                    \"type\": \"message\",\n                    \"identifier\": sub.identifier,\n                    \"message\": html,\n                })\n                .to_string();\n                let _ = sub.tx.send(frame);\n            }\n        }\n    }\n}\n\n// ── WebSocket handler ──────────────────────────────────────────\n\n/// Axum handler for `GET /cable`. Negotiates the\n/// `actioncable-v1-json` subprotocol (Turbo's client requires the\n/// echo) and hands off to the per-socket task.\npub async fn cable_handler(ws: WebSocketUpgrade) -> impl IntoResponse {\n    ws.protocols([\"actioncable-v1-json\"])\n        .on_upgrade(handle_socket)\n}\n\nasync fn handle_socket(socket: WebSocket) {\n    let (mut sender, mut receiver) = socket.split();\n\n    // Welcome frame — the Action Cable client waits for this before\n    // it sends its first `subscribe`.\n    if sender\n        .send(Message::Text(\n            json!({\"type\": \"welcome\"}).to_string().into(),\n        ))\n        .await\n        .is_err()\n    {\n        return;\n    }\n\n    // Single outbound channel merges broadcasts + pings onto the\n    // shared sender half. Cloning the tx into the ping task and\n    // the registry lets each source push independently without\n    // locking the socket writer.\n    let (tx, mut rx) = mpsc::unbounded_channel::<String>();\n    let tx_ptr = &tx as *const _ as usize;\n\n    let ping_tx = tx.clone();\n    let ping_task = tokio::spawn(async move {\n        let mut interval = tokio::time::interval(std::time::Duration::from_secs(3));\n        // First tick fires immediately; skip it so we don't ping\n        // before the welcome + confirm_subscription round-trip.\n        interval.tick().await;\n        loop {\n            interval.tick().await;\n            let ts = SystemTime::now()\n                .duration_since(UNIX_EPOCH)\n                .map(|d| d.as_secs() as i64)\n                .unwrap_or(0);\n            let frame = json!({\"type\": \"ping\", \"message\": ts}).to_string();\n            if ping_tx.send(frame).is_err() {\n                break;\n            }\n        }\n    });\n\n    let send_task = tokio::spawn(async move {\n        while let Some(frame) = rx.recv().await {\n            if sender.send(Message::Text(frame.into())).await.is_err() {\n                break;\n            }\n        }\n    });\n\n    while let Some(Ok(msg)) = receiver.next().await {\n        let Message::Text(text) = msg else { continue };\n        let Ok(payload) = serde_json::from_str::<Value>(&text) else {\n            continue;\n        };\n        if payload.get(\"command\").and_then(Value::as_str) != Some(\"subscribe\") {\n            continue;\n        }\n        let Some(identifier) = payload.get(\"identifier\").and_then(Value::as_str) else {\n            continue;\n        };\n        let Some(channel) = decode_channel(identifier) else {\n            continue;\n        };\n        CABLE.subscribe(&channel, tx.clone(), identifier);\n        let confirm = json!({\n            \"type\": \"confirm_subscription\",\n            \"identifier\": identifier,\n        })\n        .to_string();\n        let _ = tx.send(confirm);\n    }\n\n    ping_task.abort();\n    send_task.abort();\n    CABLE.unsubscribe(tx_ptr);\n}\n\n/// Recover the channel name from Turbo's `signed_stream_name`.\n/// The identifier is a JSON blob like\n/// `{\"channel\":\"Turbo::StreamsChannel\",\"signed_stream_name\":\"<base64>--<digest>\"}`;\n/// the base64 segment holds a JSON-encoded channel name (e.g.\n/// `\"articles\"`). If either decode fails we fall back to the raw\n/// identifier so tests can subscribe by literal channel string.\nfn decode_channel(identifier: &str) -> Option<String> {\n    let id_json = serde_json::from_str::<Value>(identifier).ok()?;\n    let signed = id_json\n        .get(\"signed_stream_name\")\n        .and_then(Value::as_str)?;\n    let base64_part = signed.split(\"--\").next().unwrap_or(\"\");\n    let decoded_bytes = base64::engine::general_purpose::STANDARD\n        .decode(base64_part)\n        .ok()?;\n    let decoded = std::str::from_utf8(&decoded_bytes).ok()?;\n    serde_json::from_str::<String>(decoded).ok()\n}\n\n// ── Broadcaster trait ──────────────────────────────────────────\n\n/// Implemented on models with `broadcasts_to` declarations. The\n/// emitter's `broadcasts_to` translation (Phase B) generates these\n/// implementations; the runtime calls them from the generated\n/// `save()` / `destroy()` methods at the end of a successful\n/// persist.\n///\n/// Kept separate from `Model` so that models without any broadcast\n/// hooks don't need a stub impl — the emitter only emits this for\n/// models that declare `broadcasts_to`, and the save/destroy\n/// codegen conditionally calls into it.\npub trait Broadcaster {\n    fn after_save(&self);\n    fn after_delete(&self);\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn turbo_stream_html_wraps_content_in_template() {\n        let got = turbo_stream_html(\"replace\", \"article_1\", \"<div>hi</div>\");\n        assert_eq!(\n            got,\n            r#\"<turbo-stream action=\"replace\" target=\"article_1\"><template><div>hi</div></template></turbo-stream>\"#\n        );\n    }\n\n    #[test]\n    fn turbo_stream_html_self_closes_when_empty() {\n        let got = turbo_stream_html(\"remove\", \"article_1\", \"\");\n        assert_eq!(\n            got,\n            r#\"<turbo-stream action=\"remove\" target=\"article_1\"></turbo-stream>\"#\n        );\n    }\n\n    #[test]\n    fn dom_id_for_strips_trailing_s() {\n        assert_eq!(dom_id_for(\"articles\", 7), \"article_7\");\n        assert_eq!(dom_id_for(\"comment\", 3), \"comment_3\");\n    }\n\n    #[test]\n    fn render_partial_falls_back_when_unregistered() {\n        // Use a distinct type name so parallel tests don't collide.\n        let got = render_partial(\"UnregisteredNoise\", 99);\n        assert_eq!(got, \"<div>UnregisteredNoise #99</div>\");\n    }\n\n    #[test]\n    fn decode_channel_recovers_plain_base64_name() {\n        // Construct a signed stream name for `\"articles\"`.\n        let inner = serde_json::to_string(\"articles\").unwrap();\n        let b64 =\n            base64::engine::general_purpose::STANDARD.encode(inner.as_bytes());\n        let signed = format!(\"{}--unsigned\", b64);\n        let identifier = serde_json::json!({\n            \"channel\": \"Turbo::StreamsChannel\",\n            \"signed_stream_name\": signed,\n        })\n        .to_string();\n        assert_eq!(decode_channel(&identifier).as_deref(), Some(\"articles\"));\n    }\n\n    #[test]\n    fn decode_channel_returns_none_on_bad_input() {\n        assert!(decode_channel(\"not json\").is_none());\n        assert!(decode_channel(r#\"{\"no\":\"signed\"}\"#).is_none());\n    }\n}\n"},{"path":"src/controllers/application_controller.rs","content":"#[allow(unused_imports)]\nuse crate::action_controller_base::{self, Base};\n#[allow(unused_imports)]\nuse crate::flash::Flash;\n#[allow(unused_imports)]\nuse crate::session::Session;\n#[allow(unused_imports)]\nuse crate::param_value::ParamValue;\n#[allow(unused_imports)]\nuse crate::db::Db;\n#[allow(unused_imports)]\nuse crate::route_helpers::{self, RouteHelpers};\n#[allow(unused_imports)]\nuse crate::view_helpers::{self, ViewHelpers};\n#[allow(unused_imports)]\nuse crate::http::RubyToS;\n#[allow(unused_imports)]\nuse crate::broadcasts::Broadcasts;\n#[allow(unused_imports)]\nuse crate::models::*;\n#[allow(unused_imports)]\nuse crate::views::*;\n#[allow(unused_imports)]\nuse crate::errors_ext::{raise, NotImplementedError, RecordNotFound, RecordInvalid};\n#[derive(Clone, Default)]\npub struct ApplicationController {\n}\n\nimpl ApplicationController {\n}\n\nimpl ApplicationController {\n    pub fn render(&self, content: String) {\n        crate::http::response_set_body(content);\n    }\n    pub fn render_with(&self, content: String, opts: std::collections::HashMap<String, crate::param_value::ParamValue>) {\n        let content_type = opts.get(\"content_type\").and_then(|v| v.as_str()).map(|s| s.to_string());\n        let status = opts.get(\"status\").and_then(|v| v.as_str()).map(|s| s.to_string());\n        let status_code = status.as_deref().map(crate::http::status_name_to_code_pub);\n        crate::http::response_set_body_with(content, content_type, status_code);\n    }\n    pub fn request_format(&self) -> String { crate::http::request_format_get() }\n    pub fn redirect_to(&self, url: String, opts: std::collections::HashMap<String, crate::param_value::ParamValue>) {\n        let status = opts.get(\"status\").and_then(|v| v.as_str()).unwrap_or(\"see_other\");\n        crate::http::response_set_redirect(url, crate::http::status_name_to_code_pub(status));\n    }\n    pub fn head(&self, status: &str, opts: std::collections::HashMap<String, serde_json::Value>) {\n        let content_type = opts.get(\"content_type\").and_then(|v| v.as_str()).map(|s| s.to_string());\n        crate::http::response_set_head(status, content_type);\n    }\n}\n"},{"path":"src/controllers/articles_controller.rs","content":"#[allow(unused_imports)]\nuse crate::action_controller_base::{self, Base};\n#[allow(unused_imports)]\nuse crate::flash::Flash;\n#[allow(unused_imports)]\nuse crate::session::Session;\n#[allow(unused_imports)]\nuse crate::param_value::ParamValue;\n#[allow(unused_imports)]\nuse crate::db::Db;\n#[allow(unused_imports)]\nuse crate::route_helpers::{self, RouteHelpers};\n#[allow(unused_imports)]\nuse crate::view_helpers::{self, ViewHelpers};\n#[allow(unused_imports)]\nuse crate::http::RubyToS;\n#[allow(unused_imports)]\nuse crate::broadcasts::Broadcasts;\n#[allow(unused_imports)]\nuse crate::models::*;\n#[allow(unused_imports)]\nuse crate::views::*;\n#[allow(unused_imports)]\nuse crate::errors_ext::{raise, NotImplementedError, RecordNotFound, RecordInvalid};\n#[derive(Clone, Default)]\npub struct ArticlesController {\n    pub articles: Vec<Article>,\n    pub article: Article,\n    pub flash: Flash,\n    pub params: std::collections::HashMap<String, serde_json::Value>,\n}\n\nimpl ArticlesController {\n    pub fn process_action(&mut self, action_name: &str) {\n        match action_name {\n                \"index\" => { self.index() },\n                \"show\" => { self.show() },\n                \"new\" => { self.new_action() },\n                \"edit\" => { self.edit() },\n                \"create\" => { self.create() },\n                \"update\" => { self.update() },\n                \"destroy\" => { self.destroy() }\n                _ => (),\n            }\n    }\n\n    pub fn index(&mut self) {\n        let stmt = Db::prepare(&(format!(\"{}{}\", \"SELECT id, body, created_at, title, updated_at FROM articles\", \" ORDER BY created_at DESC\")));\n        let mut results = vec![];\n        while Db::step(stmt) {\n            results.push(Article::from_stmt(stmt));\n        };\n        Db::finalize(stmt);\n        let __comments_ids = results.clone().into_iter().map(|a| { a.id() }).collect::<Vec<_>>();\n        let __comments_stmt = Db::prepare(&(format!(\"{}{}\", format!(\"{}{}\", \"SELECT id, article_id, body, commenter, created_at, updated_at FROM comments WHERE article_id IN (\", Db::escape_int_list(__comments_ids)), \")\")));\n        let mut __comments_loaded = vec![];\n        while Db::step(__comments_stmt) {\n            __comments_loaded.push(Comment::from_stmt(__comments_stmt));\n        };\n        Db::finalize(__comments_stmt);\n        results.clone().iter_mut().for_each(|a| {\n            let mut __comments_group = vec![];\n            __comments_loaded.clone().iter_mut().for_each(|r| { if r.article_id() == a.id() { __comments_group.push(r.clone()) }; });\n            a._preload_comments(__comments_group.clone());;\n        });\n        self.articles = results.clone();\n        if self.request_format() == \"json\" { self.render_with(Articles::index_json(self.articles.clone()), std::collections::HashMap::from([(\"content_type\", \"application/json\")]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>()) } else { self.render(Articles::index(self.articles.clone(), self.flash.get(\"notice\"), self.flash.get(\"alert\"))) };\n    }\n\n    pub fn show(&mut self) {\n        self.article = Article::find(self.params.get(\"id\").cloned().unwrap_or(serde_json::Value::from(\"0\")).ruby_to_s().parse::<i64>().unwrap_or(0));\n        if self.request_format() == \"json\" { self.render_with(Articles::show_json(self.article.clone()), std::collections::HashMap::from([(\"content_type\", \"application/json\")]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>()) } else { self.render(Articles::show(self.article.clone(), self.flash.get(\"notice\"), self.flash.get(\"alert\"))) };\n    }\n\n    pub fn new_action(&mut self) {\n        self.article = Article::new(std::collections::HashMap::new());\n        self.render(Articles::new(self.article.clone(), self.flash.get(\"notice\"), self.flash.get(\"alert\")));\n    }\n\n    pub fn edit(&mut self) {\n        self.article = Article::find(self.params.get(\"id\").cloned().unwrap_or(serde_json::Value::from(\"0\")).ruby_to_s().parse::<i64>().unwrap_or(0));\n        self.render(Articles::edit(self.article.clone(), self.flash.get(\"notice\"), self.flash.get(\"alert\")));\n    }\n\n    pub fn create(&mut self) {\n        self.article = Article::from_params(self.article_params());\n        if self.article.save() { if self.request_format() == \"json\" { self.render_with(Articles::show_json(self.article.clone()), std::collections::HashMap::from([(\"status\", \"created\".to_string()), (\"location\", RouteHelpers::article_path(self.article.id())), (\"content_type\", (\"application/json\").to_string())]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>()) } else { self.redirect_to(RouteHelpers::article_path(self.article.id()), std::collections::HashMap::from([(\"notice\", \"Article was successfully created.\")]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>()) } } else { self.render_with(Articles::new(self.article.clone(), self.flash.get(\"notice\"), self.flash.get(\"alert\")), std::collections::HashMap::from([(\"status\", \"unprocessable_content\")]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>()) };\n    }\n\n    pub fn update(&mut self) {\n        self.article = Article::find(self.params.get(\"id\").cloned().unwrap_or(serde_json::Value::from(\"0\")).ruby_to_s().parse::<i64>().unwrap_or(0));\n        if self.article.update(self.article_params()) { if self.request_format() == \"json\" { self.render_with(Articles::show_json(self.article.clone()), std::collections::HashMap::from([(\"status\", \"ok\".to_string()), (\"location\", RouteHelpers::article_path(self.article.id())), (\"content_type\", (\"application/json\").to_string())]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>()) } else { self.redirect_to(RouteHelpers::article_path(self.article.id()), std::collections::HashMap::from([(\"notice\", \"Article was successfully updated.\"), (\"status\", \"see_other\")]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>()) } } else { self.render_with(Articles::edit(self.article.clone(), self.flash.get(\"notice\"), self.flash.get(\"alert\")), std::collections::HashMap::from([(\"status\", \"unprocessable_content\")]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>()) };\n    }\n\n    pub fn destroy(&mut self) {\n        self.article = Article::find(self.params.get(\"id\").cloned().unwrap_or(serde_json::Value::from(\"0\")).ruby_to_s().parse::<i64>().unwrap_or(0));\n        self.article.destroy();\n        if self.request_format() == \"json\" { self.head(\"no_content\", std::collections::HashMap::from([(\"content_type\", \"application/json\")]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>()) } else { self.redirect_to(RouteHelpers::articles_path(), std::collections::HashMap::from([(\"notice\", \"Article was successfully destroyed.\"), (\"status\", \"see_other\")]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>()) };\n    }\n\n    pub fn article_params(&self) -> ArticleParams {\n        ArticleParams::from_raw(self.params.clone())\n    }\n}\n\nimpl ArticlesController {\n    pub fn render(&self, content: String) {\n        crate::http::response_set_body(content);\n    }\n    pub fn render_with(&self, content: String, opts: std::collections::HashMap<String, crate::param_value::ParamValue>) {\n        let content_type = opts.get(\"content_type\").and_then(|v| v.as_str()).map(|s| s.to_string());\n        let status = opts.get(\"status\").and_then(|v| v.as_str()).map(|s| s.to_string());\n        let status_code = status.as_deref().map(crate::http::status_name_to_code_pub);\n        crate::http::response_set_body_with(content, content_type, status_code);\n    }\n    pub fn request_format(&self) -> String { crate::http::request_format_get() }\n    pub fn redirect_to(&self, url: String, opts: std::collections::HashMap<String, crate::param_value::ParamValue>) {\n        let status = opts.get(\"status\").and_then(|v| v.as_str()).unwrap_or(\"see_other\");\n        crate::http::response_set_redirect(url, crate::http::status_name_to_code_pub(status));\n    }\n    pub fn head(&self, status: &str, opts: std::collections::HashMap<String, serde_json::Value>) {\n        let content_type = opts.get(\"content_type\").and_then(|v| v.as_str()).map(|s| s.to_string());\n        crate::http::response_set_head(status, content_type);\n    }\n}\n\n// ── rust2 wedge 2c.2: axum handler wrappers ──\n// Per-action free fns axum's Router can dispatch into. Build the\n// controller via Default, call the action, and translate the\n// thread-local response state into an `axum::response::Response`.\npub async fn _axum_index(axum::extract::Extension(_fmt): axum::extract::Extension<crate::http::RequestFormatExt>) -> axum::response::Response {\n    crate::http::response_clear();\n    crate::http::request_format_set(_fmt.0);\n    let mut c = ArticlesController::default();\n    c.index();\n    crate::http::response_into_axum(crate::http::response_take())\n}\npub async fn _axum_new(axum::extract::Extension(_fmt): axum::extract::Extension<crate::http::RequestFormatExt>) -> axum::response::Response {\n    crate::http::response_clear();\n    crate::http::request_format_set(_fmt.0);\n    let mut c = ArticlesController::default();\n    c.new_action();\n    crate::http::response_into_axum(crate::http::response_take())\n}\npub async fn _axum_create(axum::extract::Extension(_fmt): axum::extract::Extension<crate::http::RequestFormatExt>, axum::extract::Form(form): axum::extract::Form<std::collections::HashMap<String, String>>) -> axum::response::Response {\n    crate::http::response_clear();\n    crate::http::request_format_set(_fmt.0);\n    let mut params = crate::http::params_from_form(form);\n    let mut c = ArticlesController { params, ..Default::default() };\n    c.create();\n    crate::http::response_into_axum(crate::http::response_take())\n}\npub async fn _axum_show(axum::extract::Extension(_fmt): axum::extract::Extension<crate::http::RequestFormatExt>, axum::extract::Path(id_raw): axum::extract::Path<String>) -> axum::response::Response {\n    crate::http::response_clear();\n    crate::http::request_format_set(_fmt.0);\n    let mut params: std::collections::HashMap<String, serde_json::Value> = std::collections::HashMap::new();\n    let id: i64 = id_raw.strip_suffix(\".json\").unwrap_or(&id_raw).parse().unwrap_or(0);\n    params.insert(\"id\".to_string(), serde_json::Value::from(id));\n    let mut c = ArticlesController { params, ..Default::default() };\n    c.show();\n    crate::http::response_into_axum(crate::http::response_take())\n}\npub async fn _axum_edit(axum::extract::Extension(_fmt): axum::extract::Extension<crate::http::RequestFormatExt>, axum::extract::Path(id_raw): axum::extract::Path<String>) -> axum::response::Response {\n    crate::http::response_clear();\n    crate::http::request_format_set(_fmt.0);\n    let mut params: std::collections::HashMap<String, serde_json::Value> = std::collections::HashMap::new();\n    let id: i64 = id_raw.strip_suffix(\".json\").unwrap_or(&id_raw).parse().unwrap_or(0);\n    params.insert(\"id\".to_string(), serde_json::Value::from(id));\n    let mut c = ArticlesController { params, ..Default::default() };\n    c.edit();\n    crate::http::response_into_axum(crate::http::response_take())\n}\npub async fn _axum_update(axum::extract::Extension(_fmt): axum::extract::Extension<crate::http::RequestFormatExt>, axum::extract::Path(id_raw): axum::extract::Path<String>, axum::extract::Form(form): axum::extract::Form<std::collections::HashMap<String, String>>) -> axum::response::Response {\n    crate::http::response_clear();\n    crate::http::request_format_set(_fmt.0);\n    let mut params = crate::http::params_from_form(form);\n    let id: i64 = id_raw.strip_suffix(\".json\").unwrap_or(&id_raw).parse().unwrap_or(0);\n    params.insert(\"id\".to_string(), serde_json::Value::from(id));\n    let mut c = ArticlesController { params, ..Default::default() };\n    c.update();\n    crate::http::response_into_axum(crate::http::response_take())\n}\npub async fn _axum_destroy(axum::extract::Extension(_fmt): axum::extract::Extension<crate::http::RequestFormatExt>, axum::extract::Path(id_raw): axum::extract::Path<String>) -> axum::response::Response {\n    crate::http::response_clear();\n    crate::http::request_format_set(_fmt.0);\n    let mut params: std::collections::HashMap<String, serde_json::Value> = std::collections::HashMap::new();\n    let id: i64 = id_raw.strip_suffix(\".json\").unwrap_or(&id_raw).parse().unwrap_or(0);\n    params.insert(\"id\".to_string(), serde_json::Value::from(id));\n    let mut c = ArticlesController { params, ..Default::default() };\n    c.destroy();\n    crate::http::response_into_axum(crate::http::response_take())\n}\n"},{"path":"src/controllers/comments_controller.rs","content":"#[allow(unused_imports)]\nuse crate::action_controller_base::{self, Base};\n#[allow(unused_imports)]\nuse crate::flash::Flash;\n#[allow(unused_imports)]\nuse crate::session::Session;\n#[allow(unused_imports)]\nuse crate::param_value::ParamValue;\n#[allow(unused_imports)]\nuse crate::db::Db;\n#[allow(unused_imports)]\nuse crate::route_helpers::{self, RouteHelpers};\n#[allow(unused_imports)]\nuse crate::view_helpers::{self, ViewHelpers};\n#[allow(unused_imports)]\nuse crate::http::RubyToS;\n#[allow(unused_imports)]\nuse crate::broadcasts::Broadcasts;\n#[allow(unused_imports)]\nuse crate::models::*;\n#[allow(unused_imports)]\nuse crate::views::*;\n#[allow(unused_imports)]\nuse crate::errors_ext::{raise, NotImplementedError, RecordNotFound, RecordInvalid};\n#[derive(Clone, Default)]\npub struct CommentsController {\n    pub article: Article,\n    pub comment: Comment,\n    pub params: std::collections::HashMap<String, serde_json::Value>,\n}\n\nimpl CommentsController {\n    pub fn process_action(&mut self, action_name: &str) {\n        match action_name {\n                \"create\" => { self.create() },\n                \"destroy\" => { self.destroy() }\n                _ => (),\n            }\n    }\n\n    pub fn create(&mut self) {\n        self.article = Article::find(self.params.get(\"article_id\").cloned().unwrap_or(serde_json::Value::from(\"0\")).ruby_to_s().parse::<i64>().unwrap_or(0));\n        self.comment = Comment::from_params(self.comment_params());\n        self.comment.set_article_id(self.article.id());\n        if self.comment.save() { self.redirect_to(RouteHelpers::article_path(self.article.id()), std::collections::HashMap::from([(\"notice\", \"Comment was successfully created.\")]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>()) } else { self.redirect_to(RouteHelpers::article_path(self.article.id()), std::collections::HashMap::from([(\"alert\", \"Could not create comment.\")]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>()) };\n    }\n\n    pub fn destroy(&mut self) {\n        self.article = Article::find(self.params.get(\"article_id\").cloned().unwrap_or(serde_json::Value::from(\"0\")).ruby_to_s().parse::<i64>().unwrap_or(0));\n        self.comment = Comment::find(self.params.get(\"id\").cloned().unwrap_or(serde_json::Value::from(\"0\")).ruby_to_s().parse::<i64>().unwrap_or(0));\n        if self.comment.article_id() != self.article.id() { self.head(\"not_found\", std::collections::HashMap::new());\n        return; } else {  };\n        self.comment.destroy();\n        self.redirect_to(RouteHelpers::article_path(self.article.id()), std::collections::HashMap::from([(\"notice\", \"Comment was successfully deleted.\")]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>());\n    }\n\n    pub fn comment_params(&self) -> CommentParams {\n        CommentParams::from_raw(self.params.clone())\n    }\n}\n\nimpl CommentsController {\n    pub fn render(&self, content: String) {\n        crate::http::response_set_body(content);\n    }\n    pub fn render_with(&self, content: String, opts: std::collections::HashMap<String, crate::param_value::ParamValue>) {\n        let content_type = opts.get(\"content_type\").and_then(|v| v.as_str()).map(|s| s.to_string());\n        let status = opts.get(\"status\").and_then(|v| v.as_str()).map(|s| s.to_string());\n        let status_code = status.as_deref().map(crate::http::status_name_to_code_pub);\n        crate::http::response_set_body_with(content, content_type, status_code);\n    }\n    pub fn request_format(&self) -> String { crate::http::request_format_get() }\n    pub fn redirect_to(&self, url: String, opts: std::collections::HashMap<String, crate::param_value::ParamValue>) {\n        let status = opts.get(\"status\").and_then(|v| v.as_str()).unwrap_or(\"see_other\");\n        crate::http::response_set_redirect(url, crate::http::status_name_to_code_pub(status));\n    }\n    pub fn head(&self, status: &str, opts: std::collections::HashMap<String, serde_json::Value>) {\n        let content_type = opts.get(\"content_type\").and_then(|v| v.as_str()).map(|s| s.to_string());\n        crate::http::response_set_head(status, content_type);\n    }\n}\n\n// ── rust2 wedge 2c.2: axum handler wrappers ──\n// Per-action free fns axum's Router can dispatch into. Build the\n// controller via Default, call the action, and translate the\n// thread-local response state into an `axum::response::Response`.\npub async fn _axum_create(axum::extract::Extension(_fmt): axum::extract::Extension<crate::http::RequestFormatExt>, axum::extract::Path(article_id_raw): axum::extract::Path<String>, axum::extract::Form(form): axum::extract::Form<std::collections::HashMap<String, String>>) -> axum::response::Response {\n    crate::http::response_clear();\n    crate::http::request_format_set(_fmt.0);\n    let mut params = crate::http::params_from_form(form);\n    let article_id: i64 = article_id_raw.strip_suffix(\".json\").unwrap_or(&article_id_raw).parse().unwrap_or(0);\n    params.insert(\"article_id\".to_string(), serde_json::Value::from(article_id));\n    let mut c = CommentsController { params, ..Default::default() };\n    c.create();\n    crate::http::response_into_axum(crate::http::response_take())\n}\npub async fn _axum_destroy(axum::extract::Extension(_fmt): axum::extract::Extension<crate::http::RequestFormatExt>, axum::extract::Path((article_id_raw, id_raw)): axum::extract::Path<(String, String)>) -> axum::response::Response {\n    crate::http::response_clear();\n    crate::http::request_format_set(_fmt.0);\n    let mut params: std::collections::HashMap<String, serde_json::Value> = std::collections::HashMap::new();\n    let article_id: i64 = article_id_raw.strip_suffix(\".json\").unwrap_or(&article_id_raw).parse().unwrap_or(0);\n    params.insert(\"article_id\".to_string(), serde_json::Value::from(article_id));\n    let id: i64 = id_raw.strip_suffix(\".json\").unwrap_or(&id_raw).parse().unwrap_or(0);\n    params.insert(\"id\".to_string(), serde_json::Value::from(id));\n    let mut c = CommentsController { params, ..Default::default() };\n    c.destroy();\n    crate::http::response_into_axum(crate::http::response_take())\n}\n"},{"path":"src/controllers/mod.rs","content":"// Generated by Roundhouse (rust2).\n\npub mod application_controller;\npub mod articles_controller;\npub mod comments_controller;\npub use application_controller::ApplicationController;\npub use articles_controller::ArticlesController;\npub use comments_controller::CommentsController;\n"},{"path":"src/db.rs","content":"//! Roundhouse Rust DB runtime.\n//!\n//! Hand-written helpers the Rust emitter copies verbatim into each\n//! generated project as `src/db.rs`. Owns the per-test SQLite\n//! connection and hides rusqlite borrowing from the generated code\n//! — save/destroy/count/find all go through `with_conn`.\n//!\n//! Two entry points:\n//!   - `setup_test_db(schema)` — thread-local `:memory:` connection\n//!     for tests. Each test re-installs a fresh DB so prior-test\n//!     state doesn't bleed across.\n//!   - `open_production_db(path, schema)` — file-backed connections\n//!     installed into a process-wide pool (`OnceLock<Vec<Mutex<\n//!     Connection>>>`). Used by `main.rs` on server startup.\n//!     Pool size = `DATABASE_POOL_SIZE` env var, defaulting to\n//!     `std::thread::available_parallelism()`. SQLite is opened in\n//!     WAL mode so N readers actually proceed in parallel.\n//!     `with_conn` reaches either slot — test thread-local first,\n//!     then the production pool (try_lock each entry; fall back to\n//!     blocking-lock on slot 0).\n\nuse std::cell::{Cell, RefCell};\nuse std::collections::HashMap;\nuse std::fs;\nuse std::path::Path;\nuse std::sync::{Mutex, OnceLock};\n\nuse rusqlite::types::Value;\nuse rusqlite::Connection;\n\nthread_local! {\n    /// The connection the current thread's test (or request handler)\n    /// uses. `None` until `setup_test_db` initializes it.\n    static CONN: RefCell<Option<Connection>> = const { RefCell::new(None) };\n}\n\n/// Process-wide sqlite connection pool for the production server.\n/// `axum` handlers run on a multi-thread tokio runtime so per-thread\n/// thread-locals don't work; each slot in this Vec is an independent\n/// `Connection` guarded by its own `Mutex`, and `with_conn` picks\n/// whichever slot it can `try_lock` first. SQLite is opened in WAL\n/// mode (see `open_production_db`), which is what makes N readers\n/// actually proceed in parallel. Pool size defaults to\n/// `std::thread::available_parallelism()`; override with\n/// `DATABASE_POOL_SIZE`.\nstatic PROD_POOL: OnceLock<Vec<Mutex<Connection>>> = OnceLock::new();\n\n/// rusqlite per-connection prepared-statement cache capacity\n/// (roundhouse#12). Each `Connection` keeps an LRU of compiled statements\n/// keyed by SQL; `prepare_cached` reuses them, skipping the re-parse the\n/// blog's fixed query set otherwise paid on every request. Inlined\n/// literals make id-bearing queries key per-id, so the LRU also bounds\n/// memory — evicted statements are finalized by rusqlite, no leak.\n/// Placeholder binding (the planned follow-on) makes the key the static\n/// query shape. Default rusqlite capacity is 16; raise it to comfortably\n/// hold the blog's working set.\nconst STMT_CACHE_CAP: usize = 128;\n\n/// Initialize a fresh `:memory:` SQLite database on the current\n/// thread, run the supplied schema DDL, and install the connection\n/// so later `with_conn` calls can reach it. Replaces any connection\n/// left over from a previous test that ran on the same thread.\n///\n/// `schema_sql` is the generated `crate::schema_sql::CREATE_TABLES`\n/// string — passed explicitly so this file can stay target-agnostic\n/// and compile standalone in the Roundhouse repo's runtime tree.\npub fn setup_test_db(schema_sql: &str) {\n    let conn = Connection::open_in_memory().expect(\"open :memory: sqlite\");\n    conn.set_prepared_statement_cache_capacity(STMT_CACHE_CAP);\n    conn.execute_batch(schema_sql).expect(\"run schema SQL\");\n    CONN.with(|c| *c.borrow_mut() = Some(conn));\n}\n\n/// Borrow the current thread's test connection, falling back to\n/// the process-wide production connection. Panics if neither is\n/// installed — callers are generated code that runs inside either\n/// a test (whose setup already called `setup_test_db`) or a live\n/// request (whose `main.rs` already called `open_production_db`).\npub fn with_conn<R, F: FnOnce(&Connection) -> R>(f: F) -> R {\n    // Check the test-connection slot first — lets a production-\n    // configured binary still run unit tests against a per-thread\n    // in-memory DB. `CONN.with` runs its closure synchronously so\n    // we can carry the closure-ownership handoff explicitly: if\n    // the test slot has a connection, run `f` there and return\n    // Ok(result); otherwise return Err(f) and run `f` against the\n    // production mutex below.\n    let result: Result<R, F> = CONN.with(|c| {\n        let borrowed = c.borrow();\n        match borrowed.as_ref() {\n            Some(conn) => Ok(f(conn)),\n            None => Err(f),\n        }\n    });\n    match result {\n        Ok(out) => out,\n        Err(f) => {\n            let pool = PROD_POOL.get().expect(\n                \"db not initialized; call setup_test_db or open_production_db first\",\n            );\n            for slot in pool {\n                if let Ok(guard) = slot.try_lock() {\n                    return f(&guard);\n                }\n            }\n            let guard = pool[0].lock().expect(\"prod DB mutex poisoned\");\n            f(&guard)\n        }\n    }\n}\n\n/// Open a file-backed sqlite database for the production server,\n/// apply the schema DDL idempotently, and install it as the\n/// process-wide connection. Creates intermediate directories if\n/// needed — `better-sqlite3` creates the file but not its parent\n/// dir, and rusqlite mirrors that behavior; the TS runtime hit\n/// the same gotcha during smoke test, so we preempt it here.\n///\n/// `schema_sql` is the generated `schema_sql::CREATE_TABLES`\n/// string. The emitter produces `CREATE TABLE IF NOT EXISTS`\n/// directly, so re-opening an existing DB no-ops over the\n/// already-present tables.\npub fn open_production_db(path: &str, schema_sql: &str) {\n    let pool_size = std::env::var(\"DATABASE_POOL_SIZE\")\n        .ok()\n        .and_then(|s| s.parse::<usize>().ok())\n        .filter(|n| *n > 0)\n        .unwrap_or_else(|| {\n            std::thread::available_parallelism()\n                .map(|n| n.get())\n                .unwrap_or(4)\n        });\n    open_production_pool(path, schema_sql, pool_size);\n}\n\n/// Pool builder shared by `open_production_db` and the unit tests.\n/// Kept separate so tests can pick an explicit pool size without\n/// reaching for `std::env::set_var` (which is `unsafe` under\n/// Rust edition 2024).\npub fn open_production_pool(path: &str, schema_sql: &str, pool_size: usize) {\n    if path != \":memory:\" {\n        if let Some(parent) = Path::new(path).parent() {\n            if !parent.as_os_str().is_empty() && !parent.exists() {\n                fs::create_dir_all(parent).expect(\"mkdir db parent\");\n            }\n        }\n    }\n    let mut conns: Vec<Mutex<Connection>> = Vec::with_capacity(pool_size);\n    for _ in 0..pool_size {\n        let conn = Connection::open(path).expect(\"open sqlite db\");\n        conn.set_prepared_statement_cache_capacity(STMT_CACHE_CAP);\n        conn.pragma_update(None, \"journal_mode\", \"WAL\")\n            .expect(\"enable WAL\");\n        conn.pragma_update(None, \"foreign_keys\", \"ON\")\n            .expect(\"enable foreign keys\");\n        // CREATE TABLE IF NOT EXISTS is idempotent on file-backed DBs;\n        // applying per-conn is what lets a `:memory:` pool work, since\n        // each `:memory:` is an independent database.\n        conn.execute_batch(schema_sql).expect(\"apply schema\");\n        conns.push(Mutex::new(conn));\n    }\n    PROD_POOL\n        .set(conns)\n        .map_err(|_| \"PROD_POOL already initialized\")\n        .expect(\"set PROD_POOL\");\n}\n\n// ── Low-level prepare/step/column API ───────────────────────────────\n//\n// Per-statement state lives in a thread-local `STATEMENTS` table; the\n// opaque `i64` stmt id indexes into it. Rows materialize on `prepare`\n// — rusqlite's `Statement`/`Rows` borrow chain from `Connection` is\n// awkward to thread through a `RefCell<HashMap<i64, _>>`, so we eat\n// the up-front allocation in exchange for a self-contained per-stmt\n// entry. Matches `runtime/crystal/db.cr`'s `Roundhouse::Db` API\n// shape so the lowered model bodies (`Db.prepare(sql)`, `Db.step?\n// (stmt)`, etc., from `src/lower/model_to_library/adapter_emit.rs`)\n// emit the same calls under both targets.\n\n/// A materialized prepared-statement entry: all rows fetched up front\n/// + a cursor position + the most recently-stepped row snapshot.\nstruct StmtEntry {\n    rows: Vec<Vec<Value>>,\n    pos: usize,\n    current: Option<Vec<Value>>,\n}\n\nthread_local! {\n    static STATEMENTS: RefCell<HashMap<i64, StmtEntry>> =\n        RefCell::new(HashMap::new());\n    static NEXT_STMT_ID: Cell<i64> = const { Cell::new(0) };\n    static LAST_INSERT_ROWID: Cell<i64> = const { Cell::new(0) };\n}\n\n/// `Db` namespace — the lowerer (`src/lower/model_to_library/\n/// adapter_emit.rs` + `src/lower/arel/visitor.rs`) emits\n/// `Db.prepare(sql)` / `Db.step?(stmt)` / `Db.column_int(stmt, i)`\n/// against the synthesized per-model adapter methods. Mirrors the\n/// Crystal target's `Roundhouse::Db` module member-for-member.\npub struct Db;\n\nimpl Db {\n    /// Run a one-shot DDL/INSERT/UPDATE/DELETE. Captures\n    /// `last_insert_rowid` so the subsequent accessor returns the\n    /// freshly-inserted id (the typical `Db.exec(insert_sql);\n    /// id = Db.last_insert_rowid` shape in lowered persistence).\n    pub fn exec(sql: &str) {\n        with_conn(|conn| {\n            conn.execute_batch(sql).expect(\"Db::exec\");\n            LAST_INSERT_ROWID.with(|c| c.set(conn.last_insert_rowid()));\n        });\n    }\n\n    /// Prepare a SELECT, materialize every row, return the opaque\n    /// stmt id. Subsequent `step` / `column_*` / `finalize` calls take\n    /// the id by value. `prepare_cached` reuses the connection's compiled\n    /// statement (roundhouse#12) — the parse is skipped on a cache hit;\n    /// rusqlite resets the cached statement on checkout and returns it to\n    /// the LRU when the `CachedStatement` drops at the end of this closure.\n    pub fn prepare(sql: &str) -> i64 {\n        let rows: Vec<Vec<Value>> = with_conn(|conn| {\n            let mut stmt = conn.prepare_cached(sql).expect(\"Db::prepare\");\n            let n_cols = stmt.column_count();\n            let mut out: Vec<Vec<Value>> = Vec::new();\n            let mut rows = stmt.query([]).expect(\"Db::prepare query\");\n            while let Some(row) = rows.next().expect(\"Db::prepare step\") {\n                let mut col_vec = Vec::with_capacity(n_cols);\n                for i in 0..n_cols {\n                    let v: Value = row.get(i).expect(\"Db::prepare col\");\n                    col_vec.push(v);\n                }\n                out.push(col_vec);\n            }\n            out\n        });\n        let id = NEXT_STMT_ID.with(|c| {\n            let n = c.get() + 1;\n            c.set(n);\n            n\n        });\n        STATEMENTS.with(|s| {\n            s.borrow_mut().insert(\n                id,\n                StmtEntry { rows, pos: 0, current: None },\n            );\n        });\n        id\n    }\n\n    /// Advance the cursor. Snapshots the current row into the entry\n    /// and returns true; clears the snapshot + returns false when\n    /// exhausted. Idempotent on unknown stmt ids (returns false).\n    pub fn step(stmt_id: i64) -> bool {\n        STATEMENTS.with(|s| {\n            let mut map = s.borrow_mut();\n            let Some(entry) = map.get_mut(&stmt_id) else { return false };\n            if entry.pos < entry.rows.len() {\n                entry.current = Some(entry.rows[entry.pos].clone());\n                entry.pos += 1;\n                true\n            } else {\n                entry.current = None;\n                false\n            }\n        })\n    }\n\n    /// Read an integer column from the row most recently stepped.\n    /// NULL coerces to 0 (matches Crystal/TS shims); non-Int variants\n    /// best-effort coerce.\n    pub fn column_int(stmt_id: i64, i: i64) -> i64 {\n        STATEMENTS.with(|s| {\n            let map = s.borrow();\n            let Some(entry) = map.get(&stmt_id) else { return 0 };\n            let Some(row) = entry.current.as_ref() else { return 0 };\n            match row.get(i as usize) {\n                Some(Value::Integer(v)) => *v,\n                Some(Value::Real(v)) => *v as i64,\n                Some(Value::Text(t)) => t.parse().unwrap_or(0),\n                _ => 0,\n            }\n        })\n    }\n\n    /// Read a text column. NULL → \"\" (matches Crystal/TS); numeric\n    /// variants stringify.\n    pub fn column_text(stmt_id: i64, i: i64) -> String {\n        STATEMENTS.with(|s| {\n            let map = s.borrow();\n            let Some(entry) = map.get(&stmt_id) else { return String::new() };\n            let Some(row) = entry.current.as_ref() else { return String::new() };\n            match row.get(i as usize) {\n                Some(Value::Text(t)) => t.clone(),\n                Some(Value::Integer(v)) => v.to_string(),\n                Some(Value::Real(v)) => v.to_string(),\n                Some(Value::Blob(b)) => String::from_utf8_lossy(b).into_owned(),\n                _ => String::new(),\n            }\n        })\n    }\n\n    /// Read a boolean column. SQLite stores booleans as 0/1 integers\n    /// — widen to bool. Nulls coerce to false.\n    pub fn column_bool(stmt_id: i64, i: i64) -> bool {\n        Self::column_int(stmt_id, i) != 0\n    }\n\n    /// Drop the stmt-table entry. Idempotent on unknown ids.\n    pub fn finalize(stmt_id: i64) {\n        STATEMENTS.with(|s| {\n            s.borrow_mut().remove(&stmt_id);\n        });\n    }\n\n    /// Last-row-id from the most recent `exec`. SQLite-specific.\n    pub fn last_insert_rowid() -> i64 {\n        LAST_INSERT_ROWID.with(|c| c.get())\n    }\n\n    /// SQL-quote a string literal. Single quotes doubled per SQLite's\n    /// escape rule; no other byte transforms.\n    pub fn escape_string(s: &str) -> String {\n        let mut out = String::with_capacity(s.len() + 2);\n        out.push('\\'');\n        for ch in s.chars() {\n            if ch == '\\'' {\n                out.push_str(\"''\");\n            } else {\n                out.push(ch);\n            }\n        }\n        out.push('\\'');\n        out\n    }\n\n    /// Render an integer for SQL inlining.\n    pub fn escape_int(n: i64) -> String {\n        n.to_string()\n    }\n\n    /// Render an integer list for `IN (...)` eager-load batches (issue\n    /// #27). An empty list yields \"NULL\" so `IN (NULL)` is valid SQL\n    /// matching no rows — an empty `IN ()` is a syntax error.\n    pub fn escape_int_list(ids: Vec<i64>) -> String {\n        if ids.is_empty() {\n            return \"NULL\".to_string();\n        }\n        ids.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(\", \")\n    }\n\n    /// SQLite stores booleans as 0/1 integers.\n    pub fn escape_bool(b: bool) -> String {\n        (if b { \"1\" } else { \"0\" }).to_string()\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    const TINY_SCHEMA: &str = r#\"\nCREATE TABLE widgets (\n  id INTEGER PRIMARY KEY AUTOINCREMENT,\n  name TEXT\n);\n\"#;\n\n    #[test]\n    fn setup_installs_connection_with_schema() {\n        setup_test_db(TINY_SCHEMA);\n        let row_count = with_conn(|c| {\n            c.execute(\"INSERT INTO widgets (name) VALUES ('a'), ('b')\", [])\n                .expect(\"insert\")\n        });\n        assert_eq!(row_count, 2);\n        let count: i64 = with_conn(|c| {\n            c.query_row(\"SELECT COUNT(*) FROM widgets\", [], |r| r.get(0))\n                .expect(\"count\")\n        });\n        assert_eq!(count, 2);\n    }\n\n    #[test]\n    fn production_pool_serves_parallel_readers() {\n        // Force a 4-slot pool against per-conn `:memory:` databases.\n        // Each slot is independent, so we use a query that doesn't\n        // depend on shared state — purpose is to confirm `with_conn`\n        // can hand out slots concurrently without deadlocking, and\n        // that `try_lock` picks an idle slot under contention.\n        open_production_pool(\":memory:\", TINY_SCHEMA, 4);\n\n        let handles: Vec<_> = (0..16)\n            .map(|i| {\n                std::thread::spawn(move || {\n                    // Read a literal so each slot's empty :memory: is fine.\n                    let n: i64 = with_conn(|c| {\n                        c.query_row(\"SELECT ?1 + 1\", [i as i64], |r| r.get(0))\n                            .expect(\"query\")\n                    });\n                    assert_eq!(n, i as i64 + 1);\n                })\n            })\n            .collect();\n        for h in handles {\n            h.join().expect(\"worker join\");\n        }\n    }\n\n    #[test]\n    fn setup_replaces_previous_connection() {\n        setup_test_db(TINY_SCHEMA);\n        with_conn(|c| {\n            c.execute(\"INSERT INTO widgets (name) VALUES ('stale')\", [])\n                .expect(\"insert\")\n        });\n        setup_test_db(TINY_SCHEMA);\n        let count: i64 = with_conn(|c| {\n            c.query_row(\"SELECT COUNT(*) FROM widgets\", [], |r| r.get(0))\n                .expect(\"count\")\n        });\n        assert_eq!(count, 0, \"new connection should start empty\");\n    }\n}\n"},{"path":"src/errors_ext.rs","content":"//! Per-target error/raise primitives the transpiled framework runtime\n//! reaches via bare-token emit.\n//!\n//! `raise(KIND, payload)` is the Ruby-shape `raise Klass, \"...\"`\n//! emitted by the transpile pipeline. Rust has no `raise` keyword and\n//! the trio of error classes (`NotImplementedError`, `RecordNotFound`,\n//! `RecordInvalid`) doesn't transpile cleanly yet — Display + Error\n//! synthesis for `class < StandardError` is a separate emit feature.\n//!\n//! Phase 3 stub: a single `FrameworkError` enum, three module-level\n//! consts the transpile's bare tokens map to (via the `imports` field\n//! in `RUST_RUNTIME`), and a `raise` function generic over the payload\n//! type. The function returns `!` so call sites in non-Unit-returning\n//! methods compile (`!` coerces to any type).\n//!\n//! Payload is intentionally `T` (no Debug/Display bound) because the\n//! emitted code passes structs (`self`, `Base` instances) that don't\n//! derive Debug yet. Lost message content is acceptable for the\n//! contract-marker raise calls (table_name, instantiate, etc.) — the\n//! panic still surfaces the kind, which is enough to diagnose a\n//! missing subclass override.\n\n#[derive(Debug, Clone, Copy)]\npub enum FrameworkError {\n    NotImplemented,\n    RecordNotFound,\n    RecordInvalid,\n}\n\n#[allow(non_upper_case_globals)]\npub const NotImplementedError: FrameworkError = FrameworkError::NotImplemented;\n#[allow(non_upper_case_globals)]\npub const RecordNotFound: FrameworkError = FrameworkError::RecordNotFound;\n#[allow(non_upper_case_globals)]\npub const RecordInvalid: FrameworkError = FrameworkError::RecordInvalid;\n\n/// Ruby-shape `raise Klass, payload`. Panics with the framework\n/// error kind; payload is accepted but discarded.\n///\n/// Returns `!` so the caller can use it as the body of any-typed\n/// method (`fn table_name() -> String { raise(...) }` compiles).\npub fn raise<T>(kind: FrameworkError, _payload: T) -> ! {\n    panic!(\"FrameworkError::{:?}\", kind);\n}\n\n/// Placeholder for Ruby's `self.name` (class name) inside emitted\n/// class methods. The transpile lowers bare `name` calls to a free\n/// function reference; until the emit-side rewrites these to\n/// per-class string literals or `std::any::type_name::<Self>()`,\n/// this stub gives the references something to resolve to.\npub fn name() -> &'static str {\n    \"Base\"\n}\n\n// Thread-local validation-error buffer the AR shim's `save` path\n// reads through. Every emitted model's `validate` body pushes\n// user-visible messages here — rust2 emit rewrites the lowered\n// `self.errors() << msg` shape to `validation_errors_push(msg)` so\n// the messages survive the `errors() returns owned Vec<String>` shim\n// that would otherwise drop them. `save` clears the buffer before\n// running validate and reads `validation_errors_is_empty()` to\n// decide whether to persist.\n//\n// Per-thread keeps tests independent (cargo test runs tests on\n// separate threads); a global Mutex would cross-contaminate.\nthread_local! {\n    static VALIDATION_ERRORS: std::cell::RefCell<Vec<String>> =\n        std::cell::RefCell::new(Vec::new());\n}\n\npub fn validation_errors_clear() {\n    VALIDATION_ERRORS.with(|c| c.borrow_mut().clear());\n}\n\npub fn validation_errors_push(msg: String) {\n    VALIDATION_ERRORS.with(|c| c.borrow_mut().push(msg));\n}\n\npub fn validation_errors_is_empty() -> bool {\n    VALIDATION_ERRORS.with(|c| c.borrow().is_empty())\n}\n\npub fn validation_errors_snapshot() -> Vec<String> {\n    VALIDATION_ERRORS.with(|c| c.borrow().clone())\n}\n"},{"path":"src/fixtures/articles.rs","content":"#[allow(unused_imports)]\nuse crate::param_value::ParamValue;\n#[allow(unused_imports)]\nuse crate::db::Db;\n#[allow(unused_imports)]\nuse crate::broadcasts::Broadcasts;\n// Sibling-model glob so cross-file refs (Article ↔ Comment, the\n// `<Model>Row` typed-row pair) resolve through the `pub use` chain\n// that `emit_models_mod_rs` lays into `src/models/mod.rs`. Rust\n// doesn't auto-import siblings — the lowerer leaves bare `Article`\n// / `Comment` / `ArticleRow` / `CommentRow` references at every\n// `Comment.belongs_to :article`, `has_many :comments`, and\n// `instantiate(row)` call site; without this line each of those\n// E0433s independently.\n#[allow(unused_imports)]\nuse crate::models::*;\n// View modules (Phase 5b stubs). The model lowerer's broadcasts_to\n// expansion emits `Articles::article(self)` / `Comments::comment\n// (self)` partial renders inside `after_*_commit` callback bodies;\n// the actual view emit isn't yet wired through rust2, so each\n// LibraryClass produced by `lower_views_to_library_classes` lands\n// here as a fully-generic `String::new()` stub. Replace with real\n// view emit when Phase 5b lands.\n#[allow(unused_imports)]\nuse crate::views::*;\npub struct ArticlesFixtures;\n\nimpl ArticlesFixtures {\n    pub fn one() -> Article {\n        Article::find(1_i64)\n    }\n\n    pub fn two() -> Article {\n        Article::find(2_i64)\n    }\n\n    pub fn _fixtures_load_bang() {\n        let mut instance = Article::new(std::collections::HashMap::new());\n        instance.set_id(1_i64);\n        instance.set_title(\"Getting Started with Rails\");\n        instance.set_body(\"Rails is a web application framework running on the Ruby programming language.\");\n        instance.save();\n        instance = Article::new(std::collections::HashMap::new());\n        instance.set_id(2_i64);\n        instance.set_title(\"Understanding MVC Architecture\");\n        instance.set_body(\"MVC stands for Model-View-Controller. Models handle data and business logic.\");\n        instance.save();\n    }\n}\n\n// Wedge 2c.3 bare-fn compat shims — delegate to the impl.\npub fn one() -> Article { ArticlesFixtures::one() }\npub fn two() -> Article { ArticlesFixtures::two() }\n"},{"path":"src/fixtures/comments.rs","content":"#[allow(unused_imports)]\nuse crate::param_value::ParamValue;\n#[allow(unused_imports)]\nuse crate::db::Db;\n#[allow(unused_imports)]\nuse crate::broadcasts::Broadcasts;\n// Sibling-model glob so cross-file refs (Article ↔ Comment, the\n// `<Model>Row` typed-row pair) resolve through the `pub use` chain\n// that `emit_models_mod_rs` lays into `src/models/mod.rs`. Rust\n// doesn't auto-import siblings — the lowerer leaves bare `Article`\n// / `Comment` / `ArticleRow` / `CommentRow` references at every\n// `Comment.belongs_to :article`, `has_many :comments`, and\n// `instantiate(row)` call site; without this line each of those\n// E0433s independently.\n#[allow(unused_imports)]\nuse crate::models::*;\n// View modules (Phase 5b stubs). The model lowerer's broadcasts_to\n// expansion emits `Articles::article(self)` / `Comments::comment\n// (self)` partial renders inside `after_*_commit` callback bodies;\n// the actual view emit isn't yet wired through rust2, so each\n// LibraryClass produced by `lower_views_to_library_classes` lands\n// here as a fully-generic `String::new()` stub. Replace with real\n// view emit when Phase 5b lands.\n#[allow(unused_imports)]\nuse crate::views::*;\npub struct CommentsFixtures;\n\nimpl CommentsFixtures {\n    pub fn one() -> Comment {\n        Comment::find(1_i64)\n    }\n\n    pub fn two() -> Comment {\n        Comment::find(2_i64)\n    }\n\n    pub fn _fixtures_load_bang() {\n        let mut instance = Comment::new(std::collections::HashMap::new());\n        instance.set_id(1_i64);\n        instance.set_article_id(1_i64);\n        instance.set_commenter(\"Alice\");\n        instance.set_body(\"Great introduction! Rails really does make development faster.\");\n        instance.save();\n        instance = Comment::new(std::collections::HashMap::new());\n        instance.set_id(2_i64);\n        instance.set_article_id(2_i64);\n        instance.set_commenter(\"Bob\");\n        instance.set_body(\"This pattern really helps keep code organized!\");\n        instance.save();\n    }\n}\n\n// Wedge 2c.3 bare-fn compat shims — delegate to the impl.\npub fn one() -> Comment { CommentsFixtures::one() }\npub fn two() -> Comment { CommentsFixtures::two() }\n"},{"path":"src/fixtures/mod.rs","content":"// Generated by Roundhouse (rust2).\n\npub mod articles;\npub mod comments;\npub use articles::ArticlesFixtures;\npub use comments::CommentsFixtures;\n\n/// Per-test entry point. Brings up a fresh in-memory SQLite,\n/// runs the schema DDL, and loads every fixture in declaration\n/// order. Tests call this as their first line; repeat calls on\n/// the same thread reset to a clean slate.\npub fn setup() {\n    crate::db::setup_test_db(crate::schema_sql::CREATE_TABLES);\n    ArticlesFixtures::_fixtures_load_bang();\n    CommentsFixtures::_fixtures_load_bang();\n}\n"},{"path":"src/flash.rs","content":"//! ActionDispatch::Flash — per-app flash store with typed\n//! `notice`/`alert` fields plus HWIA-shape shim methods.\n//!\n//! Hand-written for rust2 Phase 3 (sibling of\n//! `runtime/ruby/action_dispatch/flash.rb` and\n//! `runtime/typescript/`'s transpiled `flash.ts`). The transpile\n//! pipeline produces broken Rust for this file's HWIA shim methods\n//! (Index trait, self-indexing); hand-writing avoids fighting those\n//! emit bugs. The struct surface matches the typed-targets contract\n//! (`is_flash_name` in `view_to_library/extra_params.rs` declares the\n//! closed `notice`/`alert` field set).\n\nuse std::collections::HashMap;\n\n#[derive(Debug, Default, Clone)]\npub struct Flash {\n    pub notice: Option<String>,\n    pub alert: Option<String>,\n}\n\nimpl Flash {\n    pub fn new() -> Self {\n        Self::default()\n    }\n\n    pub fn from_persisted(other: Option<&HashMap<String, String>>) -> Self {\n        let mut flash = Self::default();\n        if let Some(map) = other {\n            if let Some(v) = map.get(\"notice\") {\n                flash.notice = Some(v.clone());\n            }\n            if let Some(v) = map.get(\"alert\") {\n                flash.alert = Some(v.clone());\n            }\n        }\n        flash\n    }\n\n    pub fn get(&self, key: &str) -> Option<String> {\n        match key {\n            \"notice\" => self.notice.clone(),\n            \"alert\" => self.alert.clone(),\n            _ => None,\n        }\n    }\n\n    pub fn set(&mut self, key: &str, value: Option<String>) {\n        match key {\n            \"notice\" => self.notice = value,\n            \"alert\" => self.alert = value,\n            _ => {}\n        }\n    }\n\n    pub fn fetch(&self, key: &str, default: Option<String>) -> Option<String> {\n        self.get(key).or(default)\n    }\n\n    pub fn key(&self, key: &str) -> bool {\n        self.get(key).is_some()\n    }\n\n    pub fn has_key(&self, key: &str) -> bool {\n        self.key(key)\n    }\n\n    pub fn include(&self, key: &str) -> bool {\n        self.key(key)\n    }\n\n    pub fn delete(&mut self, key: &str) -> Option<String> {\n        match key {\n            \"notice\" => self.notice.take(),\n            \"alert\" => self.alert.take(),\n            _ => None,\n        }\n    }\n\n    pub fn len(&self) -> usize {\n        let mut n = 0;\n        if self.notice.is_some() {\n            n += 1;\n        }\n        if self.alert.is_some() {\n            n += 1;\n        }\n        n\n    }\n\n    pub fn is_empty(&self) -> bool {\n        self.notice.is_none() && self.alert.is_none()\n    }\n\n    pub fn keys(&self) -> Vec<&'static str> {\n        let mut out = Vec::new();\n        if self.notice.is_some() {\n            out.push(\"notice\");\n        }\n        if self.alert.is_some() {\n            out.push(\"alert\");\n        }\n        out\n    }\n\n    pub fn to_h(&self) -> HashMap<String, String> {\n        let mut out = HashMap::new();\n        if let Some(v) = &self.notice {\n            out.insert(\"notice\".to_string(), v.clone());\n        }\n        if let Some(v) = &self.alert {\n            out.insert(\"alert\".to_string(), v.clone());\n        }\n        out\n    }\n}\n"},{"path":"src/hash_ext.rs","content":"//! HashMap helpers bridging Ruby's `Hash#merge` semantics.\n//!\n//! Ruby `hash.merge(other)` returns a new hash with `other`'s entries\n//! layered on top. The transpiled framework runtime emits this on\n//! HashMap-typed receivers with mixed K/V types — typically a literal\n//! built from `(&str, &str)` or `(&str, String)` pairs merged with a\n//! parameter-typed `HashMap<String, serde_json::Value>`. Generic Rust\n//! merge traits can't bridge that K/V variance.\n//!\n//! `merge_attrs` is the pragmatic landing zone: it accepts any pair-\n//! iterator on both sides whose K is `Into<String>` and V is\n//! `Into<serde_json::Value>`, and produces a unified\n//! `HashMap<String, serde_json::Value>`. That matches the\n//! transpiled-runtime usage where the merged map is consumed by\n//! `render_attrs`, `r#where`, etc. — call sites that don't need to\n//! preserve the literal's narrower K/V types.\n\nuse serde_json::Value;\nuse std::collections::HashMap;\n\npub fn merge_attrs<I1, K1, V1, I2, K2, V2>(base: I1, other: I2) -> HashMap<String, Value>\nwhere\n    I1: IntoIterator<Item = (K1, V1)>,\n    K1: Into<String>,\n    V1: Into<Value>,\n    I2: IntoIterator<Item = (K2, V2)>,\n    K2: Into<String>,\n    V2: Into<Value>,\n{\n    let mut out: HashMap<String, Value> = HashMap::new();\n    for (k, v) in base {\n        out.insert(k.into(), v.into());\n    }\n    for (k, v) in other {\n        out.insert(k.into(), v.into());\n    }\n    out\n}\n"},{"path":"src/http.rs","content":"//! Roundhouse Rust HTTP runtime.\n//!\n//! Hand-written, shipped alongside generated code (copied in by the\n//! Rust emitter as `src/http.rs`). Provides the controller-facing\n//! types + helpers the emitter assumes exist: a `Params` wrapper over\n//! Rails-style bracketed form parameters, a `Redirect` convenience, a\n//! small `ViewCtx` carrying flash + request context to views, and\n//! re-exports of axum's `Html` / `Response` / `IntoResponse` so\n//! emitted action signatures can reference them through a single\n//! `crate::http::*` path.\n//!\n//! Axum is the HTTP framework (chosen to match railcar's precedent +\n//! the surrounding Rust ecosystem's gravity). Actions return `impl\n//! IntoResponse`; form bodies extract via `axum::extract::Form`\n//! wrapping a per-controller `#[derive(Deserialize)]` struct.\n\nuse std::collections::HashMap;\n\npub use axum::response::{Html, IntoResponse, Redirect, Response};\n\n/// Wrapper over the flat HashMap form body that axum's `Form`\n/// extractor produces. Rails posts nested keys like `article[title]=\n/// Foo&article[body]=Bar`; this type provides the `.expect(scope,\n/// &[keys])` accessor used by emitted strong-params helpers and the\n/// `[key]` lookup used by `params[:id]` style access.\n#[derive(Debug, Default, Clone)]\npub struct Params {\n    inner: HashMap<String, String>,\n}\n\nimpl Params {\n    pub fn new(inner: HashMap<String, String>) -> Self {\n        Self { inner }\n    }\n\n    /// Rails `params[:id]` / `params[\"id\"]` — return the raw string\n    /// value for a top-level key. Missing keys return an empty\n    /// string (matches Rails' `params[:missing]` returning nil when\n    /// later coerced; for Phase 4d's ID parsing, use `.int(key)`).\n    pub fn get(&self, key: &str) -> &str {\n        self.inner.get(key).map(|s| s.as_str()).unwrap_or(\"\")\n    }\n\n    /// Parse a param as an `i64`. Used in place of the Ruby\n    /// `params[:id]` which is string-typed but always gets coerced\n    /// to an integer for DB lookup. Returns 0 on missing/unparsable.\n    pub fn int(&self, key: &str) -> i64 {\n        self.inner.get(key).and_then(|s| s.parse().ok()).unwrap_or(0)\n    }\n\n    /// Strong-params extractor: pull every `scope[field]` key out of\n    /// the flat form body and return a new `HashMap<String, String>`\n    /// keyed on `field`. Emitted strong-params helpers use this to\n    /// populate their typed struct's fields.\n    ///\n    /// `params.expect(article: [:title, :body])` in Rails lowers to\n    /// `params.expect(\"article\", &[\"title\", \"body\"])` in emitted\n    /// Rust, and the returned map is consumed by the model's\n    /// from-params constructor.\n    pub fn expect(&self, scope: &str, keys: &[&str]) -> HashMap<String, String> {\n        let prefix = format!(\"{scope}[\");\n        let mut out = HashMap::new();\n        for key in keys {\n            let full = format!(\"{prefix}{key}]\");\n            if let Some(v) = self.inner.get(&full) {\n                out.insert((*key).to_string(), v.clone());\n            }\n        }\n        out\n    }\n}\n\nimpl From<HashMap<String, String>> for Params {\n    fn from(inner: HashMap<String, String>) -> Self {\n        Self::new(inner)\n    }\n}\n\n/// Convenience: emit `crate::http::redirect(&path)` from a path\n/// helper's result. Wraps axum's `Redirect::to` with the 303 See\n/// Other status that Rails uses for create/update/destroy redirects.\npub fn redirect(path: &str) -> Redirect {\n    Redirect::to(path)\n}\n\n/// Convenience: emit `crate::http::html(body)` to wrap a view's\n/// String output as an HTML response. Same as `Html(body)` but one\n/// import shorter at call sites.\npub fn html(body: String) -> Html<String> {\n    Html(body)\n}\n\n/// Error response with HTTP 422 (unprocessable entity) — Rails'\n/// convention for validation failures on create/update. Emitters\n/// wrap a view render in this on the `else` branch of `@model.save`.\npub fn unprocessable(body: String) -> (axum::http::StatusCode, Html<String>) {\n    (axum::http::StatusCode::UNPROCESSABLE_ENTITY, Html(body))\n}\n\n/// Context threaded through view functions. Phase 4d minimum: flash\n/// notice (read in every view via `notice.present?`). Later: current\n/// user, csrf token, request path, locale, etc.\n#[derive(Debug, Default, Clone)]\npub struct ViewCtx {\n    pub notice: Option<String>,\n}\n\nimpl ViewCtx {\n    pub fn new() -> Self {\n        Self::default()\n    }\n\n    pub fn with_notice(notice: impl Into<String>) -> Self {\n        Self { notice: Some(notice.into()) }\n    }\n}\n\n// ── rust2 controller-action response state ──────────────────────\n//\n// Rails controllers thread response data through implicit state —\n// `render`, `redirect_to`, `head`, and `response.headers[…] = …`\n// each accumulate into the controller's response object, which the\n// framework serializes to the HTTP body after the action returns.\n// Rust2's emit shape carries the controller as `impl X { pub fn\n// show(&mut self) }` — `&mut self` methods returning `()`. That\n// signature isn't compatible with axum's free-fn-extractor-then-\n// IntoResponse contract.\n//\n// Bridge: emit per-action axum wrapper free fns that clear this\n// thread-local, build the controller, call the action, then\n// translate the accumulated `ControllerResponse` into an\n// `axum::response::Response`. The AC::Base shim's `render` /\n// `render_with` / `redirect_to` / `head` methods (today no-ops\n// emitted at `src/emit/rust2.rs:~782`) become thin writers to\n// this state.\n//\n// Per-thread because axum dispatches each request on a tokio task\n// that's pinned to one thread for the duration of an action body\n// (controller bodies are sync `&mut self` methods — no `.await`\n// inside, so thread affinity holds). A future migration to async\n// action bodies would need a per-task storage shape (extension\n// types, task_local!, etc.).\n\n#[derive(Debug, Clone)]\npub struct ControllerResponse {\n    pub status: u16,\n    pub body: String,\n    pub content_type: String,\n    /// Set when `redirect_to` fires; the wrapper emits a 3xx with\n    /// this as the `Location` header instead of an HTML body.\n    pub location: Option<String>,\n}\n\nimpl Default for ControllerResponse {\n    fn default() -> Self {\n        Self {\n            status: 200,\n            body: String::new(),\n            content_type: \"text/html; charset=utf-8\".to_string(),\n            location: None,\n        }\n    }\n}\n\nthread_local! {\n    static RESPONSE: std::cell::RefCell<ControllerResponse> =\n        std::cell::RefCell::new(ControllerResponse::default());\n    static REQUEST_FORMAT: std::cell::RefCell<String> =\n        std::cell::RefCell::new(String::from(\"html\"));\n}\n\n/// Request extension carrying the inferred format (\"html\"/\"json\").\n/// Set by the `request_format_middleware` in `server.rs` after it\n/// strips a `.json` suffix off the URI; read by the per-action axum\n/// wrappers (extracted via `axum::extract::Extension<RequestFormatExt>`)\n/// and threaded into the thread-local before the controller body runs.\n#[derive(Clone, Debug)]\npub struct RequestFormatExt(pub String);\n\n/// Stash the inferred format on the per-task thread-local. The axum\n/// wrapper calls this synchronously immediately before the controller\n/// action body — `AC::Base#request_format` (emitted as a shim method\n/// on each controller) reads it back via `request_format_get`. No\n/// `.await` between set and read, so thread affinity holds.\npub fn request_format_set(format: String) {\n    REQUEST_FORMAT.with(|r| *r.borrow_mut() = format);\n}\n\n/// Read the current request's format. Called by the controller-shim\n/// `request_format()` method; defaults to `\"html\"` if no middleware\n/// has populated it (e.g. unit tests instantiating a controller\n/// directly).\npub fn request_format_get() -> String {\n    REQUEST_FORMAT.with(|r| r.borrow().clone())\n}\n\n/// Tag every request with its inferred format (\"html\" or \"json\") as\n/// an extension before it reaches the per-action handler. The\n/// emitted router attaches this as a `.layer()` so both `axum::serve`\n/// (production) and `axum_test::TestServer` (controller tests) share\n/// one wiring path.\n///\n/// Why an Extension and not a URI rewrite: in axum 0.8 `Router::layer`\n/// wraps each route's handler — route matching + `Path<...>`\n/// extraction happens *before* the layer runs, so URI rewrites here\n/// are too late to affect routing. The router emit registers explicit\n/// `.json`-suffixed entries for parameterless paths\n/// (`src/emit/rust2.rs::render_axum_router_body`); parameterized\n/// paths capture the `.json` tail as part of the segment (e.g.\n/// `id=\"1.json\"`), and the action wrapper strips the suffix before\n/// parsing the id as `i64`. This layer just surfaces the inferred\n/// format so the `if self.request_format() == \"json\"` branch dispatches\n/// the JSON jbuilder view.\npub async fn request_format_middleware(\n    mut req: axum::extract::Request,\n    next: axum::middleware::Next,\n) -> axum::response::Response {\n    let format = if req.uri().path().ends_with(\".json\") {\n        \"json\"\n    } else {\n        \"html\"\n    };\n    req.extensions_mut().insert(RequestFormatExt(format.to_string()));\n    next.run(req).await\n}\n\n/// Reset the thread-local to defaults. Called at the top of each\n/// axum wrapper so a prior action's state doesn't leak into the\n/// current request.\npub fn response_clear() {\n    RESPONSE.with(|r| *r.borrow_mut() = ControllerResponse::default());\n}\n\n/// `render(content)` — stash the body string. Defaults already\n/// have 200/text-html, so a bare render is fully wired.\npub fn response_set_body(body: String) {\n    RESPONSE.with(|r| r.borrow_mut().body = body);\n}\n\n/// `render_with(content, opts)` — body + content_type, optionally\n/// status. Honors common `opts` keys (`content_type`, `status`).\n/// Unknown keys ignored; the AC::Base shim's call site already\n/// strips the Ruby-only knobs.\npub fn response_set_body_with(body: String, content_type: Option<String>, status: Option<u16>) {\n    RESPONSE.with(|r| {\n        let mut resp = r.borrow_mut();\n        resp.body = body;\n        if let Some(ct) = content_type {\n            resp.content_type = ct;\n        }\n        if let Some(st) = status {\n            resp.status = st;\n        }\n    });\n}\n\n/// `redirect_to(path, opts)` — 303 See Other by default; the\n/// `status: :see_other` opt matches Rails' default convention for\n/// post-mutation redirects (avoids form re-submit on back/refresh).\npub fn response_set_redirect(location: String, status: u16) {\n    RESPONSE.with(|r| {\n        let mut resp = r.borrow_mut();\n        resp.status = status;\n        resp.location = Some(location);\n        resp.body = String::new();\n    });\n}\n\n/// `head(name, opts)` — Rails-style status symbol → numeric code.\n/// Body stays empty. Symbol names mirror `Rack::Utils::SYMBOL_TO_STATUS_CODE`.\npub fn response_set_head(status_name: &str, content_type: Option<String>) {\n    let code = status_name_to_code(status_name);\n    RESPONSE.with(|r| {\n        let mut resp = r.borrow_mut();\n        resp.status = code;\n        resp.body = String::new();\n        if let Some(ct) = content_type {\n            resp.content_type = ct;\n        }\n    });\n}\n\n/// Snapshot + reset — used by the per-action axum wrapper to read\n/// out the state immediately after the action returns. Returns\n/// owned value so the borrow on the thread-local is short.\npub fn response_take() -> ControllerResponse {\n    RESPONSE.with(|r| std::mem::take(&mut *r.borrow_mut()))\n}\n\n/// Translate a thread-local response into an `axum::response::Response`.\n/// Redirect-shaped state produces a 3xx with `Location`; otherwise\n/// emits the body with the recorded content-type + status.\npub fn response_into_axum(resp: ControllerResponse) -> axum::response::Response {\n    use axum::http::StatusCode;\n    use axum::response::IntoResponse;\n    let status = StatusCode::from_u16(resp.status).unwrap_or(StatusCode::OK);\n    if let Some(location) = resp.location {\n        let mut response = (status, ()).into_response();\n        if let Ok(hv) = axum::http::HeaderValue::from_str(&location) {\n            response.headers_mut().insert(axum::http::header::LOCATION, hv);\n        }\n        return response;\n    }\n    let body = resp.body;\n    let content_type = resp.content_type;\n    let mut response = (status, body).into_response();\n    if let Ok(hv) = axum::http::HeaderValue::from_str(&content_type) {\n        response\n            .headers_mut()\n            .insert(axum::http::header::CONTENT_TYPE, hv);\n    }\n    response\n}\n\n/// Public alias for `status_name_to_code` — exposed for the AC::Base\n/// shim emitted in `src/emit/rust2.rs`, which reaches it from\n/// outside the crate-private `http` module. Same semantics; just\n/// a re-export that survives module privacy.\npub fn status_name_to_code_pub(name: &str) -> u16 {\n    status_name_to_code(name)\n}\n\n/// Ruby `Object#to_s` analog. Rails' `inner_v.to_s` in\n/// `ActionView::ViewHelpers#render_attrs` ships through any\n/// Hash[String, untyped]; on strict-typed targets the `untyped`\n/// alias resolves to `serde_json::Value`, whose `Display` /\n/// `to_string()` emits a JSON serialization (so\n/// `Value::String(\"reload\").to_string()` becomes `\"\\\"reload\\\"\"`,\n/// not `reload`). Ruby's `String#to_s` is identity — bare string.\n///\n/// `RubyToS` bridges: implementations cover the three recv types\n/// rust2 emit lowers `untyped`-receiver `.to_s` Sends to (`str` /\n/// `String` / `serde_json::Value`). Rust resolves the impl at\n/// compile time via auto-deref, so the rust2 dispatch can emit\n/// `(recv).ruby_to_s()` uniformly without distinguishing closure\n/// params (genuinely `&String` at runtime, body-typer marks\n/// `Untyped`) from value-typed locals (genuinely `&Value`).\n///\n/// Used at every call site in `runtime/ruby/action_view/view_helpers.rb`\n/// that produces an attribute / data-attribute / link tag value;\n/// the lowered IR's `.to_s` Sends on Untyped recvs route through\n/// this trait by the recv-Ty-aware bridge in\n/// `src/emit/rust2/expr/send/dispatch.rs`.\npub trait RubyToS {\n    fn ruby_to_s(&self) -> String;\n}\n\nimpl RubyToS for str {\n    fn ruby_to_s(&self) -> String {\n        self.to_string()\n    }\n}\n\nimpl RubyToS for String {\n    fn ruby_to_s(&self) -> String {\n        self.clone()\n    }\n}\n\nimpl RubyToS for serde_json::Value {\n    fn ruby_to_s(&self) -> String {\n        match self {\n            serde_json::Value::String(s) => s.clone(),\n            serde_json::Value::Null => String::new(),\n            other => other.to_string(),\n        }\n    }\n}\n\n/// Translate a flat axum-`Form<HashMap<String, String>>` body into\n/// the nested params shape Rails controllers expect. Form names of\n/// the shape `article[title]=Foo` land at `params[\"article\"]\n/// [\"title\"] = \"Foo\"`; top-level names pass through unchanged.\n///\n/// One level of bracket-nesting only — scaffold blog forms don't\n/// reach deeper. The lowered `<Resource>Params::from_raw` factory\n/// always looks up a single nested scope (`params.get(resource)`)\n/// then individual fields under it, so the single-level shape\n/// covers every emitted call site today. Deep nesting\n/// (`comment[article_attributes][title]`) becomes a follow-on if\n/// `accepts_nested_attributes_for` lands.\npub fn params_from_form(\n    form: HashMap<String, String>,\n) -> HashMap<String, serde_json::Value> {\n    let mut out: HashMap<String, serde_json::Value> = HashMap::new();\n    for (k, v) in form {\n        if let (Some(open), Some(close)) = (k.find('['), k.rfind(']')) {\n            if close > open {\n                let scope = &k[..open];\n                let inner = &k[open + 1..close];\n                let entry = out\n                    .entry(scope.to_string())\n                    .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));\n                if let serde_json::Value::Object(map) = entry {\n                    map.insert(inner.to_string(), serde_json::Value::from(v));\n                    continue;\n                }\n            }\n        }\n        out.insert(k, serde_json::Value::from(v));\n    }\n    out\n}\n\n/// Rails status-symbol → HTTP code. Subset matching the names the\n/// scaffold emit reaches (`:ok`, `:no_content`, `:not_found`,\n/// `:unprocessable_entity`, `:see_other`). Unknown names fall back\n/// to 200 OK — the controller path that emits an unknown symbol is\n/// generally a bug the caller will see via behavior, not a route\n/// the framework should silently 500 on.\nfn status_name_to_code(name: &str) -> u16 {\n    match name {\n        \"ok\" => 200,\n        \"created\" => 201,\n        \"accepted\" => 202,\n        \"no_content\" => 204,\n        \"moved_permanently\" => 301,\n        \"found\" => 302,\n        \"see_other\" => 303,\n        \"not_modified\" => 304,\n        \"temporary_redirect\" => 307,\n        \"permanent_redirect\" => 308,\n        \"bad_request\" => 400,\n        \"unauthorized\" => 401,\n        \"forbidden\" => 403,\n        \"not_found\" => 404,\n        \"unprocessable_entity\" | \"unprocessable_content\" => 422,\n        \"internal_server_error\" => 500,\n        _ => 200,\n    }\n}\n"},{"path":"src/importmap.rs","content":"pub struct Importmap;\n\nimpl Importmap {\n    pub fn pins() -> Vec<serde_json::Value> {\n        vec![serde_json::Value::Object(std::collections::HashMap::from([(\"name\", \"application\"), (\"path\", \"/assets/application.js\")]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect()), serde_json::Value::Object(std::collections::HashMap::from([(\"name\", \"@hotwired/turbo-rails\"), (\"path\", \"/assets/turbo.min.js\")]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect()), serde_json::Value::Object(std::collections::HashMap::from([(\"name\", \"@hotwired/stimulus\"), (\"path\", \"/assets/stimulus.min.js\")]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect()), serde_json::Value::Object(std::collections::HashMap::from([(\"name\", \"@hotwired/stimulus-loading\"), (\"path\", \"/assets/stimulus-loading.js\")]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect()), serde_json::Value::Object(std::collections::HashMap::from([(\"name\", \"controllers/application\"), (\"path\", \"/assets/controllers/application.js\")]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect()), serde_json::Value::Object(std::collections::HashMap::from([(\"name\", \"controllers/hello_controller\"), (\"path\", \"/assets/controllers/hello_controller.js\")]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect()), serde_json::Value::Object(std::collections::HashMap::from([(\"name\", \"controllers\"), (\"path\", \"/assets/controllers/index.js\")]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect())]\n    }\n\n    pub fn entry() -> String {\n        (\"application\").to_string()\n    }\n}\n"},{"path":"src/inflector.rs","content":"// Generated from runtime/ruby/inflector.rb at app emit time.\n// Do not edit by hand — edit the source `.rb` and re-run emit.\n\npub struct Inflector;\n\nimpl Inflector {\n    pub fn pluralize(count: i64, word: &str) -> String {\n        if count == 1_i64 { format!(\"1 {}\", word.clone()) } else { format!(\"{} {}s\", count, word.clone()) }\n    }\n}\n"},{"path":"src/json_builder.rs","content":"// Generated from runtime/ruby/json_builder.rb at app emit time.\n// Do not edit by hand — edit the source `.rb` and re-run emit.\n\nuse crate::http::RubyToS;\n\nstatic ESCAPES: std::sync::LazyLock<std::collections::HashMap<&'static str, &'static str>> = std::sync::LazyLock::new(|| std::collections::HashMap::from([(\"\\\\\", \"\\\\\\\\\"), (\"\\\"\", \"\\\\\\\"\"), (\"\\n\", \"\\\\n\"), (\"\\r\", \"\\\\r\"), (\"\\t\", \"\\\\t\"), (\"\\u{8}\", \"\\\\b\"), (\"\\u{c}\", \"\\\\f\")]));\nstatic ESCAPE_PATTERN: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| regex::Regex::new(\"[\\\\\\\\\\\"\\\\n\\\\r\\\\t\\\\x08\\\\f]\").unwrap());\n\npub struct JsonBuilder;\n\nimpl JsonBuilder {\n    pub fn encode_string(s: &str) -> String {\n        ESCAPE_PATTERN.replace_all(&s, |__caps: &regex::Captures| -> String { (*ESCAPES.get(&__caps[0]).unwrap_or(&\"\")).to_string() }).into_owned()\n    }\n\n    pub fn encode_value(v: serde_json::Value) -> String {\n        if v.clone().is_null() { return (\"null\").to_string() };\n        if v.clone().is_boolean() { return (\"true\").to_string() };\n        if v.clone().is_boolean() { return (\"false\").to_string() };\n        if v.clone().is_i64() { return v.as_i64().unwrap().to_string() };\n        if v.clone().is_f64() { return v.as_f64().unwrap().to_string() };\n        if v.clone().is_string() { return format!(\"\\\"{}\\\"\", Self::encode_string(&(v.as_str().unwrap()))) };\n        format!(\"\\\"{}\\\"\", Self::encode_string(&(v.ruby_to_s())))\n    }\n\n    pub fn encode_datetime(s: Option<String>) -> String {\n        let Some(s) = s else { return (\"null\").to_string() };\n        let mut str = s.to_string();\n        if (str.len() as i64) < 19_i64 { return format!(\"\\\"{}\\\"\", Self::encode_string(&(str.clone()))) };\n        let date = (&str.clone()[(0_i64) as usize..((0_i64) + (10_i64)) as usize]).to_string();\n        let time = (&str.clone()[(11_i64) as usize..((11_i64) + (8_i64)) as usize]).to_string();\n        let mut ms = (\"000\").to_string();\n        if (str.len() as i64) > 20_i64 && (&str.clone()[(19_i64) as usize..((19_i64) + (1_i64)) as usize]).to_string() == \".\" { { let frac = &str.clone()[(20_i64) as usize..];\n        let mut padded = format!(\"{}000\", frac);\n        ms = (&padded[(0_i64) as usize..((0_i64) + (3_i64)) as usize]).to_string() } };\n        format!(\"\\\"{}T{}.{}Z\\\"\", date, time, ms)\n    }\n}\n"},{"path":"src/lib.rs","content":"// Generated by Roundhouse (rust2).\n\npub mod action_controller_base;\npub mod active_record_adapter;\npub mod active_record_base;\npub mod adapter_interface;\npub mod broadcasts;\npub mod cable;\npub mod controllers;\npub mod db;\npub mod errors_ext;\n#[cfg(test)]\npub mod fixtures;\npub mod flash;\npub mod hash_ext;\npub mod http;\npub mod importmap;\npub mod inflector;\npub mod json_builder;\npub mod models;\npub mod param_value;\npub mod route_helpers;\npub mod router;\npub mod schema_sql;\npub mod server;\npub mod session;\n#[cfg(test)]\npub mod test_support;\n#[cfg(test)]\npub mod tests;\npub mod view_helpers;\npub mod views;\n"},{"path":"src/main.rs","content":"// Generated by Roundhouse (rust2).\n\nuse app::{router, schema_sql, server};\n\n#[tokio::main]\nasync fn main() {\n    let db_path = std::env::var(\"DATABASE_PATH\").ok();\n    let port = std::env::var(\"PORT\").ok().and_then(|s| s.parse().ok());\n    server::start(\n        router::router(),\n        server::StartOptions {\n            db_path,\n            port,\n            schema_sql: schema_sql::CREATE_TABLES,\n            layout: Some(app::views::layouts::render_layout),\n        },\n    )\n    .await;\n}\n"},{"path":"src/models/application_record.rs","content":"#[allow(unused_imports)]\nuse crate::param_value::ParamValue;\n#[allow(unused_imports)]\nuse crate::db::Db;\n#[allow(unused_imports)]\nuse crate::broadcasts::Broadcasts;\n// Sibling-model glob so cross-file refs (Article ↔ Comment, the\n// `<Model>Row` typed-row pair) resolve through the `pub use` chain\n// that `emit_models_mod_rs` lays into `src/models/mod.rs`. Rust\n// doesn't auto-import siblings — the lowerer leaves bare `Article`\n// / `Comment` / `ArticleRow` / `CommentRow` references at every\n// `Comment.belongs_to :article`, `has_many :comments`, and\n// `instantiate(row)` call site; without this line each of those\n// E0433s independently.\n#[allow(unused_imports)]\nuse crate::models::*;\n// View modules (Phase 5b stubs). The model lowerer's broadcasts_to\n// expansion emits `Articles::article(self)` / `Comments::comment\n// (self)` partial renders inside `after_*_commit` callback bodies;\n// the actual view emit isn't yet wired through rust2, so each\n// LibraryClass produced by `lower_views_to_library_classes` lands\n// here as a fully-generic `String::new()` stub. Replace with real\n// view emit when Phase 5b lands.\n#[allow(unused_imports)]\nuse crate::views::*;\npub struct ApplicationRecord;\n\nimpl ApplicationRecord {\n    pub fn r#abstract() -> bool {\n        true\n    }\n}\n"},{"path":"src/models/article.rs","content":"#[allow(unused_imports)]\nuse crate::param_value::ParamValue;\n#[allow(unused_imports)]\nuse crate::db::Db;\n#[allow(unused_imports)]\nuse crate::broadcasts::Broadcasts;\n// Sibling-model glob so cross-file refs (Article ↔ Comment, the\n// `<Model>Row` typed-row pair) resolve through the `pub use` chain\n// that `emit_models_mod_rs` lays into `src/models/mod.rs`. Rust\n// doesn't auto-import siblings — the lowerer leaves bare `Article`\n// / `Comment` / `ArticleRow` / `CommentRow` references at every\n// `Comment.belongs_to :article`, `has_many :comments`, and\n// `instantiate(row)` call site; without this line each of those\n// E0433s independently.\n#[allow(unused_imports)]\nuse crate::models::*;\n// View modules (Phase 5b stubs). The model lowerer's broadcasts_to\n// expansion emits `Articles::article(self)` / `Comments::comment\n// (self)` partial renders inside `after_*_commit` callback bodies;\n// the actual view emit isn't yet wired through rust2, so each\n// LibraryClass produced by `lower_views_to_library_classes` lands\n// here as a fully-generic `String::new()` stub. Replace with real\n// view emit when Phase 5b lands.\n#[allow(unused_imports)]\nuse crate::views::*;\n#[derive(Clone, Default)]\npub struct Article {\n    pub id: i64,\n    pub body: String,\n    pub created_at: String,\n    pub title: String,\n    pub updated_at: String,\n    pub comments_cache: Vec<Comment>,\n    pub comments_loaded: bool,\n}\n\nimpl Article {\n    pub fn id(&self) -> i64 {\n        self.id\n    }\n\n    pub fn set_id(&mut self, value: i64) {\n        self.id = value\n    }\n\n    pub fn body(&self) -> String {\n        self.body.clone()\n    }\n\n    pub fn set_body(&mut self, value: &str) {\n        self.body = (value).to_string()\n    }\n\n    pub fn created_at(&self) -> String {\n        self.created_at.clone()\n    }\n\n    pub fn set_created_at(&mut self, value: &str) {\n        self.created_at = (value).to_string()\n    }\n\n    pub fn title(&self) -> String {\n        self.title.clone()\n    }\n\n    pub fn set_title(&mut self, value: &str) {\n        self.title = (value).to_string()\n    }\n\n    pub fn updated_at(&self) -> String {\n        self.updated_at.clone()\n    }\n\n    pub fn set_updated_at(&mut self, value: &str) {\n        self.updated_at = (value).to_string()\n    }\n\n    pub fn table_name() -> String {\n        (\"articles\").to_string()\n    }\n\n    pub fn schema_columns() -> Vec<String> {\n        vec![\"id\".to_string(), \"body\".to_string(), \"created_at\".to_string(), \"title\".to_string(), \"updated_at\".to_string()]\n    }\n\n    pub fn instantiate(row: std::collections::HashMap<String, serde_json::Value>) -> Article {\n        let mut instance = Article::from_row(ArticleRow::from_raw(row.clone()));\n        instance.mark_persisted_bang();\n        instance.clone()\n    }\n\n    pub fn from_row(row: ArticleRow) -> Article {\n        let mut instance = Article::new(std::collections::HashMap::new());\n        instance.set_id(row.id());\n        instance.set_body(&(row.body()));\n        instance.set_created_at(&(row.created_at()));\n        instance.set_title(&(row.title()));\n        instance.set_updated_at(&(row.updated_at()));\n        instance.clone()\n    }\n\n    pub fn from_stmt(stmt: i64) -> Article {\n        let mut instance = Article::new(std::collections::HashMap::new());\n        instance.set_id(Db::column_int(stmt, 0_i64));\n        instance.set_body(&(Db::column_text(stmt, 1_i64)));\n        instance.set_created_at(&(Db::column_text(stmt, 2_i64)));\n        instance.set_title(&(Db::column_text(stmt, 3_i64)));\n        instance.set_updated_at(&(Db::column_text(stmt, 4_i64)));\n        instance.mark_persisted_bang();\n        instance.clone()\n    }\n\n    pub fn assign_from_row(&mut self, row: std::collections::HashMap<String, serde_json::Value>) {\n        self.set_id((row.clone()[\"id\"]).as_i64().unwrap());\n        self.set_body((row.clone()[\"body\"]).as_str().unwrap());\n        self.set_created_at((row.clone()[\"created_at\"]).as_str().unwrap());\n        self.set_title((row.clone()[\"title\"]).as_str().unwrap());\n        self.set_updated_at((row.clone()[\"updated_at\"]).as_str().unwrap());\n    }\n\n    pub fn new(attrs: std::collections::HashMap<String, serde_json::Value>) -> Self {\n        /* TODO rust2: ExprNode::Discriminant(22) */;\n        let id = (attrs.clone().get(\"id\").cloned().unwrap_or(serde_json::Value::from(0_i64))).as_i64().unwrap();\n        let body = (attrs.clone().get(\"body\").cloned().unwrap_or(serde_json::Value::from(\"\"))).as_str().unwrap().to_string();\n        let created_at = (attrs.clone().get(\"created_at\").cloned().unwrap_or(serde_json::Value::from(\"\"))).as_str().unwrap().to_string();\n        let title = (attrs.clone().get(\"title\").cloned().unwrap_or(serde_json::Value::from(\"\"))).as_str().unwrap().to_string();\n        let updated_at = (attrs.clone().get(\"updated_at\").cloned().unwrap_or(serde_json::Value::from(\"\"))).as_str().unwrap().to_string();\n        let mut comments_cache: Vec<Comment> = vec![];\n        let mut comments_loaded: bool = false;\n        Self { id, body, created_at, title, updated_at, comments_cache, comments_loaded }\n    }\n\n    pub fn attributes(&self) -> std::collections::HashMap<String, serde_json::Value> {\n        std::collections::HashMap::from([(\"body\".to_string(), serde_json::Value::from(self.body.clone())), (\"created_at\".to_string(), serde_json::Value::from(self.created_at.clone())), (\"title\".to_string(), serde_json::Value::from(self.title.clone())), (\"updated_at\".to_string(), serde_json::Value::from(self.updated_at.clone()))])\n    }\n\n    pub fn get_index(&self, name: &str) -> serde_json::Value {\n        match name {\n                \"id\" => { serde_json::Value::from(self.id) },\n                \"body\" => { serde_json::Value::from(self.body.clone()) },\n                \"created_at\" => { serde_json::Value::from(self.created_at.clone()) },\n                \"title\" => { serde_json::Value::from(self.title.clone()) },\n                \"updated_at\" => { serde_json::Value::from(self.updated_at.clone()) }\n                _ => serde_json::Value::Null,\n            }\n    }\n\n    pub fn set_index(&mut self, name: &str, value: serde_json::Value) {\n        match name {\n                \"id\" => { self.id = (value.clone()).as_i64().unwrap() },\n                \"body\" => { self.body = (value.clone()).as_str().unwrap().to_string().to_string() },\n                \"created_at\" => { self.created_at = (value.clone()).as_str().unwrap().to_string().to_string() },\n                \"title\" => { self.title = (value.clone()).as_str().unwrap().to_string().to_string() },\n                \"updated_at\" => { self.updated_at = (value.clone()).as_str().unwrap().to_string().to_string() }\n                _ => (),\n            }\n    }\n\n    pub fn update(&mut self, p: ArticleParams) -> bool {\n        if !({ let _ = p.title(); false }) { self.set_title(&(p.title())) };\n        if !({ let _ = p.body(); false }) { self.set_body(&(p.body())) };\n        self.save()\n    }\n\n    pub fn _adapter_find_by_id(id: i64) -> Option<Article> {\n        let stmt = Db::prepare(&(format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", \"SELECT id, body, created_at, title, updated_at FROM articles\", \" WHERE \"), \"id = \"), Db::escape_int(id)), \" LIMIT 1\")));\n        let mut result: Option<Article> = None;\n        if Db::step(stmt) { result = Some(Article::from_stmt(stmt)) };\n        Db::finalize(stmt);\n        result\n    }\n\n    pub fn _adapter_all() -> Vec<Article> {\n        let stmt = Db::prepare(\"SELECT id, body, created_at, title, updated_at FROM articles\");\n        let mut results = vec![];\n        while Db::step(stmt) {\n            results.push(Article::from_stmt(stmt))\n        };\n        Db::finalize(stmt);\n        results.clone()\n    }\n\n    pub fn _adapter_insert(&self) -> i64 {\n        Db::exec(&(format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", \"INSERT INTO articles (body, created_at, title, updated_at) VALUES (\", Db::escape_string(&(self.body))), \", \"), Db::escape_string(&(self.created_at))), \", \"), Db::escape_string(&(self.title))), \", \"), Db::escape_string(&(self.updated_at))), \")\")));\n        Db::last_insert_rowid()\n    }\n\n    pub fn _adapter_update(&self) {\n        Db::exec(&(format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", \"UPDATE articles SET \", \"body = \"), Db::escape_string(&(self.body))), \", created_at = \"), Db::escape_string(&(self.created_at))), \", title = \"), Db::escape_string(&(self.title))), \", updated_at = \"), Db::escape_string(&(self.updated_at))), \" WHERE \"), \"id = \"), Db::escape_int(self.id))));\n    }\n\n    pub fn _adapter_delete(&self) {\n        Db::exec(&(format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", \"DELETE FROM articles\", \" WHERE \"), \"id = \"), Db::escape_int(self.id))));\n    }\n\n    pub fn _adapter_count() -> i64 {\n        let stmt = Db::prepare(\"SELECT COUNT(*) FROM articles\");\n        Db::step(stmt);\n        let result = Db::column_int(stmt, 0_i64);\n        Db::finalize(stmt);\n        result\n    }\n\n    pub fn _adapter_exists_by_id(id: i64) -> bool {\n        let stmt = Db::prepare(&(format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", \"SELECT 1 FROM articles\", \" WHERE \"), \"id = \"), Db::escape_int(id)), \" LIMIT 1\")));\n        let result = Db::step(stmt);\n        Db::finalize(stmt);\n        result\n    }\n\n    pub fn _adapter_truncate() {\n        Db::exec(\"DELETE FROM articles\");\n        Db::exec(\"DELETE FROM sqlite_sequence WHERE name = 'articles'\");\n    }\n\n    pub fn _adapter_reload(&mut self) -> Article {\n        let stmt = Db::prepare(&(format!(\"{}{}\", format!(\"{}{}\", \"SELECT id, body, created_at, title, updated_at FROM articles WHERE id = \", Db::escape_int(self.id)), \" LIMIT 1\")));\n        if Db::step(stmt) { { self.id = Db::column_int(stmt, 0_i64);\n        self.body = Db::column_text(stmt, 1_i64).to_string();\n        self.created_at = Db::column_text(stmt, 2_i64).to_string();\n        self.title = Db::column_text(stmt, 3_i64).to_string();\n        self.updated_at = Db::column_text(stmt, 4_i64).to_string();\n        self.mark_persisted_bang() } };\n        Db::finalize(stmt);\n        self.clone()\n    }\n\n    pub fn from_params(p: ArticleParams) -> Article {\n        let mut instance = Article::new(std::collections::HashMap::new());\n        instance.set_title(&(p.title()));\n        instance.set_body(&(p.body()));\n        instance.clone()\n    }\n\n    pub fn validate(&self) {\n        if false || self.title.is_empty() { crate::errors_ext::validation_errors_push((\"title can't be blank\").to_string()) };\n        if false || self.body.is_empty() { crate::errors_ext::validation_errors_push((\"body can't be blank\").to_string()) };\n        if !(false) { { let mut len = self.body.len() as i64;\n        if len < 10_i64 { crate::errors_ext::validation_errors_push((\"body is too short (minimum is 10)\").to_string()) }; } };\n    }\n\n    pub fn comments(&self) -> Vec<Comment> {\n        if self.comments_loaded { return self.comments_cache.clone() };\n        let stmt = Db::prepare(&(format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", \"SELECT id, article_id, body, commenter, created_at, updated_at FROM comments\", \" WHERE \"), \"article_id = \"), Db::escape_int(self.id))));\n        let mut results = vec![];\n        while Db::step(stmt) {\n            results.push(Comment::from_stmt(stmt))\n        };\n        Db::finalize(stmt);\n        results.clone()\n    }\n\n    pub fn _preload_comments(&mut self, list: Vec<Comment>) {\n        self.comments_cache = list;\n        self.comments_loaded = true;\n    }\n\n    pub fn before_destroy(&self) {\n        self.comments().iter_mut().for_each(|c| { c.destroy(); });\n    }\n\n    pub fn dom_prefix() -> String {\n        (\"article\").to_string()\n    }\n\n    pub fn after_create_commit(&self) {\n        Broadcasts::prepend(std::collections::HashMap::from([(\"stream\", (\"articles\").to_string()), (\"target\", (\"articles\").to_string()), (\"html\", Articles::article(self.clone(), None, None))]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>());\n    }\n\n    pub fn after_update_commit(&self) {\n        Broadcasts::replace(std::collections::HashMap::from([(\"stream\", (\"articles\").to_string()), (\"target\", format!(\"article_{}\", self.id)), (\"html\", Articles::article(self.clone(), None, None))]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>());\n    }\n\n    pub fn after_destroy_commit(&self) {\n        Broadcasts::remove(std::collections::HashMap::from([(\"stream\", (\"articles\").to_string()), (\"target\", format!(\"article_{}\", self.id))]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>());\n    }\n}\n\nimpl Article {\npub fn mark_persisted_bang(&mut self) { }\npub fn errors(&self) -> Vec<String> { crate::errors_ext::validation_errors_snapshot() }\npub fn save(&mut self) -> bool {\ncrate::errors_ext::validation_errors_clear();\nself.validate();\nif !crate::errors_ext::validation_errors_is_empty() { return false; }\nif self.id == 0 { self.id = self._adapter_insert(); }\nelse if Self::_adapter_exists_by_id(self.id) { self._adapter_update(); }\nelse { let _ = self._adapter_insert(); }\ntrue\n}\npub fn destroy(&mut self) { self.before_destroy(); self._adapter_delete(); }\npub fn exists(id: i64) -> bool { Self::_adapter_exists_by_id(id) }\npub fn persisted(&self) -> bool { self.id != 0 }\npub fn find(id: i64) -> Self { Self::_adapter_find_by_id(id).expect(\"record not found\") }\npub fn count() -> i64 { Self::_adapter_count() }\npub fn all() -> Vec<Article> { Self::_adapter_all() }\npub fn last() -> Option<Article> { Self::_adapter_all().last().cloned() }\npub fn reload(&mut self) { let _ = self._adapter_reload(); }\npub fn create(attrs: std::collections::HashMap<String, serde_json::Value>) -> Article { let mut m = Self::new(attrs); m.save(); m }\n}\n"},{"path":"src/models/article_params.rs","content":"#[allow(unused_imports)]\nuse crate::param_value::ParamValue;\n#[allow(unused_imports)]\nuse crate::db::Db;\n#[allow(unused_imports)]\nuse crate::broadcasts::Broadcasts;\n// Sibling-model glob so cross-file refs (Article ↔ Comment, the\n// `<Model>Row` typed-row pair) resolve through the `pub use` chain\n// that `emit_models_mod_rs` lays into `src/models/mod.rs`. Rust\n// doesn't auto-import siblings — the lowerer leaves bare `Article`\n// / `Comment` / `ArticleRow` / `CommentRow` references at every\n// `Comment.belongs_to :article`, `has_many :comments`, and\n// `instantiate(row)` call site; without this line each of those\n// E0433s independently.\n#[allow(unused_imports)]\nuse crate::models::*;\n// View modules (Phase 5b stubs). The model lowerer's broadcasts_to\n// expansion emits `Articles::article(self)` / `Comments::comment\n// (self)` partial renders inside `after_*_commit` callback bodies;\n// the actual view emit isn't yet wired through rust2, so each\n// LibraryClass produced by `lower_views_to_library_classes` lands\n// here as a fully-generic `String::new()` stub. Replace with real\n// view emit when Phase 5b lands.\n#[allow(unused_imports)]\nuse crate::views::*;\n#[derive(Clone, Default)]\npub struct ArticleParams {\n    pub title: String,\n    pub body: String,\n}\n\nimpl ArticleParams {\n    pub fn new() -> Self {\n        let mut title: String = (\"\").to_string();\n        let mut body: String = (\"\").to_string();\n        Self { title, body }\n    }\n\n    pub fn title(&self) -> String {\n        self.title.clone()\n    }\n\n    pub fn set_title(&mut self, value: &str) {\n        self.title = (value).to_string()\n    }\n\n    pub fn body(&self) -> String {\n        self.body.clone()\n    }\n\n    pub fn set_body(&mut self, value: &str) {\n        self.body = (value).to_string()\n    }\n\n    pub fn from_raw(params: std::collections::HashMap<String, ParamValue>) -> ArticleParams {\n        let mut raw_sub = params.get(\"article\").cloned().unwrap_or(serde_json::Value::Object(serde_json::Map::new()));\n        let mut sub = if raw_sub.clone().is_object() { raw_sub.clone().as_object().cloned().unwrap_or_default().into_iter().collect::<std::collections::HashMap<String, serde_json::Value>>() } else { std::collections::HashMap::new() };\n        let mut instance = ArticleParams::new();\n        let mut raw_title = sub.clone().get(\"title\").cloned().unwrap_or(serde_json::Value::from(\"\"));\n        instance.set_title(if raw_title.clone().is_string() { raw_title.as_str().unwrap() } else { \"\" });\n        let mut raw_body = sub.clone().get(\"body\").cloned().unwrap_or(serde_json::Value::from(\"\"));\n        instance.set_body(if raw_body.clone().is_string() { raw_body.as_str().unwrap() } else { \"\" });\n        instance.clone()\n    }\n\n    pub fn to_h(&self) -> std::collections::HashMap<String, String> {\n        std::collections::HashMap::from([(\"title\".to_string(), self.title.clone()), (\"body\".to_string(), self.body.clone())])\n    }\n}\n"},{"path":"src/models/article_row.rs","content":"#[allow(unused_imports)]\nuse crate::param_value::ParamValue;\n#[allow(unused_imports)]\nuse crate::db::Db;\n#[allow(unused_imports)]\nuse crate::broadcasts::Broadcasts;\n// Sibling-model glob so cross-file refs (Article ↔ Comment, the\n// `<Model>Row` typed-row pair) resolve through the `pub use` chain\n// that `emit_models_mod_rs` lays into `src/models/mod.rs`. Rust\n// doesn't auto-import siblings — the lowerer leaves bare `Article`\n// / `Comment` / `ArticleRow` / `CommentRow` references at every\n// `Comment.belongs_to :article`, `has_many :comments`, and\n// `instantiate(row)` call site; without this line each of those\n// E0433s independently.\n#[allow(unused_imports)]\nuse crate::models::*;\n// View modules (Phase 5b stubs). The model lowerer's broadcasts_to\n// expansion emits `Articles::article(self)` / `Comments::comment\n// (self)` partial renders inside `after_*_commit` callback bodies;\n// the actual view emit isn't yet wired through rust2, so each\n// LibraryClass produced by `lower_views_to_library_classes` lands\n// here as a fully-generic `String::new()` stub. Replace with real\n// view emit when Phase 5b lands.\n#[allow(unused_imports)]\nuse crate::views::*;\n#[derive(Clone, Default)]\npub struct ArticleRow {\n    pub id: i64,\n    pub body: String,\n    pub created_at: String,\n    pub title: String,\n    pub updated_at: String,\n}\n\nimpl ArticleRow {\n    pub fn id(&self) -> i64 {\n        self.id\n    }\n\n    pub fn set_id(&mut self, value: i64) {\n        self.id = value\n    }\n\n    pub fn body(&self) -> String {\n        self.body.clone()\n    }\n\n    pub fn set_body(&mut self, value: &str) {\n        self.body = (value).to_string()\n    }\n\n    pub fn created_at(&self) -> String {\n        self.created_at.clone()\n    }\n\n    pub fn set_created_at(&mut self, value: &str) {\n        self.created_at = (value).to_string()\n    }\n\n    pub fn title(&self) -> String {\n        self.title.clone()\n    }\n\n    pub fn set_title(&mut self, value: &str) {\n        self.title = (value).to_string()\n    }\n\n    pub fn updated_at(&self) -> String {\n        self.updated_at.clone()\n    }\n\n    pub fn set_updated_at(&mut self, value: &str) {\n        self.updated_at = (value).to_string()\n    }\n\n    pub fn new() -> Self {\n        let mut id: i64 = 0_i64;\n        let mut body: String = (\"\").to_string();\n        let mut created_at: String = (\"\").to_string();\n        let mut title: String = (\"\").to_string();\n        let mut updated_at: String = (\"\").to_string();\n        Self { id, body, created_at, title, updated_at }\n    }\n\n    pub fn from_raw(row: std::collections::HashMap<String, serde_json::Value>) -> ArticleRow {\n        let mut instance = ArticleRow::new();\n        instance.set_id((row.clone().get(\"id\").cloned().unwrap_or(serde_json::Value::from(0_i64))).as_i64().unwrap());\n        instance.set_body((row.clone()[\"body\"]).as_str().unwrap());\n        instance.set_created_at((row.clone()[\"created_at\"]).as_str().unwrap());\n        instance.set_title((row.clone()[\"title\"]).as_str().unwrap());\n        instance.set_updated_at((row.clone()[\"updated_at\"]).as_str().unwrap());\n        instance.clone()\n    }\n}\n"},{"path":"src/models/comment.rs","content":"#[allow(unused_imports)]\nuse crate::param_value::ParamValue;\n#[allow(unused_imports)]\nuse crate::db::Db;\n#[allow(unused_imports)]\nuse crate::broadcasts::Broadcasts;\n// Sibling-model glob so cross-file refs (Article ↔ Comment, the\n// `<Model>Row` typed-row pair) resolve through the `pub use` chain\n// that `emit_models_mod_rs` lays into `src/models/mod.rs`. Rust\n// doesn't auto-import siblings — the lowerer leaves bare `Article`\n// / `Comment` / `ArticleRow` / `CommentRow` references at every\n// `Comment.belongs_to :article`, `has_many :comments`, and\n// `instantiate(row)` call site; without this line each of those\n// E0433s independently.\n#[allow(unused_imports)]\nuse crate::models::*;\n// View modules (Phase 5b stubs). The model lowerer's broadcasts_to\n// expansion emits `Articles::article(self)` / `Comments::comment\n// (self)` partial renders inside `after_*_commit` callback bodies;\n// the actual view emit isn't yet wired through rust2, so each\n// LibraryClass produced by `lower_views_to_library_classes` lands\n// here as a fully-generic `String::new()` stub. Replace with real\n// view emit when Phase 5b lands.\n#[allow(unused_imports)]\nuse crate::views::*;\n#[derive(Clone, Default)]\npub struct Comment {\n    pub id: i64,\n    pub article_id: i64,\n    pub body: String,\n    pub commenter: String,\n    pub created_at: String,\n    pub updated_at: String,\n}\n\nimpl Comment {\n    pub fn id(&self) -> i64 {\n        self.id\n    }\n\n    pub fn set_id(&mut self, value: i64) {\n        self.id = value\n    }\n\n    pub fn article_id(&self) -> i64 {\n        self.article_id\n    }\n\n    pub fn set_article_id(&mut self, value: i64) {\n        self.article_id = value\n    }\n\n    pub fn body(&self) -> String {\n        self.body.clone()\n    }\n\n    pub fn set_body(&mut self, value: &str) {\n        self.body = (value).to_string()\n    }\n\n    pub fn commenter(&self) -> String {\n        self.commenter.clone()\n    }\n\n    pub fn set_commenter(&mut self, value: &str) {\n        self.commenter = (value).to_string()\n    }\n\n    pub fn created_at(&self) -> String {\n        self.created_at.clone()\n    }\n\n    pub fn set_created_at(&mut self, value: &str) {\n        self.created_at = (value).to_string()\n    }\n\n    pub fn updated_at(&self) -> String {\n        self.updated_at.clone()\n    }\n\n    pub fn set_updated_at(&mut self, value: &str) {\n        self.updated_at = (value).to_string()\n    }\n\n    pub fn table_name() -> String {\n        (\"comments\").to_string()\n    }\n\n    pub fn schema_columns() -> Vec<String> {\n        vec![\"id\".to_string(), \"article_id\".to_string(), \"body\".to_string(), \"commenter\".to_string(), \"created_at\".to_string(), \"updated_at\".to_string()]\n    }\n\n    pub fn instantiate(row: std::collections::HashMap<String, serde_json::Value>) -> Comment {\n        let mut instance = Comment::from_row(CommentRow::from_raw(row.clone()));\n        instance.mark_persisted_bang();\n        instance.clone()\n    }\n\n    pub fn from_row(row: CommentRow) -> Comment {\n        let mut instance = Comment::new(std::collections::HashMap::new());\n        instance.set_id(row.id());\n        instance.set_article_id(row.article_id());\n        instance.set_body(&(row.body()));\n        instance.set_commenter(&(row.commenter()));\n        instance.set_created_at(&(row.created_at()));\n        instance.set_updated_at(&(row.updated_at()));\n        instance.clone()\n    }\n\n    pub fn from_stmt(stmt: i64) -> Comment {\n        let mut instance = Comment::new(std::collections::HashMap::new());\n        instance.set_id(Db::column_int(stmt, 0_i64));\n        instance.set_article_id(Db::column_int(stmt, 1_i64));\n        instance.set_body(&(Db::column_text(stmt, 2_i64)));\n        instance.set_commenter(&(Db::column_text(stmt, 3_i64)));\n        instance.set_created_at(&(Db::column_text(stmt, 4_i64)));\n        instance.set_updated_at(&(Db::column_text(stmt, 5_i64)));\n        instance.mark_persisted_bang();\n        instance.clone()\n    }\n\n    pub fn assign_from_row(&mut self, row: std::collections::HashMap<String, serde_json::Value>) {\n        self.set_id((row.clone()[\"id\"]).as_i64().unwrap());\n        self.set_article_id((row.clone()[\"article_id\"]).as_i64().unwrap());\n        self.set_body((row.clone()[\"body\"]).as_str().unwrap());\n        self.set_commenter((row.clone()[\"commenter\"]).as_str().unwrap());\n        self.set_created_at((row.clone()[\"created_at\"]).as_str().unwrap());\n        self.set_updated_at((row.clone()[\"updated_at\"]).as_str().unwrap());\n    }\n\n    pub fn new(attrs: std::collections::HashMap<String, serde_json::Value>) -> Self {\n        /* TODO rust2: ExprNode::Discriminant(22) */;\n        let id = (attrs.clone().get(\"id\").cloned().unwrap_or(serde_json::Value::from(0_i64))).as_i64().unwrap();\n        let article_id = (attrs.clone().get(\"article_id\").cloned().unwrap_or(serde_json::Value::from(0_i64))).as_i64().unwrap();\n        let body = (attrs.clone().get(\"body\").cloned().unwrap_or(serde_json::Value::from(\"\"))).as_str().unwrap().to_string();\n        let commenter = (attrs.clone().get(\"commenter\").cloned().unwrap_or(serde_json::Value::from(\"\"))).as_str().unwrap().to_string();\n        let created_at = (attrs.clone().get(\"created_at\").cloned().unwrap_or(serde_json::Value::from(\"\"))).as_str().unwrap().to_string();\n        let updated_at = (attrs.clone().get(\"updated_at\").cloned().unwrap_or(serde_json::Value::from(\"\"))).as_str().unwrap().to_string();\n        Self { id, article_id, body, commenter, created_at, updated_at }\n    }\n\n    pub fn attributes(&self) -> std::collections::HashMap<String, serde_json::Value> {\n        std::collections::HashMap::from([(\"article_id\".to_string(), serde_json::Value::from(self.article_id)), (\"body\".to_string(), serde_json::Value::from(self.body.clone())), (\"commenter\".to_string(), serde_json::Value::from(self.commenter.clone())), (\"created_at\".to_string(), serde_json::Value::from(self.created_at.clone())), (\"updated_at\".to_string(), serde_json::Value::from(self.updated_at.clone()))])\n    }\n\n    pub fn get_index(&self, name: &str) -> serde_json::Value {\n        match name {\n                \"id\" => { serde_json::Value::from(self.id) },\n                \"article_id\" => { serde_json::Value::from(self.article_id) },\n                \"body\" => { serde_json::Value::from(self.body.clone()) },\n                \"commenter\" => { serde_json::Value::from(self.commenter.clone()) },\n                \"created_at\" => { serde_json::Value::from(self.created_at.clone()) },\n                \"updated_at\" => { serde_json::Value::from(self.updated_at.clone()) }\n                _ => serde_json::Value::Null,\n            }\n    }\n\n    pub fn set_index(&mut self, name: &str, value: serde_json::Value) {\n        match name {\n                \"id\" => { self.id = (value.clone()).as_i64().unwrap() },\n                \"article_id\" => { self.article_id = (value.clone()).as_i64().unwrap() },\n                \"body\" => { self.body = (value.clone()).as_str().unwrap().to_string().to_string() },\n                \"commenter\" => { self.commenter = (value.clone()).as_str().unwrap().to_string().to_string() },\n                \"created_at\" => { self.created_at = (value.clone()).as_str().unwrap().to_string().to_string() },\n                \"updated_at\" => { self.updated_at = (value.clone()).as_str().unwrap().to_string().to_string() }\n                _ => (),\n            }\n    }\n\n    pub fn update(&mut self, p: CommentParams) -> bool {\n        if !({ let _ = p.commenter(); false }) { self.set_commenter(&(p.commenter())) };\n        if !({ let _ = p.body(); false }) { self.set_body(&(p.body())) };\n        self.save()\n    }\n\n    pub fn _adapter_find_by_id(id: i64) -> Option<Comment> {\n        let stmt = Db::prepare(&(format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", \"SELECT id, article_id, body, commenter, created_at, updated_at FROM comments\", \" WHERE \"), \"id = \"), Db::escape_int(id)), \" LIMIT 1\")));\n        let mut result: Option<Comment> = None;\n        if Db::step(stmt) { result = Some(Comment::from_stmt(stmt)) };\n        Db::finalize(stmt);\n        result\n    }\n\n    pub fn _adapter_all() -> Vec<Comment> {\n        let stmt = Db::prepare(\"SELECT id, article_id, body, commenter, created_at, updated_at FROM comments\");\n        let mut results = vec![];\n        while Db::step(stmt) {\n            results.push(Comment::from_stmt(stmt))\n        };\n        Db::finalize(stmt);\n        results.clone()\n    }\n\n    pub fn _adapter_insert(&self) -> i64 {\n        Db::exec(&(format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", \"INSERT INTO comments (article_id, body, commenter, created_at, updated_at) VALUES (\", Db::escape_int(self.article_id)), \", \"), Db::escape_string(&(self.body))), \", \"), Db::escape_string(&(self.commenter))), \", \"), Db::escape_string(&(self.created_at))), \", \"), Db::escape_string(&(self.updated_at))), \")\")));\n        Db::last_insert_rowid()\n    }\n\n    pub fn _adapter_update(&self) {\n        Db::exec(&(format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", \"UPDATE comments SET \", \"article_id = \"), Db::escape_int(self.article_id)), \", body = \"), Db::escape_string(&(self.body))), \", commenter = \"), Db::escape_string(&(self.commenter))), \", created_at = \"), Db::escape_string(&(self.created_at))), \", updated_at = \"), Db::escape_string(&(self.updated_at))), \" WHERE \"), \"id = \"), Db::escape_int(self.id))));\n    }\n\n    pub fn _adapter_delete(&self) {\n        Db::exec(&(format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", \"DELETE FROM comments\", \" WHERE \"), \"id = \"), Db::escape_int(self.id))));\n    }\n\n    pub fn _adapter_count() -> i64 {\n        let stmt = Db::prepare(\"SELECT COUNT(*) FROM comments\");\n        Db::step(stmt);\n        let result = Db::column_int(stmt, 0_i64);\n        Db::finalize(stmt);\n        result\n    }\n\n    pub fn _adapter_exists_by_id(id: i64) -> bool {\n        let stmt = Db::prepare(&(format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", \"SELECT 1 FROM comments\", \" WHERE \"), \"id = \"), Db::escape_int(id)), \" LIMIT 1\")));\n        let result = Db::step(stmt);\n        Db::finalize(stmt);\n        result\n    }\n\n    pub fn _adapter_truncate() {\n        Db::exec(\"DELETE FROM comments\");\n        Db::exec(\"DELETE FROM sqlite_sequence WHERE name = 'comments'\");\n    }\n\n    pub fn _adapter_reload(&mut self) -> Comment {\n        let stmt = Db::prepare(&(format!(\"{}{}\", format!(\"{}{}\", \"SELECT id, article_id, body, commenter, created_at, updated_at FROM comments WHERE id = \", Db::escape_int(self.id)), \" LIMIT 1\")));\n        if Db::step(stmt) { { self.id = Db::column_int(stmt, 0_i64);\n        self.article_id = Db::column_int(stmt, 1_i64);\n        self.body = Db::column_text(stmt, 2_i64).to_string();\n        self.commenter = Db::column_text(stmt, 3_i64).to_string();\n        self.created_at = Db::column_text(stmt, 4_i64).to_string();\n        self.updated_at = Db::column_text(stmt, 5_i64).to_string();\n        self.mark_persisted_bang() } };\n        Db::finalize(stmt);\n        self.clone()\n    }\n\n    pub fn from_params(p: CommentParams) -> Comment {\n        let mut instance = Comment::new(std::collections::HashMap::new());\n        instance.set_commenter(&(p.commenter()));\n        instance.set_body(&(p.body()));\n        instance.clone()\n    }\n\n    pub fn validate(&self) {\n        if false || self.commenter.is_empty() { crate::errors_ext::validation_errors_push((\"commenter can't be blank\").to_string()) };\n        if false || self.body.is_empty() { crate::errors_ext::validation_errors_push((\"body can't be blank\").to_string()) };\n        if false || self.article_id == 0_i64 || !(Article::exists(self.article_id)) { crate::errors_ext::validation_errors_push((\"article must exist\").to_string()) };\n    }\n\n    pub fn article(&self) -> Option<Article> {\n        if !(self.article_id == 0_i64) { { let stmt = Db::prepare(&(format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", \"SELECT id, body, created_at, title, updated_at FROM articles\", \" WHERE \"), \"id = \"), Db::escape_int(self.article_id)), \" LIMIT 1\")));\n        let mut result: Option<Article> = None;\n        if Db::step(stmt) { result = Some(Article::from_stmt(stmt)) };\n        Db::finalize(stmt);\n        result } } else { None }\n    }\n\n    pub fn dom_prefix() -> String {\n        (\"comment\").to_string()\n    }\n\n    pub fn after_create_commit(&self) {\n        Broadcasts::append(std::collections::HashMap::from([(\"stream\", format!(\"article_{}_comments\", self.article_id)), (\"target\", (\"comments\").to_string()), (\"html\", Comments::comment(self.clone(), None, None))]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>());\n        let Some(parent) = self.article() else { return };\n        Broadcasts::replace(std::collections::HashMap::from([(\"stream\", (\"articles\").to_string()), (\"target\", format!(\"article_{}\", parent.id())), (\"html\", Articles::article(parent.clone().clone(), None, None))]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>());\n    }\n\n    pub fn after_update_commit(&self) {\n        Broadcasts::replace(std::collections::HashMap::from([(\"stream\", format!(\"article_{}_comments\", self.article_id)), (\"target\", format!(\"comment_{}\", self.id)), (\"html\", Comments::comment(self.clone(), None, None))]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>());\n    }\n\n    pub fn after_destroy_commit(&self) {\n        Broadcasts::remove(std::collections::HashMap::from([(\"stream\", format!(\"article_{}_comments\", self.article_id)), (\"target\", format!(\"comment_{}\", self.id))]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>());\n        let Some(parent) = self.article() else { return };\n        Broadcasts::replace(std::collections::HashMap::from([(\"stream\", (\"articles\").to_string()), (\"target\", format!(\"article_{}\", parent.id())), (\"html\", Articles::article(parent.clone().clone(), None, None))]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>());\n    }\n}\n\nimpl Comment {\npub fn mark_persisted_bang(&mut self) { }\npub fn errors(&self) -> Vec<String> { crate::errors_ext::validation_errors_snapshot() }\npub fn save(&mut self) -> bool {\ncrate::errors_ext::validation_errors_clear();\nself.validate();\nif !crate::errors_ext::validation_errors_is_empty() { return false; }\nif self.id == 0 { self.id = self._adapter_insert(); }\nelse if Self::_adapter_exists_by_id(self.id) { self._adapter_update(); }\nelse { let _ = self._adapter_insert(); }\ntrue\n}\npub fn destroy(&mut self) { self._adapter_delete(); }\npub fn exists(id: i64) -> bool { Self::_adapter_exists_by_id(id) }\npub fn persisted(&self) -> bool { self.id != 0 }\npub fn find(id: i64) -> Self { Self::_adapter_find_by_id(id).expect(\"record not found\") }\npub fn count() -> i64 { Self::_adapter_count() }\npub fn all() -> Vec<Comment> { Self::_adapter_all() }\npub fn last() -> Option<Comment> { Self::_adapter_all().last().cloned() }\npub fn reload(&mut self) { let _ = self._adapter_reload(); }\npub fn create(attrs: std::collections::HashMap<String, serde_json::Value>) -> Comment { let mut m = Self::new(attrs); m.save(); m }\n}\n"},{"path":"src/models/comment_params.rs","content":"#[allow(unused_imports)]\nuse crate::param_value::ParamValue;\n#[allow(unused_imports)]\nuse crate::db::Db;\n#[allow(unused_imports)]\nuse crate::broadcasts::Broadcasts;\n// Sibling-model glob so cross-file refs (Article ↔ Comment, the\n// `<Model>Row` typed-row pair) resolve through the `pub use` chain\n// that `emit_models_mod_rs` lays into `src/models/mod.rs`. Rust\n// doesn't auto-import siblings — the lowerer leaves bare `Article`\n// / `Comment` / `ArticleRow` / `CommentRow` references at every\n// `Comment.belongs_to :article`, `has_many :comments`, and\n// `instantiate(row)` call site; without this line each of those\n// E0433s independently.\n#[allow(unused_imports)]\nuse crate::models::*;\n// View modules (Phase 5b stubs). The model lowerer's broadcasts_to\n// expansion emits `Articles::article(self)` / `Comments::comment\n// (self)` partial renders inside `after_*_commit` callback bodies;\n// the actual view emit isn't yet wired through rust2, so each\n// LibraryClass produced by `lower_views_to_library_classes` lands\n// here as a fully-generic `String::new()` stub. Replace with real\n// view emit when Phase 5b lands.\n#[allow(unused_imports)]\nuse crate::views::*;\n#[derive(Clone, Default)]\npub struct CommentParams {\n    pub commenter: String,\n    pub body: String,\n}\n\nimpl CommentParams {\n    pub fn new() -> Self {\n        let mut commenter: String = (\"\").to_string();\n        let mut body: String = (\"\").to_string();\n        Self { commenter, body }\n    }\n\n    pub fn commenter(&self) -> String {\n        self.commenter.clone()\n    }\n\n    pub fn set_commenter(&mut self, value: &str) {\n        self.commenter = (value).to_string()\n    }\n\n    pub fn body(&self) -> String {\n        self.body.clone()\n    }\n\n    pub fn set_body(&mut self, value: &str) {\n        self.body = (value).to_string()\n    }\n\n    pub fn from_raw(params: std::collections::HashMap<String, ParamValue>) -> CommentParams {\n        let mut raw_sub = params.get(\"comment\").cloned().unwrap_or(serde_json::Value::Object(serde_json::Map::new()));\n        let mut sub = if raw_sub.clone().is_object() { raw_sub.clone().as_object().cloned().unwrap_or_default().into_iter().collect::<std::collections::HashMap<String, serde_json::Value>>() } else { std::collections::HashMap::new() };\n        let mut instance = CommentParams::new();\n        let mut raw_commenter = sub.clone().get(\"commenter\").cloned().unwrap_or(serde_json::Value::from(\"\"));\n        instance.set_commenter(if raw_commenter.clone().is_string() { raw_commenter.as_str().unwrap() } else { \"\" });\n        let mut raw_body = sub.clone().get(\"body\").cloned().unwrap_or(serde_json::Value::from(\"\"));\n        instance.set_body(if raw_body.clone().is_string() { raw_body.as_str().unwrap() } else { \"\" });\n        instance.clone()\n    }\n\n    pub fn to_h(&self) -> std::collections::HashMap<String, String> {\n        std::collections::HashMap::from([(\"commenter\".to_string(), self.commenter.clone()), (\"body\".to_string(), self.body.clone())])\n    }\n}\n"},{"path":"src/models/comment_row.rs","content":"#[allow(unused_imports)]\nuse crate::param_value::ParamValue;\n#[allow(unused_imports)]\nuse crate::db::Db;\n#[allow(unused_imports)]\nuse crate::broadcasts::Broadcasts;\n// Sibling-model glob so cross-file refs (Article ↔ Comment, the\n// `<Model>Row` typed-row pair) resolve through the `pub use` chain\n// that `emit_models_mod_rs` lays into `src/models/mod.rs`. Rust\n// doesn't auto-import siblings — the lowerer leaves bare `Article`\n// / `Comment` / `ArticleRow` / `CommentRow` references at every\n// `Comment.belongs_to :article`, `has_many :comments`, and\n// `instantiate(row)` call site; without this line each of those\n// E0433s independently.\n#[allow(unused_imports)]\nuse crate::models::*;\n// View modules (Phase 5b stubs). The model lowerer's broadcasts_to\n// expansion emits `Articles::article(self)` / `Comments::comment\n// (self)` partial renders inside `after_*_commit` callback bodies;\n// the actual view emit isn't yet wired through rust2, so each\n// LibraryClass produced by `lower_views_to_library_classes` lands\n// here as a fully-generic `String::new()` stub. Replace with real\n// view emit when Phase 5b lands.\n#[allow(unused_imports)]\nuse crate::views::*;\n#[derive(Clone, Default)]\npub struct CommentRow {\n    pub id: i64,\n    pub article_id: i64,\n    pub body: String,\n    pub commenter: String,\n    pub created_at: String,\n    pub updated_at: String,\n}\n\nimpl CommentRow {\n    pub fn id(&self) -> i64 {\n        self.id\n    }\n\n    pub fn set_id(&mut self, value: i64) {\n        self.id = value\n    }\n\n    pub fn article_id(&self) -> i64 {\n        self.article_id\n    }\n\n    pub fn set_article_id(&mut self, value: i64) {\n        self.article_id = value\n    }\n\n    pub fn body(&self) -> String {\n        self.body.clone()\n    }\n\n    pub fn set_body(&mut self, value: &str) {\n        self.body = (value).to_string()\n    }\n\n    pub fn commenter(&self) -> String {\n        self.commenter.clone()\n    }\n\n    pub fn set_commenter(&mut self, value: &str) {\n        self.commenter = (value).to_string()\n    }\n\n    pub fn created_at(&self) -> String {\n        self.created_at.clone()\n    }\n\n    pub fn set_created_at(&mut self, value: &str) {\n        self.created_at = (value).to_string()\n    }\n\n    pub fn updated_at(&self) -> String {\n        self.updated_at.clone()\n    }\n\n    pub fn set_updated_at(&mut self, value: &str) {\n        self.updated_at = (value).to_string()\n    }\n\n    pub fn new() -> Self {\n        let mut id: i64 = 0_i64;\n        let mut article_id: i64 = 0_i64;\n        let mut body: String = (\"\").to_string();\n        let mut commenter: String = (\"\").to_string();\n        let mut created_at: String = (\"\").to_string();\n        let mut updated_at: String = (\"\").to_string();\n        Self { id, article_id, body, commenter, created_at, updated_at }\n    }\n\n    pub fn from_raw(row: std::collections::HashMap<String, serde_json::Value>) -> CommentRow {\n        let mut instance = CommentRow::new();\n        instance.set_id((row.clone().get(\"id\").cloned().unwrap_or(serde_json::Value::from(0_i64))).as_i64().unwrap());\n        instance.set_article_id((row.clone().get(\"article_id\").cloned().unwrap_or(serde_json::Value::from(0_i64))).as_i64().unwrap());\n        instance.set_body((row.clone()[\"body\"]).as_str().unwrap());\n        instance.set_commenter((row.clone()[\"commenter\"]).as_str().unwrap());\n        instance.set_created_at((row.clone()[\"created_at\"]).as_str().unwrap());\n        instance.set_updated_at((row.clone()[\"updated_at\"]).as_str().unwrap());\n        instance.clone()\n    }\n}\n"},{"path":"src/models/mod.rs","content":"// Generated by Roundhouse (rust2).\n\npub mod application_record;\npub mod article;\npub mod article_params;\npub mod article_row;\npub mod comment;\npub mod comment_params;\npub mod comment_row;\npub use application_record::ApplicationRecord;\npub use article::Article;\npub use article_params::ArticleParams;\npub use article_row::ArticleRow;\npub use comment::Comment;\npub use comment_params::CommentParams;\npub use comment_row::CommentRow;\n"},{"path":"src/param_value.rs","content":"//! Recursive Rails-params value type — the rust analog of\n//! `runtime/typescript/param_value.ts` and `runtime/crystal/param_value.cr`.\n//!\n//! Rails request params shape as a recursive tree of String,\n//! Vec<ParamValue>, or HashMap<String, ParamValue>. The TS sibling\n//! declares it as a union of (string | string[] | { [k: string]:\n//! ParamValue }); Crystal as `alias ParamValue = String | Hash(String,\n//! ParamValue) | Array(ParamValue)`.\n//!\n//! In rust2 Phase 3 this is a re-export alias to `serde_json::Value`\n//! — same recursive shape, already familiar to every emit path that\n//! lowers `untyped`. Concrete enum can replace this later if the\n//! typed-value discipline gets tighter (Ty::Untyped reform).\n\npub type ParamValue = serde_json::Value;\n"},{"path":"src/route_helpers.rs","content":"pub struct RouteHelpers;\n\nimpl RouteHelpers {\n    pub fn root_path() -> String {\n        (\"/\").to_string()\n    }\n\n    pub fn articles_path() -> String {\n        (\"/articles\").to_string()\n    }\n\n    pub fn new_article_path() -> String {\n        (\"/articles/new\").to_string()\n    }\n\n    pub fn article_path(id: i64) -> String {\n        format!(\"/articles/{}\", id)\n    }\n\n    pub fn edit_article_path(id: i64) -> String {\n        format!(\"/articles/{}/edit\", id)\n    }\n\n    pub fn article_comments_path(article_id: i64) -> String {\n        format!(\"/articles/{}/comments\", article_id)\n    }\n\n    pub fn article_comment_path(article_id: i64, id: i64) -> String {\n        format!(\"/articles/{}/comments/{}\", article_id, id)\n    }\n}\n\n// Wedge 2c.3 bare-fn compat shims — delegate to `impl RouteHelpers`.\npub fn root_path() -> String { RouteHelpers::root_path() }\npub fn articles_path() -> String { RouteHelpers::articles_path() }\npub fn new_article_path() -> String { RouteHelpers::new_article_path() }\npub fn article_path(id: i64) -> String { RouteHelpers::article_path(id) }\npub fn edit_article_path(id: i64) -> String { RouteHelpers::edit_article_path(id) }\npub fn article_comments_path(article_id: i64) -> String { RouteHelpers::article_comments_path(article_id) }\npub fn article_comment_path(article_id: i64, id: i64) -> String { RouteHelpers::article_comment_path(article_id, id) }\n"},{"path":"src/router.rs","content":"// Generated from runtime/ruby/action_dispatch/router.rb at app emit time.\n// Do not edit by hand — edit the source `.rb` and re-run emit.\n\n#[derive(Clone, Default)]\npub struct Route {\n    pub verb: String,\n    pub pattern: String,\n    pub controller: String,\n    pub action: String,\n}\n\nimpl Route {\n    pub fn verb(&self) -> String {\n        self.verb.clone()\n    }\n\n    pub fn pattern(&self) -> String {\n        self.pattern.clone()\n    }\n\n    pub fn controller(&self) -> String {\n        self.controller.clone()\n    }\n\n    pub fn action(&self) -> String {\n        self.action.clone()\n    }\n\n    pub fn new(verb: &str, pattern: &str, controller: &str, action: &str) -> Self {\n        let mut verb: String = (verb).to_string();\n        let mut pattern: String = (pattern).to_string();\n        let mut controller: String = (controller).to_string();\n        let mut action: String = (action).to_string();\n        Self { verb, pattern, controller, action }\n    }\n}\n\n#[derive(Clone, Default)]\npub struct MatchResult {\n    pub controller: String,\n    pub action: String,\n    pub path_params: std::collections::HashMap<String, String>,\n}\n\nimpl MatchResult {\n    pub fn controller(&self) -> String {\n        self.controller.clone()\n    }\n\n    pub fn action(&self) -> String {\n        self.action.clone()\n    }\n\n    pub fn path_params(&self) -> std::collections::HashMap<String, String> {\n        self.path_params.clone()\n    }\n\n    pub fn new(controller: &str, action: &str, path_params: std::collections::HashMap<String, String>) -> Self {\n        let mut controller: String = (controller).to_string();\n        let mut action: String = (action).to_string();\n        let mut path_params: std::collections::HashMap<String, String> = path_params;\n        Self { controller, action, path_params }\n    }\n}\n\npub struct Router;\n\nimpl Router {\n    pub fn r#match(method: &str, path: &str, table: Vec<Route>) -> Option<MatchResult> {\n        let method_upcase = method.to_string().to_uppercase();\n        let mut i = 0_i64;\n        while i < table.len() as i64 {\n            let mut route = table.clone()[(i) as usize].clone();\n            if route.verb().to_string() == method_upcase { { let mut params = Self::match_pattern(&(route.pattern().to_string()), &(path));\n            if !(params.is_none()) { return Some(MatchResult::new(&(route.controller()), &(route.action()), params.clone().unwrap())) } } };\n            /* TODO rust2: ExprNode::Discriminant(16) */\n        };\n        None\n    }\n\n    pub fn match_pattern(pattern: &str, path: &str) -> Option<std::collections::HashMap<String, String>> {\n        let mut pattern_parts = pattern.split(\"/\").collect::<Vec<&str>>();\n        let mut path_parts = path.split(\"/\").collect::<Vec<&str>>();\n        if (pattern_parts.len() as i64) != path_parts.len() as i64 { return None };\n        let mut params: std::collections::HashMap<String, String> = std::collections::HashMap::new();\n        let mut i = 0_i64;\n        while i < pattern_parts.len() as i64 {\n            let mut pp = pattern_parts.clone()[(i) as usize].clone();\n            let ap = path_parts.clone()[(i) as usize].clone();\n            if pp.starts_with(\":\") { { params.clone().insert((&pp.clone()[(1_i64) as usize..]).to_string(), (ap.clone()).to_string()); } } else { if pp.clone() != ap.clone() { return None } };\n            /* TODO rust2: ExprNode::Discriminant(16) */\n        };\n        Some(params.clone())\n    }\n}\n\n// rust2 wedge 2c.2: concrete axum router.\n#[allow(dead_code)]\npub fn router() -> axum::Router {\n    axum::Router::new()\n        .route(\"/\", axum::routing::get(crate::controllers::articles_controller::_axum_index))\n        .route(\"/.json\", axum::routing::get(crate::controllers::articles_controller::_axum_index))\n        .route(\"/articles\", axum::routing::get(crate::controllers::articles_controller::_axum_index).post(crate::controllers::articles_controller::_axum_create))\n        .route(\"/articles.json\", axum::routing::get(crate::controllers::articles_controller::_axum_index).post(crate::controllers::articles_controller::_axum_create))\n        .route(\"/articles/new\", axum::routing::get(crate::controllers::articles_controller::_axum_new))\n        .route(\"/articles/new.json\", axum::routing::get(crate::controllers::articles_controller::_axum_new))\n        .route(\"/articles/{article_id}/comments\", axum::routing::post(crate::controllers::comments_controller::_axum_create))\n        .route(\"/articles/{article_id}/comments/{id}\", axum::routing::delete(crate::controllers::comments_controller::_axum_destroy))\n        .route(\"/articles/{id}\", axum::routing::get(crate::controllers::articles_controller::_axum_show).patch(crate::controllers::articles_controller::_axum_update).delete(crate::controllers::articles_controller::_axum_destroy))\n        .route(\"/articles/{id}/edit\", axum::routing::get(crate::controllers::articles_controller::_axum_edit))\n        .layer(axum::middleware::from_fn(crate::http::request_format_middleware))\n}\n"},{"path":"src/schema_sql.rs","content":"// Generated by Roundhouse (rust2).\n\npub const CREATE_TABLES: &str = r#\"\nCREATE TABLE IF NOT EXISTS articles (\n  id INTEGER PRIMARY KEY AUTOINCREMENT,\n  body TEXT,\n  created_at TEXT NOT NULL,\n  title TEXT,\n  updated_at TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS comments (\n  id INTEGER PRIMARY KEY AUTOINCREMENT,\n  article_id INTEGER NOT NULL,\n  body TEXT,\n  commenter TEXT,\n  created_at TEXT NOT NULL,\n  updated_at TEXT NOT NULL\n);\nCREATE INDEX IF NOT EXISTS index_comments_on_article_id ON comments (article_id);\n\"#;\n"},{"path":"src/server.rs","content":"//! Roundhouse Rust server runtime.\n//!\n//! Hand-written, shipped alongside generated code (copied in by the\n//! Rust emitter as `src/server.rs`). The emitted `main.rs` calls\n//! `start(router, opts)` to open the production DB, apply\n//! schema, install middleware, and run axum.\n//!\n//! Middleware stack (outer → inner):\n//!   - `layout_wrap` — wraps HTML responses in the full document\n//!     shell (Tailwind + importmap + Action Cable meta). Mirrors\n//!     the TS runtime's `renderLayout`.\n//!   - `method_override` — Rails forms POST `_method=patch|\n//!     put|delete`; we read the form body, rewrite the request\n//!     method, and re-inject the body so downstream `axum::Form`\n//!     extractors still work.\n//!\n//! `start` also mounts `GET /cable` onto the axum router, handing\n//! the upgrade off to `crate::cable::cable_handler`. The route is\n//! always registered — apps that don't use Turbo Streams simply\n//! never receive a client connection, and the handler is cheap\n//! (one OnceLock hashmap check on subscribe).\n\nuse std::net::SocketAddr;\n\nuse axum::{\n    body::Body,\n    extract::Request,\n    http::{header, HeaderValue, Method, StatusCode},\n    middleware::{self, Next},\n    response::Response,\n    routing::get,\n    Router,\n};\nuse tower_http::services::ServeDir;\n\nuse crate::cable;\nuse crate::db;\nuse crate::view_helpers;\n\npub struct StartOptions<'a> {\n    /// File path for the sqlite DB. Defaults to\n    /// `./storage/development.sqlite3`.\n    pub db_path: Option<String>,\n    /// Listener port. Defaults to 3000 or `PORT` env var.\n    pub port: Option<u16>,\n    /// Schema SQL to apply on startup — typically\n    /// `crate::schema_sql::CREATE_TABLES`.\n    pub schema_sql: &'a str,\n    /// Layout renderer — the emitted `render_layouts_application`\n    /// (or equivalent). Called after each non-redirect response\n    /// with the inner view body already stashed via\n    /// `view_helpers::set_yield`. When `None`, the layout-wrap\n    /// middleware falls back to the minimal synthesized shell\n    /// below. Applies to apps that don't emit a layouts/\n    /// application ERB template (e.g. tiny-blog).\n    pub layout: Option<fn() -> String>,\n}\n\n/// Process-wide layout renderer, set by `start`. Read by the\n/// `layout_wrap` middleware. Axum middleware fns can't capture\n/// runtime state cleanly without boxing + extensions, so we use\n/// a static slot — the server runs one app per process.\nstatic LAYOUT_FN: std::sync::OnceLock<fn() -> String> = std::sync::OnceLock::new();\n\n/// Start the server. Opens DB, applies schema, layers middleware\n/// on top of the caller-supplied router, and runs axum until the\n/// process exits.\npub async fn start(router: Router, opts: StartOptions<'_>) {\n    let db_path = opts\n        .db_path\n        .unwrap_or_else(|| \"storage/development.sqlite3\".to_string());\n    let port: u16 = opts.port.unwrap_or_else(|| {\n        std::env::var(\"PORT\")\n            .ok()\n            .and_then(|s| s.parse().ok())\n            .unwrap_or(3000)\n    });\n\n    if let Some(layout) = opts.layout {\n        let _ = LAYOUT_FN.set(layout);\n    }\n\n    db::open_production_db(&db_path, opts.schema_sql);\n\n    // Static assets: serve `static/assets/<name>` for `/assets/*`\n    // requests via tower-http's ServeDir. Mirrors Rails' Propshaft URL\n    // shape — the importmap pins and `stylesheet_link_tag(\"tailwind\")`\n    // both point at /assets/<name>. `bin/rh transpile rust` writes the\n    // actual files (Tailwind compile output, turbo.min.js copy) into\n    // `static/assets/`. ServeDir returns 404 on miss without consuming\n    // the request, so other routes still resolve normally.\n    let app = router\n        .nest_service(\"/assets\", ServeDir::new(\"static/assets\"))\n        .route(\"/cable\", get(cable::cable_handler))\n        .layer(middleware::from_fn(layout_wrap))\n        .layer(middleware::from_fn(method_override));\n\n    let addr: SocketAddr = ([127, 0, 0, 1], port).into();\n    let listener = tokio::net::TcpListener::bind(addr)\n        .await\n        .expect(\"bind listener\");\n    println!(\"Roundhouse server listening on http://localhost:{}\", port);\n    axum::serve(listener, app).await.expect(\"axum serve\");\n}\n\n// ── method override middleware ─────────────────────────────────\n\n/// Rails scaffold forms submit as POST with a hidden `_method`\n/// field when the real verb is PATCH / PUT / DELETE (browsers\n/// don't natively support those in form elements). We consume the\n/// form body, check for `_method`, rewrite the request method, and\n/// re-inject the buffered body so the downstream `Form` extractor\n/// still reads the params.\nasync fn method_override(req: Request, next: Next) -> Response {\n    if req.method() != Method::POST {\n        return next.run(req).await;\n    }\n    let content_type = req\n        .headers()\n        .get(header::CONTENT_TYPE)\n        .and_then(|v| v.to_str().ok())\n        .unwrap_or(\"\")\n        .to_string();\n    if !content_type.starts_with(\"application/x-www-form-urlencoded\") {\n        return next.run(req).await;\n    }\n\n    let (mut parts, body) = req.into_parts();\n    let bytes = match axum::body::to_bytes(body, 16 * 1024 * 1024).await {\n        Ok(b) => b,\n        Err(_) => {\n            return Response::builder()\n                .status(StatusCode::BAD_REQUEST)\n                .body(Body::from(\"body too large\"))\n                .unwrap();\n        }\n    };\n\n    // Scan for `_method=<verb>` in the urlencoded body. We only\n    // look for the first hit — scaffold forms emit a single\n    // _method field; more than that would be a bug upstream.\n    let body_str = std::str::from_utf8(&bytes).unwrap_or(\"\");\n    let mut override_verb: Option<Method> = None;\n    for pair in body_str.split('&') {\n        let (k, v) = match pair.split_once('=') {\n            Some(kv) => kv,\n            None => continue,\n        };\n        if k == \"_method\" {\n            let upper = v.to_ascii_uppercase();\n            override_verb = match upper.as_str() {\n                \"PATCH\" => Some(Method::PATCH),\n                \"PUT\" => Some(Method::PUT),\n                \"DELETE\" => Some(Method::DELETE),\n                _ => None,\n            };\n            break;\n        }\n    }\n    if let Some(m) = override_verb {\n        parts.method = m;\n    }\n\n    let new_req = Request::from_parts(parts, Body::from(bytes));\n    next.run(new_req).await\n}\n\n// ── layout wrap middleware ─────────────────────────────────────\n\n/// Wrap HTML-typed response bodies in the document shell. Only\n/// touches 2xx + 422 responses with `text/html` content type;\n/// redirects pass through untouched, as do non-HTML responses (the\n/// WebSocket upgrade, any JSON endpoints).\nasync fn layout_wrap(req: Request, next: Next) -> Response {\n    // Wipe any stale yield/slot state before the handler runs.\n    // Axum's multi-thread runtime means each worker thread has\n    // its own thread-local; reset covers the current worker.\n    view_helpers::reset_render_state();\n\n    let res = next.run(req).await;\n\n    let status = res.status();\n    let is_html = res\n        .headers()\n        .get(header::CONTENT_TYPE)\n        .and_then(|v| v.to_str().ok())\n        .map(|ct| ct.starts_with(\"text/html\"))\n        .unwrap_or(false);\n\n    // Pass through non-HTML + redirects.\n    if !is_html {\n        return res;\n    }\n    if status.is_redirection() {\n        return res;\n    }\n\n    let (mut parts, body) = res.into_parts();\n    let bytes = match axum::body::to_bytes(body, 16 * 1024 * 1024).await {\n        Ok(b) => b,\n        Err(_) => {\n            return Response::builder()\n                .status(StatusCode::INTERNAL_SERVER_ERROR)\n                .body(Body::from(\"response body too large\"))\n                .unwrap();\n        }\n    };\n    let inner = std::str::from_utf8(&bytes)\n        .map(str::to_string)\n        .unwrap_or_default();\n\n    // If the app has an emitted layout (`opts.layout` was set in\n    // `start`), stash the inner body for `<%= yield %>` and invoke\n    // the layout. Otherwise fall back to the minimal synthesized\n    // shell so apps without an ERB layout still render.\n    let wrapped = if let Some(layout) = LAYOUT_FN.get() {\n        view_helpers::set_yield(&inner);\n        layout()\n    } else {\n        render_layout(&inner)\n    };\n\n    parts.headers.remove(header::CONTENT_LENGTH);\n    parts.headers.insert(\n        header::CONTENT_TYPE,\n        HeaderValue::from_static(\"text/html; charset=utf-8\"),\n    );\n    Response::from_parts(parts, Body::from(wrapped))\n}\n\n/// The document shell. Asset paths point at `/assets/tailwind.css`\n/// + `/assets/turbo.min.js`, served by `tower-http`'s `ServeDir`\n/// mounted on `/assets` (see `start`). `bin/rh transpile rust` is\n/// expected to have populated `static/assets/` with the Tailwind\n/// compile output + a copy of turbo.min.js; without that step the\n/// page is unstyled but functional. Plain `@hotwired/turbo` (not\n/// `@hotwired/turbo-rails`) avoids the latter's transitive\n/// `@rails/actioncable/src` lookup, which would 404 in the browser\n/// — our cable handler at `/cable` matches turbo's default URL.\n/// Inline data-URI favicon suppresses the no-icon 404 on each\n/// page load. Used only as a fallback when no emitter layout is\n/// supplied via `StartOptions::layout`; the emitted Layouts\n/// module overrides this for apps that have `app/views/layouts/\n/// application.{erb,rb}`.\nfn render_layout(body: &str) -> String {\n    format!(\n        r##\"<!DOCTYPE html>\n<html>\n  <head>\n    <meta charset=\"utf-8\">\n    <title>Roundhouse App</title>\n    <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n    <link rel=\"icon\" href=\"data:,\">\n    <link rel=\"stylesheet\" href=\"/assets/tailwind.css\">\n    <script type=\"importmap\">\n    {{\n      \"imports\": {{\n        \"@hotwired/turbo\": \"/assets/turbo.min.js\"\n      }}\n    }}\n    </script>\n    <script type=\"module\">import \"@hotwired/turbo\";</script>\n  </head>\n  <body>\n    <main class=\"container mx-auto mt-8 px-5 flex flex-col\">\n      {}\n    </main>\n  </body>\n</html>\n\"##,\n        body,\n    )\n}\n"},{"path":"src/session.rs","content":"//! ActionDispatch::Session — per-app session store. Empty by default\n//! (real-blog uses no session keys); HWIA-shape shim methods route\n//! through an internal HashMap so apps that introduce session keys\n//! can grow the surface without a runtime rewrite.\n//!\n//! Hand-written for rust2 Phase 3 (sibling of\n//! `runtime/ruby/action_dispatch/session.rb`). The transpile pipeline\n//! produces broken Rust for this file's shim methods; hand-writing\n//! avoids fighting those emit bugs.\n\nuse std::collections::HashMap;\n\n#[derive(Debug, Default, Clone)]\npub struct Session {\n    data: HashMap<String, String>,\n}\n\nimpl Session {\n    pub fn new() -> Self {\n        Self::default()\n    }\n\n    pub fn from_persisted(other: Option<&HashMap<String, String>>) -> Self {\n        let mut session = Self::default();\n        if let Some(map) = other {\n            for (k, v) in map {\n                session.data.insert(k.clone(), v.clone());\n            }\n        }\n        session\n    }\n\n    pub fn get(&self, key: &str) -> Option<String> {\n        self.data.get(key).cloned()\n    }\n\n    pub fn set(&mut self, key: &str, value: String) {\n        self.data.insert(key.to_string(), value);\n    }\n\n    pub fn fetch(&self, key: &str, default: Option<String>) -> Option<String> {\n        self.get(key).or(default)\n    }\n\n    pub fn key(&self, key: &str) -> bool {\n        self.data.contains_key(key)\n    }\n\n    pub fn has_key(&self, key: &str) -> bool {\n        self.key(key)\n    }\n\n    pub fn include(&self, key: &str) -> bool {\n        self.key(key)\n    }\n\n    pub fn delete(&mut self, key: &str) -> Option<String> {\n        self.data.remove(key)\n    }\n\n    pub fn len(&self) -> usize {\n        self.data.len()\n    }\n\n    pub fn is_empty(&self) -> bool {\n        self.data.is_empty()\n    }\n\n    pub fn to_h(&self) -> HashMap<String, String> {\n        self.data.clone()\n    }\n}\n"},{"path":"src/test_support.rs","content":"//! Roundhouse Rust test-support runtime.\n//!\n//! Hand-written, shipped alongside generated code (copied in by the\n//! Rust emitter as `src/test_support.rs`). Emitted controller tests\n//! use this surface through the `TestResponseExt` trait, so the\n//! assertion call sites stay stable while the implementation can\n//! evolve.\n//!\n//! Phase 4d ships substring-match implementations of `assert_select`\n//! — matches railcar's choice, zero extra deps, good-enough for the\n//! scaffold blog's HTML assertions. A later upgrade to a real CSS\n//! selector engine (the `scraper` crate, `html5ever`, or similar)\n//! only needs to touch this file — emitted tests call the trait\n//! methods and are insulated from the rendering strategy.\n\nuse axum_test::TestResponse;\n\npub trait TestResponseExt {\n    /// `assert_response :success` — status 200 OK.\n    fn assert_ok(&self);\n\n    /// `assert_response :unprocessable_entity` — status 422.\n    fn assert_unprocessable(&self);\n\n    /// `assert_response <status>` for any concrete status.\n    fn assert_status(&self, code: u16);\n\n    /// `assert_redirected_to <path>`. Checks the response is a 3xx\n    /// redirect and the `Location` header matches the expected path.\n    /// Phase 4d substring-matches the path in Location to forgive\n    /// absolute-vs-relative URL differences; a stricter check can\n    /// swap in later without touching emitted tests.\n    fn assert_redirected_to(&self, path: &str);\n\n    /// `assert_select <selector>` — response body contains an\n    /// element matching the (very) loose selector form. Substring\n    /// match on the opening tag or `id=` / `class=` attribute\n    /// fragment. Covers the scaffold blog's shapes:\n    ///   \"h1\"             → contains \"<h1\"\n    ///   \"#articles\"      → contains `id=\"articles\"`\n    ///   \".p-4\"           → contains `class=\"... p-4 ...\"` (as substring)\n    ///   \"form\"           → contains \"<form\"\n    fn assert_select(&self, selector: &str);\n\n    /// `assert_select <selector>, <text>` — the `selector` check\n    /// above *and* the response body contains `text`. Phase 4d\n    /// doesn't verify the text lives inside the selector match\n    /// (would require structural parsing); a later scraper-backed\n    /// impl can tighten this.\n    fn assert_select_text(&self, selector: &str, text: &str);\n\n    /// `assert_select <selector>, minimum: N` — response body\n    /// contains at least `n` occurrences of the selector fragment.\n    /// Again substring-counted in Phase 4d.\n    fn assert_select_min(&self, selector: &str, n: usize);\n}\n\nimpl TestResponseExt for TestResponse {\n    fn assert_ok(&self) {\n        assert_eq!(self.status_code(), 200, \"expected 200 OK\");\n    }\n\n    fn assert_unprocessable(&self) {\n        assert_eq!(self.status_code(), 422, \"expected 422 Unprocessable Entity\");\n    }\n\n    fn assert_status(&self, code: u16) {\n        assert_eq!(self.status_code().as_u16(), code, \"expected status {code}\");\n    }\n\n    fn assert_redirected_to(&self, path: &str) {\n        assert!(\n            self.status_code().is_redirection(),\n            \"expected redirection, got {}\",\n            self.status_code(),\n        );\n        let location = self\n            .headers()\n            .get(axum::http::header::LOCATION)\n            .and_then(|v| v.to_str().ok())\n            .unwrap_or(\"\");\n        assert!(\n            location.contains(path),\n            \"expected Location to contain {path:?}, got {location:?}\",\n        );\n    }\n\n    fn assert_select(&self, selector: &str) {\n        let body = self.text();\n        let fragment = selector_fragment(selector);\n        assert!(\n            body.contains(&fragment),\n            \"expected body to match selector {selector:?} (looked for substring {fragment:?})\",\n        );\n    }\n\n    fn assert_select_text(&self, selector: &str, text: &str) {\n        self.assert_select(selector);\n        let body = self.text();\n        assert!(\n            body.contains(text),\n            \"expected body to contain text {text:?} under selector {selector:?}\",\n        );\n    }\n\n    fn assert_select_min(&self, selector: &str, n: usize) {\n        let body = self.text();\n        let fragment = selector_fragment(selector);\n        let count = body.matches(&fragment).count();\n        assert!(\n            count >= n,\n            \"expected at least {n} matches for selector {selector:?} (fragment {fragment:?}), got {count}\",\n        );\n    }\n}\n\n/// Map a loose selector to a substring fragment that probably appears\n/// in matching HTML. Phase 4d: handles `#id`, `.class`, `tag`, and\n/// the first element of compound selectors like `\"#comments .p-4\"`\n/// (splits on whitespace, picks the first chunk). Every match is a\n/// substring search — false positives are possible but the blog's\n/// HTML is narrow enough that the tests are a reliable signal.\nfn selector_fragment(selector: &str) -> String {\n    let first = selector.split_whitespace().next().unwrap_or(\"\");\n    if let Some(id) = first.strip_prefix('#') {\n        format!(\"id=\\\"{id}\\\"\")\n    } else if let Some(class) = first.strip_prefix('.') {\n        format!(\"{class}\\\"\")\n    } else {\n        format!(\"<{first}\")\n    }\n}\n"},{"path":"src/tests/article.rs","content":"#[allow(unused_imports)]\nuse crate::param_value::ParamValue;\n#[allow(unused_imports)]\nuse crate::db::Db;\n#[allow(unused_imports)]\nuse crate::broadcasts::Broadcasts;\n// Sibling-model glob so cross-file refs (Article ↔ Comment, the\n// `<Model>Row` typed-row pair) resolve through the `pub use` chain\n// that `emit_models_mod_rs` lays into `src/models/mod.rs`. Rust\n// doesn't auto-import siblings — the lowerer leaves bare `Article`\n// / `Comment` / `ArticleRow` / `CommentRow` references at every\n// `Comment.belongs_to :article`, `has_many :comments`, and\n// `instantiate(row)` call site; without this line each of those\n// E0433s independently.\n#[allow(unused_imports)]\nuse crate::models::*;\n// View modules (Phase 5b stubs). The model lowerer's broadcasts_to\n// expansion emits `Articles::article(self)` / `Comments::comment\n// (self)` partial renders inside `after_*_commit` callback bodies;\n// the actual view emit isn't yet wired through rust2, so each\n// LibraryClass produced by `lower_views_to_library_classes` lands\n// here as a fully-generic `String::new()` stub. Replace with real\n// view emit when Phase 5b lands.\n#[allow(unused_imports)]\nuse crate::views::*;\n\n#[allow(unused_imports)]\nuse crate::fixtures::*;\n// Generated by Roundhouse (rust2).\n\n#[test]\npub fn test_creates_an_article_with_valid_attributes() {\n        crate::fixtures::setup();\n    let mut article = ArticlesFixtures::one();\n    if { let _ = article.id(); false } { panic!(\"{}\", \"refute_nil failed\") };\n    if \"Getting Started with Rails\" != article.title() { panic!(\"{}\", \"assert_equal failed\") };\n}\n\n#[test]\npub fn test_validates_title_presence() {\n        crate::fixtures::setup();\n    let mut article = Article::new(std::collections::HashMap::from([(\"title\", \"\"), (\"body\", \"Valid body content here.\")]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>());\n    if article.save() { panic!(\"{}\", \"refute failed\") };\n}\n\n#[test]\npub fn test_validates_body_minimum_length() {\n        crate::fixtures::setup();\n    let mut article = Article::new(std::collections::HashMap::from([(\"title\", \"Valid Title\"), (\"body\", \"Short\")]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>());\n    if article.save() { panic!(\"{}\", \"refute failed\") };\n}\n\n#[test]\npub fn test_destroys_comments_when_article_is_destroyed() {\n        crate::fixtures::setup();\n    let mut article = ArticlesFixtures::one();\n    let __diff_before = Comment::count();\n    article.destroy();\n    let mut __diff_after = Comment::count();\n    if __diff_after - __diff_before != -1_i64 { panic!(\"{}\", \"Comment.count didn't change by -1\") };\n}\n"},{"path":"src/tests/articles_controller_test.rs","content":"// Generated by Roundhouse.\n\n#[allow(unused_imports)]\nuse crate::fixtures;\n#[allow(unused_imports)]\nuse crate::models::*;\n#[allow(unused_imports)]\nuse crate::route_helpers;\n#[allow(unused_imports)]\nuse crate::test_support::TestResponseExt;\n\n#[tokio::test(flavor = \"multi_thread\")]\n#[allow(unused_mut, unused_variables)]\nasync fn should_get_index() {\n    // \"should get index\"\n    fixtures::setup();\n    let server = axum_test::TestServer::new(crate::router::router()).unwrap();\n    let resp = server.get(&route_helpers::articles_path()).await;\n    resp.assert_ok();\n    resp.assert_select_text(\"h1\", &\"Articles\");\n    resp.assert_select(\"#articles\");\n    resp.assert_select_min(\"h2\", 1_i64 as usize);\n}\n\n#[tokio::test(flavor = \"multi_thread\")]\n#[allow(unused_mut, unused_variables)]\nasync fn should_get_new() {\n    // \"should get new\"\n    fixtures::setup();\n    let server = axum_test::TestServer::new(crate::router::router()).unwrap();\n    let resp = server.get(&route_helpers::new_article_path()).await;\n    resp.assert_ok();\n    resp.assert_select(\"form\");\n}\n\n#[tokio::test(flavor = \"multi_thread\")]\n#[allow(unused_mut, unused_variables)]\nasync fn should_create_article() {\n    // \"should create article\"\n    fixtures::setup();\n    let server = axum_test::TestServer::new(crate::router::router()).unwrap();\n    let _before = Article::count();\n    let resp = server.post(&route_helpers::articles_path()).form(&std::collections::HashMap::<String, String>::from([(\"article[body]\".to_string(), \"A sufficiently long body for validation.\".to_string()), (\"article[title]\".to_string(), \"New Title\".to_string())])).await;\n    let _after = Article::count();\n    assert_eq!(_after - _before, 1);\n    resp.assert_redirected_to(&route_helpers::article_path(Article::last().unwrap().id));\n    assert_eq!(\"New Title\", Article::last().unwrap().title);\n}\n\n#[tokio::test(flavor = \"multi_thread\")]\n#[allow(unused_mut, unused_variables)]\nasync fn should_not_create_article_with_invalid_params() {\n    // \"should not create article with invalid params\"\n    fixtures::setup();\n    let server = axum_test::TestServer::new(crate::router::router()).unwrap();\n    let _before = Article::count();\n    let resp = server.post(&route_helpers::articles_path()).form(&std::collections::HashMap::<String, String>::from([(\"article[title]\".to_string(), \"\".to_string()), (\"article[body]\".to_string(), \"\".to_string())])).await;\n    let _after = Article::count();\n    assert_eq!(_after - _before, 0);\n    resp.assert_unprocessable();\n}\n\n#[tokio::test(flavor = \"multi_thread\")]\n#[allow(unused_mut, unused_variables)]\nasync fn should_show_article() {\n    // \"should show article\"\n    fixtures::setup();\n    let server = axum_test::TestServer::new(crate::router::router()).unwrap();\n    let mut article = fixtures::articles::one();\n    let resp = server.get(&route_helpers::article_path(article.id)).await;\n    resp.assert_ok();\n    resp.assert_select_text(\"h1\", &article.title);\n    resp.assert_select_text(\"h2\", &\"Comments\");\n    resp.assert_select_min(\"#comments .p-4\", 1_i64 as usize);\n}\n\n#[tokio::test(flavor = \"multi_thread\")]\n#[allow(unused_mut, unused_variables)]\nasync fn should_get_edit() {\n    // \"should get edit\"\n    fixtures::setup();\n    let server = axum_test::TestServer::new(crate::router::router()).unwrap();\n    let mut article = fixtures::articles::one();\n    let resp = server.get(&route_helpers::edit_article_path(article.id)).await;\n    resp.assert_ok();\n    resp.assert_select(\"form\");\n}\n\n#[tokio::test(flavor = \"multi_thread\")]\n#[allow(unused_mut, unused_variables)]\nasync fn should_update_article() {\n    // \"should update article\"\n    fixtures::setup();\n    let server = axum_test::TestServer::new(crate::router::router()).unwrap();\n    let mut article = fixtures::articles::one();\n    let resp = server.patch(&route_helpers::article_path(article.id)).form(&std::collections::HashMap::<String, String>::from([(\"article[body]\".to_string(), article.body.to_string()), (\"article[title]\".to_string(), \"Updated Title\".to_string())])).await;\n    resp.assert_redirected_to(&route_helpers::article_path(article.id));\n    article.reload();\n    assert_eq!(\"Updated Title\", article.title);\n}\n\n#[tokio::test(flavor = \"multi_thread\")]\n#[allow(unused_mut, unused_variables)]\nasync fn should_not_update_article_with_invalid_params() {\n    // \"should not update article with invalid params\"\n    fixtures::setup();\n    let server = axum_test::TestServer::new(crate::router::router()).unwrap();\n    let mut article = fixtures::articles::one();\n    let resp = server.patch(&route_helpers::article_path(article.id)).form(&std::collections::HashMap::<String, String>::from([(\"article[title]\".to_string(), \"\".to_string()), (\"article[body]\".to_string(), \"\".to_string())])).await;\n    resp.assert_unprocessable();\n}\n\n#[tokio::test(flavor = \"multi_thread\")]\n#[allow(unused_mut, unused_variables)]\nasync fn should_destroy_article() {\n    // \"should destroy article\"\n    fixtures::setup();\n    let server = axum_test::TestServer::new(crate::router::router()).unwrap();\n    let mut article = fixtures::articles::one();\n    let _before = Article::count();\n    let resp = server.delete(&route_helpers::article_path(article.id)).await;\n    let _after = Article::count();\n    assert_eq!(_after - _before, -1);\n    resp.assert_redirected_to(&route_helpers::articles_path());\n}\n"},{"path":"src/tests/comment.rs","content":"#[allow(unused_imports)]\nuse crate::param_value::ParamValue;\n#[allow(unused_imports)]\nuse crate::db::Db;\n#[allow(unused_imports)]\nuse crate::broadcasts::Broadcasts;\n// Sibling-model glob so cross-file refs (Article ↔ Comment, the\n// `<Model>Row` typed-row pair) resolve through the `pub use` chain\n// that `emit_models_mod_rs` lays into `src/models/mod.rs`. Rust\n// doesn't auto-import siblings — the lowerer leaves bare `Article`\n// / `Comment` / `ArticleRow` / `CommentRow` references at every\n// `Comment.belongs_to :article`, `has_many :comments`, and\n// `instantiate(row)` call site; without this line each of those\n// E0433s independently.\n#[allow(unused_imports)]\nuse crate::models::*;\n// View modules (Phase 5b stubs). The model lowerer's broadcasts_to\n// expansion emits `Articles::article(self)` / `Comments::comment\n// (self)` partial renders inside `after_*_commit` callback bodies;\n// the actual view emit isn't yet wired through rust2, so each\n// LibraryClass produced by `lower_views_to_library_classes` lands\n// here as a fully-generic `String::new()` stub. Replace with real\n// view emit when Phase 5b lands.\n#[allow(unused_imports)]\nuse crate::views::*;\n\n#[allow(unused_imports)]\nuse crate::fixtures::*;\n// Generated by Roundhouse (rust2).\n\n#[test]\npub fn test_creates_a_comment_on_an_article() {\n        crate::fixtures::setup();\n    let mut comment = CommentsFixtures::one();\n    if { let _ = comment.id(); false } { panic!(\"{}\", \"refute_nil failed\") };\n    if ArticlesFixtures::one().id() != comment.article_id() { panic!(\"{}\", \"assert_equal failed\") };\n}\n\n#[test]\npub fn test_belongs_to_article_association() {\n        crate::fixtures::setup();\n    let mut article = ArticlesFixtures::one();\n    let mut comment = Comment::create(std::collections::HashMap::from([((\"article_id\").to_string(), serde_json::Value::from(article.id())), ((\"commenter\").to_string(), serde_json::Value::from((\"Commenter\").to_string())), ((\"body\").to_string(), serde_json::Value::from((\"Comment body text.\").to_string()))]));\n    if article.id() != comment.article_id() { panic!(\"{}\", \"assert_equal failed\") };\n}\n\n#[test]\npub fn test_requires_commenter() {\n        crate::fixtures::setup();\n    let mut article = ArticlesFixtures::one();\n    let mut comment = Comment::new(std::collections::HashMap::from([((\"article_id\").to_string(), serde_json::Value::from(article.id())), ((\"body\").to_string(), serde_json::Value::from((\"Comment without commenter\").to_string()))]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>());\n    if comment.save() { panic!(\"{}\", \"refute failed\") };\n}\n\n#[test]\npub fn test_requires_body() {\n        crate::fixtures::setup();\n    let mut article = ArticlesFixtures::one();\n    let mut comment = Comment::new(std::collections::HashMap::from([((\"article_id\").to_string(), serde_json::Value::from(article.id())), ((\"commenter\").to_string(), serde_json::Value::from((\"Someone\").to_string()))]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>());\n    if comment.save() { panic!(\"{}\", \"refute failed\") };\n}\n\n#[test]\npub fn test_requires_valid_article() {\n        crate::fixtures::setup();\n    let mut comment = Comment::new(std::collections::HashMap::from([((\"commenter\").to_string(), serde_json::Value::from(\"Test\")), ((\"body\").to_string(), serde_json::Value::from(\"A test comment.\")), ((\"article_id\").to_string(), serde_json::Value::from(999999_i64))]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>());\n    if comment.save() { panic!(\"{}\", \"refute failed\") };\n}\n"},{"path":"src/tests/comments_controller_test.rs","content":"// Generated by Roundhouse.\n\n#[allow(unused_imports)]\nuse crate::fixtures;\n#[allow(unused_imports)]\nuse crate::models::*;\n#[allow(unused_imports)]\nuse crate::route_helpers;\n#[allow(unused_imports)]\nuse crate::test_support::TestResponseExt;\n\n#[tokio::test(flavor = \"multi_thread\")]\n#[allow(unused_mut, unused_variables)]\nasync fn should_create_comment() {\n    // \"should create comment\"\n    fixtures::setup();\n    let server = axum_test::TestServer::new(crate::router::router()).unwrap();\n    let mut article = fixtures::articles::one();\n    let _before = Comment::count();\n    let resp = server.post(&route_helpers::article_comments_path(article.id)).form(&std::collections::HashMap::<String, String>::from([(\"comment[commenter]\".to_string(), \"Test\".to_string()), (\"comment[body]\".to_string(), \"A test comment.\".to_string())])).await;\n    let _after = Comment::count();\n    assert_eq!(_after - _before, 1);\n    resp.assert_redirected_to(&route_helpers::article_path(article.id));\n}\n\n#[tokio::test(flavor = \"multi_thread\")]\n#[allow(unused_mut, unused_variables)]\nasync fn should_not_create_comment_with_invalid_params() {\n    // \"should not create comment with invalid params\"\n    fixtures::setup();\n    let server = axum_test::TestServer::new(crate::router::router()).unwrap();\n    let mut article = fixtures::articles::one();\n    let _before = Comment::count();\n    let resp = server.post(&route_helpers::article_comments_path(article.id)).form(&std::collections::HashMap::<String, String>::from([(\"comment[commenter]\".to_string(), \"\".to_string()), (\"comment[body]\".to_string(), \"\".to_string())])).await;\n    let _after = Comment::count();\n    assert_eq!(_after - _before, 0);\n    resp.assert_redirected_to(&route_helpers::article_path(article.id));\n}\n\n#[tokio::test(flavor = \"multi_thread\")]\n#[allow(unused_mut, unused_variables)]\nasync fn should_destroy_comment() {\n    // \"should destroy comment\"\n    fixtures::setup();\n    let server = axum_test::TestServer::new(crate::router::router()).unwrap();\n    let mut article = fixtures::articles::one();\n    let mut comment = fixtures::comments::one();\n    let _before = Comment::count();\n    let resp = server.delete(&route_helpers::article_comment_path(article.id, comment.id)).await;\n    let _after = Comment::count();\n    assert_eq!(_after - _before, -1);\n    resp.assert_redirected_to(&route_helpers::article_path(article.id));\n}\n"},{"path":"src/tests/mod.rs","content":"// Generated by Roundhouse (rust2).\n\npub mod article;\npub mod articles_controller_test;\npub mod comment;\npub mod comments_controller_test;\n"},{"path":"src/view_helpers.rs","content":"// Generated from runtime/ruby/action_view/view_helpers.rb at app emit time.\n// Do not edit by hand — edit the source `.rb` and re-run emit.\n\nuse crate::active_record_base::Base;\nuse crate::hash_ext::merge_attrs;\nuse crate::http::RubyToS;\n\nstatic HTML_ESCAPES: std::sync::LazyLock<std::collections::HashMap<&'static str, &'static str>> = std::sync::LazyLock::new(|| std::collections::HashMap::from([(\"&\", \"&amp;\"), (\"<\", \"&lt;\"), (\">\", \"&gt;\"), (\"\\\"\", \"&quot;\"), (\"'\", \"&#39;\")]));\nstatic HTML_ESCAPE_PATTERN: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| regex::Regex::new(\"[&<>\\\"']\").unwrap());\n\npub struct ViewHelpers;\n\nstatic SLOTS: std::sync::Mutex<Option<std::collections::HashMap<String, String>>> = std::sync::Mutex::new(None);\n\nimpl ViewHelpers {\n    pub fn reset_slots_bang() {\n        *SLOTS.lock().unwrap() = Some(std::collections::HashMap::new())\n    }\n\n    pub fn content_for_set(slot: &str, value: &str) {\n        { SLOTS.lock().unwrap().get_or_insert_with(std::collections::HashMap::new).insert((slot).to_string(), (value).to_string()); };\n    }\n\n    pub fn content_for_get(slot: &str) -> Option<String> {\n        SLOTS.lock().unwrap().clone().unwrap_or_default().get(slot).cloned()\n    }\n\n    pub fn get_slot(slot: &str) -> String {\n        SLOTS.lock().unwrap().clone().unwrap_or_default().get(slot).cloned().unwrap_or((\"\").to_string())\n    }\n\n    pub fn get_yield() -> String {\n        SLOTS.lock().unwrap().clone().unwrap_or_default().get(\"__body__\").cloned().unwrap_or((\"\").to_string())\n    }\n\n    pub fn set_yield(content: &str) {\n        { SLOTS.lock().unwrap().get_or_insert_with(std::collections::HashMap::new).insert((\"__body__\").to_string(), (content).to_string()); };\n    }\n\n    pub fn html_escape(s: &str) -> String {\n        HTML_ESCAPE_PATTERN.replace_all(&s, |__caps: &regex::Captures| -> String { (*HTML_ESCAPES.get(&__caps[0]).unwrap_or(&\"\")).to_string() }).into_owned()\n    }\n\n    pub fn truncate(s: &str, length: i64, omission: &str) -> String {\n        if (s.len() as i64) <= length { return (s.clone()).to_string() };\n        let mut cutoff = length - omission.len() as i64;\n        if cutoff < 0_i64 { cutoff = 0_i64 };\n        format!(\"{}{}\", (&s.clone()[(0_i64) as usize..((0_i64) + (cutoff)) as usize]).to_string(), omission.clone())\n    }\n\n    pub fn dom_id(record: Base, suffix: Option<String>) -> String {\n        if suffix.is_none() { format!(\"{}_{}\", record.dom_prefix(), record.id()) } else { format!(\"{}_{}_{}\", suffix.clone().unwrap(), record.dom_prefix(), record.id()) }\n    }\n\n    pub fn link_to(text: &str, href: &str, opts: std::collections::HashMap<String, serde_json::Value>) -> String {\n        let attrs = Self::render_attrs(merge_attrs(std::collections::HashMap::from([(\"href\", href)]), opts.clone()));\n        format!(\"<a{}>{}</a>\", attrs, Self::html_escape(&(text)))\n    }\n\n    pub fn button_to(text: &str, href: &str, opts: std::collections::HashMap<String, serde_json::Value>) -> String {\n        let mut method = opts.clone().get(\"method\").cloned();\n        let form_class = opts.clone().get(\"form_class\").cloned();\n        let mut inner_opts = opts.clone().clone().clone();\n        inner_opts.remove(\"method\");\n        inner_opts.remove(\"form_class\");\n        let mut form_attrs = std::collections::HashMap::from([(\"action\", (href).to_string()), (\"method\", (\"post\").to_string())]).clone();\n        { form_attrs.clone().insert(\"class\", form_class.unwrap_or(serde_json::Value::from(\"button_to\")).to_string()); };\n        let button_attrs = Self::render_attrs(merge_attrs(std::collections::HashMap::from([(\"type\", \"submit\")]), inner_opts.clone()));\n        let method_input = if !(method.is_none()) && method.clone().unwrap().ruby_to_s() != \"post\" { format!(\"<input type=\\\"hidden\\\" name=\\\"_method\\\" value=\\\"{}\\\">\", (method.clone().unwrap()).ruby_to_s()) } else { (\"\").to_string() };\n        let auth_token_input = \"<input type=\\\"hidden\\\" name=\\\"authenticity_token\\\" value=\\\"\\\">\";\n        format!(\"<form{}>{}<button{}>{}</button>{}</form>\", Self::render_attrs(form_attrs.clone().into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>()), method_input, button_attrs, Self::html_escape(&(text)), auth_token_input)\n    }\n\n    pub fn csrf_meta_tags() -> String {\n        (\"<meta name=\\\"csrf-param\\\" content=\\\"authenticity_token\\\" />\\n<meta name=\\\"csrf-token\\\" content=\\\"\\\" />\").to_string()\n    }\n\n    pub fn csp_meta_tag() -> String {\n        (\"\").to_string()\n    }\n\n    pub fn stylesheet_link_tag(name: &str, opts: std::collections::HashMap<String, serde_json::Value>) -> String {\n        let href = format!(\"/assets/{}.css\", name);\n        let attrs = Self::render_attrs(merge_attrs(std::collections::HashMap::from([(\"rel\", (\"stylesheet\").to_string()), (\"href\", href)]), opts.clone()));\n        format!(\"<link{}>\", attrs)\n    }\n\n    pub fn javascript_importmap_tags(pins: Option<Vec<serde_json::Value>>, entry: &str) -> String {\n        if pins.is_none() || pins.clone().unwrap().is_empty() { { let mut json = (\"{\\n  \\\"imports\\\": {\\n    \\\"@hotwired/turbo\\\": \\\"/assets/turbo.min.js\\\"\\n  }\\n}\").to_string();\n        return format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", format!(\"{}{}\", \"<script type=\\\"importmap\\\" data-turbo-track=\\\"reload\\\">\", json.clone()), \"</script>\"), \"\\n\"), \"<link rel=\\\"modulepreload\\\" href=\\\"/assets/turbo.min.js\\\">\"), \"\\n\"), \"<script type=\\\"module\\\">import \\\"@hotwired/turbo\\\"</script>\") } };\n        let import_lines = pins.clone().iter().flatten().map(|p| { format!(\"    \\\"{}\\\": \\\"{}\\\"\", (p.clone()[\"name\"]).ruby_to_s(), (p.clone()[\"path\"]).ruby_to_s()) }).collect::<Vec<_>>().join(\",\\n\");\n        let mut json = format!(\"{}{}\", format!(\"{}{}\", \"{\\n  \\\"imports\\\": {\\n\", import_lines), \"\\n  }\\n}\");\n        let mut parts = vec![];\n        parts.push(format!(\"{}{}\", format!(\"{}{}\", \"<script type=\\\"importmap\\\" data-turbo-track=\\\"reload\\\">\", json.clone()), \"</script>\"));\n        pins.clone().iter().flatten().for_each(|p| { parts.push(format!(\"<link rel=\\\"modulepreload\\\" href=\\\"{}\\\">\", (p.clone()[\"path\"]).ruby_to_s())); });\n        parts.push(format!(\"<script type=\\\"module\\\">import \\\"{}\\\"</script>\", entry));\n        parts.join(\"\\n\")\n    }\n\n    pub fn turbo_stream_from(stream: serde_json::Value) -> String {\n        let encoded = { use base64::Engine; base64::engine::general_purpose::STANDARD.encode(serde_json::to_string(&stream).unwrap()) };\n        format!(\"<turbo-cable-stream-source channel=\\\"Turbo::StreamsChannel\\\" signed-stream-name=\\\"{}--unsigned\\\"></turbo-cable-stream-source>\", encoded)\n    }\n\n    pub fn csrf_token_hidden_input() -> String {\n        (\"<input type=\\\"hidden\\\" name=\\\"authenticity_token\\\" value=\\\"\\\">\").to_string()\n    }\n\n    pub fn method_override_input(method: &str) -> String {\n        let mut method_str = method.to_string();\n        if method_str.clone() == \"get\" || method_str.clone() == \"post\" { (\"\").to_string() } else { format!(\"<input type=\\\"hidden\\\" name=\\\"_method\\\" value=\\\"{}\\\">\", method_str.clone()) }\n    }\n\n    pub fn optional_value_attr(value: serde_json::Value) -> String {\n        if value.clone().is_null() || value.ruby_to_s().is_empty() { (\"\").to_string() } else { format!(\" value=\\\"{}\\\"\", Self::html_escape(&(value.ruby_to_s()))) }\n    }\n\n    pub fn escape_or_empty(value: serde_json::Value) -> String {\n        if value.clone().is_null() { (\"\").to_string() } else { Self::html_escape(&(value.ruby_to_s())) }\n    }\n\n    pub fn render_attrs(attrs: std::collections::HashMap<String, serde_json::Value>) -> String {\n        if attrs.is_empty() { return (\"\").to_string() };\n        let mut pairs = vec![];\n        attrs.clone().iter().for_each(|(k, v)| {\n            if v.clone().is_null() { /* TODO rust2: ExprNode::Discriminant(23) */ };\n            let name = k.to_string();\n            if v.clone().is_object() { v.clone().as_object().unwrap().iter().for_each(|(inner_k, inner_v)| {\n                if inner_v.clone().is_null() { /* TODO rust2: ExprNode::Discriminant(23) */ };\n                let inner_name = inner_k.ruby_to_s().replace(\"_\", \"-\");\n                pairs.push(format!(\" {}-{}=\\\"{}\\\"\", name.clone(), inner_name, Self::html_escape(&(inner_v.ruby_to_s()))))\n            }) } else { pairs.push(format!(\" {}=\\\"{}\\\"\", name.clone(), Self::html_escape(&(v.ruby_to_s())))) }\n        });\n        pairs.join(\"\")\n    }\n}\n\n// rust2 compat: bare-fn wrappers consumed by server.rs.\npub fn reset_render_state() { ViewHelpers::reset_slots_bang() }\npub fn set_yield(content: &str) { ViewHelpers::set_yield(content) }\npub fn get_yield() -> String { ViewHelpers::get_yield() }\n"},{"path":"src/views/articles.rs","content":"#[allow(unused_imports)]\nuse crate::view_helpers::{self, ViewHelpers};\n#[allow(unused_imports)]\nuse crate::route_helpers::{self, RouteHelpers};\n#[allow(unused_imports)]\nuse crate::inflector::{self, Inflector};\n#[allow(unused_imports)]\nuse crate::importmap::{self, Importmap};\n#[allow(unused_imports)]\nuse crate::json_builder::{self, JsonBuilder};\n#[allow(unused_imports)]\nuse crate::http::RubyToS;\n#[allow(unused_imports)]\nuse crate::models::*;\n#[allow(unused_imports)]\nuse crate::views::*;\npub struct Articles;\n\nimpl Articles {\n    pub fn article(article: Article, notice: Option<String>, alert: Option<String>) -> String {\n        let mut io = String::new();\n        io.push_str(&format!(\"<div id=\\\"{}\\\" class=\\\"flex flex-col sm:flex-row justify-between items-center pb-5 sm:pb-0\\\">\\n  <div class=\\\"p-4 border rounded mb-4 flex-grow\\\">\\n    <h2 class=\\\"text-xl font-bold\\\">\\n      <a href=\\\"{}\\\" class=\\\"{}\\\">{}</a>\\n      <span id=\\\"{}\\\" class=\\\"text-gray-500 text-sm font-normal ml-2\\\">\\n        ({})\\n      </span>\\n    </h2>\\n    <p class=\\\"text-gray-700 mt-2\\\">{}</p>\\n  </div>\\n  <div class=\\\"w-full sm:w-auto flex flex-col sm:flex-row space-x-2 space-y-2\\\">\\n    <a href=\\\"{}\\\" class=\\\"{}\\\">{}</a>\\n    <a href=\\\"{}\\\" class=\\\"{}\\\">{}</a>\\n    <form action=\\\"{}\\\" method=\\\"post\\\" class=\\\"{}\\\">{}<button type=\\\"submit\\\" class=\\\"{}\\\" data-turbo-confirm=\\\"{}\\\">{}</button>{}</form>\\n  </div>\\n</div>\\n\", format!(\"article_{}\", article.clone().id()), ViewHelpers::html_escape(&(RouteHelpers::article_path(article.id()))), \"text-blue-600 hover:underline\", ViewHelpers::html_escape(&(article.title())), format!(\"comments_count_article_{}\", article.clone().id()), Inflector::pluralize(article.comments().len() as i64, \"comment\"), ViewHelpers::html_escape(&(ViewHelpers::truncate(&(article.body()), 100_i64, \"...\"))), ViewHelpers::html_escape(&(RouteHelpers::article_path(article.id()))), \"w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-50 inline-block font-medium\", \"Show\", ViewHelpers::html_escape(&(RouteHelpers::edit_article_path(article.id()))), \"w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-50 inline-block font-medium\", \"Edit\", ViewHelpers::html_escape(&(RouteHelpers::article_path(article.id()))), \"button_to\", ViewHelpers::method_override_input(\"delete\"), \"w-full sm:w-auto rounded-md px-3.5 py-2.5 text-white bg-red-600 hover:bg-red-500 font-medium cursor-pointer\", \"Are you sure?\", \"Destroy\", ViewHelpers::csrf_token_hidden_input()));\n        io\n    }\n\n    pub fn form(article: Article, notice: Option<String>, alert: Option<String>) -> String {\n        let mut io = String::new();\n        let mut form_method = if article.persisted() { \"patch\" } else { \"post\" };\n        io.push_str(&format!(\"<form action=\\\"{}\\\" accept-charset=\\\"UTF-8\\\" method=\\\"post\\\" class=\\\"{}\\\">{}{}\\n\", ViewHelpers::html_escape(&(if article.persisted() { RouteHelpers::article_path(article.id()) } else { RouteHelpers::articles_path() })), \"contents\", ViewHelpers::method_override_input(&(form_method.clone())), ViewHelpers::csrf_token_hidden_input()));\n        if !(article.errors().is_empty()) { { io.push_str(&format!(\"    <div id=\\\"error_explanation\\\" class=\\\"bg-red-50 text-red-500 px-3 py-2 font-medium rounded-md mt-3\\\">\\n      <h2>{} prohibited this article from being saved:</h2>\\n\\n      <ul class=\\\"list-disc ml-6\\\">\\n        \", Inflector::pluralize(article.errors().len() as i64, \"error\")));\n        article.errors().iter_mut().for_each(|error| { io.push_str(&format!(\"\\n          <li>{}</li>\\n        \", ViewHelpers::html_escape(&(error)))); });\n        io.push_str(\"\\n      </ul>\\n    </div>\\n\") } };\n        io.push_str(&format!(\"\\n  <div class=\\\"my-5\\\">\\n    <label for=\\\"article_title\\\">Title</label>\\n    <input type=\\\"text\\\" name=\\\"article[title]\\\" id=\\\"article_title\\\"{} class=\\\"{}\\\">\\n  </div>\\n\\n  <div class=\\\"my-5\\\">\\n    <label for=\\\"article_body\\\">Body</label>\\n    <textarea name=\\\"article[body]\\\" id=\\\"article_body\\\" rows=\\\"{}\\\" class=\\\"{}\\\">{}</textarea>\\n  </div>\\n\\n  <div class=\\\"inline\\\">\\n    <input type=\\\"submit\\\" name=\\\"commit\\\" value=\\\"{}\\\" data-disable-with=\\\"{}\\\" class=\\\"{}\\\">\\n  </div>\\n</form>\", ViewHelpers::optional_value_attr(article.clone().get_index(\"title\")), \"block shadow-sm rounded-md border px-3 py-2 mt-2 w-full border-gray-400 focus:outline-blue-600\", ViewHelpers::html_escape(&(4_i64.to_string())), \"block shadow-sm rounded-md border px-3 py-2 mt-2 w-full border-gray-400 focus:outline-blue-600\", ViewHelpers::escape_or_empty(article.clone().get_index(\"body\")), ViewHelpers::html_escape(if form_method.clone() == \"patch\" { \"Update Article\" } else { \"Create Article\" }), ViewHelpers::html_escape(if form_method.clone() == \"patch\" { \"Update Article\" } else { \"Create Article\" }), \"w-full sm:w-auto rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer\"));\n        io\n    }\n\n    pub fn edit(article: Article, notice: Option<String>, alert: Option<String>) -> String {\n        let mut io = String::new();\n        ViewHelpers::content_for_set(\"title\", \"Editing article\");\n        io.push_str(&format!(\"\\n<div class=\\\"md:w-2/3 w-full\\\">\\n  <h1 class=\\\"font-bold text-4xl\\\">Editing article</h1>\\n\\n  {}\\n\\n  <a href=\\\"{}\\\" class=\\\"{}\\\">{}</a>\\n  <a href=\\\"{}\\\" class=\\\"{}\\\">{}</a>\\n</div>\\n\", Articles::form(article.clone().clone(), None, None), ViewHelpers::html_escape(&(RouteHelpers::article_path(article.id()))), \"w-full sm:w-auto text-center mt-2 sm:mt-0 sm:ml-2 rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-50 inline-block font-medium\", \"Show this article\", ViewHelpers::html_escape(&(RouteHelpers::articles_path())), \"w-full sm:w-auto text-center mt-2 sm:mt-0 sm:ml-2 rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-50 inline-block font-medium\", \"Back to articles\"));\n        io\n    }\n\n    pub fn index(articles: Vec<Article>, notice: Option<String>, alert: Option<String>) -> String {\n        let mut io = String::new();\n        io.push_str(&format!(\"{}\\n\\n\", ViewHelpers::turbo_stream_from(serde_json::Value::from(\"articles\"))));\n        ViewHelpers::content_for_set(\"title\", \"Articles\");\n        io.push_str(\"\\n<div class=\\\"w-full\\\">\\n\");\n        if !(notice.is_none()) && !(notice.clone().unwrap().is_empty()) { io.push_str(&format!(\"    <p class=\\\"py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-md inline-block\\\" id=\\\"notice\\\">{}</p>\\n\", ViewHelpers::html_escape(&(notice.clone().unwrap())))) };\n        io.push_str(&format!(\"\\n  <div class=\\\"flex justify-between items-center\\\">\\n    <h1 class=\\\"font-bold text-4xl\\\">Articles</h1>\\n    <a href=\\\"{}\\\" class=\\\"{}\\\">{}</a>\\n  </div>\\n\\n  <div id=\\\"articles\\\" class=\\\"min-w-full divide-y divide-gray-200 space-y-5\\\">\\n\", ViewHelpers::html_escape(&(RouteHelpers::new_article_path())), \"rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white block font-medium\", \"New article\"));\n        if !(articles.is_empty()) { io.push_str(\"      \");\n        articles.clone().iter_mut().for_each(|a| { io.push_str(&Articles::article(a.clone(), None, None)); });\n        io.push_str(\"\\n\") } else { io.push_str(\"      <p class=\\\"text-center my-10\\\">No articles found.</p>\\n\") };\n        io.push_str(\"  </div>\\n</div>\\n\");\n        io\n    }\n\n    pub fn new(article: Article, notice: Option<String>, alert: Option<String>) -> String {\n        let mut io = String::new();\n        ViewHelpers::content_for_set(\"title\", \"New article\");\n        io.push_str(&format!(\"\\n<div class=\\\"md:w-2/3 w-full\\\">\\n  <h1 class=\\\"font-bold text-4xl\\\">New article</h1>\\n\\n  {}\\n\\n  <a href=\\\"{}\\\" class=\\\"{}\\\">{}</a>\\n</div>\\n\", Articles::form(article.clone(), None, None), ViewHelpers::html_escape(&(RouteHelpers::articles_path())), \"w-full sm:w-auto text-center mt-2 sm:mt-0 sm:ml-2 rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-50 inline-block font-medium\", \"Back to articles\"));\n        io\n    }\n\n    pub fn show(article: Article, notice: Option<String>, alert: Option<String>) -> String {\n        let mut io = String::new();\n        ViewHelpers::content_for_set(\"title\", \"Showing article\");\n        io.push_str(\"\\n<div class=\\\"md:w-2/3 w-full\\\">\\n\");\n        if !(notice.is_none()) && !(notice.clone().unwrap().is_empty()) { io.push_str(&format!(\"    <p class=\\\"py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-md inline-block\\\" id=\\\"notice\\\">{}</p>\\n\", ViewHelpers::html_escape(&(notice.clone().unwrap())))) };\n        io.push_str(&format!(\"\\n  <h1 class=\\\"font-bold text-4xl\\\">{}</h1>\\n\\n  <div class=\\\"my-4\\\">\\n    <p class=\\\"text-gray-700\\\">{}</p>\\n  </div>\\n\\n  <a href=\\\"{}\\\" class=\\\"{}\\\">{}</a>\\n  <a href=\\\"{}\\\" class=\\\"{}\\\">{}</a>\\n  <form action=\\\"{}\\\" method=\\\"post\\\" class=\\\"{}\\\">{}<button type=\\\"submit\\\" class=\\\"{}\\\" data-turbo-confirm=\\\"{}\\\">{}</button>{}</form>\\n</div>\\n\\n<hr class=\\\"my-8\\\">\\n\\n<h2 class=\\\"text-xl font-bold mb-4\\\">Comments</h2>\\n\\n{}\\n\\n<div id=\\\"comments\\\" class=\\\"space-y-4 mb-8\\\">\\n  \", ViewHelpers::html_escape(&(article.title())), ViewHelpers::html_escape(&(article.body())), ViewHelpers::html_escape(&(RouteHelpers::edit_article_path(article.id()))), \"w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-50 inline-block font-medium\", \"Edit this article\", ViewHelpers::html_escape(&(RouteHelpers::articles_path())), \"w-full sm:w-auto text-center mt-2 sm:mt-0 sm:ml-2 rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-50 inline-block font-medium\", \"Back to articles\", ViewHelpers::html_escape(&(RouteHelpers::article_path(article.id()))), \"sm:inline-block mt-2 sm:mt-0 sm:ml-2\", ViewHelpers::method_override_input(\"delete\"), \"w-full rounded-md px-3.5 py-2.5 text-white bg-red-600 hover:bg-red-500 font-medium cursor-pointer\", \"Are you sure?\", \"Destroy this article\", ViewHelpers::csrf_token_hidden_input(), ViewHelpers::turbo_stream_from(serde_json::Value::from(format!(\"article_{}_comments\", article.id())))));\n        article.comments().iter_mut().for_each(|c| { io.push_str(&Comments::comment(c.clone(), None, None)); });\n        io.push_str(\"\\n</div>\\n\\n<h3 class=\\\"text-lg font-semibold mb-2\\\">Add a Comment</h3>\\n\\n\");\n        let mut form_record = Comment::new(std::collections::HashMap::new());\n        let form_method = \"post\";\n        io.push_str(&format!(\"<form action=\\\"{}\\\" accept-charset=\\\"UTF-8\\\" method=\\\"post\\\" class=\\\"{}\\\">{}{}\\n  <div>\\n    <label for=\\\"comment_commenter\\\" class=\\\"{}\\\">Commenter</label>\\n    <input type=\\\"text\\\" name=\\\"comment[commenter]\\\" id=\\\"comment_commenter\\\"{} class=\\\"{}\\\">\\n  </div>\\n  <div>\\n    <label for=\\\"comment_body\\\" class=\\\"{}\\\">Body</label>\\n    <textarea name=\\\"comment[body]\\\" id=\\\"comment_body\\\" rows=\\\"{}\\\" class=\\\"{}\\\">{}</textarea>\\n  </div>\\n  <input type=\\\"submit\\\" name=\\\"commit\\\" value=\\\"{}\\\" data-disable-with=\\\"{}\\\" class=\\\"{}\\\">\\n</form>\", ViewHelpers::html_escape(&(RouteHelpers::article_comments_path(article.id()))), \"space-y-4\", ViewHelpers::method_override_input(&(form_method)), ViewHelpers::csrf_token_hidden_input(), \"block font-medium\", ViewHelpers::optional_value_attr(form_record.clone().get_index(\"commenter\")), \"block w-full border rounded p-2\", \"block font-medium\", ViewHelpers::html_escape(&(3_i64.to_string())), \"block w-full border rounded p-2\", ViewHelpers::escape_or_empty(form_record.clone().get_index(\"body\")), \"Add Comment\", \"Add Comment\", \"bg-blue-600 text-white px-4 py-2 rounded\"));\n        io\n    }\n\n    pub fn article_json(article: Article) -> String {\n        let mut io = String::new();\n        io.push_str(\"{\");\n        io.push_str(\"\\\"id\\\":\");\n        io.push_str(&JsonBuilder::encode_value(serde_json::Value::from(article.id())));\n        io.push_str(\",\");\n        io.push_str(\"\\\"title\\\":\");\n        io.push_str(&JsonBuilder::encode_value(serde_json::Value::from(article.title())));\n        io.push_str(\",\");\n        io.push_str(\"\\\"body\\\":\");\n        io.push_str(&JsonBuilder::encode_value(serde_json::Value::from(article.body())));\n        io.push_str(\",\");\n        io.push_str(\"\\\"created_at\\\":\");\n        io.push_str(&JsonBuilder::encode_datetime(Some(article.created_at())).to_string());\n        io.push_str(\",\");\n        io.push_str(\"\\\"updated_at\\\":\");\n        io.push_str(&JsonBuilder::encode_datetime(Some(article.updated_at())).to_string());\n        io.push_str(\",\");\n        io.push_str(\"\\\"url\\\":\");\n        io.push_str(&JsonBuilder::encode_value(serde_json::Value::from(format!(\"{}{}\", RouteHelpers::article_path(article.id()), \".json\"))));\n        io.push_str(\"}\");\n        io\n    }\n\n    pub fn index_json(articles: Vec<Article>) -> String {\n        let mut io = String::new();\n        io.push_str(\"[\");\n        io.push_str(&articles.into_iter().map(|article| { Articles::article_json(article.clone()) }).collect::<Vec<_>>().join(\",\"));\n        io.push_str(\"]\");\n        io\n    }\n\n    pub fn show_json(article: Article) -> String {\n        let mut io = String::new();\n        io.push_str(&Articles::article_json(article.clone()));\n        io\n    }\n}\n"},{"path":"src/views/comments.rs","content":"#[allow(unused_imports)]\nuse crate::view_helpers::{self, ViewHelpers};\n#[allow(unused_imports)]\nuse crate::route_helpers::{self, RouteHelpers};\n#[allow(unused_imports)]\nuse crate::inflector::{self, Inflector};\n#[allow(unused_imports)]\nuse crate::importmap::{self, Importmap};\n#[allow(unused_imports)]\nuse crate::json_builder::{self, JsonBuilder};\n#[allow(unused_imports)]\nuse crate::http::RubyToS;\n#[allow(unused_imports)]\nuse crate::models::*;\n#[allow(unused_imports)]\nuse crate::views::*;\npub struct Comments;\n\nimpl Comments {\n    pub fn comment(comment: Comment, notice: Option<String>, alert: Option<String>) -> String {\n        let mut io = String::new();\n        io.push_str(&format!(\"<div id=\\\"{}\\\" class=\\\"p-4 bg-gray-50 rounded\\\">\\n  <p class=\\\"font-semibold\\\">{}</p>\\n  <p class=\\\"text-gray-700\\\">{}</p>\\n  <form action=\\\"{}\\\" method=\\\"post\\\" class=\\\"{}\\\">{}<button type=\\\"submit\\\" class=\\\"{}\\\" data-turbo-confirm=\\\"{}\\\">{}</button>{}</form>\\n</div>\\n\", format!(\"comment_{}\", comment.clone().id()), ViewHelpers::html_escape(&(comment.commenter())), ViewHelpers::html_escape(&(comment.body())), ViewHelpers::html_escape(&(RouteHelpers::article_comment_path(comment.article_id(), comment.id()))), \"button_to\", ViewHelpers::method_override_input(\"delete\"), \"text-red-600 text-sm mt-2\", \"Are you sure?\", \"Delete\", ViewHelpers::csrf_token_hidden_input()));\n        io\n    }\n}\n"},{"path":"src/views/layouts.rs","content":"#[allow(unused_imports)]\nuse crate::view_helpers::{self, ViewHelpers};\n#[allow(unused_imports)]\nuse crate::route_helpers::{self, RouteHelpers};\n#[allow(unused_imports)]\nuse crate::inflector::{self, Inflector};\n#[allow(unused_imports)]\nuse crate::importmap::{self, Importmap};\n#[allow(unused_imports)]\nuse crate::json_builder::{self, JsonBuilder};\n#[allow(unused_imports)]\nuse crate::http::RubyToS;\n#[allow(unused_imports)]\nuse crate::models::*;\n#[allow(unused_imports)]\nuse crate::views::*;\npub struct Layouts;\n\nimpl Layouts {\n    pub fn application(body: &str, notice: Option<String>, alert: Option<String>) -> String {\n        let mut io = String::new();\n        io.push_str(&format!(\"<!DOCTYPE html>\\n<html>\\n  <head>\\n    <title>{}</title>\\n    <meta name=\\\"viewport\\\" content=\\\"width=device-width,initial-scale=1\\\">\\n    <meta name=\\\"apple-mobile-web-app-capable\\\" content=\\\"yes\\\">\\n    <meta name=\\\"application-name\\\" content=\\\"Real Blog\\\">\\n    <meta name=\\\"mobile-web-app-capable\\\" content=\\\"yes\\\">\\n    {}\\n    {}\\n\\n    {}\\n\\n\\n    <link rel=\\\"icon\\\" href=\\\"/icon.png\\\" type=\\\"image/png\\\">\\n    <link rel=\\\"icon\\\" href=\\\"/icon.svg\\\" type=\\\"image/svg+xml\\\">\\n    <link rel=\\\"apple-touch-icon\\\" href=\\\"/icon.png\\\">\\n\\n    {}\\n    {}\\n  </head>\\n\\n  <body>\\n    <main class=\\\"container mx-auto mt-28 px-5 flex flex-col\\\">\\n      {}\\n    </main>\\n  </body>\\n</html>\\n\", ViewHelpers::html_escape(&(ViewHelpers::content_for_get(\"title\").unwrap_or(\"Real Blog\".to_string()))), ViewHelpers::csrf_meta_tags(), ViewHelpers::csp_meta_tag(), ViewHelpers::get_slot(\"head\"), format!(\"{}{}\", format!(\"{}{}\", ViewHelpers::stylesheet_link_tag(\"application\", std::collections::HashMap::from([(\"data-turbo-track\", \"reload\")]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>()), \"\\n\"), ViewHelpers::stylesheet_link_tag(\"tailwind\", std::collections::HashMap::from([(\"data-turbo-track\", \"reload\")]).into_iter().map(|(k, v)| (k.to_string(), serde_json::Value::from(v))).collect::<std::collections::HashMap<String, serde_json::Value>>())), ViewHelpers::javascript_importmap_tags(Some(Importmap::pins()), &(Importmap::entry())), body));\n        io\n    }\n\n    pub fn mailer(body: &str, notice: Option<String>, alert: Option<String>) -> String {\n        let mut io = String::new();\n        io.push_str(&format!(\"<!DOCTYPE html>\\n<html>\\n  <head>\\n    <meta http-equiv=\\\"Content-Type\\\" content=\\\"text/html; charset=utf-8\\\">\\n    <style>\\n      /* Email styles need to be inline */\\n    </style>\\n  </head>\\n\\n  <body>\\n    {}\\n  </body>\\n</html>\\n\", body));\n        io\n    }\n}\n\n// Wedge 2c.4 layout bridge — server runtime slot is\n// `fn() -> String` (no args), so wrap the 3-arg\n// template signature with a thread-local body read.\npub fn render_layout() -> String {\n    let body = crate::view_helpers::ViewHelpers::get_yield();\n    Layouts::application(&body, None, None)\n}\n"},{"path":"src/views/mod.rs","content":"// Generated by Roundhouse (rust2).\n\npub mod articles;\npub mod comments;\npub mod layouts;\npub use articles::Articles;\npub use comments::Comments;\npub use layouts::Layouts;\n"}]}