use super::design::*;
use super::templates::{ROUTE_CARGO, render};
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Copy, Default)]
pub struct GenMode {
pub db: bool,
pub auth: bool,
}
pub fn crate_ident(module_name: &str) -> String {
format!("route_{}", module_name.replace('-', "_"))
}
fn declared_id(e: &Entity) -> Option<FieldType> {
e.fields
.iter()
.find(|f| f.name == "id")
.map(|f| f.field_type)
}
fn key_rust_type(e: &Entity) -> &'static str {
match declared_id(e) {
Some(t) => t.rust_type(),
None => "i64",
}
}
fn endpoint_repo_entity<'a>(m: &'a ModuleDesign, ep: &'a Endpoint) -> Option<&'a str> {
if m.entities.is_empty() {
return None;
}
ep.request_body
.as_ref()
.map(|rb| rb.entity.as_str())
.or(ep.success.entity.as_deref())
.or_else(|| m.entities.first().map(|e| e.name.as_str()))
}
fn return_type(ep: &Endpoint) -> String {
let entity = ep.success.entity.as_deref();
match (ep.success.status, entity, ep.success.list) {
(204, _, _) => "Result<NoContent>".to_string(),
(201, Some(e), _) => format!("Result<Created<{e}>>"),
(201, None, _) => "Result<Created<serde_json::Value>>".to_string(),
(_, Some(e), true) => format!("Result<Json<Vec<{e}>>>"),
(_, Some(e), false) => format!("Result<Json<{e}>>"),
(_, None, _) => "Result<Json<serde_json::Value>>".to_string(),
}
}
fn path_params(ep: &Endpoint) -> Vec<String> {
let mut out = Vec::new();
let mut rest = ep.path.as_str();
while let Some(start) = rest.find('{') {
let Some(end_rel) = rest[start..].find('}') else {
break;
};
out.push(rest[start + 1..start + end_rel].to_string());
rest = &rest[start + end_rel + 1..];
}
out
}
fn endpoint_is_tenant_owned(m: &ModuleDesign, ep: &Endpoint, design: &Design) -> bool {
let Some(tenancy) = design.tenancy.as_ref() else {
return false;
};
let Some(entity) = endpoint_repo_entity(m, ep) else {
return false;
};
m.entities
.iter()
.find(|e| e.name == entity)
.is_some_and(|e| e.belongs_to.iter().any(|b| b.entity == tenancy.entity))
}
fn handler_params(m: &ModuleDesign, ep: &Endpoint, mode: GenMode, design: &Design) -> String {
let mut params = Vec::new();
if let Some(e) = endpoint_repo_entity(m, ep) {
params.push(format!("_repo: Dep<{e}Repo>"));
}
if mode.auth && ep.is_guarded() {
if endpoint_is_tenant_owned(m, ep, design) {
params.push("_tenant: Dep<Tenant>".to_string());
} else {
params.push("_user: CurrentUser".to_string());
}
}
let params_in_path = path_params(ep);
let key = endpoint_repo_entity(m, ep)
.and_then(|name| m.entities.iter().find(|e| e.name == name))
.map(key_rust_type)
.unwrap_or("i64");
let param_type = |p: &str| if p == "id" { key } else { "i64" };
match params_in_path.len() {
0 => {}
1 => params.push(format!(
"Path(_{p}): Path<{ty}>",
p = params_in_path[0],
ty = param_type(¶ms_in_path[0])
)),
_ => {
let names: Vec<String> = params_in_path.iter().map(|p| format!("_{p}")).collect();
let types = params_in_path
.iter()
.map(|p| param_type(p))
.collect::<Vec<_>>()
.join(", ");
params.push(format!("Path(({})): Path<({})>", names.join(", "), types));
}
}
if let Some(ref rb) = ep.request_body {
params.push(format!("Json(_body): Json<{}>", rb.entity));
}
params.join(", ")
}
fn guard_comment(m: &ModuleDesign, ep: &Endpoint, design: &Design) -> String {
if ep.required_roles.is_empty() {
return String::new();
}
let roles = ep.required_roles.join("\", \"");
if endpoint_is_tenant_owned(m, ep, design) {
format!(
" // guard: requires role \"{roles}\" — call _tenant.require_role(\"{roles}\")? before proceeding\n"
)
} else {
format!(
" // guard: requires role \"{roles}\" — add `use jerrycan::auth::require_role;` and call require_role(&_user.0.role, \"{roles}\")? before proceeding\n"
)
}
}
pub(crate) fn handlers_rs(m: &ModuleDesign, mode: GenMode, design: &Design) -> String {
let mut uses = String::from("use jerrycan::prelude::*;\n");
let mentions_entities = m
.endpoints
.iter()
.any(|ep| ep.request_body.is_some() || ep.success.entity.is_some());
if mentions_entities {
uses.push_str("use super::model::*;\n");
}
if !m.entities.is_empty() {
uses.push_str("use super::repo::*;\n");
}
if mode.auth {
let needs_tenant = m
.endpoints
.iter()
.any(|ep| ep.is_guarded() && endpoint_is_tenant_owned(m, ep, design));
let needs_user = m
.endpoints
.iter()
.any(|ep| ep.is_guarded() && !endpoint_is_tenant_owned(m, ep, design));
if needs_tenant {
uses.push_str("use shared::Tenant;\n");
}
if needs_user {
uses.push_str("use shared::CurrentUser;\n");
}
}
let mut out = format!(
"//! Handlers for `{}` — thin: extract → call → respond.\n//! Generated stubs return 500 until implemented.\n{uses}\n",
m.name
);
for ep in &m.endpoints {
let guard = if mode.auth {
guard_comment(m, ep, design)
} else {
String::new()
};
out.push_str(&format!(
"pub(crate) async fn {op}({params}) -> {ret} {{\n{guard} Err(Error::internal(\"{op} not implemented — replace this stub\"))\n}}\n\n",
op = ep.operation_id,
params = handler_params(m, ep, mode, design),
ret = return_type(ep),
));
}
out
}
pub(crate) fn model_rs(m: &ModuleDesign) -> Option<String> {
if m.entities.is_empty() {
return None;
}
let mut out = String::from(
"//! Entities and DTOs for this module.\nuse serde::{Deserialize, Serialize};\n\n",
);
for e in &m.entities {
out.push_str("#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ");
out.push_str(&e.name);
out.push_str(" {\n");
for f in &e.fields {
out.push_str(&keyword_field_attrs(&f.name, " ", false));
if !f.required {
out.push_str(" #[serde(default)]\n");
}
out.push_str(&format!(
" pub {}: {},\n",
rust_ident(&f.name),
f.field_type.rust_type()
));
}
out.push_str("}\n\n");
}
Some(out)
}
fn keyword_field_attrs(name: &str, indent: &str, db: bool) -> String {
if !is_rust_keyword(name) {
return String::new();
}
let mut s = format!("{indent}#[serde(rename = \"{name}\")]\n");
if db {
s.push_str(&format!("{indent}#[sea_orm(column_name = \"{name}\")]\n"));
}
s
}
fn col_pascal(snake: &str) -> String {
let mut out = String::with_capacity(snake.len());
for word in snake.split('_') {
let mut chars = word.chars();
if let Some(first) = chars.next() {
out.extend(first.to_uppercase());
out.push_str(chars.as_str());
}
}
out
}
pub(crate) fn model_rs_db(m: &ModuleDesign, design: &Design) -> Option<String> {
if m.entities.is_empty() {
return None;
}
let local: std::collections::HashSet<&str> =
m.entities.iter().map(|e| e.name.as_str()).collect();
let mut out = String::from(
"//! Entities and DTOs for this module (db mode: SeaORM entities).\n//! Agent-owned: edit freely.\n\n",
);
for e in &m.entities {
let snake = Design::to_snake(&e.name);
let table = design.table_name(&e.name);
let key = key_rust_type(e);
let id_default = if declared_id(e).is_some() {
""
} else {
" #[serde(default)]\n"
};
let mut fields = String::new();
for b in &e.belongs_to {
let col = Design::fk_column(&b.entity);
let ty = design.target_key_rust_type(&b.entity);
if b.on_delete == OnDelete::SetNull {
fields.push_str(" #[serde(default)]\n");
fields.push_str(&format!(" pub {col}: Option<{ty}>,\n"));
} else {
fields.push_str(&format!(" pub {col}: {ty},\n"));
}
}
for f in e.fields.iter().filter(|f| f.name != "id") {
let base = match f.field_type {
FieldType::Json => "Json",
FieldType::Boolean => "bool",
_ => f.field_type.rust_type(),
};
fields.push_str(&keyword_field_attrs(&f.name, " ", true));
let ident = rust_ident(&f.name);
if f.required {
fields.push_str(&format!(" pub {ident}: {base},\n"));
} else {
fields.push_str(" #[serde(default)]\n");
fields.push_str(&format!(" pub {ident}: Option<{base}>,\n"));
}
}
let mut relation_arms = String::new();
let mut related_impls = String::new();
for b in &e.belongs_to {
if !local.contains(b.entity.as_str()) {
continue;
}
let target_snake = Design::to_snake(&b.entity);
let fk_pascal = col_pascal(&Design::fk_column(&b.entity));
let target_pascal = &b.entity;
relation_arms.push_str(&format!(
" #[sea_orm(belongs_to = \"super::{target_snake}::Entity\", from = \"Column::{fk_pascal}\", to = \"super::{target_snake}::Column::Id\")]\n {target_pascal},\n"
));
related_impls.push_str(&format!(
"\n impl Related<super::{target_snake}::Entity> for Entity {{\n fn to() -> RelationDef {{\n Relation::{target_pascal}.def()\n }}\n }}\n"
));
}
let relation = if relation_arms.is_empty() {
" pub enum Relation {}\n".to_string()
} else {
format!(" pub enum Relation {{\n{relation_arms} }}\n")
};
out.push_str(&format!(
r#"pub mod {snake} {{
use jerrycan::db::sea_orm;
use jerrycan::db::sea_orm::entity::prelude::*;
use serde::{{Deserialize, Serialize}};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "{table}")]
pub struct Model {{
#[sea_orm(primary_key)]
{id_default} pub id: {key},
{fields} }}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
{relation}{related_impls}
impl ActiveModelBehavior for ActiveModel {{}}
}}
pub use {snake}::Model as {entity};
"#,
entity = e.name,
));
}
Some(out)
}
fn memory_repo_rs(m: &ModuleDesign) -> String {
let mut out = String::from(
"//! In-memory data access (Phase 1; jerrycan-db replaces this in Phase 2).\nuse super::model::*;\nuse std::collections::BTreeMap;\nuse std::sync::Mutex;\nuse std::sync::atomic::{AtomicI64, Ordering};\n\n",
);
for e in &m.entities {
let n = &e.name;
out.push_str(&format!(
r#"// Stub handlers don't call the repo yet; remove this allow as you implement them.
#[allow(dead_code)]
pub struct {n}Repo {{
items: Mutex<BTreeMap<i64, {n}>>,
next_id: AtomicI64,
}}
#[allow(dead_code)]
impl {n}Repo {{
pub fn new() -> Self {{
Self {{ items: Mutex::new(BTreeMap::new()), next_id: AtomicI64::new(1) }}
}}
pub fn all(&self) -> Vec<{n}> {{
self.items.lock().unwrap().values().cloned().collect()
}}
pub fn get(&self, id: i64) -> Option<{n}> {{
self.items.lock().unwrap().get(&id).cloned()
}}
pub fn insert(&self, item: {n}) -> i64 {{
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
self.items.lock().unwrap().insert(id, item);
id
}}
pub fn remove(&self, id: i64) -> bool {{
self.items.lock().unwrap().remove(&id).is_some()
}}
pub fn update(&self, id: i64, item: {n}) -> bool {{
match self.items.lock().unwrap().get_mut(&id) {{
Some(slot) => {{
*slot = item;
true
}}
None => false,
}}
}}
}}
impl Default for {n}Repo {{
fn default() -> Self {{
Self::new()
}}
}}
"#
));
}
out
}
fn model_field_names(e: &Entity) -> Vec<String> {
let mut names = vec!["id".to_string()];
for b in &e.belongs_to {
names.push(Design::fk_column(&b.entity));
}
for f in e.fields.iter().filter(|f| f.name != "id") {
names.push(f.name.clone());
}
names
}
fn active_sets(e: &Entity, with_id: bool) -> String {
let indent = " ";
let mut out = String::new();
for name in model_field_names(e) {
if name == "id" {
if !with_id {
continue;
}
if declared_id(e).is_some() {
out.push_str(&format!("{indent}id: Set(item.id),\n"));
} else {
out.push_str(&format!("{indent}id: sea_orm::ActiveValue::NotSet,\n"));
}
} else {
let ident = rust_ident(&name);
out.push_str(&format!("{indent}{ident}: Set(item.{ident}),\n"));
}
}
out
}
fn scoped_methods(e: &Entity, design: &Design) -> String {
let Some(tenancy) = design.tenancy.as_ref() else {
return String::new();
};
if !e.belongs_to.iter().any(|b| b.entity == tenancy.entity) {
return String::new();
}
let entity = &e.name;
let snake = Design::to_snake(entity);
let fk_col = Design::fk_column(&tenancy.entity);
let fk_pascal = col_pascal(&fk_col);
let fk_ty = design.target_key_rust_type(&tenancy.entity);
let key = key_rust_type(e);
let update_sets = active_sets(e, false);
format!(
r#"
// Tenant-scoped accessors — handlers must use these for tenant-owned data (JL0006).
pub async fn all_for(&self, {fk_col}: {fk_ty}) -> Result<Vec<{entity}>> {{
{snake}::Entity::find()
.filter({snake}::Column::{fk_pascal}.eq({fk_col}))
.order_by_asc({snake}::Column::Id)
.all(self.db.conn())
.await
.map_err(db_error)
}}
pub async fn get_for(&self, {fk_col}: {fk_ty}, id: {key}) -> Result<Option<{entity}>> {{
{snake}::Entity::find_by_id(id)
.filter({snake}::Column::{fk_pascal}.eq({fk_col}))
.one(self.db.conn())
.await
.map_err(db_error)
}}
pub async fn remove_for(&self, {fk_col}: {fk_ty}, id: {key}) -> Result<bool> {{
let r = {snake}::Entity::delete_many()
.filter({snake}::Column::Id.eq(id))
.filter({snake}::Column::{fk_pascal}.eq({fk_col}))
.exec(self.db.conn())
.await
.map_err(db_error)?;
Ok(r.rows_affected > 0)
}}
pub async fn update_for(&self, {fk_col}: {fk_ty}, id: {key}, item: {entity}) -> Result<bool> {{
// Scope the write to the tenant: only proceed if the row is already
// theirs (a foreign or unknown id is a no-op, returning false → 404).
if {snake}::Entity::find_by_id(id)
.filter({snake}::Column::{fk_pascal}.eq({fk_col}))
.one(self.db.conn())
.await
.map_err(db_error)?
.is_none()
{{
return Ok(false);
}}
let m = {snake}::ActiveModel {{
id: Set(item.id),
{update_sets} }};
match m.update(self.db.conn()).await {{
Ok(_) => Ok(true),
Err(sea_orm::DbErr::RecordNotUpdated) => Ok(false),
Err(e) => Err(db_error(e)),
}}
}}
"#
)
}
fn sql_repo(e: &Entity, design: &Design) -> String {
let entity = &e.name;
let snake = Design::to_snake(entity);
let key = key_rust_type(e);
let insert_sets = active_sets(e, true);
let update_sets = active_sets(e, false);
let scoped = scoped_methods(e, design);
let insert_body = if key == "String" {
format!(
" pub async fn insert(&self, item: {entity}) -> Result<{key}> {{\n\
\x20 let id = item.id.clone();\n\
\x20 {snake}::Entity::insert({snake}::ActiveModel {{\n\
{insert_sets} }})\n\
\x20 .exec(self.db.conn())\n\
\x20 .await\n\
\x20 .map_err(db_error)?;\n\
\x20 Ok(id)\n\
\x20 }}"
)
} else {
format!(
" pub async fn insert(&self, item: {entity}) -> Result<{key}> {{\n\
\x20 let row = {snake}::ActiveModel {{\n\
{insert_sets} }}\n\
\x20 .insert(self.db.conn())\n\
\x20 .await\n\
\x20 .map_err(db_error)?;\n\
\x20 Ok(row.id)\n\
\x20 }}"
)
};
format!(
r#"pub struct {entity}Repo {{
db: Db,
}}
/// DI factory — registered by the tool-owned lib.rs via `.provide_dep`.
pub(crate) async fn {snake}_repo(db: Dep<Db>) -> Result<{entity}Repo> {{
Ok({entity}Repo {{ db: (*db).clone() }})
}}
// Stub handlers don't call the repo yet; remove this allow as you implement them.
#[allow(dead_code)]
impl {entity}Repo {{
pub async fn all(&self) -> Result<Vec<{entity}>> {{
{snake}::Entity::find()
.order_by_asc({snake}::Column::Id)
.all(self.db.conn())
.await
.map_err(db_error)
}}
pub async fn get(&self, id: {key}) -> Result<Option<{entity}>> {{
{snake}::Entity::find_by_id(id)
.one(self.db.conn())
.await
.map_err(db_error)
}}
{insert_body}
pub async fn remove(&self, id: {key}) -> Result<bool> {{
let r = {snake}::Entity::delete_by_id(id)
.exec(self.db.conn())
.await
.map_err(db_error)?;
Ok(r.rows_affected > 0)
}}
pub async fn update(&self, id: {key}, item: {entity}) -> Result<bool> {{
let m = {snake}::ActiveModel {{
id: Set(id),
{update_sets} }};
match m.update(self.db.conn()).await {{
Ok(_) => Ok(true),
Err(sea_orm::DbErr::RecordNotUpdated) => Ok(false),
Err(e) => Err(db_error(e)),
}}
}}
{scoped}}}
"#,
)
}
pub(crate) fn repo_rs(m: &ModuleDesign, mode: GenMode, design: &Design) -> Option<String> {
if m.entities.is_empty() {
return None;
}
if !mode.db {
return Some(memory_repo_rs(m));
}
let has_scoped = m
.entities
.iter()
.any(|e| !scoped_methods(e, design).is_empty());
let filter_imports = if has_scoped {
"ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, QueryFilter, QueryOrder"
} else {
"ActiveModelTrait, ActiveValue::Set, EntityTrait, QueryOrder"
};
let mut out = format!(
"//! Data access — SeaORM over jerrycan::db (agent-owned; edit freely).\nuse jerrycan::db::sea_orm;\nuse jerrycan::db::sea_orm::{{{filter_imports}}};\nuse jerrycan::db::{{db_error, Db}};\nuse jerrycan::prelude::*;\n\nuse super::model::*;\n\n",
);
for e in &m.entities {
out.push_str(&sql_repo(e, design));
}
Some(out)
}
fn schema_sql<S: sea_query::SchemaStatementBuilder>(stmt: &S, backend_is_pg: bool) -> String {
use sea_query::{PostgresQueryBuilder, SqliteQueryBuilder};
if backend_is_pg {
stmt.build(PostgresQueryBuilder)
} else {
stmt.build(SqliteQueryBuilder)
}
}
fn ddl_typed(
c: &mut sea_query::ColumnDef,
t: FieldType,
backend_is_pg: bool,
) -> &mut sea_query::ColumnDef {
match t {
FieldType::String | FieldType::Datetime | FieldType::Uuid => c.text(),
FieldType::Integer => c.big_integer(),
FieldType::Boolean => c.boolean(),
FieldType::Float => c.double(),
FieldType::Json => {
if backend_is_pg {
c.json_binary()
} else {
c.text()
}
}
}
}
fn migration_ddl(m: &ModuleDesign, backend_is_pg: bool, design: &Design) -> Option<String> {
use sea_query::{Alias, ColumnDef, Expr, ForeignKey, ForeignKeyAction, Index, Table};
if m.entities.is_empty() {
return None;
}
let mut out = String::new();
let mut indexes = String::new();
let mut comments = String::new();
let local: std::collections::HashSet<&str> =
m.entities.iter().map(|e| e.name.as_str()).collect();
for e in &m.entities {
let tbl = design.table_name(&e.name);
let mut table = Table::create();
table.table(Alias::new(tbl.clone()));
let mut pk = ColumnDef::new(Alias::new("id"));
match declared_id(e) {
Some(t) if t != FieldType::Integer => {
ddl_typed(&mut pk, t, backend_is_pg)
.not_null()
.primary_key();
}
_ => {
pk.big_integer().not_null().auto_increment().primary_key();
}
}
table.col(&mut pk);
for b in &e.belongs_to {
let col = Design::fk_column(&b.entity);
let target_table = design.table_name(&b.entity);
let mut fk_col = ColumnDef::new(Alias::new(col.clone()));
match design.target_key_rust_type(&b.entity) {
"String" => ddl_typed(&mut fk_col, FieldType::String, backend_is_pg),
_ => fk_col.big_integer(),
};
if b.on_delete != OnDelete::SetNull {
fk_col.not_null();
}
table.col(&mut fk_col);
if local.contains(b.entity.as_str()) {
let action = match b.on_delete {
OnDelete::Cascade => ForeignKeyAction::Cascade,
OnDelete::SetNull => ForeignKeyAction::SetNull,
OnDelete::Restrict => ForeignKeyAction::Restrict,
};
table.foreign_key(
ForeignKey::create()
.name(format!("fk_{tbl}_{col}"))
.from(Alias::new(tbl.clone()), Alias::new(col.clone()))
.to(Alias::new(target_table), Alias::new("id"))
.on_delete(action),
);
} else {
let idx_name = format!("idx_{tbl}_{col}");
if e.fields.iter().any(|f| f.name == col && f.index) {
} else {
let mut idx = Index::create();
idx.name(idx_name)
.table(Alias::new(tbl.clone()))
.col(Alias::new(col.clone()));
indexes.push_str(&schema_sql(&idx, backend_is_pg));
indexes.push_str(";\n\n");
}
comments.push_str(&format!(
"-- {col}: references {target_table}.id (cross-module; enforced by handlers, see schema.json)\n"
));
}
}
for f in e.fields.iter().filter(|f| f.name != "id") {
let mut col = ColumnDef::new(Alias::new(f.name.as_str()));
ddl_typed(&mut col, f.field_type, backend_is_pg);
if f.required {
col.not_null();
}
if f.unique {
col.unique_key();
}
if let Some(values) = &f.values {
col.check(Expr::col(Alias::new(f.name.as_str())).is_in(values.clone()));
}
table.col(&mut col);
if f.index {
let idx_name = format!("idx_{tbl}_{name}", name = f.name);
let mut idx = Index::create();
idx.name(idx_name)
.table(Alias::new(tbl.clone()))
.col(Alias::new(f.name.as_str()));
indexes.push_str(&schema_sql(&idx, backend_is_pg));
indexes.push_str(";\n\n");
}
}
out.push_str(&schema_sql(&table, backend_is_pg));
out.push_str(";\n\n");
out.push_str(&comments);
comments.clear();
out.push_str(&indexes);
indexes.clear();
}
if let Some(tenancy) = &design.tenancy
&& m.entities.iter().any(|e| e.name == tenancy.entity)
{
let tenant_table = design.table_name(&tenancy.entity);
let members = format!("{}_members", Design::to_snake(&tenancy.entity));
let fk = Design::fk_column(&tenancy.entity);
let mut table = Table::create();
table.table(Alias::new(members.clone()));
let mut pk = ColumnDef::new(Alias::new("id"));
pk.big_integer().not_null().auto_increment().primary_key();
table.col(&mut pk);
table.col(ColumnDef::new(Alias::new("user_id")).text().not_null());
let mut fk_col = ColumnDef::new(Alias::new(fk.clone()));
match design.target_key_rust_type(&tenancy.entity) {
"String" => ddl_typed(&mut fk_col, FieldType::String, backend_is_pg),
_ => fk_col.big_integer(),
};
fk_col.not_null();
table.col(&mut fk_col);
table.col(ColumnDef::new(Alias::new("role")).text().not_null());
table.foreign_key(
ForeignKey::create()
.name(format!("fk_{members}_{fk}"))
.from(Alias::new(members.clone()), Alias::new(fk.clone()))
.to(Alias::new(tenant_table), Alias::new("id"))
.on_delete(ForeignKeyAction::Cascade),
);
out.push_str(&schema_sql(&table, backend_is_pg));
out.push_str(";\n\n");
let mut uniq = Index::create();
uniq.unique()
.name(format!("idx_{members}_user_tenant"))
.table(Alias::new(members.clone()))
.col(Alias::new("user_id"))
.col(Alias::new(fk.clone()));
out.push_str(&schema_sql(&uniq, backend_is_pg));
out.push_str(";\n\n");
}
Some(out)
}
fn write_module_migrations(
crate_dir: &Path,
m: &ModuleDesign,
created: &mut Vec<String>,
root: &Path,
design: &Design,
) -> Result<(), String> {
if let Some(ddl) = migration_ddl(m, false, design) {
write_agent_owned(
&crate_dir.join("migrations/sqlite/0001_create_tables.sql"),
&ddl,
created,
root,
)?;
}
if let Some(ddl) = migration_ddl(m, true, design) {
write_agent_owned(
&crate_dir.join("migrations/postgres/0001_create_tables.sql"),
&ddl,
created,
root,
)?;
}
write_subtree_migrations(crate_dir, m, created, root, design)
}
fn write_subtree_migrations(
crate_dir: &Path,
m: &ModuleDesign,
created: &mut Vec<String>,
root: &Path,
design: &Design,
) -> Result<(), String> {
for sub in &m.subroutes {
let sub_snake = sub.name.replace('-', "_");
if let Some(ddl) = migration_ddl(sub, false, design) {
write_agent_owned(
&crate_dir.join(format!(
"migrations/sqlite/0001_create_tables_{sub_snake}.sql"
)),
&ddl,
created,
root,
)?;
}
if let Some(ddl) = migration_ddl(sub, true, design) {
write_agent_owned(
&crate_dir.join(format!(
"migrations/postgres/0001_create_tables_{sub_snake}.sql"
)),
&ddl,
created,
root,
)?;
}
write_subtree_migrations(crate_dir, sub, created, root, design)?;
}
Ok(())
}
pub(crate) fn deps_rs(m: &ModuleDesign) -> String {
let mut out = String::from(
"//! Agent-owned: module-scoped dependencies and middleware.\nuse jerrycan::prelude::*;\n\n/// Called by the tool-owned lib.rs — register module deps/middleware here;\n/// regeneration never touches this file.\npub(crate) fn configure(module: Module) -> Module {\n",
);
for dep in &m.dependencies {
out.push_str(&format!(
" // declared dependency `{dep}`: define a type and .provide/.provide_dep it here\n"
));
}
out.push_str(" module\n}\n");
out
}
fn route_lines(m: &ModuleDesign, indent: &str) -> String {
let mut order: Vec<&str> = Vec::new();
let mut by_path: std::collections::HashMap<&str, Vec<&Endpoint>> =
std::collections::HashMap::new();
for ep in &m.endpoints {
if !by_path.contains_key(ep.path.as_str()) {
order.push(&ep.path);
}
by_path.entry(&ep.path).or_default().push(ep);
}
let mut out = String::new();
for path in order {
let eps = &by_path[path];
let mut chain = format!(
"{}(handlers::{})",
eps[0].method.builder_fn(),
eps[0].operation_id
);
for ep in &eps[1..] {
chain.push_str(&format!(
".{}(handlers::{})",
ep.method.builder_fn(),
ep.operation_id
));
}
out.push_str(&format!("{indent}.route(\"{path}\", {chain})\n"));
}
out
}
fn module_body(m: &ModuleDesign, indent: &str, mode: GenMode) -> String {
let mut body = format!("{indent}Module::new(\"{}\")\n", m.name);
for e in &m.entities {
if mode.db {
body.push_str(&format!(
"{indent} .provide_dep(repo::{}_repo)\n",
Design::to_snake(&e.name)
));
} else {
body.push_str(&format!(
"{indent} .provide(repo::{}Repo::new())\n",
e.name
));
}
}
body.push_str(&route_lines(m, &format!("{indent} ")));
for sub in &m.subroutes {
body.push_str(&format!(
"{indent} .mount(\"{}\", subroutes::{}::module())\n",
sub.effective_mount(),
sub.name.replace('-', "_"),
));
}
body
}
fn mod_decls(m: &ModuleDesign) -> String {
let mut out = String::from("mod deps;\nmod handlers;\n");
if !m.entities.is_empty() {
out.push_str("mod model;\nmod repo;\n");
}
if !m.subroutes.is_empty() {
out.push_str("mod subroutes;\n");
}
out
}
pub(crate) fn lib_rs(m: &ModuleDesign, mode: GenMode) -> String {
format!(
"//! Route module `{name}` — TOOL-OWNED, regenerated by `jerrycan generate`.\n//! The sole public item is `module()`; agent code lives in handlers/model/repo/deps.\n#![forbid(unsafe_code)]\n\n{mods}\nuse jerrycan::prelude::*;\n\n/// Build this module's routes, subroutes, and scoped dependencies.\npub fn module() -> Module {{\n deps::configure(\n{body} )\n}}\n",
name = m.name,
mods = mod_decls(m),
body = module_body(m, " ", mode),
)
}
fn subroute_mod_rs(m: &ModuleDesign, mode: GenMode) -> String {
format!(
"//! Subroute `{name}` — TOOL-OWNED mod.rs; same fractal shape as a module.\n\n{mods}\nuse jerrycan::prelude::*;\n\npub(crate) fn module() -> Module {{\n deps::configure(\n{body} )\n}}\n",
name = m.name,
mods = mod_decls(m),
body = module_body(m, " ", mode),
)
}
fn write_tool_owned(
path: &Path,
content: &str,
created: &mut Vec<String>,
root: &Path,
) -> Result<(), String> {
fs::create_dir_all(path.parent().expect("file path has parent")).map_err(|e| e.to_string())?;
fs::write(path, content).map_err(|e| format!("write {}: {e}", path.display()))?;
created.push(rel(path, root));
Ok(())
}
fn write_agent_owned(
path: &Path,
content: &str,
created: &mut Vec<String>,
root: &Path,
) -> Result<(), String> {
if path.exists() {
return Ok(()); }
write_tool_owned(path, content, created, root)
}
fn rel(path: &Path, root: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.display()
.to_string()
}
pub fn write_module(
routes_dir: &Path,
m: &ModuleDesign,
mode: GenMode,
design: &Design,
) -> Result<Vec<String>, String> {
let root = routes_dir
.ancestors()
.nth(2)
.unwrap_or(routes_dir)
.to_path_buf();
let crate_dir = routes_dir.join(&m.name);
let src = crate_dir.join("src");
let mut created = Vec::new();
let cargo = render(ROUTE_CARGO, &[("name", &m.name)])?;
write_tool_owned(&crate_dir.join("Cargo.toml"), &cargo, &mut created, &root)?;
write_tool_owned(&src.join("lib.rs"), &lib_rs(m, mode), &mut created, &root)?;
write_unit_files(&src, m, mode, design, &mut created, &root)?;
write_subroutes(&src, m, mode, design, &mut created, &root)?;
if mode.db {
write_module_migrations(&crate_dir, m, &mut created, &root, design)?;
}
Ok(created)
}
fn write_unit_files(
dir: &Path,
m: &ModuleDesign,
mode: GenMode,
design: &Design,
created: &mut Vec<String>,
root: &Path,
) -> Result<(), String> {
write_agent_owned(
&dir.join("handlers.rs"),
&handlers_rs(m, mode, design),
created,
root,
)?;
write_agent_owned(&dir.join("deps.rs"), &deps_rs(m), created, root)?;
let model = if mode.db {
model_rs_db(m, design)
} else {
model_rs(m)
};
if let Some(model) = model {
write_agent_owned(&dir.join("model.rs"), &model, created, root)?;
}
if let Some(repo) = repo_rs(m, mode, design) {
write_agent_owned(&dir.join("repo.rs"), &repo, created, root)?;
}
Ok(())
}
fn write_subroutes(
src: &Path,
m: &ModuleDesign,
mode: GenMode,
design: &Design,
created: &mut Vec<String>,
root: &Path,
) -> Result<(), String> {
if m.subroutes.is_empty() {
return Ok(());
}
let sub_root = src.join("subroutes");
let mut decls = String::from("//! TOOL-OWNED: subroute declarations.\n");
for sub in &m.subroutes {
decls.push_str(&format!("pub(crate) mod {};\n", sub.name.replace('-', "_")));
}
write_tool_owned(&sub_root.join("mod.rs"), &decls, created, root)?;
for sub in &m.subroutes {
let dir = sub_root.join(sub.name.replace('-', "_"));
write_tool_owned(
&dir.join("mod.rs"),
&subroute_mod_rs(sub, mode),
created,
root,
)?;
write_unit_files(&dir, sub, mode, design, created, root)?;
write_subroutes(&dir, sub, mode, design, created, root)?; }
Ok(())
}
pub fn add_dependency(design: &mut Design, module_path: &str, dep: &str) -> Result<(), String> {
let m = module_by_path_mut(design, module_path)
.ok_or_else(|| format!("module `{module_path}` not found in design.json"))?;
if !m.dependencies.iter().any(|d| d == dep) {
m.dependencies.push(dep.to_string());
}
Ok(())
}
fn is_snake_name(s: &str) -> bool {
!s.is_empty()
&& s.starts_with(|c: char| c.is_ascii_lowercase())
&& s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}
pub fn generate_migration(root: &Path, module: &str, name: &str) -> Result<Vec<String>, String> {
if !is_snake_name(name) {
return Err(format!(
"migration name `{name}` is not snake_case — use lowercase words joined by `_` (e.g. add_due_index)"
));
}
let crate_dir = root.join("crates/routes").join(module);
if !crate_dir.is_dir() {
let available = available_modules(root);
return Err(format!(
"module `{module}` not found under crates/routes — available: {available}"
));
}
let sqlite_dir = crate_dir.join("migrations/sqlite");
let next = next_migration_number(&sqlite_dir);
let stem = format!("{next:04}_{name}");
let body = format!(
"-- {module} {name}\n-- Write your ALTER/CREATE statements here. Both dialect files must contain\n-- the equivalent change; jerrycan check applies them to a throwaway sqlite\n-- database, so a broken migration fails fast.\n"
);
let mut created = Vec::new();
write_agent_owned(
&crate_dir.join(format!("migrations/sqlite/{stem}.sql")),
&body,
&mut created,
root,
)?;
write_agent_owned(
&crate_dir.join(format!("migrations/postgres/{stem}.sql")),
&body,
&mut created,
root,
)?;
let design = Design::from_path(&root.join("design.json"))?;
let modified = super::mounting::regenerate(root, &design)?;
created.extend(modified);
Ok(created)
}
fn next_migration_number(sqlite_dir: &Path) -> u32 {
let mut max = 0u32;
if let Ok(entries) = fs::read_dir(sqlite_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_none_or(|x| x != "sql") {
continue;
}
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
let prefix: String = stem.chars().take_while(|c| c.is_ascii_digit()).collect();
if let Ok(n) = prefix.parse::<u32>() {
max = max.max(n);
}
}
}
}
max + 1
}
fn available_modules(root: &Path) -> String {
let routes = root.join("crates/routes");
let mut names: Vec<String> = Vec::new();
if let Ok(entries) = fs::read_dir(&routes) {
for entry in entries.flatten() {
if entry.path().is_dir() {
names.push(entry.file_name().to_string_lossy().into_owned());
}
}
}
names.sort();
if names.is_empty() {
"(none)".to_string()
} else {
names.join(", ")
}
}
#[derive(Debug, serde::Serialize)]
pub struct RouteEntry {
pub method: String,
pub path: String,
pub module: String,
pub handler: String,
}
pub fn route_map(design: &Design) -> Vec<RouteEntry> {
fn walk(m: &ModuleDesign, prefix: &str, top: &str, out: &mut Vec<RouteEntry>) {
let base = format!("{}{}", prefix, m.effective_mount());
for ep in &m.endpoints {
out.push(RouteEntry {
method: format!("{:?}", ep.method),
path: format!("{}{}", base.trim_end_matches('/'), ep.path),
module: top.to_string(),
handler: ep.operation_id.clone(),
});
}
for sub in &m.subroutes {
walk(sub, &base, top, out);
}
}
let mut out = Vec::new();
for m in &design.modules {
walk(m, "", &m.name, &mut out);
}
out
}
pub fn module_by_path<'a>(design: &'a Design, path: &str) -> Option<&'a ModuleDesign> {
let mut parts = path.split('/');
let first = parts.next()?;
let mut cur = design.modules.iter().find(|m| m.name == first)?;
for part in parts {
cur = cur.subroutes.iter().find(|s| s.name == part)?;
}
Some(cur)
}
pub fn module_by_path_mut<'a>(design: &'a mut Design, path: &str) -> Option<&'a mut ModuleDesign> {
let mut parts = path.split('/');
let first = parts.next()?;
let mut cur = design.modules.iter_mut().find(|m| m.name == first)?;
for part in parts {
cur = cur.subroutes.iter_mut().find(|s| s.name == part)?;
}
Some(cur)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::platform::design::tests::MINIMAL;
fn demo() -> Design {
serde_json::from_str(MINIMAL).unwrap()
}
fn todos() -> ModuleDesign {
demo().modules.into_iter().next().unwrap()
}
#[test]
fn cross_module_fk_is_an_unenforced_indexed_column() {
let d: Design = serde_json::from_str(crate::platform::design::tests::V1_FULL).unwrap();
let ddl = migration_ddl(&d.modules[1], false, &d).unwrap();
let lower = ddl.to_lowercase();
assert!(
lower.contains("\"workspace_id\""),
"fk column from belongs_to: {ddl}"
);
assert!(
!lower.contains("foreign key") && !lower.contains("references \"workspaces\""),
"cross-module belongs_to must not emit a FK constraint: {ddl}"
);
assert!(
lower.contains("create index") && lower.contains("idx_leads_workspace_id"),
"cross-module fk column must be indexed: {ddl}"
);
assert!(
ddl.contains(
"-- workspace_id: references workspaces.id (cross-module; enforced by handlers, see schema.json)"
),
"documenting comment: {ddl}"
);
assert!(lower.contains("unique"), "phone unique: {ddl}");
let ws = migration_ddl(&d.modules[0], false, &d)
.unwrap()
.to_lowercase();
assert!(
ws.contains("check") && ws.contains("'trial'"),
"enum check: {ws}"
);
}
#[test]
fn intra_module_fk_keeps_its_constraint_and_policy() {
let mut d: Design = serde_json::from_str(crate::platform::design::tests::V1_FULL).unwrap();
let member: Entity = serde_json::from_str(
r#"{
"name": "Member",
"belongs_to": [{ "entity": "Workspace", "on_delete": "cascade" }],
"fields": [{ "name": "email", "type": "string" }]
}"#,
)
.unwrap();
d.modules[0].entities.push(member);
let ddl = migration_ddl(&d.modules[0], false, &d)
.unwrap()
.to_lowercase();
assert!(ddl.contains("\"workspace_id\""), "fk column: {ddl}");
assert!(
ddl.contains("foreign key") && ddl.contains("references \"workspaces\""),
"intra-module belongs_to keeps a real FK constraint: {ddl}"
);
assert!(
ddl.contains("on delete cascade"),
"intra-module fk keeps its policy: {ddl}"
);
}
#[test]
fn optional_fields_are_nullable_columns() {
let d: Design = serde_json::from_str(crate::platform::design::tests::V1_FULL).unwrap();
let ddl = migration_ddl(&d.modules[1], false, &d)
.unwrap()
.to_lowercase();
assert!(!ddl.contains("\"custom\" text not null"), "{ddl}");
assert!(!ddl.contains("default ''"), "no zero-defaults: {ddl}");
}
#[test]
fn ddl_booleans_are_native_and_set_null_fks_nullable() {
let mut d: Design = serde_json::from_str(crate::platform::design::tests::V1_FULL).unwrap();
d.modules[1].entities[0].fields.push(Field {
name: "active".into(),
field_type: FieldType::Boolean,
required: true,
unique: false,
index: false,
values: None,
});
d.modules[1].entities[0].belongs_to[0].on_delete = OnDelete::SetNull;
let ddl = migration_ddl(&d.modules[1], false, &d)
.unwrap()
.to_lowercase();
assert!(ddl.contains("\"active\" boolean not null"), "{ddl}");
assert!(
!ddl.contains("\"workspace_id\" bigint not null"),
"set_null fk must be nullable: {ddl}"
);
}
#[test]
fn intra_module_set_null_fk_keeps_its_policy() {
let mut d: Design = serde_json::from_str(crate::platform::design::tests::V1_FULL).unwrap();
let member: Entity = serde_json::from_str(
r#"{
"name": "Member",
"belongs_to": [{ "entity": "Workspace", "on_delete": "set_null" }],
"fields": [{ "name": "email", "type": "string" }]
}"#,
)
.unwrap();
d.modules[0].entities.push(member);
let ddl = migration_ddl(&d.modules[0], false, &d)
.unwrap()
.to_lowercase();
assert!(ddl.contains("foreign key"), "intra-module FK kept: {ddl}");
assert!(ddl.contains("on delete set null"), "policy kept: {ddl}");
}
#[test]
fn tenancy_generates_the_membership_table_in_the_tenant_module() {
let d: Design = serde_json::from_str(crate::platform::design::tests::V1_FULL).unwrap();
let ws = migration_ddl(&d.modules[0], false, &d)
.unwrap()
.to_lowercase();
assert!(ws.contains("create table \"workspace_members\""), "{ws}");
assert!(
ws.contains("\"user_id\"") && ws.contains("\"role\""),
"{ws}"
);
assert!(
ws.contains("\"user_id\" text"),
"membership user_id must be TEXT for uuid/string user pks: {ws}"
);
assert!(
ws.contains("on delete cascade"),
"member rows die with the tenant: {ws}"
);
}
#[test]
fn db_mode_models_are_sea_orm_entities() {
let d: Design = serde_json::from_str(crate::platform::design::tests::V1_FULL).unwrap();
let m = &d.modules[1];
let src = model_rs_db(m, &d).unwrap();
assert!(src.contains("pub mod lead {"), "{src}");
assert!(src.contains("pub enum Relation {}"), "{src}");
assert!(
src.contains("use jerrycan::db::sea_orm;"),
"facade alias, no direct dep: {src}"
);
assert!(src.contains("#[sea_orm(table_name = \"leads\")]"), "{src}");
assert!(src.contains("#[sea_orm(primary_key)]"), "{src}");
assert!(
src.contains("pub workspace_id: i64"),
"fk column from belongs_to: {src}"
);
assert!(
src.contains("pub custom: Option<Json>"),
"json + optional: {src}"
);
assert!(src.contains("pub use lead::Model as Lead;"), "{src}");
assert!(
src.contains("impl ActiveModelBehavior for ActiveModel {}"),
"{src}"
);
}
#[test]
fn keyword_field_names_become_raw_identifiers_with_preserved_wire_and_sql_names() {
let d: Design = serde_json::from_str(
r#"{ "name": "webhooks", "contract_version": 1, "dependencies": ["db"],
"modules": [{ "name": "events",
"entities": [{ "name": "Event", "fields": [
{ "name": "id", "type": "integer" },
{ "name": "type", "type": "string" } ] }],
"endpoints": [{ "operation_id": "create_event", "method": "POST", "path": "/",
"request_body": { "entity": "Event" },
"success": { "status": 201, "entity": "Event" } }] }] }"#,
)
.unwrap();
assert!(
crate::platform::questions::validate(&d).is_empty(),
"a `type` field must not raise a question: {:?}",
crate::platform::questions::validate(&d)
);
let m = &d.modules[0];
let model = model_rs_db(m, &d).unwrap();
assert!(
model.contains("#[serde(rename = \"type\")]\n #[sea_orm(column_name = \"type\")]\n pub r#type: String,"),
"keyword field is a raw ident carrying rename + column_name: {model}"
);
let e = &m.entities[0];
let sets = active_sets(e, true);
assert!(
sets.contains("r#type: Set(item.r#type),"),
"ActiveModel binds the raw ident: {sets}"
);
let mem = model_rs(m).unwrap();
assert!(
mem.contains("#[serde(rename = \"type\")]\n pub r#type: String,"),
"memory Model raw ident + rename: {mem}"
);
assert!(
!mem.contains("column_name"),
"no sea_orm attr in memory mode: {mem}"
);
}
#[test]
fn intra_module_relation_synthetic_pk_and_set_null_fk() {
let mut d: Design = serde_json::from_str(crate::platform::design::tests::V1_FULL).unwrap();
let member: Entity = serde_json::from_str(
r#"{
"name": "Member",
"belongs_to": [{ "entity": "Workspace", "on_delete": "set_null" }],
"fields": [{ "name": "email", "type": "string" }]
}"#,
)
.unwrap();
d.modules[0].entities.push(member);
let src = model_rs_db(&d.modules[0], &d).unwrap();
assert!(
src.contains(
"#[sea_orm(primary_key)]\n #[serde(default)]\n pub id: i64,"
),
"{src}"
);
assert!(
src.contains("#[serde(default)]\n pub workspace_id: Option<i64>,"),
"{src}"
);
assert!(
src.contains("#[sea_orm(belongs_to = \"super::workspace::Entity\", from = \"Column::WorkspaceId\", to = \"super::workspace::Column::Id\")]"),
"{src}"
);
assert!(src.contains("Relation::Workspace.def()"), "{src}");
assert!(
src.contains("impl Related<super::workspace::Entity> for Entity"),
"{src}"
);
}
#[test]
fn db_repos_query_via_sea_orm() {
let d: Design = serde_json::from_str(crate::platform::design::tests::V1_FULL).unwrap();
let src = repo_rs(
&d.modules[1],
GenMode {
db: true,
auth: true,
},
&d,
)
.unwrap();
assert!(src.contains("lead::Entity::find()"), "{src}");
assert!(src.contains(".all(self.db.conn())"), "{src}");
assert!(
src.contains("pub async fn insert(&self, item: Lead) -> Result<i64>"),
"{src}"
);
assert!(!src.contains("self.db.pool()"), "{src}");
assert!(
!src.contains("build_any_sqlx"),
"repos are SeaORM now: {src}"
);
}
#[test]
fn tenant_owned_entities_get_scoped_methods() {
let d: Design = serde_json::from_str(crate::platform::design::tests::V1_FULL).unwrap();
let src = repo_rs(
&d.modules[1],
GenMode {
db: true,
auth: true,
},
&d,
)
.unwrap();
assert!(
src.contains("pub async fn all_for(&self, workspace_id: i64)"),
"{src}"
);
assert!(
src.contains("pub async fn get_for(&self, workspace_id: i64, id: i64)"),
"{src}"
);
assert!(
src.contains("pub async fn remove_for(&self, workspace_id: i64, id: i64)"),
"{src}"
);
assert!(
src.contains("pub async fn update_for(&self, workspace_id: i64, id: i64, item:"),
"{src}"
);
assert!(
src.contains("Column::WorkspaceId.eq(workspace_id)"),
"{src}"
);
}
#[test]
fn handler_signatures_follow_the_mapping_rules() {
let m = todos();
let h = handlers_rs(&m, GenMode::default(), &demo());
assert!(
h.contains(
"pub(crate) async fn list_todos(_repo: Dep<TodoRepo>) -> Result<Json<Vec<Todo>>>"
),
"{h}"
);
assert!(
h.contains(
"pub(crate) async fn create_todo(_repo: Dep<TodoRepo>, Json(_body): Json<Todo>) -> Result<Created<Todo>>"
),
"{h}"
);
assert!(
h.contains(
"pub(crate) async fn delete_todo(_repo: Dep<TodoRepo>, Path(_id): Path<i64>) -> Result<NoContent>"
),
"{h}"
);
assert!(h.contains("not implemented — replace this stub"));
}
#[test]
fn multi_param_endpoints_map_to_path_tuples() {
let mut m = todos();
m.endpoints.push(Endpoint {
operation_id: "move_todo".into(),
method: HttpMethod::POST,
path: "/{id}/position/{slot}".into(),
auth_required: false,
required_roles: vec![],
public: false,
probe: ProbePolicy::default(),
request_body: None,
success: Success {
status: 204,
entity: None,
list: false,
},
errors: vec![],
});
let h = handlers_rs(&m, GenMode::default(), &demo());
assert!(
h.contains("pub(crate) async fn move_todo(_repo: Dep<TodoRepo>, Path((_id, _slot)): Path<(i64, i64)>) -> Result<NoContent>"),
"{h}"
);
}
#[test]
fn subroute_under_param_mount_keeps_single_path_for_its_leaf() {
let m: ModuleDesign = serde_json::from_str(
r#"{
"name": "ws",
"mount": "/ws/{ws}",
"endpoints": [
{ "operation_id": "list_ws", "method": "GET", "path": "/",
"success": { "status": 200 } }
],
"subroutes": [
{
"name": "leads",
"mount": "/leads",
"endpoints": [
{ "operation_id": "show_lead", "method": "GET", "path": "/{id}",
"success": { "status": 200 } }
]
}
]
}"#,
)
.unwrap();
let sub = &m.subroutes[0];
let h = handlers_rs(sub, GenMode::default(), &demo());
assert!(
h.contains("pub(crate) async fn show_lead(Path(_id): Path<i64>)"),
"leaf endpoint under a param mount must stay single-Path: {h}"
);
assert!(!h.contains("Path((_"), "no tuple over the mount param: {h}");
}
#[test]
fn lib_rs_groups_routes_by_path_and_mounts_subroutes() {
let m = todos();
let lib = lib_rs(&m, GenMode::default());
assert!(lib.contains("pub fn module() -> Module"), "{lib}");
assert!(
lib.contains(".route(\"/\", get(handlers::list_todos).post(handlers::create_todo))"),
"{lib}"
);
assert!(
lib.contains(".route(\"/{id}\", delete(handlers::delete_todo))"),
"{lib}"
);
assert!(
lib.contains(".mount(\"/comments\", subroutes::comments::module())"),
"{lib}"
);
assert!(lib.contains(".provide(repo::TodoRepo::new())"), "{lib}");
assert!(
lib.contains("deps::configure("),
"agent hook must wrap the module: {lib}"
);
assert!(lib.contains("#![forbid(unsafe_code)]"));
}
#[test]
fn db_repo_factory_name_matches_for_multi_word_entities() {
let mut d: Design = serde_json::from_str(crate::platform::design::tests::V1_FULL).unwrap();
let api_key: Entity = serde_json::from_str(
r#"{ "name": "ApiKey", "fields": [{ "name": "token", "type": "string" }] }"#,
)
.unwrap();
d.modules[1].entities.push(api_key);
let mode = GenMode {
db: true,
auth: true,
};
let lib = lib_rs(&d.modules[1], mode);
assert!(
lib.contains(".provide_dep(repo::api_key_repo)"),
"lib.rs must reference the snake_case repo factory: {lib}"
);
let repo = repo_rs(&d.modules[1], mode, &d).unwrap();
assert!(
repo.contains("pub(crate) async fn api_key_repo("),
"repo.rs must define the snake_case repo factory: {repo}"
);
}
#[test]
fn model_and_repo_are_generated_from_entities() {
let m = todos();
let model = model_rs(&m).unwrap();
assert!(model.contains("pub struct Todo"));
assert!(model.contains("pub title: String"));
assert!(
model.contains("#[serde(default)]\n pub done: bool"),
"{model}"
);
let repo = repo_rs(&m, GenMode::default(), &demo()).unwrap();
assert!(repo.contains("pub struct TodoRepo"));
assert!(
repo.contains("#[allow(dead_code)]"),
"stub-phase repo must pass -D warnings: {repo}"
);
for method in [
"pub fn all(",
"pub fn get(",
"pub fn insert(",
"pub fn remove(",
"pub fn update(",
] {
assert!(repo.contains(method), "{repo}");
}
}
#[test]
fn write_module_respects_the_ownership_rule() {
let tmp = tempfile::tempdir().unwrap();
let routes = tmp.path().join("crates/routes");
let d = demo();
let m = &d.modules[0];
let created = write_module(&routes, m, GenMode::default(), &d).unwrap();
assert!(created.iter().any(|p| p.ends_with("todos/src/lib.rs")));
assert!(
created
.iter()
.any(|p| p.ends_with("todos/src/subroutes/comments/mod.rs"))
);
let handlers = routes.join("todos/src/handlers.rs");
fs::write(&handlers, "// AGENT CODE\n").unwrap();
let lib = routes.join("todos/src/lib.rs");
fs::write(&lib, "// hand edit\n").unwrap();
write_module(&routes, m, GenMode::default(), &d).unwrap();
assert_eq!(
fs::read_to_string(&handlers).unwrap(),
"// AGENT CODE\n",
"agent-owned: preserved"
);
assert!(
fs::read_to_string(&lib)
.unwrap()
.contains("pub fn module()"),
"tool-owned: restored"
);
}
#[test]
fn declared_id_field_becomes_the_pk_not_a_duplicate_column() {
let mut m = todos();
m.entities[0].fields.insert(
0,
Field {
name: "id".into(),
field_type: FieldType::Integer,
required: true,
unique: false,
index: false,
values: None,
},
);
let ddl = migration_ddl(&m, false, &demo()).unwrap();
assert_eq!(
ddl.matches("\"id\"").count(),
1,
"one id column only:\n{ddl}"
);
assert!(
ddl.contains("PRIMARY KEY AUTOINCREMENT"),
"sqlite autoincrement pk: {ddl}"
);
let pg = migration_ddl(&m, true, &demo()).unwrap();
assert!(
pg.to_lowercase().contains("bigserial"),
"postgres serial pk: {pg}"
);
assert_eq!(pg.matches("\"id\"").count(), 1, "{pg}");
}
#[test]
fn text_id_keys_the_table_repo_and_handlers_consistently() {
let mut m = todos();
m.entities[0].fields.insert(
0,
Field {
name: "id".into(),
field_type: FieldType::Uuid,
required: true,
unique: false,
index: false,
values: None,
},
);
let ddl = migration_ddl(&m, false, &demo()).unwrap();
assert!(
ddl.to_lowercase()
.contains("\"id\" text not null primary key"),
"text pk, no autoincrement: {ddl}"
);
assert!(!ddl.contains("AUTOINCREMENT"), "{ddl}");
assert_eq!(ddl.matches("\"id\"").count(), 1, "{ddl}");
let repo = repo_rs(
&m,
GenMode {
db: true,
..GenMode::default()
},
&demo(),
)
.unwrap();
assert!(
repo.contains("pub async fn get(&self, id: String)"),
"{repo}"
);
assert!(
repo.contains("pub async fn insert(&self, item: Todo) -> Result<String>"),
"{repo}"
);
assert!(!repo.contains(".last_insert_id()"), "{repo}");
let h = handlers_rs(&m, GenMode::default(), &demo());
assert!(h.contains("Path(_id): Path<String>"), "{h}");
}
#[test]
fn subroutes_without_entities_have_no_model_or_repo() {
let m = todos();
let sub = &m.subroutes[0];
assert!(model_rs(sub).is_none());
assert!(repo_rs(sub, GenMode::default(), &demo()).is_none());
let h = handlers_rs(sub, GenMode::default(), &demo());
assert!(
h.contains("pub(crate) async fn list_comments() -> Result<Json<serde_json::Value>>"),
"{h}"
);
}
}