use crate::ui::OnboardingError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Field {
pub(crate) name: String,
pub(crate) kind: FieldKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum FieldKind {
Str,
Text,
Email,
Phone,
Int,
Bigint,
Float,
Decimal,
Bool,
Timestamp,
Date,
Time,
Uuid,
Json,
Choice(Vec<String>),
Fk(String),
}
impl FieldKind {
fn vocabulary_list() -> &'static str {
"str, text, int, bigint, bool, timestamp, json, float, date, time, decimal, uuid, email, phone, choice:<v1>,<v2>,..., fk:<Model>"
}
}
pub(crate) fn parse_field(input: &str) -> Result<Field, OnboardingError> {
let (name, type_str) = match input.split_once(':') {
Some(parts) => parts,
None => {
return Err(OnboardingError {
problem: format!("`{input}` is not a valid `--field` value."),
why: "Expected format: `<name>:<type>`. Example: `email:str`.".into(),
fix: "Re-run with the colon-separated form.".into(),
retry: format!("rustio-admin startapp <name> --field {input}:str"),
details: None,
});
}
};
validate_field_name(name).map_err(|e| field_name_error(name, e))?;
let kind = parse_kind(type_str).map_err(|e| field_type_error(type_str, e))?;
Ok(Field {
name: name.to_string(),
kind,
})
}
pub(crate) fn validate_unique_names(fields: &[Field]) -> Result<(), OnboardingError> {
for i in 0..fields.len() {
for j in (i + 1)..fields.len() {
if fields[i].name == fields[j].name {
return Err(OnboardingError {
problem: format!("Field `{}` declared twice.", fields[i].name),
why: "Each field on a model must have a unique name.".into(),
fix: "Rename one of the duplicates.".into(),
retry: "(re-run the command with distinct field names)".into(),
details: None,
});
}
}
}
Ok(())
}
fn parse_kind(type_str: &str) -> Result<FieldKind, &'static str> {
if let Some(rest) = type_str.strip_prefix("fk:") {
if rest.is_empty() {
return Err("fk_missing_target");
}
if rest.contains(':') {
return Err("fk_extra_segment");
}
validate_camel_case(rest).map_err(|_| "fk_bad_target")?;
return Ok(FieldKind::Fk(rest.to_string()));
}
if type_str == "fk" {
return Err("fk_missing_target");
}
if let Some(rest) = type_str.strip_prefix("choice:") {
return parse_choice_values(rest);
}
if type_str == "choice" {
return Err("choice_missing_values");
}
match type_str {
"str" => Ok(FieldKind::Str),
"text" => Ok(FieldKind::Text),
"email" => Ok(FieldKind::Email),
"phone" => Ok(FieldKind::Phone),
"int" => Ok(FieldKind::Int),
"bigint" => Ok(FieldKind::Bigint),
"float" => Ok(FieldKind::Float),
"decimal" => Ok(FieldKind::Decimal),
"bool" => Ok(FieldKind::Bool),
"timestamp" => Ok(FieldKind::Timestamp),
"date" => Ok(FieldKind::Date),
"time" => Ok(FieldKind::Time),
"uuid" => Ok(FieldKind::Uuid),
"json" => Ok(FieldKind::Json),
_ => Err("unknown_type"),
}
}
fn parse_choice_values(rest: &str) -> Result<FieldKind, &'static str> {
if rest.is_empty() {
return Err("choice_missing_values");
}
let mut values: Vec<String> = Vec::new();
for raw in rest.split(',') {
if raw.is_empty() {
return Err("choice_empty_value");
}
if !raw
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err("choice_bad_value");
}
if values.iter().any(|v| v == raw) {
return Err("choice_dup_value");
}
values.push(raw.to_string());
}
Ok(FieldKind::Choice(values))
}
fn field_name_error(name: &str, code: &str) -> OnboardingError {
match code {
"empty" => OnboardingError {
problem: "Field name is empty.".into(),
why: "Expected format: `<name>:<type>`. The portion before the colon is the field name.".into(),
fix: "Re-run with a non-empty name, e.g. `--field email:str`.".into(),
retry: "(re-run with a valid `--field`)".into(),
details: None,
},
"starts_with_digit" => OnboardingError {
problem: format!("Field name `{name}` starts with a digit."),
why: "Rust struct field names cannot begin with a digit.".into(),
fix: format!("Pick a name that starts with a letter or underscore, e.g. `{name}_field` or `count_{name}`."),
retry: "(re-run with a valid field name)".into(),
details: None,
},
"bad_chars" => OnboardingError {
problem: format!("Field name `{name}` contains invalid characters."),
why: "Field names use ASCII lowercase letters, digits, and `_` only (snake_case).".into(),
fix: "Re-run with a snake_case name, e.g. `full_name`, `is_active`, `user_id`.".into(),
retry: "(re-run with a valid field name)".into(),
details: None,
},
"rust_keyword" => OnboardingError {
problem: format!("`{name}` is a reserved Rust identifier and cannot be a field name."),
why: "Rust forbids using keywords as struct field names.".into(),
fix: format!("Pick a non-reserved name, e.g. `{name}_` or a synonym (e.g. `class` -> `classroom`)."),
retry: format!("(re-run with `--field {name}_:<type>` or a renamed alternative)"),
details: None,
},
_ => OnboardingError {
problem: format!("Field name `{name}` is invalid."),
why: "Field names use ASCII lowercase letters, digits, and `_` only (snake_case), must not start with a digit, and cannot be a Rust keyword.".into(),
fix: "Re-run with a valid snake_case name.".into(),
retry: "(re-run with a valid field name)".into(),
details: None,
},
}
}
fn field_type_error(type_str: &str, code: &str) -> OnboardingError {
match code {
"unknown_type" => OnboardingError {
problem: format!("`{type_str}` is not a valid field type."),
why: format!("Accepted types are: {}.", FieldKind::vocabulary_list()),
fix: format!("Re-run with one of those, e.g. `--field <name>:str`. (You passed `{type_str}`.)"),
retry: "(re-run with a valid type token)".into(),
details: None,
},
"fk_missing_target" => OnboardingError {
problem: "`fk` requires a target model name.".into(),
why: "Expected format: `fk:<Model>` where `<Model>` is the CamelCase name of an existing model (e.g. `fk:Doctor`).".into(),
fix: "Re-run with the model name, e.g. `--field patient:fk:Patient`.".into(),
retry: "(re-run with `fk:<Model>`)".into(),
details: None,
},
"fk_extra_segment" => OnboardingError {
problem: format!("`{type_str}` has too many colon-separated segments."),
why: "Expected exactly: `fk:<Model>`. No further qualifiers are supported (no `fk:Model:cascade`, `fk:Model:nullable`, etc.).".into(),
fix: "Re-run with just `fk:<Model>`; edit the generated migration if you need `ON DELETE`/`ON UPDATE` clauses.".into(),
retry: "(re-run with `fk:<Model>`)".into(),
details: None,
},
"fk_bad_target" => OnboardingError {
problem: "FK target model name must be in CamelCase.".into(),
why: "Rust struct names are CamelCase; the FK references the struct's table by its name (e.g. `Patient` -> `patients`).".into(),
fix: "Re-run with the CamelCase form, e.g. `fk:Patient` (not `fk:patient` or `fk:my_patient`).".into(),
retry: "(re-run with a CamelCase model name)".into(),
details: None,
},
"choice_missing_values" => OnboardingError {
problem: "`choice` requires a comma-separated list of values.".into(),
why: "Expected format: `choice:<v1>,<v2>,...` (e.g. `choice:active,inactive`).".into(),
fix: "Re-run with the values, e.g. `--field status:choice:active,inactive`.".into(),
retry: "(re-run with `choice:<v1>,<v2>,...`)".into(),
details: None,
},
"choice_empty_value" => OnboardingError {
problem: format!("`{type_str}` has an empty choice value."),
why: "Every value between commas must be non-empty (no `choice:a,,b` or trailing comma).".into(),
fix: "Re-run with non-empty values, e.g. `choice:active,inactive`.".into(),
retry: "(re-run with non-empty comma-separated values)".into(),
details: None,
},
"choice_bad_value" => OnboardingError {
problem: format!("`{type_str}` has a choice value with invalid characters."),
why: "Choice values use ASCII letters, digits, `_`, and `-` only — so they embed safely in the generated CHECK constraint and `<select>` without escaping.".into(),
fix: "Re-run with simple tokens, e.g. `choice:in_progress,done` (use `in_progress`, not `in progress`).".into(),
retry: "(re-run with `[A-Za-z0-9_-]` values)".into(),
details: None,
},
"choice_dup_value" => OnboardingError {
problem: format!("`{type_str}` declares the same choice value twice."),
why: "Each choice value must be distinct.".into(),
fix: "Re-run with a deduplicated list.".into(),
retry: "(re-run with distinct values)".into(),
details: None,
},
_ => OnboardingError {
problem: format!("`{type_str}` is not a valid field type."),
why: format!("Accepted types are: {}.", FieldKind::vocabulary_list()),
fix: format!("Re-run with one of those, e.g. `--field <name>:str`. (You passed `{type_str}`.)"),
retry: "(re-run with a valid type token)".into(),
details: None,
},
}
}
fn validate_field_name(name: &str) -> Result<(), &'static str> {
if name.is_empty() {
return Err("empty");
}
if name.starts_with(|c: char| c.is_ascii_digit()) {
return Err("starts_with_digit");
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
{
return Err("bad_chars");
}
if is_rust_keyword(name) {
return Err("rust_keyword");
}
Ok(())
}
fn validate_camel_case(name: &str) -> Result<(), &'static str> {
if name.is_empty() {
return Err("empty");
}
let mut chars = name.chars();
let first = chars.next().unwrap();
if !first.is_ascii_uppercase() {
return Err("must_start_upper");
}
if !name.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err("bad_chars");
}
Ok(())
}
fn is_rust_keyword(name: &str) -> bool {
matches!(
name,
"as" | "async"
| "await"
| "break"
| "const"
| "continue"
| "crate"
| "dyn"
| "else"
| "enum"
| "extern"
| "false"
| "fn"
| "for"
| "if"
| "impl"
| "in"
| "let"
| "loop"
| "match"
| "mod"
| "move"
| "mut"
| "pub"
| "ref"
| "return"
| "self"
| "static"
| "struct"
| "super"
| "trait"
| "true"
| "type"
| "unsafe"
| "use"
| "where"
| "while"
| "yield"
| "_"
)
}
pub(crate) struct Render {
pub(crate) imports: String,
pub(crate) struct_fields: String,
pub(crate) column_decls_sql: String,
pub(crate) columns_literal: String,
pub(crate) insert_columns_literal: String,
pub(crate) from_row_assignments: String,
pub(crate) insert_values_expr: String,
pub(crate) list_display_literal: String,
pub(crate) search_fields_literal: String,
}
pub(crate) fn render(fields: &[Field]) -> Render {
let specs: Vec<FieldSpec> = fields.iter().map(|f| f.kind.spec(&f.name)).collect();
let mut import_lines: Vec<&'static str> = vec!["use rustio_admin::{ModelAdmin, RustioAdmin};"];
let mut extra_imports: Vec<&'static str> = Vec::new();
for spec in &specs {
if let Some(imp) = spec.needs_import {
if !extra_imports.contains(&imp) {
extra_imports.push(imp);
}
}
}
for (i, imp) in extra_imports.into_iter().enumerate() {
import_lines.insert(i, imp);
}
let struct_fields = fields
.iter()
.zip(&specs)
.map(|(f, s)| match s.field_attr.as_deref() {
Some(attr) => format!(" {attr}\n pub {}: {},", f.name, s.rust_type),
None => format!(" pub {}: {},", f.name, s.rust_type),
})
.collect::<Vec<_>>()
.join("\n");
let column_decls_sql = fields
.iter()
.zip(&specs)
.map(|(f, s)| format!(",\n \"{}\" {}", f.name, s.sql_decl))
.collect::<String>();
let mut columns: Vec<String> = vec!["\"id\"".into()];
columns.extend(fields.iter().map(|f| format!("\"{}\"", f.name)));
let columns_literal = columns.join(", ");
let insert_columns_literal = fields
.iter()
.map(|f| format!("\"{}\"", f.name))
.collect::<Vec<_>>()
.join(", ");
let from_row_assignments = fields
.iter()
.zip(&specs)
.map(|(f, s)| {
format!(
" {}: row.{}(\"{}\")?,",
f.name, s.row_getter, f.name
)
})
.collect::<Vec<_>>()
.join("\n");
let insert_values_expr = fields
.iter()
.zip(&specs)
.map(|(f, s)| {
if s.insert_needs_clone {
format!("self.{}.clone().into()", f.name)
} else {
format!("self.{}.into()", f.name)
}
})
.collect::<Vec<_>>()
.join(", ");
let list_display_literal = columns.join(", ");
let search_fields: Vec<String> = fields
.iter()
.zip(&specs)
.filter(|(_, s)| s.is_text_search)
.map(|(f, _)| format!("\"{}\"", f.name))
.collect();
let search_fields_literal = search_fields.join(", ");
Render {
imports: import_lines.join("\n"),
struct_fields,
column_decls_sql,
columns_literal,
insert_columns_literal,
from_row_assignments,
insert_values_expr,
list_display_literal,
search_fields_literal,
}
}
struct FieldSpec {
rust_type: &'static str,
sql_decl: String,
row_getter: &'static str,
insert_needs_clone: bool,
is_text_search: bool,
needs_import: Option<&'static str>,
field_attr: Option<String>,
}
impl FieldKind {
fn spec(&self, name: &str) -> FieldSpec {
match self {
FieldKind::Str | FieldKind::Text => FieldSpec {
rust_type: "String",
sql_decl: "TEXT NOT NULL".into(),
row_getter: "get_string",
insert_needs_clone: true,
is_text_search: true,
needs_import: None,
field_attr: None,
},
FieldKind::Email => FieldSpec {
rust_type: "String",
sql_decl: "TEXT NOT NULL".into(),
row_getter: "get_string",
insert_needs_clone: true,
is_text_search: true,
needs_import: None,
field_attr: Some("#[rustio(format = \"email\")]".into()),
},
FieldKind::Phone => FieldSpec {
rust_type: "String",
sql_decl: "TEXT NOT NULL".into(),
row_getter: "get_string",
insert_needs_clone: true,
is_text_search: true,
needs_import: None,
field_attr: Some("#[rustio(format = \"phone\")]".into()),
},
FieldKind::Int => FieldSpec {
rust_type: "i32",
sql_decl: "INTEGER NOT NULL".into(),
row_getter: "get_i32",
insert_needs_clone: false,
is_text_search: false,
needs_import: None,
field_attr: None,
},
FieldKind::Bigint => FieldSpec {
rust_type: "i64",
sql_decl: "BIGINT NOT NULL".into(),
row_getter: "get_i64",
insert_needs_clone: false,
is_text_search: false,
needs_import: None,
field_attr: None,
},
FieldKind::Float => FieldSpec {
rust_type: "f64",
sql_decl: "DOUBLE PRECISION NOT NULL".into(),
row_getter: "get_f64",
insert_needs_clone: false,
is_text_search: false,
needs_import: None,
field_attr: None,
},
FieldKind::Decimal => FieldSpec {
rust_type: "Decimal",
sql_decl: "NUMERIC NOT NULL".into(),
row_getter: "get_decimal",
insert_needs_clone: false,
is_text_search: false,
needs_import: Some("use rustio_admin::Decimal;"),
field_attr: None,
},
FieldKind::Bool => FieldSpec {
rust_type: "bool",
sql_decl: "BOOLEAN NOT NULL DEFAULT FALSE".into(),
row_getter: "get_bool",
insert_needs_clone: false,
is_text_search: false,
needs_import: None,
field_attr: None,
},
FieldKind::Timestamp => FieldSpec {
rust_type: "DateTime<Utc>",
sql_decl: "TIMESTAMPTZ NOT NULL".into(),
row_getter: "get_datetime",
insert_needs_clone: false,
is_text_search: false,
needs_import: Some("use rustio_admin::{DateTime, Utc};"),
field_attr: None,
},
FieldKind::Date => FieldSpec {
rust_type: "NaiveDate",
sql_decl: "DATE NOT NULL".into(),
row_getter: "get_date",
insert_needs_clone: false,
is_text_search: false,
needs_import: Some("use rustio_admin::NaiveDate;"),
field_attr: None,
},
FieldKind::Time => FieldSpec {
rust_type: "NaiveTime",
sql_decl: "TIME NOT NULL".into(),
row_getter: "get_time",
insert_needs_clone: false,
is_text_search: false,
needs_import: Some("use rustio_admin::NaiveTime;"),
field_attr: None,
},
FieldKind::Uuid => FieldSpec {
rust_type: "Uuid",
sql_decl: "UUID NOT NULL".into(),
row_getter: "get_uuid",
insert_needs_clone: false,
is_text_search: false,
needs_import: Some("use rustio_admin::Uuid;"),
field_attr: None,
},
FieldKind::Json => FieldSpec {
rust_type: "serde_json::Value",
sql_decl: "JSONB NOT NULL".into(),
row_getter: "get_json",
insert_needs_clone: true,
is_text_search: false,
needs_import: None,
field_attr: None,
},
FieldKind::Choice(values) => {
let sql_values = values
.iter()
.map(|v| format!("'{v}'"))
.collect::<Vec<_>>()
.join(", ");
let attr_values = values
.iter()
.map(|v| format!("\"{v}\""))
.collect::<Vec<_>>()
.join(", ");
FieldSpec {
rust_type: "String",
sql_decl: format!("TEXT NOT NULL CHECK (\"{name}\" IN ({sql_values}))"),
row_getter: "get_string",
insert_needs_clone: true,
is_text_search: false,
needs_import: None,
field_attr: Some(format!("#[rustio(choices = [{attr_values}])]")),
}
}
FieldKind::Fk(target) => FieldSpec {
rust_type: "i64",
sql_decl: format!(
"BIGINT NOT NULL REFERENCES {}(id)",
pluralise_camel_to_snake(target)
),
row_getter: "get_i64",
insert_needs_clone: false,
is_text_search: false,
needs_import: None,
field_attr: None,
},
}
}
}
pub(crate) fn pluralise_snake(s: &str) -> String {
if s.is_empty() {
return String::new();
}
if let Some(stem) = s.strip_suffix('y') {
let prev = stem.chars().last();
return if matches!(prev, Some(c) if "aeiou".contains(c)) {
format!("{s}s")
} else {
format!("{stem}ies")
};
}
if s.ends_with("ch") || s.ends_with("sh") {
return format!("{s}es");
}
let last = s.chars().last().unwrap();
if matches!(last, 's' | 'x' | 'z') {
return format!("{s}es");
}
format!("{s}s")
}
fn pluralise_camel_to_snake(name: &str) -> String {
let mut snake = String::with_capacity(name.len() + 2);
for (i, c) in name.chars().enumerate() {
if c.is_ascii_uppercase() {
if i > 0 {
snake.push('_');
}
snake.push(c.to_ascii_lowercase());
} else {
snake.push(c);
}
}
pluralise_snake(&snake)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_each_simple_type() {
let cases = [
("name:str", FieldKind::Str),
("body:text", FieldKind::Text),
("count:int", FieldKind::Int),
("user_id:bigint", FieldKind::Bigint),
("price:float", FieldKind::Float),
("amount:decimal", FieldKind::Decimal),
("active:bool", FieldKind::Bool),
("at:timestamp", FieldKind::Timestamp),
("birth_date:date", FieldKind::Date),
("start_time:time", FieldKind::Time),
("public_id:uuid", FieldKind::Uuid),
("contact:email", FieldKind::Email),
("mobile:phone", FieldKind::Phone),
("meta:json", FieldKind::Json),
];
for (input, expected) in cases {
let f = parse_field(input).unwrap_or_else(|e| panic!("{input}: {e}"));
assert_eq!(f.kind, expected);
}
}
#[test]
fn parse_choice_with_values() {
let f = parse_field("status:choice:active,inactive").unwrap();
assert_eq!(f.name, "status");
assert_eq!(
f.kind,
FieldKind::Choice(vec!["active".into(), "inactive".into()])
);
}
#[test]
fn parse_choice_single_value_ok() {
let f = parse_field("flag:choice:on").unwrap();
assert_eq!(f.kind, FieldKind::Choice(vec!["on".into()]));
}
#[test]
fn parse_choice_allows_underscore_and_dash() {
let f = parse_field("state:choice:in_progress,on-hold").unwrap();
assert_eq!(
f.kind,
FieldKind::Choice(vec!["in_progress".into(), "on-hold".into()])
);
}
#[test]
fn parse_rejects_bare_choice() {
let e = parse_field("status:choice").unwrap_err();
assert!(e.problem.contains("comma-separated list"));
}
#[test]
fn parse_rejects_choice_empty_value() {
let e = parse_field("status:choice:active,,inactive").unwrap_err();
assert!(e.problem.contains("empty choice value"));
}
#[test]
fn parse_rejects_choice_bad_chars() {
let e = parse_field("status:choice:in progress").unwrap_err();
assert!(e.problem.contains("invalid characters"));
}
#[test]
fn parse_rejects_choice_duplicate() {
let e = parse_field("status:choice:a,a").unwrap_err();
assert!(e.problem.contains("twice"));
}
#[test]
fn render_choice_emits_check_and_attribute() {
let r = render(&fs(&["status:choice:active,inactive"]));
assert!(r.struct_fields.contains(
" #[rustio(choices = [\"active\", \"inactive\"])]\n pub status: String,"
));
assert!(r.column_decls_sql.contains(
",\n \"status\" TEXT NOT NULL CHECK (\"status\" IN ('active', 'inactive'))"
));
assert!(r
.from_row_assignments
.contains("status: row.get_string(\"status\")?,"));
assert_eq!(r.search_fields_literal, "");
}
#[test]
fn parse_fk_with_camelcase_target() {
let f = parse_field("doctor:fk:Doctor").unwrap();
assert_eq!(f.name, "doctor");
assert_eq!(f.kind, FieldKind::Fk("Doctor".into()));
}
#[test]
fn parse_fk_target_can_have_digits() {
let f = parse_field("scan:fk:CTScan2").unwrap();
assert_eq!(f.kind, FieldKind::Fk("CTScan2".into()));
}
#[test]
fn parse_rejects_missing_colon() {
let e = parse_field("name").unwrap_err();
assert!(e.problem.contains("`name` is not a valid"));
assert!(e.why.contains("Expected format"));
}
#[test]
fn parse_rejects_unknown_type() {
let e = parse_field("name:varchar").unwrap_err();
assert!(e.problem.contains("`varchar` is not a valid field type"));
assert!(e
.why
.contains("str, text, int, bigint, bool, timestamp, json"));
}
#[test]
fn parse_rejects_empty_field_name() {
let e = parse_field(":str").unwrap_err();
assert!(e.problem.contains("empty"));
}
#[test]
fn parse_rejects_field_name_starting_with_digit() {
let e = parse_field("1count:int").unwrap_err();
assert!(e.problem.contains("starts with a digit"));
}
#[test]
fn parse_rejects_field_name_with_uppercase() {
let e = parse_field("Name:str").unwrap_err();
assert!(e.problem.contains("invalid characters"));
}
#[test]
fn parse_rejects_field_name_with_dash() {
let e = parse_field("full-name:str").unwrap_err();
assert!(e.problem.contains("invalid characters"));
}
#[test]
fn parse_rejects_rust_keyword() {
let e = parse_field("type:str").unwrap_err();
assert!(e.problem.contains("`type` is a reserved Rust identifier"));
let e = parse_field("self:str").unwrap_err();
assert!(e.problem.contains("`self`"));
let e = parse_field("match:str").unwrap_err();
assert!(e.problem.contains("`match`"));
}
#[test]
fn parse_rejects_underscore_only_name() {
let e = parse_field("_:str").unwrap_err();
assert!(e.problem.contains("`_`"));
}
#[test]
fn parse_rejects_bare_fk() {
let e = parse_field("patient:fk").unwrap_err();
assert!(e.problem.contains("fk` requires a target model"));
}
#[test]
fn parse_rejects_fk_with_empty_target() {
let e = parse_field("patient:fk:").unwrap_err();
assert!(e.problem.contains("fk` requires a target model"));
}
#[test]
fn parse_rejects_fk_with_lowercase_target() {
let e = parse_field("patient:fk:patient").unwrap_err();
assert!(e.problem.contains("CamelCase"));
}
#[test]
fn parse_rejects_fk_with_extra_segment() {
let e = parse_field("patient:fk:Patient:cascade").unwrap_err();
assert!(e.problem.contains("too many colon"));
}
#[test]
fn validate_unique_catches_duplicates() {
let fields = vec![
parse_field("name:str").unwrap(),
parse_field("name:text").unwrap(),
];
let e = validate_unique_names(&fields).unwrap_err();
assert!(e.problem.contains("Field `name` declared twice"));
}
#[test]
fn validate_unique_passes_for_distinct() {
let fields = vec![
parse_field("name:str").unwrap(),
parse_field("body:text").unwrap(),
parse_field("at:timestamp").unwrap(),
];
assert!(validate_unique_names(&fields).is_ok());
}
fn fs(specs: &[&str]) -> Vec<Field> {
specs.iter().map(|s| parse_field(s).unwrap()).collect()
}
#[test]
fn render_struct_fields_in_declared_order() {
let r = render(&fs(&["name:str", "active:bool", "at:timestamp"]));
assert_eq!(
r.struct_fields,
" pub name: String,\n pub active: bool,\n pub at: DateTime<Utc>,"
);
}
#[test]
fn render_imports_include_chrono_only_when_timestamp_present() {
let r = render(&fs(&["name:str"]));
assert!(!r.imports.contains("DateTime"));
let r = render(&fs(&["at:timestamp"]));
assert!(r.imports.contains("use rustio_admin::{DateTime, Utc};"));
}
#[test]
fn render_sql_decls_for_every_kind() {
let r = render(&fs(&[
"n:str",
"b:text",
"c:int",
"d:bigint",
"e:bool",
"f:timestamp",
"g:json",
"h:fk:Doctor",
]));
assert!(r.column_decls_sql.contains(",\n \"n\" TEXT NOT NULL"));
assert!(r.column_decls_sql.contains(",\n \"b\" TEXT NOT NULL"));
assert!(r.column_decls_sql.contains(",\n \"c\" INTEGER NOT NULL"));
assert!(r.column_decls_sql.contains(",\n \"d\" BIGINT NOT NULL"));
assert!(r
.column_decls_sql
.contains(",\n \"e\" BOOLEAN NOT NULL DEFAULT FALSE"));
assert!(
r.column_decls_sql
.contains(",\n \"f\" TIMESTAMPTZ NOT NULL\n")
|| r.column_decls_sql.ends_with("\"f\" TIMESTAMPTZ NOT NULL")
|| r.column_decls_sql
.contains(",\n \"f\" TIMESTAMPTZ NOT NULL,")
);
assert!(!r.column_decls_sql.contains("DEFAULT NOW()"));
assert!(r.column_decls_sql.contains(",\n \"g\" JSONB NOT NULL"));
assert!(!r.column_decls_sql.contains("'{}'"));
assert!(r
.column_decls_sql
.contains(",\n \"h\" BIGINT NOT NULL REFERENCES doctors(id)"));
}
#[test]
fn render_quotes_reserved_keyword_columns() {
let r = render(&fs(&["order:fk:Order", "group:choice:a,b"]));
assert!(
r.column_decls_sql
.contains(",\n \"order\" BIGINT NOT NULL REFERENCES orders(id)"),
"reserved fk column must be quoted: {}",
r.column_decls_sql
);
assert!(
r.column_decls_sql
.contains(",\n \"group\" TEXT NOT NULL CHECK (\"group\" IN ('a', 'b'))"),
"reserved choice column must be quoted in decl and CHECK: {}",
r.column_decls_sql
);
assert!(r.columns_literal.contains("\"order\""));
assert!(r.columns_literal.contains("\"group\""));
}
#[test]
fn render_new_scalar_types_float_date_time() {
let r = render(&fs(&["price:float", "birth_date:date", "start_time:time"]));
assert!(r.struct_fields.contains(" pub price: f64,"));
assert!(r.struct_fields.contains(" pub birth_date: NaiveDate,"));
assert!(r.struct_fields.contains(" pub start_time: NaiveTime,"));
assert!(r
.column_decls_sql
.contains(",\n \"price\" DOUBLE PRECISION NOT NULL"));
assert!(r
.column_decls_sql
.contains(",\n \"birth_date\" DATE NOT NULL"));
assert!(r
.column_decls_sql
.contains(",\n \"start_time\" TIME NOT NULL"));
assert!(r
.from_row_assignments
.contains("price: row.get_f64(\"price\")?,"));
assert!(r
.from_row_assignments
.contains("birth_date: row.get_date(\"birth_date\")?,"));
assert!(r
.from_row_assignments
.contains("start_time: row.get_time(\"start_time\")?,"));
assert!(r.imports.contains("use rustio_admin::NaiveDate;"));
assert!(r.imports.contains("use rustio_admin::NaiveTime;"));
assert!(!r.imports.contains("DateTime"));
assert!(r.insert_values_expr.contains("self.price.into()"));
assert!(r.insert_values_expr.contains("self.birth_date.into()"));
assert!(r.insert_values_expr.contains("self.start_time.into()"));
assert_eq!(r.search_fields_literal, "");
}
#[test]
fn render_decimal_and_uuid_types() {
let r = render(&fs(&["price:decimal", "public_id:uuid"]));
assert!(r.struct_fields.contains(" pub price: Decimal,"));
assert!(r.struct_fields.contains(" pub public_id: Uuid,"));
assert!(r
.column_decls_sql
.contains(",\n \"price\" NUMERIC NOT NULL"));
assert!(r
.column_decls_sql
.contains(",\n \"public_id\" UUID NOT NULL"));
assert!(r
.from_row_assignments
.contains("price: row.get_decimal(\"price\")?,"));
assert!(r
.from_row_assignments
.contains("public_id: row.get_uuid(\"public_id\")?,"));
assert!(r.imports.contains("use rustio_admin::Decimal;"));
assert!(r.imports.contains("use rustio_admin::Uuid;"));
assert!(!r.imports.contains("DateTime"));
assert!(r.insert_values_expr.contains("self.price.into()"));
assert!(r.insert_values_expr.contains("self.public_id.into()"));
assert_eq!(r.search_fields_literal, "");
}
#[test]
fn render_email_and_phone_carry_format_attribute() {
let r = render(&fs(&["work_email:email", "mobile:phone"]));
assert!(r
.struct_fields
.contains(" #[rustio(format = \"email\")]\n pub work_email: String,"));
assert!(r
.struct_fields
.contains(" #[rustio(format = \"phone\")]\n pub mobile: String,"));
assert!(r
.column_decls_sql
.contains(",\n \"work_email\" TEXT NOT NULL"));
assert!(r
.column_decls_sql
.contains(",\n \"mobile\" TEXT NOT NULL"));
assert!(r
.from_row_assignments
.contains("work_email: row.get_string(\"work_email\")?,"));
assert!(!r.imports.contains("chrono"));
assert_eq!(r.search_fields_literal, r#""work_email", "mobile""#);
}
#[test]
fn render_columns_literal_includes_id_first() {
let r = render(&fs(&["name:str", "active:bool"]));
assert_eq!(r.columns_literal, r#""id", "name", "active""#);
assert_eq!(r.insert_columns_literal, r#""name", "active""#);
}
#[test]
fn render_from_row_uses_correct_getter_per_type() {
let r = render(&fs(&[
"n:str",
"c:int",
"d:bigint",
"e:bool",
"f:timestamp",
"g:json",
"h:fk:Doctor",
]));
assert!(r
.from_row_assignments
.contains("n: row.get_string(\"n\")?,"));
assert!(r.from_row_assignments.contains("c: row.get_i32(\"c\")?,"));
assert!(r.from_row_assignments.contains("d: row.get_i64(\"d\")?,"));
assert!(r.from_row_assignments.contains("e: row.get_bool(\"e\")?,"));
assert!(r
.from_row_assignments
.contains("f: row.get_datetime(\"f\")?,"));
assert!(r.from_row_assignments.contains("g: row.get_json(\"g\")?,"));
assert!(r.from_row_assignments.contains("h: row.get_i64(\"h\")?,"));
}
#[test]
fn render_insert_values_clones_string_and_json_only() {
let r = render(&fs(&["n:str", "c:int", "e:bool", "f:timestamp", "g:json"]));
assert!(r.insert_values_expr.contains("self.n.clone().into()"));
assert!(r.insert_values_expr.contains("self.c.into()"));
assert!(r.insert_values_expr.contains("self.e.into()"));
assert!(r.insert_values_expr.contains("self.f.into()"));
assert!(r.insert_values_expr.contains("self.g.clone().into()"));
}
#[test]
fn render_search_fields_only_text_kinds() {
let r = render(&fs(&["name:str", "body:text", "count:int", "active:bool"]));
assert_eq!(r.search_fields_literal, r#""name", "body""#);
}
#[test]
fn render_search_fields_empty_when_no_text_kinds() {
let r = render(&fs(&["count:int", "active:bool"]));
assert_eq!(r.search_fields_literal, "");
}
#[test]
fn pluralise_handles_common_shapes() {
assert_eq!(pluralise_camel_to_snake("Patient"), "patients");
assert_eq!(pluralise_camel_to_snake("Doctor"), "doctors");
assert_eq!(pluralise_camel_to_snake("Category"), "categories");
assert_eq!(pluralise_camel_to_snake("Box"), "boxes");
assert_eq!(pluralise_camel_to_snake("Status"), "statuses");
assert_eq!(pluralise_camel_to_snake("Branch"), "branches");
assert_eq!(pluralise_camel_to_snake("Dish"), "dishes");
assert_eq!(pluralise_camel_to_snake("BookReview"), "book_reviews");
}
#[test]
fn pluralise_snake_default_adds_s() {
assert_eq!(pluralise_snake("task"), "tasks");
assert_eq!(pluralise_snake("patient"), "patients");
assert_eq!(pluralise_snake("book_review"), "book_reviews");
}
#[test]
fn pluralise_snake_sxz_take_es() {
assert_eq!(pluralise_snake("class"), "classes");
assert_eq!(pluralise_snake("bus"), "buses");
assert_eq!(pluralise_snake("status"), "statuses");
assert_eq!(pluralise_snake("box"), "boxes");
assert_eq!(pluralise_snake("quiz"), "quizes");
}
#[test]
fn pluralise_snake_ch_sh_take_es() {
assert_eq!(pluralise_snake("branch"), "branches");
assert_eq!(pluralise_snake("dish"), "dishes");
}
#[test]
fn pluralise_snake_consonant_y_becomes_ies() {
assert_eq!(pluralise_snake("category"), "categories");
assert_eq!(pluralise_snake("city"), "cities");
assert_eq!(pluralise_snake("party"), "parties");
}
#[test]
fn pluralise_snake_vowel_y_keeps_y() {
assert_eq!(pluralise_snake("monkey"), "monkeys");
assert_eq!(pluralise_snake("survey"), "surveys");
assert_eq!(pluralise_snake("day"), "days");
}
#[test]
fn pluralise_snake_empty_returns_empty() {
assert_eq!(pluralise_snake(""), "");
}
}