use std::path::PathBuf;
use crate::builder::canonical::canonicalize;
use crate::builder::draft::{Draft, Field, Model};
use crate::builder::hash::{admin_hash, initial_migration_hash, mod_hash, model_hash};
use crate::builder::lockfile::EMITTER_VERSION;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct GeneratedFile {
pub path: PathBuf,
pub content: String,
pub schema_hash: String,
}
pub(crate) fn generate_all(draft: &Draft) -> Vec<GeneratedFile> {
let mut out = Vec::new();
out.push(emit_mod_rs(draft));
out.push(emit_admin_rs(draft));
out.push(emit_models_mod_rs(draft));
for model in &draft.models {
out.push(emit_model_rs(draft, model));
}
out.push(emit_initial_migration(draft));
out
}
fn doctrine_header(file_kind: HeaderStyle, schema_hash: &str) -> String {
let version = env!("CARGO_PKG_VERSION");
let (prefix, _close) = file_kind.markers();
format!(
"{prefix} @generated by rustio {version} from .rustio/draft.toml\n\
{prefix} SPDX-SchemaHash: {schema_hash}\n\
{prefix} SPDX-EmitterVersion: {EMITTER_VERSION}\n\
{prefix} To change, edit draft.toml and run `rustio commit`. Manual\n\
{prefix} edits will be overwritten.\n"
)
}
#[derive(Debug, Clone, Copy)]
enum HeaderStyle {
Rust,
Sql,
}
impl HeaderStyle {
fn markers(self) -> (&'static str, &'static str) {
match self {
HeaderStyle::Rust => ("//", ""),
HeaderStyle::Sql => ("--", ""),
}
}
}
fn emit_mod_rs(draft: &Draft) -> GeneratedFile {
let hash = mod_hash(draft);
let mut body = String::new();
body.push_str(&doctrine_header(HeaderStyle::Rust, &hash));
body.push('\n');
body.push_str("pub mod admin;\n");
body.push_str("pub mod models;\n");
GeneratedFile {
path: PathBuf::from("src/_generated/mod.rs"),
content: canonicalize(&body),
schema_hash: hash,
}
}
fn emit_admin_rs(draft: &Draft) -> GeneratedFile {
let hash = admin_hash(draft);
let mut body = String::new();
body.push_str(&doctrine_header(HeaderStyle::Rust, &hash));
body.push('\n');
body.push_str("use rustio_admin::Admin;\n");
if !draft.models.is_empty() {
body.push('\n');
for m in &draft.models {
body.push_str(&format!(
"use super::models::{}::{};\n",
snake(&m.name),
m.name
));
}
}
body.push('\n');
body.push_str("/// Generator-owned admin builder. `main.rs` calls this to\n");
body.push_str("/// obtain an `Admin` with every model from `draft.toml`\n");
body.push_str("/// registered. The developer chains further configuration\n");
body.push_str("/// (`.app_name(...)`, `.public_url(...)`, etc.) after this.\n");
body.push_str("pub fn build_admin() -> Admin {\n");
if draft.models.is_empty() {
body.push_str(" Admin::new()\n");
} else {
body.push_str(" Admin::new()\n");
for m in &draft.models {
body.push_str(&format!(" .model::<{}>()\n", m.name));
}
}
body.push_str("}\n");
GeneratedFile {
path: PathBuf::from("src/_generated/admin.rs"),
content: canonicalize(&body),
schema_hash: hash,
}
}
fn emit_models_mod_rs(draft: &Draft) -> GeneratedFile {
let hash = mod_hash(draft);
let mut body = String::new();
body.push_str(&doctrine_header(HeaderStyle::Rust, &hash));
if !draft.models.is_empty() {
body.push('\n');
for m in &draft.models {
body.push_str(&format!("pub mod {};\n", snake(&m.name)));
}
}
GeneratedFile {
path: PathBuf::from("src/_generated/models/mod.rs"),
content: canonicalize(&body),
schema_hash: hash,
}
}
fn emit_model_rs(draft: &Draft, model: &Model) -> GeneratedFile {
let hash = model_hash(draft, &model.name).expect("model is present in draft");
let mut body = String::new();
body.push_str(&doctrine_header(HeaderStyle::Rust, &hash));
body.push('\n');
body.push_str("use chrono::{DateTime, Utc};\n");
body.push_str("use rustio_admin::{Model, ModelAdmin, Result, Row, RustioAdmin, Value};\n");
body.push('\n');
body.push_str("#[derive(Debug, Clone, RustioAdmin)]\n");
body.push_str(&format!("pub struct {} {{\n", model.name));
body.push_str(" pub id: i64,\n");
for f in &model.fields {
body.push_str(&format!(" pub {}: {},\n", f.name, rust_type(f)));
}
body.push_str(" pub created_at: DateTime<Utc>,\n");
body.push_str("}\n\n");
let cols: Vec<String> = std::iter::once("id".to_string())
.chain(model.fields.iter().map(|f| f.name.clone()))
.chain(std::iter::once("created_at".to_string()))
.collect();
let insert_cols: Vec<String> = model
.fields
.iter()
.map(|f| f.name.clone())
.chain(std::iter::once("created_at".to_string()))
.collect();
let cols_lit = cols
.iter()
.map(|c| format!("\"{c}\""))
.collect::<Vec<_>>()
.join(", ");
let insert_cols_lit = insert_cols
.iter()
.map(|c| format!("\"{c}\""))
.collect::<Vec<_>>()
.join(", ");
body.push_str(&format!("impl Model for {} {{\n", model.name));
body.push_str(&format!(
" const TABLE: &'static str = \"{}\";\n",
model.table
));
body.push_str(&format!(
" const COLUMNS: &'static [&'static str] = &[{cols_lit}];\n"
));
body.push_str(&format!(
" const INSERT_COLUMNS: &'static [&'static str] = &[{insert_cols_lit}];\n\n"
));
body.push_str(" fn id(&self) -> i64 {\n");
body.push_str(" self.id\n");
body.push_str(" }\n\n");
body.push_str(" fn from_row(row: Row<'_>) -> Result<Self> {\n");
body.push_str(" Ok(Self {\n");
body.push_str(" id: row.get_i64(\"id\")?,\n");
for f in &model.fields {
body.push_str(&format!(
" {}: row.{}(\"{}\")?,\n",
f.name,
row_accessor(f),
f.name
));
}
body.push_str(" created_at: row.get_datetime(\"created_at\")?,\n");
body.push_str(" })\n");
body.push_str(" }\n\n");
body.push_str(" fn insert_values(&self) -> Vec<Value> {\n");
body.push_str(" vec![\n");
for f in &model.fields {
body.push_str(&format!(
" Value::from({}),\n",
insert_expr(&f.name, f)
));
}
body.push_str(" Value::from(self.created_at),\n");
body.push_str(" ]\n");
body.push_str(" }\n");
body.push_str("}\n\n");
body.push_str(&format!("impl ModelAdmin for {} {{}}\n", model.name));
GeneratedFile {
path: PathBuf::from(format!("src/_generated/models/{}.rs", snake(&model.name))),
content: canonicalize(&body),
schema_hash: hash,
}
}
fn emit_initial_migration(draft: &Draft) -> GeneratedFile {
let hash = initial_migration_hash(draft);
let mut body = String::new();
body.push_str(&doctrine_header(HeaderStyle::Sql, &hash));
body.push('\n');
body.push_str("-- Rollback hint (free-form, not parsed -- DESIGN_BUILDER.md §7.5):\n");
body.push_str("-- To roll back, DROP TABLE every table below in reverse order.\n");
body.push_str("-- Verify FK constraints first if any have been added by hand.\n");
body.push('\n');
for m in &draft.models {
body.push_str(&format!("CREATE TABLE {} (\n", m.table));
let name_w = m
.fields
.iter()
.map(|f| f.name.len())
.max()
.unwrap_or(0)
.max(10);
body.push_str(&format!(
" {:<width$} BIGSERIAL PRIMARY KEY,\n",
"id",
width = name_w
));
for f in &m.fields {
let unique = if f.unique { " UNIQUE" } else { "" };
body.push_str(&format!(
" {:<width$} {:<11} NOT NULL{},\n",
f.name,
sql_type(f),
unique,
width = name_w
));
}
body.push_str(&format!(
" {:<width$} TIMESTAMPTZ NOT NULL DEFAULT NOW()\n",
"created_at",
width = name_w
));
body.push_str(");\n\n");
}
GeneratedFile {
path: PathBuf::from("migrations/0001_initial.sql"),
content: canonicalize(&body),
schema_hash: hash,
}
}
fn rust_type(field: &Field) -> &'static str {
match field.r#type.as_str() {
"text" => "String",
"integer" => "i64",
"boolean" => "bool",
"timestamp" => "DateTime<Utc>",
_ => unreachable!("FIELD_TYPES is closed; unknown type would have been refused upstream"),
}
}
fn row_accessor(field: &Field) -> &'static str {
match field.r#type.as_str() {
"text" => "get_string",
"integer" => "get_i64",
"boolean" => "get_bool",
"timestamp" => "get_datetime",
_ => unreachable!(),
}
}
fn insert_expr(field_name: &str, field: &Field) -> String {
match field.r#type.as_str() {
"text" => format!("self.{field_name}.clone()"),
"integer" | "boolean" | "timestamp" => format!("self.{field_name}"),
_ => unreachable!(),
}
}
fn sql_type(field: &Field) -> &'static str {
match field.r#type.as_str() {
"text" => "TEXT",
"integer" => "BIGINT",
"boolean" => "BOOLEAN",
"timestamp" => "TIMESTAMPTZ",
_ => unreachable!(),
}
}
fn snake(name: &str) -> String {
let mut out = String::new();
for (i, c) in name.chars().enumerate() {
if c.is_ascii_uppercase() && i > 0 {
out.push('_');
}
out.push(c.to_ascii_lowercase());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::builder::draft::{Field, Project};
fn sample() -> Draft {
Draft {
schema_version: 1,
project: Project {
name: "demo".into(),
rust_version: "1.88".into(),
builder_pinned: env!("CARGO_PKG_VERSION").into(),
created_at: "2026-05-15T10:30:00Z".into(),
},
models: vec![Model {
name: "Patient".into(),
table: "patients".into(),
fields: vec![Field {
name: "full_name".into(),
r#type: "text".into(),
required: true,
unique: false,
}],
}],
}
}
#[test]
fn every_emitted_file_carries_doctrine_header() {
for file in generate_all(&sample()) {
assert!(
file.content.contains("@generated by rustio"),
"missing @generated marker in {:?}:\n{}",
file.path,
file.content
);
assert!(
file.content.contains("SPDX-SchemaHash:"),
"missing SchemaHash marker in {:?}",
file.path,
);
assert!(
file.content.contains("SPDX-EmitterVersion:"),
"missing EmitterVersion marker in {:?}",
file.path,
);
assert!(
file.content.contains(&file.schema_hash),
"header hash does not match file.schema_hash in {:?}",
file.path,
);
}
}
#[test]
fn output_is_canonical_text() {
for file in generate_all(&sample()) {
assert!(!file.content.contains('\r'), "CR in {:?}", file.path);
assert!(
file.content.ends_with('\n'),
"no trailing LF in {:?}",
file.path
);
assert!(
!file.content.ends_with("\n\n"),
"trailing blank lines in {:?}",
file.path
);
}
}
#[test]
fn generation_is_deterministic() {
let a = generate_all(&sample());
let b = generate_all(&sample());
assert_eq!(a.len(), b.len());
for (x, y) in a.iter().zip(b.iter()) {
assert_eq!(x.path, y.path);
assert_eq!(x.content, y.content, "non-stable output in {:?}", x.path);
assert_eq!(x.schema_hash, y.schema_hash);
}
}
#[test]
fn model_file_includes_field_columns() {
let files = generate_all(&sample());
let model_file = files
.iter()
.find(|f| f.path.ends_with("models/patient.rs"))
.expect("patient.rs in generated set");
assert!(model_file.content.contains("pub struct Patient"));
assert!(model_file.content.contains("pub full_name: String"));
assert!(model_file
.content
.contains("TABLE: &'static str = \"patients\""));
assert!(model_file.content.contains("get_string(\"full_name\")"));
assert!(model_file.content.contains("impl ModelAdmin for Patient"));
}
#[test]
fn admin_file_registers_each_model() {
let mut d = sample();
d.models.push(Model {
name: "Doctor".into(),
table: "doctors".into(),
fields: vec![],
});
let files = generate_all(&d);
let admin = files.iter().find(|f| f.path.ends_with("admin.rs")).unwrap();
assert!(admin.content.contains(".model::<Patient>()"));
assert!(admin.content.contains(".model::<Doctor>()"));
assert!(admin.content.contains("pub fn build_admin() -> Admin"));
}
#[test]
fn initial_migration_creates_each_table() {
let mut d = sample();
d.models.push(Model {
name: "Doctor".into(),
table: "doctors".into(),
fields: vec![],
});
let files = generate_all(&d);
let mig = files
.iter()
.find(|f| f.path.ends_with("0001_initial.sql"))
.unwrap();
assert!(mig.content.starts_with("-- @generated"));
assert!(mig.content.contains("CREATE TABLE patients"));
let line = mig
.content
.lines()
.find(|l| l.trim_start().starts_with("full_name "))
.expect("full_name line emitted");
assert!(line.contains("TEXT"), "{line}");
assert!(line.contains("NOT NULL"), "{line}");
assert!(
!line.contains("full_nameTEXT"),
"identifier collapsed against type: {line}"
);
assert!(mig.content.contains("CREATE TABLE doctors"));
assert!(
mig.content.contains("Rollback hint"),
"rollback hint required by §7.5"
);
}
#[test]
fn snake_helper_matches_macro_convention() {
assert_eq!(snake("Patient"), "patient");
assert_eq!(snake("BlogPost"), "blog_post");
assert_eq!(snake("user"), "user");
}
#[test]
fn long_field_names_keep_separator_from_type_column() {
let d = Draft {
schema_version: 1,
project: Project {
name: "demo".into(),
rust_version: "1.88".into(),
builder_pinned: env!("CARGO_PKG_VERSION").into(),
created_at: "2026-05-15T10:30:00Z".into(),
},
models: vec![Model {
name: "Vehicle".into(),
table: "vehicles".into(),
fields: vec![
Field {
name: "vin".into(),
r#type: "text".into(),
required: true,
unique: true,
},
Field {
name: "engine_displacement_cc".into(),
r#type: "integer".into(),
required: true,
unique: false,
},
],
}],
};
let files = generate_all(&d);
let mig = files
.iter()
.find(|f| f.path.ends_with("0001_initial.sql"))
.unwrap();
assert!(
mig.content.contains("vin TEXT"),
"short vin should align to long-name width:\n{}",
mig.content
);
assert!(
mig.content.contains("engine_displacement_cc BIGINT"),
"long field name must keep ≥2 spaces before its type:\n{}",
mig.content
);
assert!(
!mig.content.contains("engine_displacement_ccBIGINT"),
"long field name collapsed into its type -- v0.14.0 codegen bug regressed:\n{}",
mig.content
);
}
#[test]
fn unique_modifier_emits_sql_constraint() {
let mut d = sample();
d.models[0].fields[0].unique = true;
let files = generate_all(&d);
let mig = files
.iter()
.find(|f| f.path.ends_with("0001_initial.sql"))
.unwrap();
assert!(
mig.content.contains("UNIQUE"),
"missing UNIQUE constraint:\n{}",
mig.content,
);
}
}