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,
unique: 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, unique) = match kind.strip_suffix(":unique") {
Some(kind) => (kind, true),
None => (kind, false),
};
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"
));
}
if unique && matches!(kind, Kind::Boolean | Kind::References) {
return Err(format!(
"`{spec}`: only string, text and number fields can be unique"
));
}
Ok(Field {
name: name.to_owned(),
kind,
optional,
unique,
})
}
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,
access: Option<&str>,
no_access: bool,
) -> Result<(), String> {
let glacier = dialect == crate::Dialect::Glacier;
check_app()?;
let api = api_app();
let names = Names::new(name)?;
let mut fields = parse_fields(specs)?;
let access = match access {
Some(access) => Some(access),
None if no_access || !glacier || !Path::new(ROUTES_GLACIER).exists() => None,
None if Path::new("src/access/org_access.glacier").exists() && org_urls()?.is_some() => {
println!("Owned by the request's org (`--no-access` for records everyone shares)");
Some("org")
}
None if Path::new("src/access/user_access.glacier").exists() => {
println!("Owned by the signed-in user (`--no-access` for records everyone shares)");
Some("user")
}
None => None,
};
let owner = match access {
None => None,
Some(owner @ ("user" | "org")) => {
if !glacier || !Path::new(ROUTES_GLACIER).exists() {
return Err("`--access` writes .glacier: it needs src/routes.glacier".into());
}
let (file, generator) = if owner == "org" {
(
"src/access/org_access.glacier",
"glacier generate access org",
)
} else {
(
"src/access/user_access.glacier",
"glacier generate authentication",
)
};
if !Path::new(file).exists() {
return Err(format!(
"run `{generator}` first: `--access {owner}` needs {file}"
));
}
if owner == "org" && org_urls()?.is_none() {
return Err(
"`--access org` needs the org's pages in src/routes.glacier, a scope through \
`:in_org`, as `glacier generate access org` writes"
.into(),
);
}
Some(owner)
}
Some(other) => return Err(format!("`--access {other}`: expected `user` or `org`")),
};
if let Some(owner) = owner
&& !fields
.iter()
.any(|f| f.name == owner && f.kind == Kind::References)
{
fields.push(parse_field(&format!("{owner}:references"))?);
}
let owned = owner.is_some();
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, owner)?;
if let Some(owner) = owner {
own_model(&names, owner)?;
}
match (glacier, api) {
(true, false) if owned => write(
&glacier_path,
&owned_controller(&glacier_controller(&names), &names),
)?,
(true, true) if owned => write(
&glacier_path,
&owned_controller(&glacier_api_controller(&names), &names),
)?,
(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 nested = owner == Some("org") && org_urls()? == Some(OrgUrls::Nested);
let view = |source: String| {
if nested {
nested_view(&source, &names)
} else {
source
}
};
let dir = format!("src/views/{}", names.plural);
write(
&format!("{dir}/index.html"),
&view(index_view(&names, &fields)),
)?;
write(
&format!("{dir}/show.html"),
&view(show_view(&names, &fields)),
)?;
write(&format!("{dir}/new.html"), &view(new_view(&names)))?;
write(&format!("{dir}/edit.html"), &view(edit_view(&names)))?;
write(
&format!("{dir}/_form.html"),
&view(form_view(&names, &fields)),
)?;
}
if glacier {
let test = format!("controllers/{}_controller", names.plural);
let ready = parents_ready(&fields);
let source = match (api, owner) {
(false, Some(owner)) => owned_tests(&names, &fields, owner),
(true, Some(owner)) => owned_api_tests(&names, &fields, owner),
(true, None) => glacier_api_tests(&names, &fields, ready),
(false, None) => 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, glacier)?;
if let Some(owner) = owner {
add_owned_route(&names, api, owner)?;
println!(
"\nNext: glacier db migrate, then sign in and visit /{}: each {owner} sees their own",
names.plural
);
return Ok(());
}
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/layouts/app.html").exists() && !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, None)?;
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, 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}.glacier");
refuse_existing(&[&path, &format!("src/jobs/{file}.rs")])?;
let mut args = String::new();
for field in &fields {
let _ = writeln!(args, " arg {}: {}", field.column(), field.rust_type());
}
if !args.is_empty() {
args.push('\n');
}
let example = if fields.is_empty() {
format!("{job} {{}}")
} else {
let values: Vec<String> = fields
.iter()
.map(|f| format!("{}: …", f.column()))
.collect();
format!("{job} {{ {} }}", values.join(", "))
};
write(
&path,
&format!(
"# Queue it with `{example}.enqueue()!`; the app's workers run it.\n\
# `queue: :name`, `priority: 5`, `attempts: 3`, `unique_by an_arg` and\n\
# `expires_in 1.days()` go above the `def`s when you need them.\n\
module {job} do\n\
\x20 implements Job\n\
{args} def perform do\n\
\x20 end\n\
end\n"
),
)?;
if discovers() {
if !has_jobs_migration()? {
crate::generate::migration_with("create_glacier_jobs", JOBS_MIGRATION)?;
}
} else 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>.glacier (or .rs),\n\
// and `all()` for `App::jobs`; `glacier generate job` adds to this list.\n\
glacier::jobs!({file});\n"
),
)?;
if !has_jobs_migration()? {
crate::generate::migration_with("create_glacier_jobs", JOBS_MIGRATION)?;
}
wire_jobs()?;
}
println!(
"\nNext: glacier db migrate, then queue it with `{example}.enqueue()!`.\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/policies/{file}.glacier");
refuse_existing(&[&path, &format!("src/policies/{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\
module {model}Policy do{rules}end\n",
lower = names.human.to_lowercase(),
),
)?;
declare_folder(
"policies",
"The app's authorisation policies: every .glacier (or .rs) file in this folder",
)?;
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(())
}
pub fn declare_folder(folder: &str, what: &str) -> Result<(), String> {
let mod_rs = format!("src/{folder}/mod.rs");
if discovers() || Path::new(&mod_rs).exists() {
return Ok(());
}
write(
&mod_rs,
&format!("// {what}, found by `folder!()`.\nglacier::folder!();\n"),
)?;
let lib_path = "src/lib.rs";
let lib = fs::read_to_string(lib_path).map_err(|e| format!("reading {lib_path}: {e}"))?;
let declare = format!("pub mod {folder};");
if !lib.contains(&declare) {
let anchor = [
"pub mod routes;",
"// The route table",
"pub mod controllers;",
]
.iter()
.find_map(|anchor| lib.find(anchor));
let updated = match anchor {
Some(at) => format!("{}{declare}\n{}", &lib[..at], &lib[at..]),
None => format!("{lib}\n{declare}\n"),
};
fs::write(lib_path, updated).map_err(|e| format!("writing {lib_path}: {e}"))?;
println!(" update {lib_path}");
}
Ok(())
}
pub fn mailer(name: &str, actions: &[String]) -> Result<(), String> {
check_app()?;
if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(format!(
"`{name}`: expected a mailer name like ReportMailer"
));
}
let base = snake_case(name);
let base = base.strip_suffix("_mailer").unwrap_or(&base).to_owned();
let file = format!("{base}_mailer");
let mailer = pascal_case(&file);
let actions: Vec<String> = if actions.is_empty() {
vec!["notify".to_owned()]
} else {
actions.iter().map(|a| snake_case(a)).collect()
};
let path = format!("src/mailers/{file}.glacier");
if api_app() {
return Err(
"a mailer's mail is built from templates in src/views/: this app has none (an API app?)"
.into(),
);
}
let mut paths = vec![path.clone(), format!("src/mailers/{file}.rs")];
for action in &actions {
paths.push(format!("src/views/{file}/{action}.html"));
paths.push(format!("src/views/{file}/{action}.txt"));
}
refuse_existing(&paths.iter().map(String::as_str).collect::<Vec<_>>())?;
let mut methods = String::new();
for action in &actions {
let subject = humanize(action);
let _ = write!(
methods,
"\n # Send it with `{mailer}.{action}(…).deliver_later()!`; its parts are\n \
# src/views/{file}/{action}.html and .txt, given `name` as a prop.\n \
def {action}(to: &str, name: &str) do\n \
mail to: to, subject: \"{subject}\", with: [name: name]\n \
end\n"
);
}
write(
&path,
&format!(
"# Mail sent by the app. Each `def` builds one mail; `deliver_later`\n\
# sends it from a job, `deliver_now` at once. `from: \"…\"` and\n\
# `layout: :mailer` go above the `def`s when you need them.\n\
module {mailer} do\n\
\x20 implements Mailer\n{methods}end\n"
),
)?;
for action in &actions {
write(
&format!("src/views/{file}/{action}.html"),
"{% props name: String %}\n<p>Hello {{ name }},</p>\n",
)?;
write(
&format!("src/views/{file}/{action}.txt"),
"{% props name: String %}\nHello {{ name }},\n",
)?;
}
let list = "src/mailers/mod.rs";
let existing = fs::read_to_string(list).unwrap_or_default();
if discovers() {
} else if existing.contains("glacier::mailers!(") {
add_to_list(list, "mailers", &file)?;
} else if !existing.is_empty() {
append_line(list, &format!("glacier::mailers!({file});"))?;
} else {
write(
list,
&format!(
"//! The app's mailers, from src/mailers/<name>.glacier (or .rs);\n\
//! `glacier generate mailer` adds to this list.\n\
glacier::mailers!({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 !discovers() && !lib.contains("pub mod mailers;") {
let updated = match lib.find("pub mod models;") {
Some(at) => format!("{}pub mod mailers;\n{}", &lib[..at], &lib[at..]),
None => format!("pub mod mailers;\n{lib}"),
};
fs::write(lib_path, updated).map_err(|e| format!("writing {lib_path}: {e}"))?;
println!(" update {lib_path}");
}
let config_path = "config/default.toml";
let config = fs::read_to_string(config_path).unwrap_or_default();
let has_mail = config.lines().any(|l| l.trim() == "[mail]");
if !has_mail {
let name = crate_name().unwrap_or_else(|_| "App".to_owned());
let title = humanize(&name);
let section = format!(
"\n[mail]\n# Who mail comes from: change it before sending real mail.\nfrom = \"{title} <hello@example.com>\"\n"
);
fs::write(
config_path,
format!(
"{}{section}",
config.trim_end_matches('\n').to_owned() + "\n"
),
)
.map_err(|e| format!("writing {config_path}: {e}"))?;
println!(" update {config_path}");
}
let first = &actions[0];
let sender = if has_mail {
String::new()
} else {
"\nChange the sender ([mail] from) in config/default.toml before sending real mail."
.to_owned()
};
println!(
"\nNext: send one with `{mailer}.{first}(&user.email, &user.name).deliver_later()!`.{sender}\n\
In development it's kept at /dev/mail; in tests, `delivered_mail()` lists it.\n\
Delivery in production: `[mail] delivery = \"smtp\"` (or sendgrid, postmark); see the Mail guide."
);
Ok(())
}
pub fn client(name: &str, base_url: &str) -> Result<(), String> {
check_app()?;
if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(format!("`{name}`: expected a client name like ConvertKit"));
}
if !(base_url.starts_with("https://") || base_url.starts_with("http://")) {
return Err(format!(
"`{base_url}`: expected the API's base URL, like https://api.example.com/v1"
));
}
let file = snake_case(name);
let client = pascal_case(&file);
let upper = file.to_ascii_uppercase();
let env_var = format!("{upper}_API_KEY");
let base = base_url.trim_end_matches('/');
let path = format!("src/clients/{file}.glacier");
let test = format!("tests/clients/{file}.test.glacier");
refuse_existing(&[&path, &format!("src/clients/{file}.rs"), &test])?;
write(
&path,
&format!(
r#"# {client}'s API. Its key comes from the config's `{file}.api_key` (or
# the `GLACIER__{upper}__API_KEY` environment variable, which overrides
# it), else `{env_var}`; `{file}.base_url` points it elsewhere. Use it as
# `{client}.from_config(&config)?`, and add a `def` per call the app makes.
module {client} do
implements Client
base: "{base}"
bearer: secret("{file}.api_key", "{env_var}")
retries: 2
# An example call: `GET {base}/<path>`, as JSON.
def fetch(path: &str) -> JsonValue do
get(path).fetch()!
end
end
"#
),
)?;
write(
&test,
&format!(
r#"# Tests for the {client} client, generated by `glacier generate client`.
# Tests make no real requests: each is answered by a stub.
alias Clients.{client}
test "fetches JSON with the API key" do
stub_request("GET", "{base}/status", 200, serde_json::json!({{"ok": true}}))
answer = {client}.from_config(&test_config())?.fetch("status")!
assert answer["ok"] == true
assert requests()[0].headers["authorization"] == "Bearer test-key"
end
"#
),
)?;
let test_config = "config/test.toml";
let config = fs::read_to_string(test_config).unwrap_or_default();
if !config.lines().any(|l| l.trim() == format!("[{file}]")) {
let section = format!(
"\n[{file}]\n# A stand-in key: tests make no real requests.\napi_key = \"test-key\"\n"
);
fs::write(
test_config,
format!(
"{}{section}",
config.trim_end_matches('\n').to_owned() + "\n"
),
)
.map_err(|e| format!("writing {test_config}: {e}"))?;
println!(" update {test_config}");
}
let list = "src/clients/mod.rs";
let existing = fs::read_to_string(list).unwrap_or_default();
if discovers() {
} else if existing.is_empty() {
write(
list,
&format!(
"//! Clients for other services' APIs, from src/clients/<name>.glacier (or\n\
//! .rs); `glacier generate client` adds to this list.\n\
glacier::clients!({file});\n"
),
)?;
} else {
add_to_list(list, "clients", &file)?;
}
let lib_path = "src/lib.rs";
let lib = fs::read_to_string(lib_path).map_err(|e| format!("reading {lib_path}: {e}"))?;
if !discovers() && !lib.contains("pub mod clients;") {
let updated = match lib.find("pub mod controllers;") {
Some(at) => format!("{}pub mod clients;\n{}", &lib[..at], &lib[at..]),
None => format!("pub mod clients;\n{lib}"),
};
fs::write(lib_path, updated).map_err(|e| format!("writing {lib_path}: {e}"))?;
println!(" update {lib_path}");
}
if Path::new("tests/glacier.rs").exists() {
add_to_list("tests/glacier.rs", "tests", &format!("clients::{file}"))?;
}
add_serde_json()?;
println!(
"\nNext: set the API key as {file}.api_key in config/production.toml (or\n\
GLACIER__{upper}__API_KEY, or {env_var}), add a `def` per call the app makes, and\n\
use it with `{client}.from_config(&config)?.fetch(\"…\")!`.\n\
Failures are `glacier::client::ClientError`s."
);
Ok(())
}
pub fn middleware(name: &str) -> Result<(), String> {
let file = snake_case(name);
let middleware = pascal_case(&file);
if Path::new("src/middleware.rs").exists() {
return Err(
"src/middleware.rs holds the app's middleware: move it to src/middleware/mod.rs \
first, so each piece can have its own file"
.to_owned(),
);
}
let path = format!("src/middleware/{file}.glacier");
refuse_existing(&[&path, &format!("src/middleware/{file}.rs")])?;
write(
&path,
&format!(
r#"# Runs around each request of the routes whose pipeline it's in.
module {middleware} do
implements Middleware
def call(ctx, next) do
# Answer here (`redirect`, `head :forbidden`), or pass the request on
# and return (or change) the response.
next(ctx)
end
end
"#
),
)?;
let list = "src/middleware/mod.rs";
let existing = fs::read_to_string(list).unwrap_or_default();
if discovers() {
} else if existing.is_empty() {
write(
list,
&format!(
"//! The app's own middleware, from src/middleware/<name>.glacier (or .rs);\n\
//! `glacier generate middleware` adds to this list.\n\
glacier::middleware!({file});\n"
),
)?;
} else {
add_to_list(list, "middleware", &file)?;
}
let lib_path = "src/lib.rs";
let lib = fs::read_to_string(lib_path).map_err(|e| format!("reading {lib_path}: {e}"))?;
if !discovers() && !lib.contains("pub mod middleware;") {
let updated = match lib.find("pub mod models;") {
Some(at) => format!("{}pub mod middleware;\n{}", &lib[..at], &lib[at..]),
None => format!("pub mod middleware;\n{lib}"),
};
fs::write(lib_path, updated).map_err(|e| format!("writing {lib_path}: {e}"))?;
println!(" update {lib_path}");
}
println!(
"\nNext: add it to a pipeline in src/routes.glacier (`pipeline admin =\n\
Pipeline.browser().with({middleware})`, with `alias Middleware.{middleware}`), or\n\
to every request with `.middleware(middleware::{middleware})` in src/lib.rs."
);
Ok(())
}
fn add_serde_json() -> 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_json"))
{
return Ok(());
}
let Some(at) = manifest.find("[dependencies]\n") else {
println!("\nAdd serde_json to {path}: serde_json = \"1\"");
return Ok(());
};
let at = at + "[dependencies]\n".len();
let updated = format!("{}serde_json = \"1\"\n{}", &manifest[..at], &manifest[at..]);
fs::write(path, updated).map_err(|e| format!("writing {path}: {e}"))?;
println!(" update {path}");
Ok(())
}
pub fn webhook(provider: &str) -> Result<(), String> {
check_app()?;
let name = snake_case(provider);
if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(format!(
"`{provider}`: expected a provider like stripe or github"
));
}
let upper = name.to_ascii_uppercase();
let env_var = format!("{upper}_WEBHOOK_SECRET");
let (constructor, example) = match name.as_str() {
"stripe" => (
"glacier::webhooks::Stripe::new(&[&secret])".to_owned(),
"checkout.session.completed",
),
"github" => (
"glacier::webhooks::GitHub::new(&[&secret])".to_owned(),
"push",
),
"shopify" => (
"glacier::webhooks::Shopify::new(&[&secret])".to_owned(),
"orders/create",
),
"lemon_squeezy" => (
"glacier::webhooks::LemonSqueezy::new(&[&secret])".to_owned(),
"order_created",
),
"paddle" => (
"glacier::webhooks::Paddle::new(&[&secret])".to_owned(),
"transaction.completed",
),
other => (
format!(
"glacier::webhooks::Hmac::new(\"{other}\", \"X-Signature\", &[&secret]).provider()"
),
"*",
),
};
let title = match name.as_str() {
"github" => "GitHub".to_owned(),
_ => pascal_case(&name),
};
let provider_fn = format!(
r#"
/// {title}'s webhooks, verified with the signing secret from the config's
/// `{name}.webhook_secret` (or the `GLACIER__{upper}__WEBHOOK_SECRET`
/// environment variable, which overrides it), else `{env_var}`. Pass the
/// old secret too while rotating. Handlers run in a job; a failing one is
/// retried.
fn {name}(config: &Config) -> glacier::Result<Provider> {{
// Without a secret no request verifies, so nothing is accepted.
let secret = config
.secret("{name}.webhook_secret", "{env_var}")?
.unwrap_or_default();
Ok({constructor}
.on("{example}", |event: Event| async move {{
// Sync from the source: fetch the object the event names and
// upsert it, so a repeated or reordered event does no harm.
let _payload = event.payload();
Ok(())
}}))
}}
"#
);
let glacier_app = Path::new(ROUTES_GLACIER).exists() && !Path::new("src/webhooks.rs").exists();
let path = if glacier_app {
"src/webhooks.glacier"
} else {
"src/webhooks.rs"
};
let marker = " // glacier generate webhook adds providers here.\n";
let existed = Path::new(path).exists();
if glacier_app {
let provider = if constructor.starts_with("glacier::webhooks::Hmac") {
format!(":hmac, name: \"{name}\", header: \"X-Signature\"")
} else {
format!(":{name}")
};
let block = format!(
" # {title}'s webhooks, verified with the signing secret from the config's\n \
# `{name}.webhook_secret` (or the `GLACIER__{upper}__WEBHOOK_SECRET`\n \
# environment variable, which overrides it), else `{env_var}`.\n \
# Without a secret no request verifies, so nothing is accepted. Pass\n \
# the old secret too while rotating: `secret: [new, old]`. Handlers\n \
# run in a job; a failing one is retried.\n \
provider {provider}, secret: secret(\"{name}.webhook_secret\", \"{env_var}\") do\n \
on \"{example}\" do\n \
# Sync from the source: fetch the object the event names and\n \
# upsert it, so a repeated or reordered event does no harm.\n \
_payload = event.payload()\n \
end\n \
end\n"
);
if Path::new(path).exists() {
let current = fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
if current.contains(&format!("provider :{name},"))
|| current.contains(&format!("name: \"{name}\""))
{
return Err(format!("{path} already has {name}"));
}
let Some(at) = current.trim_end().rfind("\nend") else {
return Err(format!(
"{path}: add the {name} provider to `webhooks do` yourself"
));
};
let mut updated = current.clone();
updated.insert_str(at + 1, &format!("\n{block}"));
fs::write(path, updated).map_err(|e| format!("writing {path}: {e}"))?;
println!(" update {path}");
return Ok(());
}
write(
path,
&format!(
"# The webhooks the app receives, at `POST /webhooks/<provider>`.\n\
# Each event is stored once in `webhook_events` and handled in a job.\n\
webhooks do\n{block}end\n"
),
)?;
} else if Path::new(path).exists() {
let current = fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
if current.contains(&format!("fn {name}(")) {
return Err(format!("{path} already has {name}"));
}
let Some(at) = current.find(marker) else {
return Err(format!(
"{path}: add `.provider({name}(config)?)` to `all()` yourself"
));
};
let mut updated = current.clone();
updated.insert_str(
at + marker.len(),
&format!(" .provider({name}(config)?)\n"),
);
updated.push_str(&provider_fn);
fs::write(path, updated).map_err(|e| format!("writing {path}: {e}"))?;
rustfmt(path);
println!(" update {path}");
} else {
write(
path,
&format!(
"//! The webhooks the app receives, at `POST /webhooks/<provider>`.\n\
//! Each event is stored once in `webhook_events` and handled in a job.\n\n\
use glacier::Config;\n\
use glacier::webhooks::{{Event, Provider, Webhooks}};\n\n\
/// The app's providers, with their secrets from `config`.\n\
pub fn all(config: &Config) -> glacier::Result<Webhooks> {{\n Ok(Webhooks::new()\n{marker} .provider({name}(config)?))\n}}\n{provider_fn}"
),
)?;
}
if !existed {
ensure_job_workers()?;
crate::generate::migration_with(
"create_webhook_events",
"migration do\n up do\n glacier::webhooks::create_table(s)\n end\n\n \
down do\n drop_table :webhook_effects\n drop_table :webhook_events\n end\nend\n",
)?;
let lib_path = "src/lib.rs";
let lib = fs::read_to_string(lib_path).map_err(|e| format!("reading {lib_path}: {e}"))?;
let lib = if discovers() { String::new() } else { lib };
let mut updated = lib.clone();
let declare = if glacier_app {
"glacier::modules!(webhooks);\n"
} else {
"pub mod webhooks;\n"
};
if !updated.contains(declare) {
updated = match updated.find("pub mod views;\n") {
Some(at) => {
let at = at + "pub mod views;\n".len();
format!("{}{declare}{}", &updated[..at], &updated[at..])
}
None => format!("{updated}\n{declare}"),
};
}
if !updated.contains("webhooks::all(") {
let start = " Ok(App::new()\n";
let anchor = " .migrations(migrations::all())\n";
match (updated.find(start), updated.find(anchor)) {
(Some(start_at), Some(at)) if start_at < at => {
updated.insert_str(
at + anchor.len(),
" // Webhooks from other services, handled in jobs.\n .webhooks(webhooks)\n",
);
updated.insert_str(
start_at,
" // Their secrets come from the config, before it moves into the app.\n let webhooks = webhooks::all(&config)?;\n",
);
}
_ => println!(
"\nRegister them on your App in {lib_path}:\n\n .webhooks(webhooks::all(&config)?)"
),
}
}
if !discovers() && updated != lib {
fs::write(lib_path, updated).map_err(|e| format!("writing {lib_path}: {e}"))?;
println!(" update {lib_path}");
}
if Path::new(ROUTES_GLACIER).exists() {
let routes = ROUTES_GLACIER;
let table = fs::read_to_string(routes).map_err(|e| format!("reading {routes}: {e}"))?;
if !table.contains("glacier.webhooks.receive") {
match table.trim_end().strip_suffix("end") {
Some(head) => {
let updated = format!(
"{}\n\n # Webhooks: no CSRF, as they're signed instead.\n scope \"/\" do\n post \"/webhooks/:provider\", glacier.webhooks.receive\n end\nend\n",
head.trim_end()
);
fs::write(routes, updated).map_err(|e| format!("writing {routes}: {e}"))?;
println!(" update {routes}");
}
None => println!(
"\nAdd the route: post \"/webhooks/:provider\", glacier.webhooks.receive"
),
}
}
return Ok(());
}
let routes = "src/routes.rs";
let table = fs::read_to_string(routes).map_err(|e| format!("reading {routes}: {e}"))?;
if !table.contains("glacier::webhooks::receive") {
match table.trim_end().strip_suffix('}') {
Some(head) => {
let updated = format!(
"{head}\n // Webhooks: no CSRF, as they're signed instead.\n scope \"/\" {{\n post \"/webhooks/:provider\" => glacier::webhooks::receive;\n }}\n}}\n"
);
fs::write(routes, updated).map_err(|e| format!("writing {routes}: {e}"))?;
println!(" update {routes}");
}
None => println!(
"\nAdd the route: post \"/webhooks/:provider\" => glacier::webhooks::receive"
),
}
}
}
println!(
"\nNext: glacier db migrate, set the signing secret as {name}.webhook_secret in\n\
config/production.toml (or GLACIER__{upper}__WEBHOOK_SECRET, or {env_var}), and\n\
point {name} at https://<your host>/webhooks/{name}. `glacier webhooks` lists\n\
what arrived; `glacier webhooks replay ID` handles one again."
);
Ok(())
}
fn ensure_job_workers() -> Result<(), String> {
if !has_jobs_migration()? {
crate::generate::migration_with("create_glacier_jobs", JOBS_MIGRATION)?;
}
if discovers() {
return Ok(());
}
let path = "src/lib.rs";
let lib = fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
if lib.contains(".jobs(") {
return Ok(());
}
let anchor = " .migrations(migrations::all())\n";
match lib.find(anchor) {
Some(at) => {
let mut updated = lib.clone();
updated.insert_str(
at + anchor.len(),
" // Workers for background jobs, which handle webhook events.\n .jobs(glacier::jobs::Jobs::new())\n",
);
fs::write(path, updated).map_err(|e| format!("writing {path}: {e}"))?;
println!(" update {path}");
}
None => println!(
"\nStart job workers on your App in {path}:\n\n .jobs(glacier::jobs::Jobs::new())"
),
}
Ok(())
}
pub(crate) const JOBS_MIGRATION: &str = "# The jobs tables: as 0.1 made them, then what later versions add.\n\
migration do\n up do\n glacier::jobs::create_table(s)\n glacier::jobs::upgrade_table(s)\n end\n\n \
down do\n drop_table :glacier_job_schedules\n drop_table :glacier_jobs\n end\nend\n";
pub(crate) fn has_jobs_migration() -> Result<bool, String> {
let (dir, _) = crate::generate::migrations_dir();
let Ok(entries) = fs::read_dir(dir) else {
return Ok(false);
};
Ok(entries
.filter_map(|entry| fs::read_to_string(entry.ok()?.path()).ok())
.any(|source| source.contains("glacier::jobs::create_table")))
}
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 models;") {
Some(at) => format!("{}pub mod jobs;\n{}", &updated[..at], &updated[at..]),
None => format!("pub mod jobs;\n{updated}"),
};
}
if updated.contains(".jobs(glacier::jobs::Jobs::new())") {
updated = updated
.replace(
" // Workers for background jobs, which handle webhook events.\n",
" // Background jobs, run by workers `serve` starts.\n",
)
.replace(".jobs(glacier::jobs::Jobs::new())", ".jobs(jobs::all())");
}
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(())
}
pub(crate) fn check_app() -> Result<(), String> {
let required: &[&str] = if discovers() {
&["Cargo.toml", "src/routes"]
} else {
&[
"Cargo.toml",
"src/routes",
"src/models/mod.rs",
"src/controllers/mod.rs",
]
};
for &required in required {
let exists = if required == "src/routes" {
Path::new(ROUTES_GLACIER).exists() || Path::new("src/routes.rs").exists()
} else {
Path::new(required).exists()
};
if !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,
owner: Option<&str>,
) -> Result<(), String> {
let source = if glacier {
glacier_migration(names, fields, owner)
} else {
migration(names, fields, owner)
};
crate::generate::migration_with(&format!("create_{}", names.plural), &source)?;
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, 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::*;"),
)
}
}
pub(crate) 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}"))?;
rustfmt(path);
println!(" create {path}");
Ok(())
}
pub(crate) fn rustfmt(path: &str) {
if path.ends_with(".rs") {
let _ = std::process::Command::new("rustfmt")
.args(["--edition", "2024", path])
.stderr(std::process::Stdio::null())
.status();
}
}
fn append_line(path: &str, line: &str) -> Result<(), String> {
if path.ends_with("mod.rs") && discovers() {
return Ok(());
}
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}"))?;
rustfmt(path);
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 discovers() -> bool {
fs::read_to_string("src/lib.rs").is_ok_and(|lib| lib.contains("glacier::app!"))
}
fn lists_tests_itself(path: &str) -> bool {
path == "tests/glacier.rs"
&& fs::read_to_string(path).is_ok_and(|file| file.contains("glacier::tests!()"))
}
pub(crate) fn add_app_setting(line: &str, comment: &str) -> Result<bool, String> {
let path = "src/app.glacier";
let Ok(app) = fs::read_to_string(path) else {
return Ok(false);
};
if app.lines().any(|l| l.trim() == line) {
return Ok(true);
}
let setting = line.split_whitespace().next().unwrap_or("");
let lines: Vec<&str> = app.lines().collect();
let at = lines
.iter()
.rposition(|l| {
l.trim_start().starts_with(&format!("{setting} "))
&& l.starts_with(" ")
&& !l.starts_with(" ")
})
.map(|i| i + 1)
.or_else(|| {
lines
.iter()
.position(|l| l.starts_with(" def ") || l.starts_with(" defp "))
})
.or_else(|| lines.iter().rposition(|l| l.trim() == "end"));
let Some(at) = at else {
return Ok(false);
};
let mut updated: Vec<String> = lines.iter().map(|l| (*l).to_owned()).collect();
let mut insert = Vec::new();
if !comment.is_empty() {
insert.push(format!(" # {comment}"));
}
insert.push(format!(" {line}"));
if updated.get(at).is_some_and(|l| l.starts_with(" def")) {
insert.push(String::new());
}
updated.splice(at..at, insert);
fs::write(path, updated.join("\n") + "\n").map_err(|e| format!("writing {path}: {e}"))?;
println!(" update {path}");
Ok(true)
}
pub(crate) fn add_to_list(path: &str, list: &str, name: &str) -> Result<(), String> {
if (path.ends_with("mod.rs") && discovers()) || lists_tests_itself(path) {
return Ok(());
}
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}"))?;
rustfmt(path);
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";
if lists_tests_itself(path) {
return Ok(());
}
if !Path::new(path).exists() {
return write(path, TESTS_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> {
if Path::new(ROUTES_GLACIER).exists() {
return add_glacier_route(&glacier_route(line));
}
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(())
}
pub(crate) const ROUTES_GLACIER: &str = "src/routes.glacier";
pub(crate) fn glacier_route(line: &str) -> String {
let line = line.trim().trim_end_matches(';');
let Some((head, rest)) = line.split_once(" => ") else {
return line.to_owned();
};
let (rest, only) = match rest.split_once(", only [") {
Some((before, after)) => {
let (list, after) = after.split_once(']').unwrap_or((after, ""));
let actions: Vec<String> = list.split(',').map(|a| format!(":{}", a.trim())).collect();
(
format!("{before}{after}"),
Some(format!("only: [{}]", actions.join(", "))),
)
}
None => (rest.to_owned(), None),
};
let mut parts = rest.split(", ");
let target = parts.next().unwrap_or_default().replace("::", ".");
let target = match target.split_once('.') {
Some((owner, action)) if !owner.ends_with("Controller") => {
format!("{owner}Controller.{action}")
}
None if !target.ends_with("Controller") => format!("{target}Controller"),
_ => target,
};
let mut out = format!("{head}, {target}");
if let Some(only) = only {
out.push_str(&format!(", {only}"));
}
for part in parts {
match part.strip_prefix("as ") {
Some(name) => out.push_str(&format!(", as: :{name}")),
None => out.push_str(&format!(", {part}")),
}
}
out
}
fn add_glacier_route(route: &str) -> Result<(), String> {
let path = ROUTES_GLACIER;
let routes = fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
if routes.lines().any(|l| l.trim() == route) {
return Ok(());
}
let anchor = if api_app() {
"scope \"/\", through: :api do"
} else {
"scope \"/\", through: :browser do"
};
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}end");
routes[at..]
.find(&close)
.map(|n| (at + n + 1, format!("{indent} ")))
});
match end {
Some((insert_at, indent)) => {
let mut updated = routes.clone();
updated.insert_str(insert_at, &format!("{indent}{route}\n"));
fs::write(path, updated).map_err(|e| format!("writing {path}: {e}"))?;
println!(" update {path}");
}
None => println!("\nAdd this to the routes in {path}:\n\n {route}"),
}
Ok(())
}
fn indexes(fields: &[Field], owner: Option<&str>) -> Vec<(Vec<String>, bool)> {
let owner = owner.map(|owner| format!("{owner}_id"));
let mut indexes = Vec::new();
let unique: Vec<&Field> = fields.iter().filter(|f| f.unique).collect();
if let Some(owner) = &owner
&& unique.is_empty()
{
indexes.push((vec![owner.clone()], false));
}
for field in unique {
let mut columns: Vec<String> = owner.iter().cloned().collect();
columns.push(field.column());
indexes.push((columns, true));
}
indexes
}
fn migration(names: &Names, fields: &[Field], owner: Option<&str>) -> 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};");
}
let mut index_lines = String::new();
for (columns, unique) in indexes(fields, owner) {
let list = columns
.iter()
.map(|c| format!("\"{c}\""))
.collect::<Vec<_>>()
.join(", ");
let method = if unique {
"add_unique_index"
} else {
"add_index"
};
let _ = writeln!(
index_lines,
" s.{method}(\"{}\", &[{list}]);",
names.plural
);
}
format!(
r#"use glacier::db::migration::*;
pub fn up(s: &mut Schema) {{
s.create_table("{table}", |t| {{
{columns} t.timestamps();
}});
{index_lines}}}
pub fn down(s: &mut Schema) {{
s.drop_table("{table}");
}}
"#,
table = names.plural,
)
}
fn glacier_migration(names: &Names, fields: &[Field], owner: Option<&str>) -> 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!(" {method} :{}", field.name);
if !field.optional {
line.push_str(", null: false");
}
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}");
}
let mut index_lines = String::new();
for (columns, unique) in indexes(fields, owner) {
let list = match columns.as_slice() {
[one] => format!(":{one}"),
many => format!(
"[{}]",
many.iter()
.map(|c| format!(":{c}"))
.collect::<Vec<_>>()
.join(", ")
),
};
let unique = if unique { ", unique: true" } else { "" };
let _ = writeln!(index_lines, " add_index :{}, {list}{unique}", names.plural);
}
format!(
"migration do\n create_table :{table} do\n{columns} timestamps\n end\n{index_lines}end\n",
table = names.plural,
)
}
fn model(names: &Names, fields: &[Field]) -> String {
let mut body = String::new();
for field in fields {
match (
!field.optional && matches!(field.kind, Kind::String | Kind::Text),
field.unique,
) {
(true, true) => body.push_str(" #[validate(required, unique)]\n"),
(true, false) => body.push_str(" #[validate(required)]\n"),
(false, true) => body.push_str(" #[validate(unique)]\n"),
(false, false) => {}
}
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 unique = if field.unique { ", unique" } else { "" };
let _ = writeln!(
body,
" field {}: {}{required}{unique}",
field.column(),
field.rust_type()
);
}
format!(
"module {} do\n implements Model\n\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#"module {controller}Controller do
implements Controller
def index do
{plural} = {model}.order(inserted_at: :desc)!
render {plural}, title: "{human}s"
end
def show do
{singular} = {model}.find(params[:id])!
render {singular}, title: "{human}"
end
def new do
render {singular}: {model}.new(), title: "New {lower}"
end
def create do
{singular} = {model}.build(params)
if {singular}.save()! do
redirect {singular}, notice: "{human} created."
else
render :new, {singular}, title: "New {lower}", status: 422
end
end
def edit do
{singular} = {model}.find(params[:id])!
render {singular}, title: "Edit {lower}"
end
def update do
{singular} = {model}.find(params[:id])!
if {singular}.update(params)! do
redirect {singular}, notice: "{human} updated."
else
render :edit, {singular}, title: "Edit {lower}", status: 422
end
end
def destroy do
{model}.find(params[: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#"module {controller}Controller do
implements Controller
def index do
{plural} = {model}.order(inserted_at: :desc)!
json {plural}
end
def show do
{singular} = {model}.find(params[:id])!
json {singular}
end
def create do
{singular} = {model}.build(params.require("{singular}")?)
if {singular}.save()! do
json {singular}, status: :created
else
json {singular}.errors_json(), status: :unprocessable_entity
end
end
def update do
{singular} = {model}.find(params[:id])!
if {singular}.update(params.require("{singular}")?)! do
json {singular}
else
json {singular}.errors_json(), status: :unprocessable_entity
end
end
def destroy do
{model}.find(params[: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}: Array<{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 {
if Path::new("src/components/input.html").exists()
&& Path::new("src/components/button.html").exists()
{
return component_form_view(names, fields);
}
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 component_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 = field.column();
let kind = match field.kind {
Kind::Text => r#" type="textarea""#,
Kind::Boolean => r#" type="checkbox""#,
Kind::Integer | Kind::Bigint | Kind::References => r#" type="number""#,
Kind::Float => r#" type="number" step="any""#,
Kind::String => "",
};
let _ = write!(
inputs,
"\n <.input field={{{record}.field(\"{name}\")}}{kind} />"
);
}
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.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!(
"module {controller}Controller do\n implements Controller\n\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
}
fn own_model(names: &Names, owner: &str) -> Result<(), String> {
let path = glacier_model_path(names);
let model = fs::read_to_string(&path).map_err(|e| format!("reading {path}: {e}"))?;
let owned = model.replacen(
&format!(" belongs_to :{owner}\n"),
&format!(" owned_by :{owner}\n"),
1,
);
fs::write(&path, owned).map_err(|e| format!("writing {path}: {e}"))
}
fn owned_controller(source: &str, names: &Names) -> String {
let model = &names.model;
source
.replace(
&format!("{model}.order("),
&format!("{model}.for(access).order("),
)
.replace(
&format!("{model}.find(params[:id])!"),
&format!("{model}.for(access).find(params[:id])!"),
)
.replace(
&format!("{model}.build(params"),
&format!("{model}.for(access).build(params"),
)
.replacen(
" implements Controller\n",
" implements Controller\n\n # Every action reads the records of the request's access: anyone\n # else's are `404 Not Found`.\n",
1,
)
}
fn add_owned_route(names: &Names, api: bool, owner: &str) -> Result<(), String> {
let path = ROUTES_GLACIER;
let routes = fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
let protect = if api { "api" } else { "browser" };
let only = if api {
", only: [:index, :show, :create, :update, :destroy]"
} else {
""
};
let route = format!(
"resources \"/{}\", {}Controller{only}",
names.plural, names.controller
);
if routes.lines().any(|l| l.trim() == route) {
return Ok(());
}
let anchor = if owner == "org" && org_urls()? == Some(OrgUrls::Nested) {
format!("scope \"/orgs/:org_id\", through: [:{protect}, :authenticated, :in_org] do\n")
} else if owner == "org" {
format!("scope \"/\", through: [:{protect}, :authenticated, :in_org] do\n")
} else {
format!("scope \"/\", through: [:{protect}, :authenticated] do\n")
};
let mut updated = routes.clone();
match updated.find(&anchor) {
Some(at) => updated.insert_str(at + anchor.len(), &format!(" {route}\n")),
None => match updated.rfind("\nend") {
Some(close) => {
updated.insert_str(close + 1, &format!("\n {anchor} {route}\n end\n"))
}
None => {
println!("\nAdd this to the routes in {path}:\n\n {anchor} {route}\n end");
return Ok(());
}
},
}
fs::write(path, updated).map_err(|e| format!("writing {path}: {e}"))?;
println!(" update {path}");
Ok(())
}
fn owned_tests(names: &Names, fields: &[Field], owner: &str) -> String {
let Names {
singular,
plural,
human,
model,
..
} = names;
let lower = human.to_lowercase();
let first_value = sample_value(fields.first().expect("at least one field"));
let enter = match org_urls().ok().flatten() {
Some(OrgUrls::Prefix) => "glacier::testing::path_prefix(\"/#{org.id}\")",
Some(OrgUrls::Nested) => "glacier::testing::path_prefix(\"/orgs/#{org.id}\")",
_ => "post \"/orgs/#{org.id}/switch\"",
};
let sign_in = if owner == "org" {
format!(
"
# Signs in as a new user, as a browser would, into a new org of theirs,
# and returns the org.
def sign_in -> Org do
user = insert(User)
get \"/session/new\"
post \"/session\", [email_address: &user.email_address, password: \"password\"]
org = insert(Org)
insert(Membership, org_id: org.id, user_id: user.id)
{enter}
org
end
"
)
} else {
"
# Signs in as a new user, as a browser would, and returns them.
def sign_in -> User do
user = insert(User)
get \"/session/new\"
post \"/session\", [email_address: &user.email_address, password: \"password\"]
user
end
"
.to_owned()
};
let mut out = format!(
r#"# Tests for /{plural}, generated by `glacier generate scaffold --access {owner}`:
# each {owner} sees and changes only their own {plural}.
{sign_in}
test "lists the {owner}'s own {plural}" do
{owner} = sign_in()
insert({model}, {owner}_id: {owner}.id)
res = get "/{plural}"
assert res.status == :ok
assert res.body =~ "{first_value}"
end
test "creates a {lower} for the {owner}" do
{owner} = sign_in()
get "/{plural}/new"
# The form's {owner}_id is ignored: the {lower} is the access's.
res = post "/{plural}", params_for({model})
assert res.status == :see_other
assert get(res.location).body =~ "{human} created."
assert {model}.first()!.unwrap().{owner}_id == {owner}.id
end
"#
);
if let Some(field) = required_field(fields) {
let column = field.column();
out.push_str(&format!(
r#"
test "rejects a {lower} without {column}" do
sign_in()
get "/{plural}/new"
res = post "/{plural}", params_for({model}), {column}: ""
assert res.status == :unprocessable_entity
assert res.body =~ "can't be blank"
end
test "updates the {owner}'s own {lower}" do
{owner} = sign_in()
{singular} = insert({model}, {owner}_id: {owner}.id)
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 the {owner}'s own {lower}" do
{owner} = sign_in()
{singular} = insert({model}, {owner}_id: {owner}.id)
res = delete {singular}
assert res.status == :see_other
assert get({singular}).status == :not_found
end
# Another {owner}'s {lower} is as missing as one that doesn't exist.
test "another {owner}'s {lower} is not found" do
sign_in()
{singular} = insert({model})
assert get("/{plural}").status == :ok
refute get("/{plural}").body =~ "{first_value}"
# Asked for under this {owner}'s own URLs.
path = "/{plural}/#{{{singular}.id}}"
assert get(&path).status == :not_found
assert get("/{plural}/#{{{singular}.id}}/edit").status == :not_found
assert patch(&path, params_for({model})).status == :not_found
assert delete(&path).status == :not_found
assert {model}.count()! == 1
end
"#
));
out
}
fn owned_api_tests(names: &Names, fields: &[Field], owner: &str) -> String {
let Names {
singular,
plural,
human,
model,
..
} = names;
let lower = human.to_lowercase();
let first_value = sample_value(fields.first().expect("at least one field"));
let sign_in = if owner == "org" {
"
# Signs in as a new user with a bearer token, into a new org of theirs
# (its paths under /orgs/:org_id), and returns the org.
def sign_in -> Org do
user = insert(User)
token = Session.start(user.id, None)!
glacier::testing::bearer(&token)
org = insert(Org)
insert(Membership, org_id: org.id, user_id: user.id)
glacier::testing::path_prefix(\"/orgs/#{org.id}\")
org
end
"
} else {
"
# Signs in as a new user with a bearer token, and returns them.
def sign_in -> User do
user = insert(User)
token = Session.start(user.id, None)!
glacier::testing::bearer(&token)
user
end
"
};
let mut out = format!(
r#"# Tests for /{plural}, generated by `glacier generate scaffold --access {owner}`:
# each {owner} reads and changes only their own {plural}.
{sign_in}
test "lists the {owner}'s own {plural}" do
{owner} = sign_in()
insert({model}, {owner}_id: {owner}.id)
res = get "/{plural}"
assert res.status == :ok
assert res.body =~ "{first_value}"
end
test "creates a {lower} for the {owner}" do
{owner} = sign_in()
res = post_json "/{plural}", params_for({model})
assert res.status == :created
assert {model}.first()!.unwrap().{owner}_id == {owner}.id
end
"#
);
if let Some(field) = required_field(fields) {
let column = field.column();
out.push_str(&format!(
r#"
test "updates the {owner}'s own {lower}" do
{owner} = sign_in()
{singular} = insert({model}, {owner}_id: {owner}.id)
res = patch_json {singular}, params_for({model}), {column}: "Updated"
assert res.status == :ok
assert get({singular}).body =~ "Updated"
end
"#
));
}
out.push_str(&format!(
r#"
# Another {owner}'s {lower} is as missing as one that doesn't exist.
test "another {owner}'s {lower} is not found" do
sign_in()
{singular} = insert({model})
refute get("/{plural}").body =~ "{first_value}"
# Asked for under this {owner}'s own URLs.
path = "/{plural}/#{{{singular}.id}}"
assert get(&path).status == :not_found
assert patch_json(&path, params_for({model})).status == :not_found
assert delete(&path).status == :not_found
assert {model}.count()! == 1
end
"#
));
out
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum OrgUrls {
Session,
Prefix,
Nested,
}
fn org_urls() -> Result<Option<OrgUrls>, String> {
let routes = fs::read_to_string(ROUTES_GLACIER).unwrap_or_default();
if routes.contains("scope \"/orgs/:org_id\", through: [") && routes.contains(":in_org] do") {
return Ok(Some(OrgUrls::Nested));
}
if !routes.contains(":authenticated, :in_org] do") {
return Ok(None);
}
let prefixed = fs::read_to_string("src/app.glacier").is_ok_and(|app| app.contains("IdPrefix"));
Ok(Some(if prefixed {
OrgUrls::Prefix
} else {
OrgUrls::Session
}))
}
fn nested_view(source: &str, names: &Names) -> String {
let Names {
model,
singular,
plural,
..
} = names;
source
.replace(
&format!("edit_{singular}_path({singular}.id)"),
&format!("format!(\"{{}}/edit\", {singular}.to_path())"),
)
.replace(
&format!("new_{singular}_path()"),
&format!("format!(\"{{}}/new\", {model}::collection_path())"),
)
.replace(
&format!("{plural}_path()"),
&format!("{model}::collection_path()"),
)
}
#[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,
unique: false,
}
);
let email = parse_field("email:string:unique").unwrap();
assert!(email.unique && !email.optional);
assert!(parse_field("nick:string:optional:unique").unwrap().unique);
assert!(parse_field("done:boolean:unique").is_err());
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");
}
}