use std::fmt::Write as _;
use std::fs;
use std::path::Path;
use glacier_inflect::{pascal_case, pluralize, snake_case};
#[derive(Debug, Clone, Copy, PartialEq)]
enum Kind {
String,
Text,
Integer,
Bigint,
Float,
Boolean,
References,
}
#[derive(Debug, PartialEq)]
struct Field {
name: String,
kind: Kind,
optional: bool,
}
impl Field {
fn column(&self) -> String {
match self.kind {
Kind::References => format!("{}_id", self.name),
_ => self.name.clone(),
}
}
fn rust_type(&self) -> &'static str {
match (self.kind, self.optional) {
(Kind::String | Kind::Text, false) => "String",
(Kind::String | Kind::Text, true) => "Option<String>",
(Kind::Integer, _) => "i32",
(Kind::Bigint | Kind::References, _) => "i64",
(Kind::Float, _) => "f64",
(Kind::Boolean, _) => "bool",
}
}
fn label(&self) -> String {
let words = self.name.replace('_', " ");
let mut chars = words.chars();
chars
.next()
.map(|c| c.to_uppercase().chain(chars).collect())
.unwrap_or_default()
}
}
fn parse_field(spec: &str) -> Result<Field, String> {
let (name, kind) = spec
.split_once(':')
.ok_or_else(|| format!("`{spec}`: expected name:type, e.g. title:string"))?;
let (kind, optional) = match kind
.strip_suffix(":optional")
.or_else(|| kind.strip_suffix('?'))
{
Some(kind) => (kind, true),
None => (kind, false),
};
if name.is_empty()
|| !name.starts_with(|c: char| c.is_ascii_lowercase())
|| !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
{
return Err(format!("`{name}`: field names must be snake_case"));
}
if ["id", "inserted_at", "updated_at"].contains(&name) {
return Err(format!("`{name}` is added automatically"));
}
let kind = match kind {
"string" => Kind::String,
"text" => Kind::Text,
"integer" => Kind::Integer,
"bigint" => Kind::Bigint,
"float" => Kind::Float,
"boolean" => Kind::Boolean,
"references" => Kind::References,
other => {
return Err(format!(
"`{other}`: types are string, text, integer, bigint, float, boolean and references"
));
}
};
if optional && !matches!(kind, Kind::String | Kind::Text) {
return Err(format!(
"`{spec}`: only string and text fields can be optional"
));
}
Ok(Field {
name: name.to_owned(),
kind,
optional,
})
}
struct Names {
model: String,
singular: String,
plural: String,
controller: String,
human: String,
}
impl Names {
fn new(name: &str) -> Result<Self, String> {
if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(format!(
"`{name}`: expected a model name like Post or BlogPost"
));
}
let singular = snake_case(name);
let plural = pluralize(&singular);
let human = {
let words = singular.replace('_', " ");
let mut chars = words.chars();
chars
.next()
.map(|c| c.to_uppercase().chain(chars).collect())
.unwrap_or_default()
};
Ok(Self {
model: pascal_case(&singular),
controller: pascal_case(&plural),
singular,
plural,
human,
})
}
}
pub fn scaffold(name: &str, specs: &[String], dialect: crate::Dialect) -> Result<(), String> {
let glacier = dialect == crate::Dialect::Glacier;
check_app()?;
let api = api_app();
let names = Names::new(name)?;
let fields = parse_fields(specs)?;
let controller_path = format!("src/controllers/{}_controller.rs", names.plural);
let glacier_path = format!("src/controllers/{}_controller.glacier", names.plural);
refuse_existing(&[
&model_path(&names),
&glacier_model_path(&names),
&controller_path,
&glacier_path,
])?;
write_model(&names, &fields, glacier)?;
match (glacier, api) {
(true, false) => write(&glacier_path, &glacier_controller(&names))?,
(false, false) => write(&controller_path, &controller(&names))?,
(true, true) => write(&glacier_path, &glacier_api_controller(&names))?,
(false, true) => write(&controller_path, &api_controller(&names))?,
}
if !api {
let dir = format!("src/views/{}", names.plural);
write(&format!("{dir}/index.html"), &index_view(&names, &fields))?;
write(&format!("{dir}/show.html"), &show_view(&names, &fields))?;
write(&format!("{dir}/new.html"), &new_view(&names))?;
write(&format!("{dir}/edit.html"), &edit_view(&names))?;
write(&format!("{dir}/_form.html"), &form_view(&names, &fields))?;
}
if glacier {
let test = format!("controllers/{}_controller", names.plural);
let ready = parents_ready(&fields);
let source = if api {
glacier_api_tests(&names, &fields, ready)
} else {
glacier_tests(&names, &fields, ready)
};
write(&format!("tests/{test}.test.glacier"), &source)?;
add_to_tests(&[&test])?;
} else {
let source = if api {
api_tests(&names, &fields, &crate_name()?)
} else {
tests(&names, &fields, &crate_name()?)
};
write(&format!("tests/{}_controller.rs", names.plural), &source)?;
}
register_controller(&names.plural, &names.controller, glacier)?;
let only = if api {
", only [index, show, create, update, destroy]"
} else {
""
};
add_route(&format!(
" resources \"/{}\" => {}{only};",
names.plural, names.controller
))?;
println!("\nNext: glacier db migrate, then visit /{}", names.plural);
Ok(())
}
pub(crate) fn api_app() -> bool {
!Path::new("src/views.rs").exists()
}
pub fn model_only(name: &str, specs: &[String], dialect: crate::Dialect) -> Result<(), String> {
check_app()?;
let names = Names::new(name)?;
let fields = parse_fields(specs)?;
refuse_existing(&[&model_path(&names), &glacier_model_path(&names)])?;
write_model(&names, &fields, dialect == crate::Dialect::Glacier)?;
println!("\nNext: glacier db migrate");
Ok(())
}
pub fn controller_only(
name: &str,
actions: &[String],
dialect: crate::Dialect,
) -> Result<(), String> {
let glacier = dialect == crate::Dialect::Glacier;
check_app()?;
let api = api_app();
let file = snake_case(name);
let file = file.strip_suffix("_controller").unwrap_or(&file).to_owned();
if file.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(format!(
"`{name}`: expected a controller name like Pages or Reports"
));
}
let controller = pascal_case(&file);
if actions.is_empty() {
return Err("give at least one action, e.g. about".into());
}
for action in actions {
let valid = action.starts_with(|c: char| c.is_ascii_lowercase())
&& action
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
if !valid {
return Err(format!(
"`{action}`: actions are snake_case names, e.g. about"
));
}
}
let rust_path = format!("src/controllers/{file}_controller.rs");
let glacier_path = format!("src/controllers/{file}_controller.glacier");
refuse_existing(&[&rust_path, &glacier_path])?;
if glacier {
write(&glacier_path, &glacier_actions(&controller, actions, api))?;
} else {
write(&rust_path, &rust_actions(&controller, actions, api))?;
}
for action in actions.iter().filter(|_| !api) {
write(
&format!("src/views/{file}/{action}.html"),
&format!(
"<h1>{controller}#{action}</h1>\n<p>Find me in <code>src/views/{file}/{action}.html</code>.</p>\n"
),
)?;
}
if glacier {
let test = format!("controllers/{file}_controller");
write(
&format!("tests/{test}.test.glacier"),
&glacier_action_tests(&file, &controller, actions),
)?;
add_to_tests(&[&test])?;
} else {
write(
&format!("tests/{file}_controller.rs"),
&rust_action_tests(&file, &controller, actions, &crate_name()?),
)?;
}
register_controller(&file, &controller, glacier)?;
for action in actions {
add_route(&format!(
" get \"/{file}/{action}\" => {controller}::{action}, as {file}_{action};"
))?;
}
println!("\nNext: visit /{file}/{}", actions[0]);
Ok(())
}
pub fn job(name: &str, specs: &[String]) -> Result<(), String> {
check_app()?;
let file = snake_case(name);
let file = file.strip_suffix("_job").unwrap_or(&file).to_owned();
if file.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(format!("`{name}`: expected a job name like SendReport"));
}
let job = pascal_case(&file);
let fields = specs
.iter()
.map(|s| parse_field(s))
.collect::<Result<Vec<_>, _>>()?;
let path = format!("src/jobs/{file}.rs");
refuse_existing(&[&path])?;
let mut body = String::new();
for field in &fields {
let _ = writeln!(body, " pub {}: {},", field.column(), field.rust_type());
}
let struct_def = if fields.is_empty() {
format!("pub struct {job};\n")
} else {
format!("pub struct {job} {{\n{body}}}\n")
};
write(
&path,
&format!(
r#"use glacier::prelude::*;
use serde::{{Deserialize, Serialize}};
/// Queue it with `{job} {{ … }}.enqueue().await?`; the app's workers run it.
#[derive(Serialize, Deserialize)]
{struct_def}
impl Job for {job} {{
/// Stored with each queued job: keep it if you rename the type.
const NAME: &'static str = "{file}";
async fn perform(self) -> Result<()> {{
Ok(())
}}
}}
"#
),
)?;
if Path::new("src/jobs/mod.rs").exists() {
add_to_list("src/jobs/mod.rs", "jobs", &file)?;
} else {
write(
"src/jobs/mod.rs",
&format!(
"// Declares each background job, from src/jobs/<name>.rs, and `all()`\n\
// for `App::jobs`; `glacier generate job` adds to this list.\n\
glacier::jobs!({file});\n"
),
)?;
crate::generate::migration_with(
"create_glacier_jobs",
"use glacier::db::migration::*;\n\n\
pub fn up(s: &mut Schema) {\n glacier::jobs::create_table(s);\n}\n\n\
pub fn down(s: &mut Schema) {\n s.drop_table(\"glacier_jobs\");\n}\n",
)?;
wire_jobs()?;
}
add_serde()?;
println!(
"\nNext: glacier db migrate, then queue it with `{job} {{ … }}.enqueue().await?`.\n\
`glacier server` runs the workers; in tests, `glacier::testing::perform_jobs()` runs\n\
what's queued."
);
Ok(())
}
pub fn policy(name: &str) -> Result<(), String> {
check_app()?;
let names = Names::new(name.strip_suffix("Policy").unwrap_or(name))?;
if !Path::new("src/models/user.glacier").exists() && !Path::new("src/models/user.rs").exists() {
return Err(
"policies decide for a User: run `glacier generate authentication` first".into(),
);
}
let file = format!("{}_policy", names.singular);
let path = format!("src/services/{file}.glacier");
refuse_existing(&[&path, &format!("src/services/{file}.rs")])?;
let Names {
model, singular, ..
} = &names;
let mut rules = String::new();
for (action, doc) in [
("index", "list them"),
("show", "see one"),
("create", "make one"),
("update", "change this one"),
("destroy", "delete this one"),
] {
let record = if matches!(action, "index" | "create") {
String::new()
} else {
format!(", _{singular}: &{model}")
};
let _ = write!(
rules,
"\n # Whether the user may {doc}.\n def {action}(_user: &User{record}) -> bool do\n false\n end\n"
);
}
write(
&path,
&format!(
"# Who may do what with {lower}s. Each rule is `false` until you decide\n\
# (drop the `_` from the arguments it uses); use them in a controller:\n\
# `authorize {model}Policy.update(user, {singular})`.\n\
service {model}Policy do{rules}end\n",
lower = names.human.to_lowercase(),
),
)?;
if Path::new("src/services/mod.rs").exists() {
add_to_list("src/services/mod.rs", "services", &file)?;
} else {
write(
"src/services/mod.rs",
&format!(
"// Declares each service, from src/services/<name>.glacier (compiled by\n\
// build.rs into the hidden .glacier/ folder) or a plain <name>.rs.\n\
glacier::services!({file});\n"
),
)?;
let lib_path = "src/lib.rs";
let lib = fs::read_to_string(lib_path).map_err(|e| format!("reading {lib_path}: {e}"))?;
if !lib.contains("pub mod services;") {
let updated = match lib.find("pub mod routes;") {
Some(at) => format!("{}pub mod services;\n{}", &lib[..at], &lib[at..]),
None => format!("pub mod services;\n{lib}"),
};
fs::write(lib_path, updated).map_err(|e| format!("writing {lib_path}: {e}"))?;
println!(" update {lib_path}");
}
}
println!(
"\nNext: decide each rule in {path}, then `authorize {model}Policy.show(user, {singular})`\n\
in the actions. Add `VerifyAuthorized` to a pipeline to catch actions that forget."
);
Ok(())
}
fn wire_jobs() -> Result<(), String> {
let path = "src/lib.rs";
let lib = fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
let mut updated = lib.clone();
if !updated.contains("pub mod jobs;") {
updated = match updated.find("pub mod migrations;") {
Some(at) => format!("{}pub mod jobs;\n{}", &updated[..at], &updated[at..]),
None => format!("pub mod jobs;\n{updated}"),
};
}
if !updated.contains("jobs::all()") {
let anchor = " .migrations(migrations::all())\n";
match updated.find(anchor) {
Some(at) => updated.insert_str(
at + anchor.len(),
" // Background jobs, run by workers `serve` starts.\n .jobs(jobs::all())\n",
),
None => println!("\nRegister the jobs on your App in {path}:\n\n .jobs(jobs::all())"),
}
}
if updated != lib {
fs::write(path, updated).map_err(|e| format!("writing {path}: {e}"))?;
println!(" update {path}");
}
Ok(())
}
fn add_serde() -> Result<(), String> {
let path = "Cargo.toml";
let manifest = fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
if manifest
.lines()
.any(|l| l.trim_start().starts_with("serde ") || l.trim_start().starts_with("serde="))
{
return Ok(());
}
let Some(at) = manifest.find("[dependencies]\n") else {
println!("\nAdd serde to {path}: serde = {{ version = \"1\", features = [\"derive\"] }}");
return Ok(());
};
let at = at + "[dependencies]\n".len();
let updated = format!(
"{}serde = {{ version = \"1\", features = [\"derive\"] }}\n{}",
&manifest[..at],
&manifest[at..]
);
fs::write(path, updated).map_err(|e| format!("writing {path}: {e}"))?;
println!(" update {path}");
Ok(())
}
pub(crate) fn check_app() -> Result<(), String> {
for required in [
"Cargo.toml",
"src/routes.rs",
"src/models/mod.rs",
"src/controllers/mod.rs",
] {
if !Path::new(required).exists() {
return Err(format!(
"{required} not found; run this from an app made with `glacier new`"
));
}
}
Ok(())
}
fn parse_fields(specs: &[String]) -> Result<Vec<Field>, String> {
let fields = specs
.iter()
.map(|s| parse_field(s))
.collect::<Result<Vec<_>, _>>()?;
if fields.is_empty() {
return Err("give at least one field, e.g. title:string".into());
}
Ok(fields)
}
pub(crate) fn refuse_existing(paths: &[&str]) -> Result<(), String> {
match paths.iter().find(|p| Path::new(p).exists()) {
Some(path) => Err(format!("{path} already exists")),
None => Ok(()),
}
}
fn model_path(names: &Names) -> String {
format!("src/models/{}.rs", names.singular)
}
fn glacier_model_path(names: &Names) -> String {
format!("src/models/{}.glacier", names.singular)
}
fn write_model(names: &Names, fields: &[Field], glacier: bool) -> Result<(), String> {
crate::generate::migration_with(
&format!("create_{}", names.plural),
&migration(names, fields),
)?;
if glacier {
write(&glacier_model_path(names), &glacier_model(names, fields))?;
add_to_models(&names.singular)?;
} else {
write(&model_path(names), &model(names, fields))?;
append_line(
"src/models/mod.rs",
&format!("mod {0};\npub use {0}::*;", names.singular),
)?;
}
if glacier {
let ready = parents_ready(fields);
let factory = format!("factories/{}", names.singular);
write(
&format!("tests/{factory}.glacier"),
&factory_source(names, fields),
)?;
let test = format!("models/{}", names.singular);
write(
&format!("tests/{test}.test.glacier"),
&glacier_model_tests(names, fields, ready),
)?;
add_to_tests(&[&factory, &test])?;
} else {
write(
&format!("tests/{}_model.rs", names.singular),
&rust_model_tests(names, fields, &crate_name()?),
)?;
}
Ok(())
}
fn register_controller(file: &str, controller: &str, glacier: bool) -> Result<(), String> {
if glacier {
add_to_controllers(file)
} else {
append_line(
"src/controllers/mod.rs",
&format!("mod {file}_controller;\npub use {file}_controller::{controller};"),
)
}
}
fn crate_name() -> Result<String, String> {
let manifest =
fs::read_to_string("Cargo.toml").map_err(|e| format!("reading Cargo.toml: {e}"))?;
manifest
.lines()
.find_map(|line| {
let value = line.trim().strip_prefix("name")?.trim().strip_prefix('=')?;
Some(value.trim().trim_matches('"').replace('-', "_"))
})
.ok_or_else(|| "no package name in Cargo.toml".into())
}
pub(crate) fn write(path: &str, contents: &str) -> Result<(), String> {
if let Some(dir) = Path::new(path).parent() {
fs::create_dir_all(dir).map_err(|e| format!("creating {}: {e}", dir.display()))?;
}
fs::write(path, contents).map_err(|e| format!("writing {path}: {e}"))?;
println!(" create {path}");
Ok(())
}
fn append_line(path: &str, line: &str) -> Result<(), String> {
let mut contents = fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
if contents.contains(line) {
return Ok(());
}
if !contents.ends_with('\n') && !contents.is_empty() {
contents.push('\n');
}
if !contents.is_empty() {
contents.push('\n');
}
contents.push_str(line);
contents.push('\n');
fs::write(path, contents).map_err(|e| format!("writing {path}: {e}"))?;
println!(" update {path}");
Ok(())
}
pub(crate) fn add_to_controllers(name: &str) -> Result<(), String> {
add_to_list("src/controllers/mod.rs", "controllers", name)
}
pub(crate) fn add_to_models(name: &str) -> Result<(), String> {
add_to_list("src/models/mod.rs", "models", name)
}
pub(crate) fn add_to_list(path: &str, list: &str, name: &str) -> Result<(), String> {
let mods = fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
let marker = format!("glacier::{list}!(");
let Some(start) = mods.find(&marker).map(|i| i + marker.len()) else {
return append_line(path, &format!("glacier::{list}!({name});"));
};
let end = start
+ mods[start..]
.find(')')
.ok_or_else(|| format!("unclosed glacier::{list}!("))?;
let mut names: Vec<&str> = mods[start..end]
.split(',')
.map(str::trim)
.filter(|n| !n.is_empty())
.collect();
if names.contains(&name) {
return Ok(());
}
names.push(name);
let updated = format!("{}{}{}", &mods[..start], names.join(", "), &mods[end..]);
fs::write(path, updated).map_err(|e| format!("writing {path}: {e}"))?;
println!(" update {path}");
Ok(())
}
fn has_factory(singular: &str) -> bool {
Path::new(&format!("tests/factories/{singular}.glacier")).exists()
}
fn parents_ready(fields: &[Field]) -> bool {
fields
.iter()
.filter(|f| f.kind == Kind::References)
.all(|f| has_factory(&f.name))
}
fn factory_source(names: &Names, fields: &[Field]) -> String {
let model = &names.model;
let mut out = format!(
"# Valid fields for a {model}, used by `insert({model})` and\n\
# `params_for({model})` in tests. Override any of them per call:\n\
# `insert({model}, {}: …)`.\n",
fields[0].column()
);
let mut body = String::new();
for field in fields {
let value = match field.kind {
Kind::References if has_factory(&field.name) => {
let parent = pascal_case(&field.name);
format!("insert({parent}).id")
}
Kind::References => "1".to_owned(),
Kind::String | Kind::Text => format!("\"{}\"", sample_value(field)),
_ => sample_value(field),
};
let _ = writeln!(body, " {}: {value}", field.column());
}
let _ = write!(out, "\nfactory {model} do\n{body}end\n");
out
}
pub(crate) fn add_to_tests(new_names: &[&str]) -> Result<(), String> {
let path = "tests/glacier.rs";
let file = fs::read_to_string(path)
.unwrap_or_else(|_| TESTS_RS.replace("controllers::pages_controller", ""));
let marker = "glacier::tests!(";
let start = file
.find(marker)
.ok_or("no glacier::tests!( in tests/glacier.rs")?
+ marker.len();
let end = start + file[start..].find(')').ok_or("unclosed glacier::tests!(")?;
let mut names: Vec<&str> = file[start..end]
.split(',')
.map(str::trim)
.filter(|n| !n.is_empty())
.collect();
let before = names.len();
let new_names: Vec<String> = new_names.iter().map(|n| n.replace('/', "::")).collect();
for name in &new_names {
if !names.contains(&name.as_str()) {
names.push(name);
}
}
names.sort_unstable();
if names.len() == before && Path::new(path).exists() {
return Ok(());
}
let updated = format!("{}{}{}", &file[..start], names.join(", "), &file[end..]);
if !Path::new(path).exists() {
return write(path, &updated);
}
fs::write(path, updated).map_err(|e| format!("writing {path}: {e}"))?;
println!(" update {path}");
Ok(())
}
pub const TESTS_RS: &str = include_str!("../templates/tests_glacier.rs.tmpl");
fn add_route(line: &str) -> Result<(), String> {
let path = "src/routes.rs";
let routes = fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
if routes.contains(line) {
return Ok(());
}
let anchor = if api_app() {
"scope \"/\" through api {"
} else {
"scope \"/\" through browser {"
};
let end = routes.find(anchor).and_then(|at| {
let scope_line_start = routes[..at].rfind('\n').map_or(0, |n| n + 1);
let indent = &routes[scope_line_start..at];
let close = format!("\n{indent}}}");
routes[at..].find(&close).map(|n| at + n + 1)
});
match end {
Some(insert_at) => {
let mut updated = routes.clone();
updated.insert_str(insert_at, &format!("{line}\n"));
fs::write(path, updated).map_err(|e| format!("writing {path}: {e}"))?;
println!(" update {path}");
}
None => println!("\nAdd this to the routes! table in {path}:\n\n{line}"),
}
Ok(())
}
fn migration(names: &Names, fields: &[Field]) -> String {
let mut columns = String::new();
for field in fields {
let method = match field.kind {
Kind::String => "string",
Kind::Text => "text",
Kind::Integer => "integer",
Kind::Bigint => "bigint",
Kind::Float => "float",
Kind::Boolean => "boolean",
Kind::References => "references",
};
let mut line = format!(" t.{method}(\"{}\")", field.name);
if !field.optional {
line.push_str(".not_null()");
}
match field.kind {
Kind::Boolean => line.push_str(".default(false)"),
Kind::Integer | Kind::Bigint | Kind::Float => line.push_str(".default(0)"),
_ => {}
}
let _ = writeln!(columns, "{line};");
}
format!(
r#"use glacier::db::migration::*;
pub fn up(s: &mut Schema) {{
s.create_table("{table}", |t| {{
{columns} t.timestamps();
}});
}}
pub fn down(s: &mut Schema) {{
s.drop_table("{table}");
}}
"#,
table = names.plural,
)
}
fn model(names: &Names, fields: &[Field]) -> String {
let mut body = String::new();
for field in fields {
if !field.optional && matches!(field.kind, Kind::String | Kind::Text) {
body.push_str(" #[validate(required)]\n");
}
let _ = writeln!(body, " {}: {},", field.column(), field.rust_type());
}
format!(
r#"/// `#[model]` adds `id` and timestamps, and casts these fields from form
/// params. Validations run on every save.
#[glacier::model]
pub struct {model} {{
{body}}}
"#,
model = names.model,
)
}
fn glacier_model(names: &Names, fields: &[Field]) -> String {
let mut body = String::new();
for field in fields {
if field.kind == Kind::References {
let _ = writeln!(body, " belongs_to :{}", field.name);
continue;
}
let required = if !field.optional && matches!(field.kind, Kind::String | Kind::Text) {
", required"
} else {
""
};
let _ = writeln!(
body,
" field {}: {}{required}",
field.column(),
field.rust_type()
);
}
format!("model {} do\n{body}end\n", names.model)
}
fn controller(names: &Names) -> String {
let Names {
model,
singular,
plural,
controller,
human,
..
} = names;
format!(
r#"use glacier::prelude::*;
use crate::models::{model};
pub struct {controller};
#[glacier::controller]
impl {controller} {{
async fn index() {{
let {plural} = {model}::order(|r| r.inserted_at.desc()).await?;
render!({plural}; title = "{human}s")
}}
async fn show(ctx: Context) {{
let {singular} = {model}::find(ctx.id()?).await?;
render!({singular}; title = "{human}")
}}
async fn new() {{
render!({singular}: {model}::new(); title = "New {lower}")
}}
async fn create(ctx: Context) {{
let mut {singular} = {model}::build(ctx.params());
if {singular}.save().await? {{
redirect!({singular}, notice: "{human} created.")
}} else {{
render!({singular}; template = new, title = "New {lower}", status = 422)
}}
}}
async fn edit(ctx: Context) {{
let {singular} = {model}::find(ctx.id()?).await?;
render!({singular}; title = "Edit {lower}")
}}
async fn update(ctx: Context) {{
let mut {singular} = {model}::find(ctx.id()?).await?;
if {singular}.update(ctx.params()).await? {{
redirect!({singular}, notice: "{human} updated.")
}} else {{
render!({singular}; template = edit, title = "Edit {lower}", status = 422)
}}
}}
async fn destroy(ctx: Context) {{
{model}::find(ctx.id()?).await?.delete().await?;
redirect!({model}::collection_path(), notice: "{human} deleted.")
}}
}}
"#,
lower = human.to_lowercase(),
)
}
fn glacier_controller(names: &Names) -> String {
let Names {
model,
singular,
plural,
controller,
human,
..
} = names;
format!(
r#"controller {controller} do
def index do
{plural} = {model}.order(inserted_at: :desc)!
render {plural}, title: "{human}s"
end
def show(ctx) do
{singular} = {model}.find(ctx.id()?)!
render {singular}, title: "{human}"
end
def new do
render {singular}: {model}.new(), title: "New {lower}"
end
def create(ctx) do
{singular} = {model}.build(ctx.params())
if {singular}.save()! do
redirect {singular}, notice: "{human} created."
else
render :new, {singular}, title: "New {lower}", status: 422
end
end
def edit(ctx) do
{singular} = {model}.find(ctx.id()?)!
render {singular}, title: "Edit {lower}"
end
def update(ctx) do
{singular} = {model}.find(ctx.id()?)!
if {singular}.update(ctx.params())! do
redirect {singular}, notice: "{human} updated."
else
render :edit, {singular}, title: "Edit {lower}", status: 422
end
end
def destroy(ctx) do
{model}.find(ctx.id()?)!.delete()!
redirect {model}.collection_path(), notice: "{human} deleted."
end
end
"#,
lower = human.to_lowercase(),
)
}
fn api_controller(names: &Names) -> String {
let Names {
model,
singular,
plural,
controller,
..
} = names;
format!(
r#"use glacier::prelude::*;
use crate::models::{model};
pub struct {controller};
#[glacier::controller]
impl {controller} {{
async fn index() {{
let {plural} = {model}::order(|r| r.inserted_at.desc()).await?;
json!({plural})
}}
async fn show(ctx: Context) {{
let {singular} = {model}::find(ctx.id()?).await?;
json!({singular})
}}
async fn create(ctx: Context) {{
let mut {singular} = {model}::build(ctx.params().require("{singular}")?);
if {singular}.save().await? {{
json!({singular}, status = "created")
}} else {{
json!({singular}.errors_json(), status = "unprocessable_entity")
}}
}}
async fn update(ctx: Context) {{
let mut {singular} = {model}::find(ctx.id()?).await?;
if {singular}.update(ctx.params().require("{singular}")?).await? {{
json!({singular})
}} else {{
json!({singular}.errors_json(), status = "unprocessable_entity")
}}
}}
async fn destroy(ctx: Context) {{
{model}::find(ctx.id()?).await?.delete().await?;
Ok(Response::head(StatusCode::NO_CONTENT))
}}
}}
"#
)
}
fn glacier_api_controller(names: &Names) -> String {
let Names {
model,
singular,
plural,
controller,
..
} = names;
format!(
r#"controller {controller} do
def index do
{plural} = {model}.order(inserted_at: :desc)!
json {plural}
end
def show(ctx) do
{singular} = {model}.find(ctx.id()?)!
json {singular}
end
def create(ctx) do
{singular} = {model}.build(ctx.params().require("{singular}")?)
if {singular}.save()! do
json {singular}, status: :created
else
json {singular}.errors_json(), status: :unprocessable_entity
end
end
def update(ctx) do
{singular} = {model}.find(ctx.id()?)!
if {singular}.update(ctx.params().require("{singular}")?)! do
json {singular}
else
json {singular}.errors_json(), status: :unprocessable_entity
end
end
def destroy(ctx) do
{model}.find(ctx.id()?)!.delete()!
head :no_content
end
end
"#
)
}
fn index_view(names: &Names, fields: &[Field]) -> String {
let Names {
model,
singular,
plural,
human,
..
} = names;
let headers: String = fields
.iter()
.map(|f| format!("<th>{}</th>", f.label()))
.collect();
let cells: String = fields
.iter()
.map(|f| format!("\n <td>{{{{ {singular}.{} }}}}</td>", f.column()))
.collect();
format!(
r#"{{% props {plural}: Vec<{model}> %}}
<h1>{human}s</h1>
{{% if {plural}.is_empty() -%}}
<p>No {lower}s yet.</p>
{{%- else -%}}
<table>
<thead>
<tr>{headers}<th></th></tr>
</thead>
<tbody>
{{%- for {singular} in {plural} %}}
<tr>{cells}
<td>
{{{{ link_to("Show", {singular}) }}}}
{{{{ link_to("Edit", edit_{singular}_path({singular}.id)) }}}}
</td>
</tr>
{{%- end %}}
</tbody>
</table>
{{%- end %}}
<p><a href="{{{{ new_{singular}_path() }}}}">New {lower}</a></p>
"#,
lower = human.to_lowercase(),
)
}
fn show_view(names: &Names, fields: &[Field]) -> String {
let Names {
model,
singular,
plural,
human,
..
} = names;
let rows: String = fields
.iter()
.map(|f| {
format!(
"\n <dt>{}</dt>\n <dd>{{{{ {singular}.{} }}}}</dd>",
f.label(),
f.column()
)
})
.collect();
format!(
r#"{{% props {singular}: {model} %}}
<h1>{human}</h1>
<dl>{rows}
</dl>
<p>
<a href="{{{{ edit_{singular}_path({singular}.id) }}}}">Edit</a>
<a href="{{{{ {plural}_path() }}}}">Back</a>
</p>
{{{{ form_tag({singular}.to_path(), "delete") }}}}<button>Delete</button></form>
"#
)
}
fn new_view(names: &Names) -> String {
let Names {
model,
singular,
plural,
human,
..
} = names;
format!(
r#"{{% props {singular}: {model} %}}
<h1>New {lower}</h1>
{{{{ Form {{ {singular} }}.render() }}}}
<p><a href="{{{{ {plural}_path() }}}}">Back</a></p>
"#,
lower = human.to_lowercase(),
)
}
fn edit_view(names: &Names) -> String {
let Names {
model,
singular,
plural,
human,
..
} = names;
format!(
r#"{{% props {singular}: {model} %}}
<h1>Edit {lower}</h1>
{{%- let path = {singular}.to_path() %}}
{{{{ Form {{ {singular} }}.render() }}}}
<p>
<a href="{{{{ path }}}}">Show</a>
<a href="{{{{ {plural}_path() }}}}">Back</a>
</p>
"#,
lower = human.to_lowercase(),
)
}
fn form_view(names: &Names, fields: &[Field]) -> String {
let (model, record) = (&names.model, &names.singular);
let mut inputs = String::new();
for field in fields {
let (name, label) = (field.column(), field.label());
let input = match field.kind {
Kind::Text => {
format!(r#"<textarea name="{name}">{{{{ {record}.text("{name}") }}}}</textarea>"#)
}
Kind::Boolean => format!(
r#"<input type="hidden" name="{name}" value="false"><input type="checkbox" name="{name}" value="true"{{% if {record}.text("{name}") == "true" %}} checked{{% end %}}>"#
),
Kind::Integer | Kind::Bigint | Kind::References => format!(
r#"<input type="number" name="{name}" value="{{{{ {record}.text("{name}") }}}}">"#
),
Kind::Float => format!(
r#"<input type="number" step="any" name="{name}" value="{{{{ {record}.text("{name}") }}}}">"#
),
Kind::String => {
format!(r#"<input name="{name}" value="{{{{ {record}.text("{name}") }}}}">"#)
}
};
let _ = write!(
inputs,
r#"
<p>
<label>{label}<br>{input}</label>
{{%- for message in {record}.errors_on("{name}") %}}
<small class="error">{label} {{{{ message }}}}</small>
{{%- end %}}
</p>"#
);
}
format!(
r#"{{% props {record}: {model} %}}
{{{{ form_for(&{record}) }}}}{inputs}
<button>Save</button>
</form>
"#
)
}
fn sample_value(field: &Field) -> String {
match field.kind {
Kind::String | Kind::Text => format!("Example {}", field.name.replace('_', " ")),
Kind::Integer | Kind::Bigint | Kind::References => "1".into(),
Kind::Float => "1.5".into(),
Kind::Boolean => "true".into(),
}
}
fn glacier_tests(names: &Names, fields: &[Field], parents_ready: bool) -> String {
let Names {
singular,
plural,
human,
model,
..
} = names;
let lower = human.to_lowercase();
let skip = skip_note(fields, parents_ready);
let first_value = sample_value(fields.first().expect("at least one field"));
let text_field = required_field(fields);
let mut out = format!(
r#"# Tests for /{plural}, generated by `glacier generate scaffold`.
# Load a form first, as a browser would, for its CSRF token.
setup do
get "/{plural}/new"
end
test "lists {plural}"{skip} do
insert({model})
res = get "/{plural}"
assert res.status == :ok
assert res.body =~ "{first_value}"
end
test "creates a {lower}"{skip} do
res = post "/{plural}", params_for({model})
assert res.status == :see_other
assert get(res.location).body =~ "{human} created."
end
"#
);
if let Some(field) = text_field {
let column = field.column();
out.push_str(&format!(
r#"
test "rejects a {lower} without {column}" do
res = post "/{plural}", params_for({model}), {column}: ""
assert res.status == :unprocessable_entity
assert res.body =~ "can't be blank"
end
test "updates a {lower}"{skip} do
{singular} = insert({model})
res = patch {singular}, params_for({model}), {column}: "Updated"
assert res.status == :see_other
assert get({singular}).body =~ "Updated"
end
"#
));
}
out.push_str(&format!(
r#"
test "deletes a {lower}"{skip} do
{singular} = insert({model})
res = delete {singular}
assert res.status == :see_other
assert get({singular}).status == :not_found
end
"#
));
out
}
fn glacier_api_tests(names: &Names, fields: &[Field], parents_ready: bool) -> String {
let Names {
singular,
plural,
human,
model,
..
} = names;
let lower = human.to_lowercase();
let skip = skip_note(fields, parents_ready);
let first_value = sample_value(fields.first().expect("at least one field"));
let mut out = format!(
r#"# Tests for /{plural}, generated by `glacier generate scaffold`.
test "lists {plural}"{skip} do
insert({model})
res = get "/{plural}"
assert res.status == :ok
assert res.body =~ "{first_value}"
end
test "creates a {lower}"{skip} do
res = post_json "/{plural}", params_for({model})
assert res.status == :created
assert {model}.count()! == 1
end
"#
);
if let Some(field) = required_field(fields) {
let column = field.column();
out.push_str(&format!(
r#"
test "rejects a {lower} without {column}" do
res = post_json "/{plural}", params_for({model}), {column}: ""
assert res.status == :unprocessable_entity
assert res.body =~ "can't be blank"
end
test "updates a {lower}"{skip} do
{singular} = insert({model})
res = patch_json {singular}, params_for({model}), {column}: "Updated"
assert res.status == :ok
assert get({singular}).body =~ "Updated"
end
"#
));
}
out.push_str(&format!(
r#"
test "deletes a {lower}"{skip} do
{singular} = insert({model})
res = delete {singular}
assert res.status == :no_content
assert get({singular}).status == :not_found
end
"#
));
out
}
fn checked_field(fields: &[Field]) -> Option<(&Field, String)> {
let field = fields
.iter()
.find(|f| !f.optional && f.kind != Kind::References)?;
let value = match field.kind {
Kind::String | Kind::Text => format!("\"{}\"", sample_value(field)),
_ => sample_value(field),
};
Some((field, value))
}
fn required_field(fields: &[Field]) -> Option<&Field> {
fields
.iter()
.find(|f| matches!(f.kind, Kind::String | Kind::Text) && !f.optional)
}
fn skip_note(fields: &[Field], parents_ready: bool) -> String {
if parents_ready {
return String::new();
}
let parents: Vec<String> = fields
.iter()
.filter(|f| f.kind == Kind::References && !has_factory(&f.name))
.map(|f| format!("tests/factories/{}.glacier", f.name))
.collect();
format!(", skip: \"needs {}\"", parents.join(" and "))
}
fn glacier_model_tests(names: &Names, fields: &[Field], parents_ready: bool) -> String {
let Names {
model, singular, ..
} = names;
let lower = names.human.to_lowercase();
let skip = skip_note(fields, parents_ready);
let mut out = format!(
"# Tests for the {model} model, generated by `glacier generate`.\n\
\n\
test \"saves a valid {lower}\"{skip} do\n {singular} = insert({model})\n assert {model}.count()! == 1\n"
);
if let Some((field, value)) = checked_field(fields) {
let _ = writeln!(out, " assert {singular}.{} == {value}", field.column());
}
out.push_str("end\n");
if let Some(field) = required_field(fields) {
let column = field.column();
let _ = write!(
out,
"\ntest \"requires {column}\" do\n saved = {model}.create(params_for({model}, {column}: \"\"))!\n assert errors_on(saved) =~ \"{} can't be blank\"\nend\n",
column.replace('_', " ")
);
}
out
}
fn rust_model_tests(names: &Names, fields: &[Field], crate_name: &str) -> String {
let Names {
model, singular, ..
} = names;
let valid: String = fields
.iter()
.map(|f| format!(" (\"{}\", \"{}\"),\n", f.column(), sample_value(f)))
.collect();
let ignore = if fields.iter().any(|f| f.kind == Kind::References) {
"#[ignore = \"create the referenced records first, then remove this\"]\n"
} else {
""
};
let mut out = format!(
r#"//! Tests for the {model} model, generated by `glacier generate`.
use glacier::prelude::*;
use glacier::testing;
use {crate_name}::models::{model};
/// Runs `test` against a fresh, migrated test database.
async fn with_db(test: impl Future<Output = ()>) {{
let (_config, repo) = testing::setup({crate_name}::migrations::all()).await;
glacier::db::with(&repo, test).await;
}}
/// Fields that pass validation.
fn valid() -> Params {{
[
{valid} ]
.into_iter()
.collect()
}}
{ignore}#[glacier::test]
async fn saves_a_valid_{singular}() {{
with_db(async {{
{model}::create(valid()).await.unwrap().unwrap();
assert_eq!({model}::count().await.unwrap(), 1);
}})
.await;
}}
"#
);
if let Some(field) = required_field(fields) {
let column = field.column();
let message = format!("{} can't be blank", column.replace('_', " "));
let _ = write!(
out,
r#"
#[glacier::test]
async fn requires_{column}() {{
with_db(async {{
let mut params = valid();
params.insert("{column}", "");
let mut record = {model}::build(params);
assert!(!record.save().await.unwrap());
assert!(record.full_messages().contains(&"{message}".to_owned()));
}})
.await;
}}
"#
);
}
out
}
fn humanize(action: &str) -> String {
let words = action.replace('_', " ");
let mut chars = words.chars();
chars
.next()
.map(|c| c.to_uppercase().chain(chars).collect())
.unwrap_or_default()
}
fn glacier_actions(controller: &str, actions: &[String], api: bool) -> String {
let defs: Vec<String> = actions
.iter()
.map(|a| {
if api {
format!(" def {a} do\n json [message: \"{controller}#{a}\"]\n end\n")
} else {
format!(
" def {a} do\n render title: \"{}\"\n end\n",
humanize(a)
)
}
})
.collect();
format!("controller {controller} do\n{}end\n", defs.join("\n"))
}
fn rust_actions(controller: &str, actions: &[String], api: bool) -> String {
let defs: Vec<String> = actions
.iter()
.map(|a| {
if api {
format!(
" async fn {a}() {{\n let mut body = glacier::serialize::Object::new();\n body.value(\"message\", &\"{controller}#{a}\");\n json!(body.into_json())\n }}\n"
)
} else {
format!(
" async fn {a}() {{\n render!(; title = \"{}\")\n }}\n",
humanize(a)
)
}
})
.collect();
format!(
"use glacier::prelude::*;\n\npub struct {controller};\n\n#[glacier::controller]\nimpl {controller} {{\n{}}}\n",
defs.join("\n")
)
}
fn glacier_action_tests(file: &str, controller: &str, actions: &[String]) -> String {
let tests: Vec<String> = actions
.iter()
.map(|a| {
format!(
"test \"GET /{file}/{a}\" do\n res = get \"/{file}/{a}\"\n assert res.status == :ok\n assert res.body =~ \"{controller}#{a}\"\nend\n"
)
})
.collect();
format!(
"# Tests for {controller}, generated by `glacier generate controller`.\n\n{}",
tests.join("\n")
)
}
fn rust_action_tests(file: &str, controller: &str, actions: &[String], crate_name: &str) -> String {
let mut out = format!(
r#"//! Tests for {controller}, generated by `glacier generate controller`.
use glacier::prelude::*;
use glacier::testing::{{self, TestClient}};
/// A client for a fresh app with its own freshly migrated test database.
async fn client() -> TestClient {{
let (config, repo) = testing::setup({crate_name}::migrations::all()).await;
TestClient::new({crate_name}::app(config, repo).unwrap())
}}
"#
);
for a in actions {
let _ = write!(
out,
r#"
#[glacier::test]
async fn gets_{a}() {{
let res = client().await.get("/{file}/{a}").await;
assert_eq!(res.status(), StatusCode::OK);
assert!(res.body_text().contains("{controller}#{a}"));
}}
"#
);
}
out
}
fn tests(names: &Names, fields: &[Field], crate_name: &str) -> String {
let Names {
singular,
plural,
human,
..
} = names;
let valid: String = fields
.iter()
.map(|f| format!(" (\"{}\", \"{}\"),\n", f.column(), sample_value(f)))
.collect();
let ignore = if fields.iter().any(|f| f.kind == Kind::References) {
"#[ignore = \"create the referenced records first, then remove this\"]\n"
} else {
""
};
let text_field = fields
.iter()
.find(|f| matches!(f.kind, Kind::String | Kind::Text) && !f.optional);
let first = fields.first().expect("at least one field");
let mut out = format!(
r#"//! Tests for /{plural}, generated by `glacier generate scaffold`.
use glacier::prelude::*;
use glacier::testing::{{self, TestClient}};
/// A client for a fresh app with its own freshly migrated test database.
async fn client() -> TestClient {{
let (config, repo) = testing::setup({crate_name}::migrations::all()).await;
TestClient::new({crate_name}::app(config, repo).unwrap())
}}
/// Form fields that pass validation.
fn valid() -> Vec<(&'static str, &'static str)> {{
vec![
{valid} ]
}}
/// Creates a {lower} through the form and returns its path.
async fn create(c: &TestClient) -> String {{
// Load the form first, as a browser would; it carries the CSRF token.
c.get("/{plural}/new").await;
let res = c.post_form("/{plural}", &valid()).await;
assert_eq!(res.status(), StatusCode::SEE_OTHER, "{{}}", res.body_text());
res.header("location").unwrap().to_owned()
}}
{ignore}#[glacier::test]
async fn lists_{plural}() {{
let c = client().await;
create(&c).await;
let res = c.get("/{plural}").await;
assert_eq!(res.status(), StatusCode::OK);
assert!(res.body_text().contains("{first_value}"));
}}
{ignore}#[glacier::test]
async fn creates_and_shows_a_{singular}() {{
let c = client().await;
let path = create(&c).await;
let body = c.get(&path).await.body_text();
assert!(body.contains("{human} created."), "{{body}}");
assert!(body.contains("{first_value}"), "{{body}}");
}}
"#,
lower = human.to_lowercase(),
first_value = sample_value(first),
);
if let Some(field) = text_field {
let column = field.column();
out.push_str(&format!(
r#"
#[glacier::test]
async fn rejects_a_{singular}_without_{column}() {{
let c = client().await;
c.get("/{plural}/new").await;
let mut form = valid();
form.retain(|(name, _)| *name != "{column}");
form.push(("{column}", ""));
let res = c.post_form("/{plural}", &form).await;
assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert!(res.body_text().contains("can't be blank"));
}}
{ignore}#[glacier::test]
async fn updates_a_{singular}() {{
let c = client().await;
let path = create(&c).await;
c.get(&format!("{{path}}/edit")).await;
let mut form = valid();
form.retain(|(name, _)| *name != "{column}");
form.push(("{column}", "Updated"));
let res = c.patch_form(&path, &form).await;
assert_eq!(res.status(), StatusCode::SEE_OTHER);
assert!(c.get(&path).await.body_text().contains("Updated"));
}}
"#
));
}
out.push_str(&format!(
r#"
{ignore}#[glacier::test]
async fn deletes_a_{singular}() {{
let c = client().await;
let path = create(&c).await;
let res = c.delete(&path).await;
assert_eq!(res.status(), StatusCode::SEE_OTHER);
assert_eq!(c.get(&path).await.status(), StatusCode::NOT_FOUND);
}}
"#
));
out
}
fn api_tests(names: &Names, fields: &[Field], crate_name: &str) -> String {
let Names {
singular, plural, ..
} = names;
let valid: String = fields
.iter()
.map(|f| format!(" (\"{}\", \"{}\"),\n", f.column(), sample_value(f)))
.collect();
let ignore = if fields.iter().any(|f| f.kind == Kind::References) {
"#[ignore = \"create the referenced records first, then remove this\"]\n"
} else {
""
};
let first = fields.first().expect("at least one field");
let mut out = format!(
r#"//! Tests for /{plural}, generated by `glacier generate scaffold`.
use std::collections::BTreeMap;
use glacier::prelude::*;
use glacier::testing::{{self, TestClient}};
/// A client for a fresh app with its own freshly migrated test database.
async fn client() -> TestClient {{
let (config, repo) = testing::setup({crate_name}::migrations::all()).await;
TestClient::new({crate_name}::app(config, repo).unwrap())
}}
/// Fields that pass validation, as `{{"{singular}": {{…}}}}`.
fn valid() -> BTreeMap<&'static str, BTreeMap<&'static str, &'static str>> {{
BTreeMap::from([("{singular}", BTreeMap::from([
{valid} ]))])
}}
/// Creates a {singular} and returns its path.
async fn create(c: &TestClient) -> String {{
let res = c.post_json("/{plural}", &valid()).await;
assert_eq!(res.status(), StatusCode::CREATED, "{{}}", res.body_text());
format!("/{plural}/{{}}", res.body_json()["id"])
}}
{ignore}#[glacier::test]
async fn lists_{plural}() {{
let c = client().await;
create(&c).await;
let res = c.get("/{plural}").await;
assert_eq!(res.status(), StatusCode::OK);
assert!(res.body_text().contains("{first_value}"));
}}
"#,
first_value = sample_value(first),
);
if let Some(field) = required_field(fields) {
let column = field.column();
out.push_str(&format!(
r#"
#[glacier::test]
async fn rejects_a_{singular}_without_{column}() {{
let c = client().await;
let mut body = valid();
body.get_mut("{singular}").unwrap().insert("{column}", "");
let res = c.post_json("/{plural}", &body).await;
assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert!(res.body_text().contains("can't be blank"));
}}
{ignore}#[glacier::test]
async fn updates_a_{singular}() {{
let c = client().await;
let path = create(&c).await;
let mut body = valid();
body.get_mut("{singular}").unwrap().insert("{column}", "Updated");
let res = c.patch_json(&path, &body).await;
assert_eq!(res.status(), StatusCode::OK);
assert!(c.get(&path).await.body_text().contains("Updated"));
}}
"#
));
}
out.push_str(&format!(
r#"
{ignore}#[glacier::test]
async fn deletes_a_{singular}() {{
let c = client().await;
let path = create(&c).await;
let res = c.delete(&path).await;
assert_eq!(res.status(), StatusCode::NO_CONTENT);
assert_eq!(c.get(&path).await.status(), StatusCode::NOT_FOUND);
}}
"#
));
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_fields() {
assert_eq!(parse_field("body:text:optional"), parse_field("body:text?"));
assert_eq!(
parse_field("body:text?").unwrap(),
Field {
name: "body".into(),
kind: Kind::Text,
optional: true
}
);
assert_eq!(
parse_field("author:references").unwrap().column(),
"author_id"
);
assert!(parse_field("title").is_err());
assert!(parse_field("Title:string").is_err());
assert!(parse_field("x:uuid").is_err());
assert!(parse_field("n:integer?").is_err());
assert!(parse_field("id:bigint").is_err());
}
#[test]
fn derives_names() {
let n = Names::new("BlogPost").unwrap();
assert_eq!(
(
n.model.as_str(),
n.singular.as_str(),
n.plural.as_str(),
n.controller.as_str(),
n.human.as_str()
),
(
"BlogPost",
"blog_post",
"blog_posts",
"BlogPosts",
"Blog post"
)
);
assert_eq!(Names::new("category").unwrap().controller, "Categories");
}
}