use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use std::path::Path;
use super::dsl::{Field, FieldKind, IdType, parse_fields};
use super::emit::{Action, Plan, Revert};
use super::model::{
ModelOptions, augment_fields_for_soft_delete, field_by_name, parse_model_metadata,
plan_cargo_deps, plan_model_with_options,
};
use super::naming::{humanize_label, pascal, pluralize, snake};
use super::schema_edit::{
add_mod_declaration, create_table_sql_with_metadata_and_id, ensure_autumn_web_feature,
ensure_dependency_feature, ensure_dev_dependency_test_support,
ensure_dev_dependency_tokio_test_features, unique_index_name, update_main_rs,
};
use super::{GenerateError, ensure_project_root, read_or_empty};
pub(super) const SCAFFOLD_EXTRA_DEPS: &[(&str, &str)] = &[
("maud", "{ version = \"0.27\", features = [\"axum\"] }"),
("serde_urlencoded", "\"0.7\""),
("url", "\"2\""),
];
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[allow(clippy::struct_excessive_bools)] pub struct ScaffoldOptions {
pub model: ModelOptions,
pub queries: Vec<String>,
pub api: bool,
pub live: bool,
pub live_validation: bool,
pub no_policy: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct QuerySpec {
method: String,
field_name: String,
rust_type: String,
}
pub fn has_shared_layout(main_src: &str) -> bool {
layout_param_counts(main_src).into_iter().any(|n| n == 4)
}
fn layout_param_counts(main_src: &str) -> Vec<usize> {
const NEEDLE: &[u8] = b"pub fn layout";
let bytes = main_src.as_bytes();
let mut counts = Vec::new();
let mut i = 0;
while i < bytes.len() {
if bytes[i..].starts_with(NEEDLE) {
if let Some(count) = layout_arity_at(bytes, i + NEEDLE.len()) {
counts.push(count);
}
i += NEEDLE.len();
continue;
}
match bytes[i] {
b'/' if bytes.get(i + 1) == Some(&b'/') => {
i += 2;
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
}
b'/' if bytes.get(i + 1) == Some(&b'*') => {
i += 2;
let mut depth = 1u32;
while i < bytes.len() && depth > 0 {
if bytes[i] == b'/' && bytes.get(i + 1) == Some(&b'*') {
depth += 1;
i += 2;
} else if bytes[i] == b'*' && bytes.get(i + 1) == Some(&b'/') {
depth -= 1;
i += 2;
} else {
i += 1;
}
}
}
b'r' | b'b' if is_raw_string_start(bytes, i) && !prev_is_ident_char(bytes, i) => {
i = skip_raw_string(bytes, i);
}
b'"' => {
i += 1;
while i < bytes.len() {
match bytes[i] {
b'\\' => i += 2,
b'"' => {
i += 1;
break;
}
_ => i += 1,
}
}
}
b'\'' => {
if bytes.get(i + 1) == Some(&b'\\') {
i += 2;
while i < bytes.len() && bytes[i] != b'\'' {
i += 1;
}
i += 1;
} else if bytes.get(i + 2) == Some(&b'\'') {
i += 3;
} else {
i += 1;
}
}
_ => i += 1,
}
}
counts
}
fn is_raw_string_start(bytes: &[u8], start: usize) -> bool {
let mut i = start;
if bytes.get(i) == Some(&b'b') {
i += 1;
}
if bytes.get(i) != Some(&b'r') {
return false;
}
i += 1;
while bytes.get(i) == Some(&b'#') {
i += 1;
}
bytes.get(i) == Some(&b'"')
}
fn skip_raw_string(bytes: &[u8], start: usize) -> usize {
let mut i = start;
if bytes.get(i) == Some(&b'b') {
i += 1;
}
i += 1;
let mut hashes = 0usize;
while bytes.get(i) == Some(&b'#') {
hashes += 1;
i += 1;
}
i += 1;
while i < bytes.len() {
if bytes[i] == b'"' {
let close = i + 1;
if close + hashes <= bytes.len()
&& bytes[close..close + hashes].iter().all(|&b| b == b'#')
{
return close + hashes;
}
}
i += 1;
}
bytes.len()
}
fn prev_is_ident_char(bytes: &[u8], i: usize) -> bool {
i > 0 && (bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_')
}
fn layout_arity_at(bytes: &[u8], after_name: usize) -> Option<usize> {
match bytes.get(after_name) {
Some(b'(' | b'<') => {}
Some(c) if c.is_ascii_whitespace() => {}
_ => return None,
}
let mut i = after_name;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i < bytes.len() && bytes[i] == b'<' {
let mut depth = 0i32;
while i < bytes.len() {
match bytes[i] {
b'<' => depth += 1,
b'>' => {
depth -= 1;
if depth == 0 {
i += 1;
break;
}
}
_ => {}
}
i += 1;
}
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
}
if i >= bytes.len() || bytes[i] != b'(' {
return None;
}
let mut paren = 0i32;
let mut angle = 0i32;
let mut bracket = 0i32;
let mut commas = 0usize;
let mut saw_param = false;
let mut closed = false;
while i < bytes.len() {
match bytes[i] {
b'(' => paren += 1,
b')' => {
paren -= 1;
if paren == 0 {
closed = true;
break;
}
}
b'[' => bracket += 1,
b']' => bracket -= 1,
b'<' => angle += 1,
b'>' if angle > 0 => angle -= 1,
b',' if paren == 1 && angle == 0 && bracket == 0 => commas += 1,
b' ' | b'\t' | b'\r' | b'\n' | b'>' => {}
_ => saw_param = true,
}
i += 1;
}
if !closed {
return None;
}
if !saw_param {
return Some(0);
}
Some(commas + 1)
}
#[cfg(test)]
pub fn plan_scaffold(
project_root: &Path,
name: &str,
field_tokens: &[String],
timestamp: &str,
) -> Result<Plan, GenerateError> {
plan_scaffold_with_options(
project_root,
name,
field_tokens,
timestamp,
&ScaffoldOptions::default(),
)
}
pub fn plan_scaffold_with_options(
project_root: &Path,
name: &str,
field_tokens: &[String],
timestamp: &str,
options: &ScaffoldOptions,
) -> Result<Plan, GenerateError> {
plan_scaffold_with_options_impl(project_root, name, field_tokens, timestamp, options, false)
}
pub fn plan_scaffold_with_options_for_revert(
project_root: &Path,
name: &str,
field_tokens: &[String],
timestamp: &str,
options: &ScaffoldOptions,
) -> Result<Plan, GenerateError> {
plan_scaffold_with_options_impl(project_root, name, field_tokens, timestamp, options, true)
}
#[allow(clippy::too_many_lines)]
fn plan_scaffold_with_options_impl(
project_root: &Path,
name: &str,
field_tokens: &[String],
timestamp: &str,
options: &ScaffoldOptions,
for_revert: bool,
) -> Result<Plan, GenerateError> {
ensure_project_root(project_root)?;
if options.model.id_type == IdType::Uuid {
return Err(GenerateError::Config(
"UUID primary keys are not yet supported for `generate scaffold`: the \
generated `#[repository]` REST API is currently limited to i64 primary \
keys. Use `generate model --id uuid` for the model and migration, or \
omit `--id` to use the default BIGSERIAL key."
.to_owned(),
));
}
let mut fields = parse_fields(field_tokens)?;
super::model::apply_unique_flags(&mut fields, &options.model.uniques)?;
if !options.api {
validate_enum_field_names_against_routes_imports(&fields, &pascal(name))?;
}
let resolved_shard_key = resolve_shard_key(&fields, &options.model)?;
let model_options_with_key = ModelOptions {
shard_key: resolved_shard_key,
..options.model.clone()
};
let options_with_key = ScaffoldOptions {
model: model_options_with_key,
queries: options.queries.clone(),
api: options.api,
live: options.live,
live_validation: options.live_validation,
no_policy: options.no_policy,
};
let mut plan = plan_model_with_options(
project_root,
name,
field_tokens,
timestamp,
&options_with_key.model,
)?;
let metadata = parse_model_metadata(&fields, &options_with_key.model)?;
let queries = parse_query_specs(&fields, &options_with_key.queries)?;
let form_fields = fields
.iter()
.filter(|field| !metadata.defaults().contains_key(&field.name))
.cloned()
.collect::<Vec<_>>();
let pascal_name = pascal(name);
let snake_name = snake(name);
let plural = pluralize(&snake_name);
let policy_on = !options_with_key.api && !options_with_key.no_policy;
let owner_column = {
let columns: Vec<String> = fields.iter().map(|f| f.name.clone()).collect();
let user_refs: Vec<String> = fields
.iter()
.filter(|f| f.kind.is_reference() && f.reference_table().as_deref() == Some("users"))
.map(|f| f.name.clone())
.collect();
super::policy::detect_owner_column(&columns, &user_refs).map(|name| {
let nullable = fields
.iter()
.find(|f| f.name == name)
.is_some_and(|f| f.nullable);
super::policy::OwnerColumn { name, nullable }
})
};
let authorize_routes = policy_on;
let owner_authorizes = policy_on && owner_column.is_some();
let authorize_wiring = owner_authorizes
&& !options_with_key.model.sharded
&& !options_with_key.live
&& !options_with_key.live_validation;
if !options_with_key.model.searchable.is_empty()
&& owner_authorizes
&& options_with_key.model.sharded
&& !options_with_key.live
&& !options_with_key.live_validation
{
return Err(GenerateError::Config(format!(
"`--searchable` is not yet supported for owner-scoped `--sharded` models: the \
sharded index/search run per-shard through the UNSCOPED `search_page`, so the \
generated `GET /{plural}/search` endpoint would expose other users' rows in the \
same shard that the `#[secured]` index view filters out (owner column `{owner}`). \
Drop `--sharded` (the standard Db path supports owner-scoped search), remove \
`--searchable`, drop the owner column, or pass `--no-policy` to opt out of owner \
scoping. Track: owner-scoped FTS for sharded repositories (follow-up to #1841).",
owner = owner_column.as_ref().map_or("user_id", |o| o.name.as_str()),
)));
}
let missing_reference_targets: BTreeSet<String> = form_fields
.iter()
.filter(|f| f.kind.is_reference())
.filter_map(|f| {
let table = f.reference_table()?;
if table == plural {
return None;
}
let base = f.name.strip_suffix("_id").unwrap_or(&f.name);
if super::model::model_file_exists(project_root, &table, base) {
None
} else {
Some(f.name.clone())
}
})
.collect();
if !options_with_key.api && !options_with_key.live_validation {
for f in form_fields.iter().filter(|f| f.kind.is_reference()) {
if !missing_reference_targets.contains(&f.name) {
continue;
}
let Some(table) = f.reference_table() else {
continue;
};
let base = f.name.strip_suffix("_id").unwrap_or(&f.name);
plan.warn(format!(
"'{}' will render as a plain numeric id input in the generated form: \
without the '{base}' model, table '{table}' has no `src/schema.rs` entry \
to load select options from. Generate the '{base}' model first (or re-run \
this scaffold once it exists) to get a select of {table} ids instead.",
f.name
));
}
}
for f in form_fields.iter().filter(|f| f.kind.is_reference()) {
if missing_reference_targets.contains(&f.name) {
continue;
}
let Some(label) = &f.constraints.label else {
continue;
};
let base = f.name.strip_suffix("_id").unwrap_or(&f.name);
let columns = target_string_columns(project_root, f, &plural, &form_fields);
if !columns.iter().any(|(c, _)| c == label) {
let detail = if columns.is_empty() {
format!("the '{base}' model has no string columns")
} else {
format!(
"the '{base}' model's string columns are: {}",
columns
.iter()
.map(|(c, _)| c.as_str())
.collect::<Vec<_>>()
.join(", ")
)
};
plan.warn(format!(
"reference label '{label}' on '{}' is not a usable string column ({detail}); \
falling back to the default display column (its raw id when there is none).",
f.name
));
}
}
let repos_dir = project_root.join("src").join("repositories");
plan.create(
repos_dir.join(format!("{snake_name}.rs")),
render_repository_file(
&pascal_name,
&snake_name,
&queries,
options_with_key.model.soft_delete,
options_with_key.api,
options_with_key.model.sharded,
options_with_key.live,
!options_with_key.model.searchable.is_empty(),
authorize_wiring
.then(|| owner_column.as_ref().map(|o| o.name.as_str()))
.flatten(),
),
);
let repo_mod_path = repos_dir.join("mod.rs");
plan.modify(
repo_mod_path.clone(),
add_mod_declaration(&read_or_empty(&repo_mod_path), &snake_name),
);
plan.push_revert(Revert::ModDecl {
path: repo_mod_path,
name: snake_name.clone(),
});
if !options_with_key.api {
if !options_with_key.live && !options_with_key.live_validation && !for_revert {
let main_for_layout_check = project_root.join("src").join("main.rs");
let main_src = match std::fs::read_to_string(&main_for_layout_check) {
Ok(src) => src,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(GenerateError::Io(e)),
};
if !has_shared_layout(&main_src) {
return Err(GenerateError::Config(
"`autumn generate scaffold` requires a shared `pub fn layout` in src/main.rs \
so the generated views can render through \
`crate::layout(title, current_path, flash, content)` (4 args). Run this inside \
an app created by `autumn new`, or add a shared \
`pub fn layout(title: &str, current_path: &str, flash: maud::Markup, content: maud::Markup) -> maud::Markup` \
to src/main.rs and re-run. (Use `--api` to scaffold JSON-only endpoints that need no layout.)"
.to_owned(),
));
}
}
let routes_dir = project_root.join("src").join("routes");
plan.create(
routes_dir.join(format!("{plural}.rs")),
render_routes_file(
project_root,
&pascal_name,
&snake_name,
&plural,
&form_fields,
&fields,
options_with_key.model.sharded,
options_with_key.model.soft_delete,
options_with_key.model.id_type,
options_with_key.live,
options_with_key.live_validation,
metadata.validations(),
&missing_reference_targets,
authorize_routes,
owner_column.as_ref().map(|o| o.name.as_str()),
&options_with_key.model.searchable,
),
);
let route_mod_path = routes_dir.join("mod.rs");
plan.modify(
route_mod_path.clone(),
add_mod_declaration(&read_or_empty(&route_mod_path), &plural),
);
plan.push_revert(Revert::ModDecl {
path: route_mod_path,
name: plural.clone(),
});
}
if policy_on {
super::policy::push_policy_files(
&mut plan,
project_root,
&pascal_name,
&snake_name,
&plural,
owner_column.as_ref(),
&format!("crate::models::{snake_name}::{pascal_name}"),
);
}
let smoke_test_fields =
augment_fields_for_soft_delete(&fields, options_with_key.model.soft_delete)?;
plan.create(
project_root.join("tests").join(format!("{snake_name}.rs")),
render_smoke_test(
&pascal_name,
&plural,
options_with_key.api,
&smoke_test_fields,
options_with_key.model.id_type,
metadata.indexes(),
metadata.defaults(),
metadata.validations(),
authorize_wiring,
),
);
let main_path = project_root.join("src").join("main.rs");
let main_existing = std::fs::read_to_string(&main_path).map_err(|_| {
GenerateError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("missing {}", main_path.display()),
))
})?;
let validated_field_names: Vec<String> = if options_with_key.live_validation {
metadata.validations().keys().cloned().collect()
} else {
Vec::new()
};
let search_enabled = !options_with_key.model.searchable.is_empty()
&& !options_with_key.live
&& !options_with_key.live_validation;
let sm_field_names: Vec<String> = if options_with_key.api {
Vec::new()
} else {
fields
.iter()
.filter(|f| f.state_machine.is_some())
.map(|f| f.name.clone())
.collect()
};
let route_entries = main_route_entries(
&plural,
&snake_name,
options_with_key.api,
options_with_key.live,
search_enabled && !options_with_key.api,
&validated_field_names,
&sm_field_names,
);
let mut mods = vec!["models", "schema", "repositories"];
if !options_with_key.api {
mods.push("routes");
}
if policy_on {
mods.push("policies");
}
let updated = update_main_rs(&main_existing, &mods, &route_entries);
let updated = if policy_on {
super::schema_edit::add_policy_registration_to_app(
&updated,
&format!("crate::models::{snake_name}::{pascal_name}"),
&pascal_name,
&snake_name,
)
} else {
updated
};
plan.modify(main_path.clone(), updated);
plan.push_revert(Revert::RoutesEntries {
path: main_path.clone(),
entries: route_entries,
});
if policy_on {
plan.push_revert(Revert::PolicyRegistration {
path: main_path,
pascal: pascal_name.clone(),
snake: snake_name.clone(),
});
}
plan.actions.retain(|a| !a.path().ends_with("Cargo.toml"));
let mut combined: Vec<(&str, &str)> = super::model::MODEL_DEPS
.iter()
.copied()
.chain(SCAFFOLD_EXTRA_DEPS.iter().copied())
.collect();
if !options_with_key.api || metadata.has_validator_rules() {
combined.push((
"validator",
"{ version = \"0.20\", features = [\"derive\"] }",
));
}
if fields.iter().any(|f| f.kind.is_decimal()) {
combined.push((
"rust_decimal",
"{ version = \"1\", features = [\"db-diesel2-postgres\", \"serde\"] }",
));
}
plan_cargo_deps(
&mut plan,
project_root,
&combined,
&project_root.join("src/models"),
);
if !options_with_key.api {
let cargo_path = project_root.join("Cargo.toml");
let base = plan
.actions
.iter()
.rev()
.find_map(|a| match a {
Action::Modify { path, contents } if path == &cargo_path => Some(contents.clone()),
_ => None,
})
.unwrap_or_else(|| read_or_empty(&cargo_path));
let updated = ensure_autumn_web_feature(&base, "maud");
if updated != base {
plan.actions.retain(|a| a.path() != cargo_path);
plan.modify(cargo_path.clone(), updated);
}
plan.push_revert(Revert::CargoAutumnWebFeature {
path: cargo_path,
feature: "maud".to_owned(),
owner_dir: Some(project_root.join("src").join("routes")),
});
}
if search_enabled && !options_with_key.api {
let cargo_path = project_root.join("Cargo.toml");
let base = plan
.actions
.iter()
.rev()
.find_map(|a| match a {
Action::Modify { path, contents } if path == &cargo_path => Some(contents.clone()),
_ => None,
})
.unwrap_or_else(|| read_or_empty(&cargo_path));
let updated = ensure_autumn_web_feature(&base, "htmx");
if updated != base {
plan.actions.retain(|a| a.path() != cargo_path);
plan.modify(cargo_path.clone(), updated);
}
plan.push_revert(Revert::CargoAutumnWebFeature {
path: cargo_path,
feature: "htmx".to_owned(),
owner_dir: Some(project_root.join("src").join("routes")),
});
}
if has_attachment_fields(&fields) {
let cargo_path = project_root.join("Cargo.toml");
let base = plan
.actions
.iter()
.rev()
.find_map(|a| match a {
Action::Modify { path, contents } if path == &cargo_path => Some(contents.clone()),
_ => None,
})
.unwrap_or_else(|| read_or_empty(&cargo_path));
let feats: &[&str] = &["storage", "multipart"];
let mut updated = base.clone();
for feat in feats {
updated = ensure_autumn_web_feature(&updated, feat);
}
updated = ensure_dependency_feature(&updated, "uuid", "v4");
if updated != base {
plan.actions.retain(|a| a.path() != cargo_path);
plan.modify(cargo_path.clone(), updated);
}
let models_dir = project_root.join("src").join("models");
for feat in feats {
plan.push_revert(Revert::CargoAutumnWebFeature {
path: cargo_path.clone(),
feature: (*feat).to_owned(),
owner_dir: Some(models_dir.clone()),
});
}
}
if options_with_key.live || options_with_key.live_validation {
let cargo_path = project_root.join("Cargo.toml");
let base = plan
.actions
.iter()
.rev()
.find_map(|a| match a {
Action::Modify { path, contents } if path == &cargo_path => Some(contents.clone()),
_ => None,
})
.unwrap_or_else(|| read_or_empty(&cargo_path));
let mut updated = base.clone();
let feats: &[&str] = if options.live {
&["htmx", "maud", "ws"]
} else {
&["htmx", "maud"]
};
for feat in feats {
updated = ensure_autumn_web_feature(&updated, feat);
}
if updated != base {
plan.actions.retain(|a| a.path() != cargo_path);
plan.modify(cargo_path.clone(), updated);
}
let routes_dir = project_root.join("src").join("routes");
for feat in feats {
plan.push_revert(Revert::CargoAutumnWebFeature {
path: cargo_path.clone(),
feature: (*feat).to_owned(),
owner_dir: Some(routes_dir.clone()),
});
}
}
{
let cargo_path = project_root.join("Cargo.toml");
let base = plan
.actions
.iter()
.rev()
.find_map(|a| match a {
Action::Modify { path, contents } if path == &cargo_path => Some(contents.clone()),
_ => None,
})
.unwrap_or_else(|| read_or_empty(&cargo_path));
let updated = ensure_dev_dependency_test_support(&base, env!("CARGO_PKG_VERSION"));
if updated != base {
plan.actions.retain(|a| a.path() != cargo_path);
plan.modify(cargo_path.clone(), updated);
}
plan.push_revert(Revert::CargoAutumnWebDevFeature {
path: cargo_path,
feature: "test-support".to_owned(),
owner_dir: Some(project_root.join("src").join("models")),
});
}
{
let cargo_path = project_root.join("Cargo.toml");
let base = plan
.actions
.iter()
.rev()
.find_map(|a| match a {
Action::Modify { path, contents } if path == &cargo_path => Some(contents.clone()),
_ => None,
})
.unwrap_or_else(|| read_or_empty(&cargo_path));
let updated = ensure_dev_dependency_tokio_test_features(&base);
if updated != base {
plan.actions.retain(|a| a.path() != cargo_path);
plan.modify(cargo_path, updated);
}
}
if options_with_key.live_validation {
let autumn_toml_path = project_root.join("autumn.toml");
if autumn_toml_path.exists() {
let toml_existing = read_or_empty(&autumn_toml_path);
if let Some(updated_toml) =
append_submit_token_validate_exempt_to_toml(&toml_existing, &plural)
{
plan.modify(autumn_toml_path.clone(), updated_toml);
plan.push_revert(Revert::SubmitTokenValidateExempt {
path: autumn_toml_path,
plural,
});
}
}
}
Ok(plan)
}
const ROUTES_FILE_RESERVED_NAMES: &[&str] = &[
"Path",
"Page",
"PageRequest",
"Bytes",
"CsrfFormField",
"CsrfToken",
"SubmitFormField",
"SubmitToken",
"PagerOptions",
"Flash",
"ShardedDb",
"Db",
"AutumnError",
"AutumnResult",
"Markup",
"RunQueryDsl",
"ToString",
"Changeset",
"IntoChangeset",
];
fn validate_enum_field_names_against_routes_imports(
fields: &[Field],
pascal_name: &str,
) -> Result<(), GenerateError> {
let form_ty = format!("{pascal_name}Form");
for f in fields {
let Some(enum_ty) = f.enum_type_name() else {
continue;
};
if ROUTES_FILE_RESERVED_NAMES.contains(&enum_ty.as_str()) {
return Err(GenerateError::InvalidField {
token: format!("{}:enum{{...}}", f.name),
reason: format!(
"the generated enum type '{enum_ty}' collides with a name the scaffold's \
routes file always imports; rename the field"
),
});
}
if enum_ty == form_ty {
return Err(GenerateError::InvalidField {
token: format!("{}:enum{{...}}", f.name),
reason: format!(
"the generated enum type '{enum_ty}' collides with the scaffold's own \
generated '{form_ty}' changeset form struct; rename the field"
),
});
}
}
Ok(())
}
fn parse_query_specs(
fields: &[Field],
queries: &[String],
) -> Result<Vec<QuerySpec>, GenerateError> {
let mut parsed = Vec::with_capacity(queries.len());
for query in queries {
let (method, field_name) =
query
.split_once(':')
.ok_or_else(|| GenerateError::InvalidField {
token: query.clone(),
reason: "expected `method:field`, for example `find_by_tag:tag`".into(),
})?;
let method = method.trim();
let field_name = field_name.trim();
if !method.starts_with("find_by_") || !is_valid_fn_name(method) {
return Err(GenerateError::InvalidField {
token: query.clone(),
reason: "query method must be a valid `find_by_<field>` function name".into(),
});
}
let method_field = method
.strip_prefix("find_by_")
.expect("prefix checked above");
let field =
field_by_name(fields, field_name).ok_or_else(|| GenerateError::InvalidField {
token: query.clone(),
reason: format!("unknown field '{field_name}'"),
})?;
if method_field != field_name {
return Err(GenerateError::InvalidField {
token: query.clone(),
reason: format!(
"query method suffix '{method_field}' must match field '{field_name}'"
),
});
}
if field.is_enum() {
return Err(GenerateError::InvalidField {
token: query.clone(),
reason: format!("`--query` on enum field '{field_name}' is not yet supported"),
});
}
if parsed.iter().any(|spec: &QuerySpec| spec.method == method) {
return Err(GenerateError::InvalidField {
token: query.clone(),
reason: format!("duplicate query method '{method}'"),
});
}
parsed.push(QuerySpec {
method: method.to_owned(),
field_name: field_name.to_owned(),
rust_type: field.rust_type(),
});
}
for field in fields {
if field.unique
&& !field.is_enum()
&& !parsed.iter().any(|spec| spec.field_name == field.name)
{
parsed.push(QuerySpec {
method: format!("find_by_{}", field.name),
field_name: field.name.clone(),
rust_type: field.rust_type(),
});
}
}
Ok(parsed)
}
fn is_valid_fn_name(name: &str) -> bool {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
(first.is_ascii_lowercase() || first == '_')
&& chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
}
fn resolve_shard_key(
fields: &[Field],
options: &ModelOptions,
) -> Result<Option<String>, GenerateError> {
if !options.sharded {
return Ok(None);
}
if let Some(ref key) = options.shard_key {
let valid = key == "id" || field_by_name(fields, key).is_some();
if !valid {
return Err(GenerateError::InvalidField {
token: key.clone(),
reason: format!(
"shard_key field `{key}` does not exist on this model; \
pass an existing field name or `id`"
),
});
}
return Ok(Some(key.clone()));
}
if field_by_name(fields, "tenant_id").is_some() {
return Ok(Some("tenant_id".to_owned()));
}
Ok(Some("id".to_owned()))
}
pub(super) fn render_repository_for_pull(
pascal_name: &str,
snake_name: &str,
table: &str,
) -> String {
format!(
"//! Generated by `autumn db pull`.\n\
//!\n\
//! `#[repository]` auto-generates CRUD methods and JSON REST handlers.\n\
//! Mount mutating API handlers only after adding a repository policy.\n\
\n\
use crate::models::{snake_name}::{{{pascal_name}, New{pascal_name}, Update{pascal_name}}};\n\
use crate::schema::{table};\n\
\n\
#[autumn_web::repository({pascal_name}, table = \"{table}\", api = \"/api/{table}\")]\n\
pub trait {pascal_name}Repository {{\n\
}}\n"
)
}
#[allow(clippy::fn_params_excessive_bools, clippy::too_many_arguments)]
fn render_repository_file(
pascal_name: &str,
snake_name: &str,
queries: &[QuerySpec],
soft_delete: bool,
api: bool,
sharded: bool,
live: bool,
searchable: bool,
owner_column: Option<&str>,
) -> String {
let plural = pluralize(snake_name);
let query_body = render_repository_queries(pascal_name, queries);
let soft_delete_attr = if soft_delete { ", soft_delete" } else { "" };
let broadcasts_attr = if live { ", broadcasts = true" } else { "" };
let draft_ext_import = if live {
format!(", {pascal_name}DraftExt")
} else {
String::new()
};
let searchable_attr = if searchable { ", searchable" } else { "" };
let owner_attr = owner_column.map_or(String::new(), |col| format!(", owner = {col}"));
let sharded_note = if sharded {
format!(
"//!\n\
//! This is a shard-aware repository. Handlers construct it via\n\
//! `Pg{pascal_name}Repository::from_shard(&db)` where `db` is a `ShardedDb` extractor;\n\
//! the extractor routes the request to the correct shard automatically.\n"
)
} else {
String::new()
};
let api_sharded_note = if sharded && api {
"//!\n\
//! Note: auto-generated REST handlers (mounted via `api = ...`) route through\n\
//! the control pool, not individual shards. Shard-aware REST is planned for a\n\
//! future release. Use the HTML handlers or build custom shard-aware endpoints\n\
//! with `ShardedDb` in the meantime.\n"
} else {
""
};
let doc_comment = if api {
format!(
"//! Generated by `autumn generate scaffold --api`.\n\
//!\n\
//! `#[repository]` auto-generates CRUD methods and JSON REST handlers.\n\
//! When using `--api`, all 5 JSON CRUD endpoints are mounted in `src/main.rs`.\n\
//! Note: To start the application in a production profile, you must either\n\
//! add a policy (e.g. `policy = SomePolicy`) to this repository or explicitly\n\
//! allow unguarded writes by setting `allow_unauthorized_repository_api = true`\n\
//! under `[security]` in `autumn.toml`.\n\
{api_sharded_note}\
{sharded_note}"
)
} else {
format!(
"//! Generated by `autumn generate scaffold`.\n\
//!\n\
//! `#[repository]` auto-generates CRUD methods and JSON REST handlers.\n\
//! The scaffold registers only read handlers in `src/main.rs` by\n\
//! default. A record-level `{pascal_name}Policy`/`{pascal_name}Scope` is\n\
//! generated in `src/policies/{snake_name}.rs` and registered on the app\n\
//! (unless `--no-policy`); reference it from `#[repository(..., policy =\n\
//! {pascal_name}Policy)]` to guard the JSON mutating API too.\n\
{sharded_note}"
)
};
let api_stream_handler = if api && live {
format!(
"\n/// `GET /{plural}/stream` — SSE stream for live OOB fragments.\n\
///\n\
/// Clients subscribe here to receive `hx-swap-oob` fragments whenever a\n\
/// `{snake_name}` is saved, updated, or deleted via the API.\n\
#[autumn_web::get(\"/{plural}/stream\")]\n\
pub async fn stream(\n\
\x20\x20\x20\x20state: autumn_web::extract::State<autumn_web::AppState>,\n\
) -> impl autumn_web::reexports::axum::response::IntoResponse {{\n\
\x20\x20\x20\x20autumn_web::sse::stream(&state, \"{plural}\")\n\
}}\n"
)
} else {
String::new()
};
let list_id = format!("{plural}-list");
let fragment_item_content = if api {
"(self.id)".to_string()
} else {
format!("a href=(crate::routes::{plural}::paths::show(self.id)) {{ (self.id) }}")
};
let live_fragment_impl = if live {
format!(
"\nimpl autumn_web::live::LiveFragment for {pascal_name} {{\n\
\x20\x20\x20\x20fn dom_id_for(id: i64) -> String {{\n\
\x20\x20\x20\x20\x20\x20\x20\x20format!(\"{snake_name}-{{id}}\")\n\
\x20\x20\x20\x20}}\n\
\x20\x20\x20\x20fn dom_id(&self) -> String {{\n\
\x20\x20\x20\x20\x20\x20\x20\x20Self::dom_id_for(self.id)\n\
\x20\x20\x20\x20}}\n\
\x20\x20\x20\x20fn render_fragment(&self) -> maud::Markup {{\n\
\x20\x20\x20\x20\x20\x20\x20\x20maud::html! {{\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20li id=(self.dom_id()) {{\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20{fragment_item_content}\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20}}\n\
\x20\x20\x20\x20\x20\x20\x20\x20}}\n\
\x20\x20\x20\x20}}\n\
\x20\x20\x20\x20fn insert_swap() -> autumn_web::htmx::OobSwap {{\n\
\x20\x20\x20\x20\x20\x20\x20\x20autumn_web::htmx::OobSwap::Target(\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20autumn_web::htmx::OobMethod::BeforeEnd,\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\"#{list_id}\".to_string(),\n\
\x20\x20\x20\x20\x20\x20\x20\x20)\n\
\x20\x20\x20\x20}}\n\
}}\n"
)
} else {
String::new()
};
format!(
"{doc_comment}\n\
use crate::models::{snake_name}::{{{pascal_name}, New{pascal_name}, Update{pascal_name}{draft_ext_import}}};\n\
use crate::schema::{plural};\n\
\n\
#[autumn_web::repository({pascal_name}, api = \"/api/{plural}\"{soft_delete_attr}{broadcasts_attr}{searchable_attr}{owner_attr})]\n\
pub trait {pascal_name}Repository {{\n\
{query_body}\
}}\n\
{live_fragment_impl}\
{api_stream_handler}"
)
}
fn render_repository_queries(pascal_name: &str, queries: &[QuerySpec]) -> String {
let mut out = String::with_capacity(queries.len() * 64);
for query in queries {
use std::fmt::Write as _;
let _ = writeln!(
out,
" fn {method}({field}: {rust_type}) -> Vec<{pascal_name}>;",
method = query.method,
field = query.field_name,
rust_type = query.rust_type,
);
}
out
}
const PARSE_LOCAL_DATETIME_FN: &str = r#"
/// Parse a browser `datetime-local` value (`YYYY-MM-DDTHH:MM`, seconds and
/// fractional seconds optional) into a `NaiveDateTime`. A malformed value is
/// a 400 (undecodable input) rather than a field validation error.
///
/// Accepts a trailing `%.f` so a value pre-filled from a stored row (see the
/// generated `From<&Row>` impl, which formats with fractional seconds) round-
/// trips exactly on a no-op resubmit instead of being silently truncated to
/// whole seconds — the generated `update` handler writes every column back
/// unconditionally, so any precision lost here would corrupt the stored value.
fn parse_local_datetime(value: &str) -> AutumnResult<chrono::NaiveDateTime> {
chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S%.f")
.or_else(|_| chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M"))
.map_err(|err| AutumnError::bad_request_msg(format!("invalid datetime: {err}")))
}
"#;
struct ModelFormParts {
form_struct: String,
into_new_fn: String,
from_row_impl: String,
datetime_helper: String,
}
#[allow(
clippy::too_many_lines,
reason = "One match arm per field-kind widget — splitting it produces less \
readable output, not more. See render_changeset_form_inputs's \
module-level comment for the shared invariants."
)]
fn render_model_form(
pascal_name: &str,
fields: &[Field],
validations: &BTreeMap<String, Vec<String>>,
) -> ModelFormParts {
use std::fmt::Write;
let mut struct_fields = String::new();
let mut into_new = String::new();
let mut from_row = String::new();
let mut needs_datetime = false;
for f in fields {
let name = &f.name;
let is_constrained_required_numeric = is_constrained_required_numeric(f);
if is_constrained_required_numeric {
let _ = writeln!(struct_fields, " #[validate(required)]");
}
if let Some(rules) = validations.get(name) {
for rule in rules {
let _ = writeln!(struct_fields, " #[validate({rule})]");
}
}
if f.kind.is_attachment() {
let _ = writeln!(into_new, " {name}: None,");
} else if f.kind == FieldKind::Bool {
if f.nullable {
let _ = writeln!(struct_fields, " pub {name}: Option<bool>,");
} else {
let _ = writeln!(
struct_fields,
" #[serde(default)]\n pub {name}: bool,"
);
}
let _ = writeln!(into_new, " {name}: form.{name},");
let _ = writeln!(from_row, " {name}: row.{name},");
} else if f.kind == FieldKind::Bytea {
if f.nullable {
let _ = writeln!(struct_fields, " pub {name}: Option<String>,");
let _ = writeln!(
into_new,
" {name}: form.{name}.as_ref().map(|value| value.clone().into_bytes()),"
);
let _ = writeln!(
from_row,
" {name}: row.{name}.as_ref().map(|value| String::from_utf8_lossy(value).into_owned()),"
);
} else {
let _ = writeln!(struct_fields, " pub {name}: String,");
let _ = writeln!(
into_new,
" {name}: form.{name}.clone().into_bytes(),"
);
let _ = writeln!(
from_row,
" {name}: String::from_utf8_lossy(&row.{name}).into_owned(),"
);
}
} else if matches!(f.kind, FieldKind::NaiveDateTime | FieldKind::DateTime) {
needs_datetime = true;
let to_utc = if f.kind == FieldKind::DateTime {
".and_utc()"
} else {
""
};
if f.nullable {
let _ = writeln!(struct_fields, " pub {name}: Option<String>,");
let _ = writeln!(
into_new,
" {name}: form.{name}.as_deref().filter(|value| !value.is_empty())\n\
\x20\x20\x20\x20\x20\x20\x20\x20.map(|value| parse_local_datetime(value).map(|dt| dt{to_utc}))\n\
\x20\x20\x20\x20\x20\x20\x20\x20.transpose()?,"
);
let _ = writeln!(
from_row,
" {name}: row.{name}.as_ref().map(|dt| dt.format(\"%Y-%m-%dT%H:%M:%S%.f\").to_string()),"
);
} else {
let _ = writeln!(struct_fields, " pub {name}: String,");
let _ = writeln!(
into_new,
" {name}: parse_local_datetime(&form.{name})?{to_utc},"
);
let _ = writeln!(
from_row,
" {name}: row.{name}.format(\"%Y-%m-%dT%H:%M:%S%.f\").to_string(),"
);
}
} else if let Some(enum_ty) = f.enum_type_name() {
let mut arms = String::new();
for v in &f.variants {
let _ = write!(arms, "{enum_ty}::{} => \"{v}\".to_string(), ", pascal(v));
}
if f.nullable {
let _ = writeln!(struct_fields, " pub {name}: Option<String>,");
let _ = writeln!(
into_new,
" {name}: form.{name}.as_deref()\n\
\x20\x20\x20\x20\x20\x20\x20\x20.map(|value| value.parse::<{enum_ty}>())\n\
\x20\x20\x20\x20\x20\x20\x20\x20.transpose()\n\
\x20\x20\x20\x20\x20\x20\x20\x20.map_err(|err| autumn_web::AutumnError::bad_request_msg(format!(\"{name}: {{err}}\")))?,"
);
let _ = writeln!(
from_row,
" {name}: row.{name}.as_ref().map(|value| match value {{ {arms}}}),"
);
} else {
let _ = writeln!(struct_fields, " pub {name}: String,");
let _ = writeln!(
into_new,
" {name}: form.{name}.parse::<{enum_ty}>()\n\
\x20\x20\x20\x20\x20\x20\x20\x20.map_err(|err| autumn_web::AutumnError::bad_request_msg(format!(\"{name}: {{err}}\")))?,"
);
let _ = writeln!(
from_row,
" {name}: match &row.{name} {{ {arms}}},"
);
}
} else if is_constrained_required_numeric {
let rust_type = f.rust_type();
let _ = writeln!(struct_fields, " pub {name}: Option<{rust_type}>,");
let _ = writeln!(
into_new,
" {name}: form.{name}.ok_or_else(|| autumn_web::AutumnError::bad_request_msg(\"{name}: is required\"))?,"
);
let _ = writeln!(from_row, " {name}: Some(row.{name}),");
} else if !f.nullable
&& matches!(
f.kind,
FieldKind::I32
| FieldKind::I64
| FieldKind::F32
| FieldKind::F64
| FieldKind::Uuid
| FieldKind::Decimal { .. }
| FieldKind::References
)
{
let rust_type = f.rust_type();
let _ = writeln!(struct_fields, " pub {name}: String,");
let _ = writeln!(
into_new,
" {name}: form.{name}.parse::<{rust_type}>()\n\
\x20\x20\x20\x20\x20\x20\x20\x20.map_err(|err| autumn_web::AutumnError::bad_request_msg(format!(\"{name}: {{err}}\")))?,"
);
let _ = writeln!(from_row, " {name}: row.{name}.to_string(),");
} else {
if f.state_machine.is_some() {
let _ = writeln!(struct_fields, " #[serde(default)]");
}
let _ = writeln!(
struct_fields,
" pub {name}: {rust_type},",
rust_type = f.rust_type()
);
let _ = writeln!(into_new, " {name}: form.{name}.clone(),");
let _ = writeln!(from_row, " {name}: row.{name}.clone(),");
}
}
let form_struct = format!(
"#[derive(serde::Deserialize, serde::Serialize, Default, validator::Validate)]\n\
pub struct {pascal_name}Form {{\n\
{struct_fields}}}"
);
let into_new_fn = format!(
"/// Convert the validated form into `New{pascal_name}` on the success\n\
/// path. Enum/datetime parsing lives here (a bad value is a 400,\n\
/// not a validation error — see `{pascal_name}Form`). Attachment\n\
/// columns are seeded to `None` and set from the streamed upload by\n\
/// the create/update handler.\n\
fn into_new(form: &{pascal_name}Form) -> AutumnResult<New{pascal_name}> {{\n \
Ok(New{pascal_name} {{\n\
{into_new} }})\n\
}}"
);
let from_row_impl = format!(
"/// Seed a form changeset from a persisted row (edit-form GET).\n\
impl From<&{pascal_name}> for {pascal_name}Form {{\n \
fn from(row: &{pascal_name}) -> Self {{\n \
Self {{\n\
{from_row} }}\n \
}}\n\
}}"
);
let datetime_helper = if needs_datetime {
PARSE_LOCAL_DATETIME_FN.to_owned()
} else {
String::new()
};
ModelFormParts {
form_struct,
into_new_fn,
from_row_impl,
datetime_helper,
}
}
#[allow(
clippy::too_many_lines,
reason = "This is a single template — splitting it produces less readable output, \
not more. The whole point is one place that prints one file."
)]
#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
fn render_routes_file(
project_root: &Path,
pascal_name: &str,
snake_name: &str,
plural: &str,
fields: &[Field],
all_fields: &[Field],
sharded: bool,
soft_delete: bool,
id_type: IdType,
live: bool,
live_validation: bool,
validations: &BTreeMap<String, Vec<String>>,
missing_reference_targets: &BTreeSet<String>,
authorize: bool,
owner: Option<&str>,
searchable: &[String],
) -> String {
let id_rust = id_type.rust_type();
let search_enabled = !searchable.is_empty() && !live && !live_validation;
let validated_fields: Vec<&str> = validations.keys().map(String::as_str).collect();
let unique_fields: Vec<&Field> = fields.iter().filter(|f| f.unique).collect();
let sm_fields: Vec<&Field> = fields
.iter()
.filter(|f| f.state_machine.is_some())
.collect();
let has_state_machine = !sm_fields.is_empty();
let update_columns = render_update_columns(plural, fields);
let nullable_field_match = render_nullable_field_match(fields);
let has_attachments = has_attachment_fields(fields);
let owner_col = owner.unwrap_or("user_id");
let owner_scoped_index = authorize && owner.is_some();
let owner_scoped_standard = owner_scoped_index && !sharded && !live && !live_validation;
let authz_params_full = if authorize {
"\n autumn_web::extract::State(state): autumn_web::extract::State<autumn_web::AppState>,\
\n session: autumn_web::session::Session"
} else {
""
};
let authz_params_session_only = if authorize {
"\n session: autumn_web::session::Session"
} else {
""
};
let create_update_authz_params = if has_attachments {
authz_params_session_only
} else {
authz_params_full
};
let edit_destroy_authz_params = authz_params_full;
let create_update_state_expr = if has_attachments { "&*state" } else { "&state" };
let edit_destroy_state_expr = "&state";
let authz_attachment_update = if authorize && has_attachments {
format!(
"autumn_web::authorization::authorize::<{pascal_name}>({create_update_state_expr}, &session, \"update\", ¤t).await?;\n "
)
} else {
String::new()
};
let display_reference_fields: Vec<&Field> = fields
.iter()
.filter(|f| f.kind.is_reference() && !missing_reference_targets.contains(&f.name))
.collect();
let reference_fields: Vec<&Field> = display_reference_fields.clone();
let has_reference_selects = !reference_fields.is_empty();
let shared_layout = !live && !live_validation;
let layout_fn = if shared_layout {
"crate::layout"
} else {
"layout"
};
let cp = |path: &str| -> String {
if shared_layout {
format!("\"{path}\", ")
} else {
String::new()
}
};
let cp_index = cp(&format!("/{plural}"));
let cp_show = cp(&format!("/{plural}"));
let cp_new = cp(&format!("/{plural}/new"));
let cp_edit = cp(&format!("/{plural}"));
let flash_arg = "flash_messages(&flash.consume().await)";
let reference_displays: BTreeMap<String, ReferenceDisplay> = display_reference_fields
.iter()
.filter_map(|f| {
resolve_reference_display(project_root, f, plural, all_fields)
.map(|d| (f.name.clone(), d))
})
.collect();
let model_form = render_model_form(pascal_name, fields, validations);
let enum_import_suffix: String =
fields
.iter()
.filter_map(Field::enum_type_name)
.fold(String::new(), |mut out, ty| {
let _ = write!(out, ", {ty}");
out
});
let destroy_stmt = if live {
if sharded {
format!(
"let repo = Pg{pascal_name}Repository::from_shard(&db);\n \
repo.delete_by_id(*id).await?;\n \
let deleted = 1;"
)
} else {
"repo.delete_by_id(*id).await?;\n let deleted = 1;".to_owned()
}
} else if soft_delete {
format!(
"let deleted = diesel::update(\n {plural}::table.find(*id).filter({plural}::deleted_at.is_null()),\n )\n \
.set({plural}::deleted_at.eq(Some(chrono::Utc::now().naive_utc())))\n \
.execute(&mut *db)\n .await?;"
)
} else {
format!(
"let deleted = diesel::delete({plural}::table.find(*id))\n \
.execute(&mut *db)\n .await?;"
)
};
let create_stmt = if live {
if sharded {
format!(
"let repo = Pg{pascal_name}Repository::from_shard(&db);\n \
repo.save(&new).await?;"
)
} else {
"repo.save(&new).await?;".to_owned()
}
} else {
format!(
"diesel::insert_into({plural}::table)\n \
.values(&new)\n \
.execute(&mut *db)\n .await?;"
)
};
let update_changeset_expr = render_update_changeset_expr(pascal_name, fields);
let update_stmt = if live {
if sharded {
format!(
"let repo = Pg{pascal_name}Repository::from_shard(&db);\n \
let update_changes = {update_changeset_expr};\n \
repo.update(*id, &update_changes).await?;\n \
let updated = 1;"
)
} else {
format!(
"let update_changes = {update_changeset_expr};\n \
repo.update(*id, &update_changes).await?;\n \
let updated = 1;"
)
}
} else {
format!(
"let updated = diesel::update({plural}::table.find(*id))\n \
.set(({update_columns}))\n \
.execute(&mut *db)\n .await?;"
)
};
let form_enctype = if has_attachments {
" enctype=\"multipart/form-data\""
} else {
""
};
let db_ty = if sharded { "ShardedDb" } else { "Db" };
let create_signature = if live && !sharded {
if has_attachments {
format!(
"flash: Flash, state: autumn_web::extract::State<autumn_web::AppState>, repo: Pg{pascal_name}Repository, body: Bytes"
)
} else {
format!("flash: Flash, repo: Pg{pascal_name}Repository, body: Bytes")
}
} else {
if has_attachments {
format!(
"flash: Flash, state: autumn_web::extract::State<autumn_web::AppState>, mut db: {db_ty}, body: Bytes"
)
} else {
format!("flash: Flash, mut db: {db_ty}, body: Bytes")
}
};
let update_signature = if live && !sharded {
if has_attachments {
format!(
"flash: Flash,\n state: autumn_web::extract::State<autumn_web::AppState>,\n id: Path<{id_rust}>,\n repo: Pg{pascal_name}Repository,\n body: Bytes,"
)
} else {
format!(
"flash: Flash,\n id: Path<{id_rust}>,\n repo: Pg{pascal_name}Repository,\n body: Bytes,"
)
}
} else {
if has_attachments {
format!(
"flash: Flash,\n state: autumn_web::extract::State<autumn_web::AppState>,\n id: Path<{id_rust}>,\n mut db: {db_ty},\n body: Bytes,"
)
} else {
format!(
"flash: Flash,\n id: Path<{id_rust}>,\n mut db: {db_ty},\n body: Bytes,"
)
}
};
let destroy_signature_arg = if live && !sharded {
format!("repo: Pg{pascal_name}Repository")
} else {
format!("mut db: {db_ty}")
};
let create_signature = create_signature.replacen(
"flash: Flash",
"flash: Flash, csrf: Option<CsrfToken>, csrf_field: Option<CsrfFormField>, \
submit_token: Option<SubmitToken>, submit_field: Option<SubmitFormField>",
1,
);
let update_signature = update_signature.replacen(
"flash: Flash,",
"flash: Flash,\n csrf: Option<CsrfToken>,\n csrf_field: Option<CsrfFormField>,\n \
submit_token: Option<SubmitToken>,\n submit_field: Option<SubmitFormField>,",
1,
);
let (create_signature, update_signature) = if has_reference_selects && live && !sharded {
(
create_signature.replacen("body: Bytes", &format!("mut db: {db_ty}, body: Bytes"), 1),
update_signature.replacen(
"body: Bytes",
&format!("mut db: {db_ty},\n body: Bytes"),
1,
),
)
} else {
(create_signature, update_signature)
};
let new_form_db_param = if has_reference_selects {
format!("mut db: {db_ty},\n ")
} else {
String::new()
};
let (create_signature, update_signature, destroy_signature_arg) = if authorize {
(
create_signature.replacen(
"body: Bytes",
&format!("{create_update_authz_params},\n body: Bytes"),
1,
),
update_signature.replacen(
"\n body: Bytes",
&format!("{create_update_authz_params},\n body: Bytes"),
1,
),
format!("{destroy_signature_arg},{edit_destroy_authz_params}"),
)
} else {
(create_signature, update_signature, destroy_signature_arg)
};
let (create_signature, update_signature) = if has_attachments {
(
create_signature.replace(
"body: Bytes",
"mut multipart: autumn_web::extract::Multipart",
),
update_signature.replace(
"body: Bytes",
"mut multipart: autumn_web::extract::Multipart",
),
)
} else {
(create_signature, update_signature)
};
let decode_call = "decode_form(body)?".to_owned();
let decode_form_sig = format!("fn decode_form(body: Bytes) -> AutumnResult<{pascal_name}Form>");
let into_new_call = "into_new(changeset.data())?".to_owned();
let attachment_fields: Vec<&Field> = fields.iter().filter(|f| f.kind.is_attachment()).collect();
let blob_cleanup = if has_attachments {
"for key in &saved_blob_keys {\n let _ = store.delete(key).await;\n }\n "
} else {
""
};
let form_decode_block = if has_attachments {
let mut blob_decls = String::new();
let mut match_arms = String::new();
for f in &attachment_fields {
let name = &f.name;
let _ = writeln!(
blob_decls,
" let mut {name}_blob: Option<autumn_web::storage::Blob> = None;"
);
let _ = write!(
match_arms,
" Some(\"{name}\") => {{\n \
if field.file_name().is_some_and(|file_name| !file_name.is_empty()) {{\n \
let key = format!(\n \
\"{plural}/{name}/{{}}_{{}}\",\n \
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default(),\n \
uuid::Uuid::new_v4()\n \
);\n \
let blob = field.save_to_blob_store(&*store, key).await?;\n \
saved_blob_keys.push(blob.key.clone());\n \
{name}_blob = Some(blob);\n \
}}\n \
}}\n"
);
}
format!(
"let store = state\n \
.extension::<autumn_web::storage::BlobStoreState>()\n \
.ok_or_else(|| AutumnError::internal_server_error_msg(\"storage not configured\"))?\n \
.store()\n \
.clone();\n \
let mut saved_blob_keys: Vec<String> = Vec::new();\n\
{blob_decls} \
let __parse_result: AutumnResult<{pascal_name}Form> = async {{\n \
let mut form_pairs: Vec<(String, String)> = Vec::new();\n \
while let Some(field) = multipart.next_field().await? {{\n \
let field_name = field.name().map(str::to_owned);\n \
match field_name.as_deref() {{\n\
{match_arms} \
Some(other) => {{\n \
let value = String::from_utf8(field.bytes_limited().await?)\n \
.map_err(|_| AutumnError::bad_request_msg(\"form field was not valid UTF-8\"))?;\n \
form_pairs.push((other.to_owned(), value));\n \
}}\n \
None => {{}}\n \
}}\n \
}}\n \
let encoded = url::form_urlencoded::Serializer::new(String::new())\n \
.extend_pairs(form_pairs.iter().map(|(key, value)| (key.as_str(), value.as_str())))\n \
.finish();\n \
let form = decode_form(Bytes::from(encoded))?;\n \
Ok(form)\n \
}}\n \
.await;\n \
let form = match __parse_result {{\n \
Ok(form) => form,\n \
Err(err) => {{\n \
{blob_cleanup}return Err(err);\n \
}}\n \
}};"
)
} else {
format!("let form = {decode_call};")
};
let into_new_bind = format!(
"let mut new = match into_new(changeset.data()) {{\n \
Ok(new) => new,\n \
Err(err) => {{\n \
{blob_cleanup}return Err(err);\n \
}}\n \
}};"
);
let create_new_block = if has_attachments {
let mut binds = String::new();
for f in &attachment_fields {
let name = &f.name;
let _ = write!(binds, "\n new.{name} = {name}_blob;");
}
format!("{into_new_bind}{binds}")
} else {
format!("let new = {into_new_call};")
};
let update_new_block = if has_attachments {
let load_current = if live && !sharded {
format!(
"let current = match repo.find_by_id(*id).await {{\n \
Ok(Some(current)) => current,\n \
Ok(None) => {{\n \
{blob_cleanup}return Err(AutumnError::not_found_msg(format!(\"{pascal_name} with id {{}} not found\", *id)));\n \
}}\n \
Err(err) => {{\n \
{blob_cleanup}return Err(err);\n \
}}\n \
}};\n "
)
} else if sharded {
format!(
"let current = match Pg{pascal_name}Repository::from_shard(&db).find_by_id(*id).await {{\n \
Ok(Some(current)) => current,\n \
Ok(None) => {{\n \
{blob_cleanup}return Err(AutumnError::not_found_msg(format!(\"{pascal_name} with id {{}} not found\", *id)));\n \
}}\n \
Err(err) => {{\n \
{blob_cleanup}return Err(err);\n \
}}\n \
}};\n "
)
} else {
format!(
"let current: {pascal_name} = match {plural}::table\n \
.find(*id)\n \
.select({pascal_name}::as_select())\n \
.first(&mut *db)\n \
.await\n \
{{\n \
Ok(current) => current,\n \
Err(err) => {{\n \
{blob_cleanup}return Err(AutumnError::not_found(err));\n \
}}\n \
}};\n "
)
};
let mut binds = String::new();
for f in &attachment_fields {
let name = &f.name;
let _ = write!(binds, "\n new.{name} = {name}_blob.or(current.{name});");
}
format!("{load_current}{authz_attachment_update}{into_new_bind}{binds}")
} else {
format!("let new = {into_new_call};")
};
let (new_form_body, edit_form_body, form_for_helper) = if live_validation {
let changeset_inputs = render_changeset_form_inputs(
fields,
"changeset",
live_validation,
&validated_fields,
&reference_fields,
);
let mut option_loads = String::with_capacity(reference_fields.len() * 80);
for f in &reference_fields {
let name = &f.name;
let _ = writeln!(
option_loads,
" let {name}_options = {name}_select_options(&mut db).await?;"
);
}
let new_form_layout = format!(
"{layout_fn}(\"New {pascal_name}\", {cp_new}{flash_arg}, html! {{\n \
h1 {{ \"New {pascal_name}\" }}\n \
form action=(paths::create()) method=\"post\"{form_enctype} {{\n \
(csrf_input(csrf.as_ref(), csrf_field.as_ref()))\n \
(submit_token_input(submit_token.as_ref(), submit_field.as_ref()))\n{changeset_inputs} \
(autumn_web::a11y::Button::new(\"Create\").submit())\n \
}}\n \
}})"
);
let edit_form_layout = format!(
"{layout_fn}(&format!(\"Edit {pascal_name} #{{}}\", *id), {cp_edit}{flash_arg}, html! {{\n \
h1 {{ \"Edit {pascal_name} #\" (*id) }}\n \
form action=(paths::update(*id)) method=\"post\"{form_enctype} {{\n \
(csrf_input(csrf.as_ref(), csrf_field.as_ref()))\n \
(submit_token_input(submit_token.as_ref(), submit_field.as_ref()))\n{changeset_inputs} \
(autumn_web::a11y::Button::new(\"Save\").submit())\n \
}}\n \
form action=(paths::delete(*id)) method=\"post\" {{\n \
(csrf_input(csrf.as_ref(), csrf_field.as_ref()))\n \
button type=\"submit\" onclick=\"return confirm('Delete this {pascal_name}?')\" {{ \"Delete\" }}\n \
}}\n \
}})"
);
let (new_form_body, edit_form_body) = if has_reference_selects {
(
format!("{{\n{option_loads} {new_form_layout}\n }}"),
format!("{{\n{option_loads} {edit_form_layout}\n }}"),
)
} else {
(new_form_layout, edit_form_layout)
};
let option_loaders =
render_reference_option_loaders(&display_reference_fields, db_ty, &reference_displays);
(new_form_body, edit_form_body, option_loaders)
} else {
let mut option_loads = String::new();
let mut option_args = String::new();
for f in &reference_fields {
let name = &f.name;
let _ = writeln!(
option_loads,
" let {name}_options = {name}_select_options(&mut db).await?;"
);
let _ = write!(option_args, ", &{name}_options");
}
let form_exclude_create = if has_state_machine { ", false" } else { "" };
let form_exclude_edit = if has_state_machine { ", true" } else { "" };
let new_form_layout = format!(
"{layout_fn}(\"New {pascal_name}\", {cp_new}{flash_arg}, html! {{\n \
h1 {{ \"New {pascal_name}\" }}\n \
({snake_name}_form_for(&changeset, paths::create(), \"Create\", csrf.as_ref(), csrf_field.as_ref(), submit_token.as_ref(), submit_field.as_ref(){option_args}{form_exclude_create}))\n \
}})"
);
let edit_form_layout = format!(
"{layout_fn}(&format!(\"Edit {pascal_name} #{{}}\", *id), {cp_edit}{flash_arg}, html! {{\n \
h1 {{ \"Edit {pascal_name} #\" (*id) }}\n \
({snake_name}_form_for(&changeset, paths::update(*id), \"Save\", csrf.as_ref(), csrf_field.as_ref(), submit_token.as_ref(), submit_field.as_ref(){option_args}{form_exclude_edit}))\n \
form action=(paths::delete(*id)) method=\"post\" {{\n \
(csrf_input(csrf.as_ref(), csrf_field.as_ref()))\n \
button type=\"submit\" onclick=\"return confirm('Delete this {pascal_name}?')\" {{ \"Delete\" }}\n \
}}\n \
}})"
);
let (new_form_body, edit_form_body) = if has_reference_selects {
(
format!("{{\n{option_loads} {new_form_layout}\n }}"),
format!("{{\n{option_loads} {edit_form_layout}\n }}"),
)
} else {
(new_form_layout, edit_form_layout)
};
(
new_form_body,
edit_form_body,
render_form_for_helper(pascal_name, snake_name, fields, missing_reference_targets)
+ &render_reference_option_loaders(&reference_fields, db_ty, &reference_displays),
)
};
let form_model_impl = render_form_model_impl(pascal_name);
let unique_constraints_const = if unique_fields.is_empty() {
String::new()
} else {
render_unique_constraints_const(plural, &unique_fields, all_fields)
};
let create_insert_block = if !unique_fields.is_empty() {
format!(
"let result: AutumnResult<()> = async {{\n \
{create_stmt}\n \
Ok(())\n \
}}.await;\n \
if let Err(err) = result {{\n \
if let Some((field, message)) = autumn_web::error::unique_violation_field(&err, UNIQUE_CONSTRAINTS) {{\n \
let mut errors = std::collections::HashMap::new();\n \
errors.insert(field.to_string(), vec![message.to_string()]);\n \
let changeset = Changeset::from_errors(changeset.into_inner(), errors);\n \
{blob_cleanup}return Ok((autumn_web::reexports::http::StatusCode::UNPROCESSABLE_ENTITY, {new_form_body}).into_response());\n \
}}\n \
{blob_cleanup}return Err(err);\n \
}}\n \
flash.success(\"{pascal_name} created\").await;\n \
Ok(autumn_web::Redirect::to(&paths::index()).into_response())"
)
} else if has_attachments {
format!(
"let result: AutumnResult<()> = async {{\n \
{create_stmt}\n \
Ok(())\n \
}}.await;\n \
if let Err(err) = result {{\n \
{blob_cleanup}return Err(err);\n \
}}\n \
flash.success(\"{pascal_name} created\").await;\n \
Ok(autumn_web::Redirect::to(&paths::index()).into_response())"
)
} else {
format!(
"{create_stmt}\n \
flash.success(\"{pascal_name} created\").await;\n \
Ok(autumn_web::Redirect::to(&paths::index()).into_response())"
)
};
let authz_create_call = if authorize {
format!(
"autumn_web::authorization::authorize_create::<{pascal_name}>({create_update_state_expr}, &session).await?;\n "
)
} else {
String::new()
};
let create_fn = format!(
"/// `POST /{plural}` — validate the submission and create a {snake_name},\n\
/// or re-render the form at 422 with inline errors and preserved input.\n\
#[secured]\n\
#[post(\"/{plural}\")]\n\
pub async fn create({create_signature}) -> AutumnResult<autumn_web::reexports::axum::response::Response> {{\n \
use autumn_web::reexports::axum::response::IntoResponse as _;\n \
{authz_create_call}\
{form_decode_block}\n \
let changeset = form.into_changeset();\n \
if !changeset.is_valid() {{\n \
{blob_cleanup}return Ok((autumn_web::reexports::http::StatusCode::UNPROCESSABLE_ENTITY, {new_form_body}).into_response());\n \
}}\n \
{create_new_block}\n \
{create_insert_block}\n\
}}\n"
);
let update_apply_block = if !unique_fields.is_empty() {
format!(
"let result: AutumnResult<usize> = async {{\n \
{update_stmt}\n \
Ok(updated)\n \
}}.await;\n \
let updated = match result {{\n \
Ok(updated) => updated,\n \
Err(err) => {{\n \
if let Some((field, message)) = autumn_web::error::unique_violation_field(&err, UNIQUE_CONSTRAINTS) {{\n \
let mut errors = std::collections::HashMap::new();\n \
errors.insert(field.to_string(), vec![message.to_string()]);\n \
let changeset = Changeset::from_errors(changeset.into_inner(), errors);\n \
{blob_cleanup}return Ok((autumn_web::reexports::http::StatusCode::UNPROCESSABLE_ENTITY, {edit_form_body}).into_response());\n \
}}\n \
{blob_cleanup}return Err(err);\n \
}}\n \
}};\n \
if updated == 0 {{\n \
{blob_cleanup}return Err(AutumnError::not_found_msg(format!(\n \
\"{pascal_name} with id {{}} not found\", *id\n \
)));\n \
}}\n \
flash.success(\"{pascal_name} updated\").await;\n \
Ok(autumn_web::Redirect::to(&paths::show(*id)).into_response())"
)
} else if has_attachments {
format!(
"let result: AutumnResult<usize> = async {{\n \
{update_stmt}\n \
Ok(updated)\n \
}}.await;\n \
let updated = match result {{\n \
Ok(updated) => updated,\n \
Err(err) => {{\n \
{blob_cleanup}return Err(err);\n \
}}\n \
}};\n \
if updated == 0 {{\n \
{blob_cleanup}return Err(AutumnError::not_found_msg(format!(\n \
\"{pascal_name} with id {{}} not found\", *id\n \
)));\n \
}}\n \
flash.success(\"{pascal_name} updated\").await;\n \
Ok(autumn_web::Redirect::to(&paths::show(*id)).into_response())"
)
} else {
format!(
"{update_stmt}\n \
if updated == 0 {{\n \
return Err(AutumnError::not_found_msg(format!(\n \
\"{pascal_name} with id {{}} not found\", *id\n \
)));\n \
}}\n \
flash.success(\"{pascal_name} updated\").await;\n \
Ok(autumn_web::Redirect::to(&paths::show(*id)).into_response())"
)
};
let load_current_stmt = |binding: &str| -> String {
if live && !sharded {
format!(
"let {binding}: {pascal_name} = repo.find_by_id(*id).await?\n \
.ok_or_else(|| AutumnError::not_found_msg(format!(\"{pascal_name} with id {{}} not found\", *id)))?;\n "
)
} else if sharded {
format!(
"let {binding}: {pascal_name} = Pg{pascal_name}Repository::from_shard(&db).find_by_id(*id).await?\n \
.ok_or_else(|| AutumnError::not_found_msg(format!(\"{pascal_name} with id {{}} not found\", *id)))?;\n "
)
} else {
format!(
"let {binding}: {pascal_name} = {plural}::table\n \
.find(*id)\n \
.select({pascal_name}::as_select())\n \
.first(&mut *db)\n \
.await\n \
.map_err(AutumnError::not_found)?;\n "
)
}
};
let authz_update_preamble = if authorize && !has_attachments {
format!(
"{load}autumn_web::authorization::authorize::<{pascal_name}>({state}, &session, \"update\", ¤t).await?;\n ",
load = load_current_stmt("current"),
state = create_update_state_expr,
)
} else {
String::new()
};
let authz_destroy_preamble = if authorize {
format!(
"{load}autumn_web::authorization::authorize::<{pascal_name}>({state}, &session, \"delete\", ¤t).await?;\n ",
load = load_current_stmt("current"),
state = edit_destroy_state_expr,
)
} else {
String::new()
};
let authz_edit_call = if authorize {
format!(
"autumn_web::authorization::authorize::<{pascal_name}>({edit_destroy_state_expr}, &session, \"edit\", &row).await?;\n "
)
} else {
String::new()
};
let update_fn = format!(
"/// `POST /{plural}/{{id}}/update` — validate and apply form data to a row,\n\
/// or re-render the edit form at 422 with inline errors and preserved input.\n\
/// Uses column-by-column `diesel::update().set(...)` (same convention as\n\
/// `examples/todo-app`) so we don't need `AsChangeset` on `New{pascal_name}`.\n\
#[secured]\n\
#[post(\"/{plural}/{{id}}/update\")]\n\
pub async fn update(\n \
{update_signature}\n\
) -> AutumnResult<autumn_web::reexports::axum::response::Response> {{\n \
use autumn_web::reexports::axum::response::IntoResponse as _;\n \
{authz_update_preamble}\
{form_decode_block}\n \
let changeset = form.into_changeset();\n \
if !changeset.is_valid() {{\n \
{blob_cleanup}return Ok((autumn_web::reexports::http::StatusCode::UNPROCESSABLE_ENTITY, {edit_form_body}).into_response());\n \
}}\n \
{update_new_block}\n \
{update_apply_block}\n\
}}\n"
);
let form_struct = &model_form.form_struct;
let into_new_fn = &model_form.into_new_fn;
let from_row_impl = &model_form.from_row_impl;
let parse_datetime_helper = &model_form.datetime_helper;
let li_render = if live {
format!(
r#"li id=(format!("{snake_name}-{{}}", row.id)) {{ a href=(paths::show(row.id)) {{ "{pascal_name} #{{}}" (row.id) }} }}"#
)
} else {
String::new() };
let live_ul_render = if live {
format!(
r#"@if page_req.page() == 1 {{
ul id="{plural}-list" hx-ext="sse" sse-connect=(paths::events()) sse-swap="message" hx-swap="none" {{
@for row in &page_data.content {{
{li_render}
}}
}}
}} @else {{
ul id="{plural}-list" {{
@for row in &page_data.content {{
{li_render}
}}
}}
}}"#
)
} else {
String::new()
};
let columns_let = if live {
String::new()
} else {
render_columns_vec(pascal_name, fields, &reference_displays, false)
};
let index_label_loads = if live {
String::new()
} else {
render_index_reference_label_loads(&display_reference_fields, &reference_displays)
};
let index_db_param = if index_label_loads.is_empty() {
""
} else {
"mut db: Db,\n "
};
let sharded_index_db = if index_label_loads.is_empty() {
"db: ShardedDb"
} else {
"mut db: ShardedDb"
};
let index_columns_labeled = if index_label_loads.is_empty() {
columns_let
} else {
render_columns_vec(pascal_name, fields, &reference_displays, true)
};
let table_render = if live {
String::new()
} else if owner_scoped_index && !owner_scoped_standard {
format!(
r#"(autumn_web::widgets::data_table(&page_data.content, &columns, &autumn_web::widgets::DataTableConfig::new("No {plural} yet.").base_path(&paths::index())))"#
)
} else {
format!(
r#"(autumn_web::widgets::data_table(&page_data.content, &columns, &autumn_web::widgets::DataTableConfig::new("No {plural} yet.").base_path(&paths::index()).query(raw_query.as_deref().unwrap_or_default()).active_sort(list_query.sort().unwrap_or_default()).active_dir(list_query.direction())))"#
)
};
let list_render = if live { &live_ul_render } else { &table_render };
let pager_line = if live {
r"(pagination_nav(&page_data, &PagerOptions::new(&paths::index())))".to_string()
} else {
r"(pagination_nav(&page_data, &PagerOptions::new(&paths::index()).query(pager_query)))"
.to_string()
};
let index_list_block = if search_enabled {
format!(
"script src=(autumn_web::htmx::HTMX_JS_PATH) {{}}\n \
(autumn_web::widgets::active_search_input(\"{snake_name}-search\", \"Search {pascal_name}s\", \
&autumn_web::widgets::ActiveSearchConfig::new(\"/{plural}/search\", \"#{plural}-search-results\")\
.placeholder(\"Search {pascal_name}s…\")))\n \
div id=\"{plural}-search-results\" {{\n {list_render}\n {pager_line}\n }}"
)
} else {
format!("{list_render}\n {pager_line}")
};
let show_rows = render_show_property_rows(all_fields, &reference_displays);
let show_label_loads = render_show_reference_label_loads(all_fields, &reference_displays);
let owner_index_db_param = if sharded {
"mut db: ShardedDb"
} else {
"mut db: Db"
};
let index_handler = if owner_scoped_standard {
format!(
r#"/// `GET /{plural}` — paginated, sortable, filterable list of the {snake_name}s
/// the current user owns.
///
/// Accepts `?page=N&size=M` paging plus allowlisted `?sort=col&dir=asc|desc`
/// and `?filter[col]=val` params (the [`ListQuery`] extractor). Unknown or
/// malicious sort/filter keys are ignored against the model's column allowlist,
/// so this never 400s and can never inject SQL. Filtered to the authenticated
/// user's rows (owner column `{owner_col}`) via the repository's owner-scoped
/// `list_scoped` — the same rule the registered `{pascal_name}Scope` enforces
/// for `#[repository(scope = ...)]` index routes. This handler never calls the
/// unscoped `list`/`page`, so it cannot return another user's rows.
#[secured]
#[get("/{plural}")]
pub async fn index(
list_query: ListQuery,
RawQuery(raw_query): RawQuery,
page_req: PageRequest,
autumn_web::extract::State(state): autumn_web::extract::State<autumn_web::AppState>,
session: autumn_web::session::Session,
repo: Pg{pascal_name}Repository,
{index_db_param}flash: Flash,
) -> AutumnResult<Markup> {{
let ctx = autumn_web::authorization::PolicyContext::from_request(&state, &session).await;
let owner_id = ctx.user_id_i64().unwrap_or(-1);
let page_data: Page<{pascal_name}> = repo.list_scoped(owner_id, &list_query, &page_req).await?;
let pager_query = raw_query.as_deref().unwrap_or("");
{index_label_loads}{index_columns_labeled} Ok({layout_fn}("{pascal_name} index", {cp_index}{flash_arg}, html! {{
h1 {{ "{pascal_name}s" }}
a href=(paths::new()) {{ "New {pascal_name}" }}
{index_list_block}
}}))
}}"#
)
} else if owner_scoped_index {
format!(
r#"/// `GET /{plural}` — paginated list of {snake_name}s the current user owns.
///
/// Accepts `?page=N&size=M` query parameters via the [`PageRequest`] extractor.
/// Out-of-range or missing values are clamped silently — list endpoints never
/// return HTTP 400 for bad paging parameters. Filtered to the authenticated
/// user's rows (owner column `{owner_col}`) — the same rule the registered
/// `{pascal_name}Scope` enforces for `#[repository(scope = ...)]` index routes.
#[secured]
#[get("/{plural}")]
pub async fn index(
page_req: PageRequest,
autumn_web::extract::State(state): autumn_web::extract::State<autumn_web::AppState>,
session: autumn_web::session::Session,
{owner_index_db_param},
flash: Flash,
) -> AutumnResult<Markup> {{
let ctx = autumn_web::authorization::PolicyContext::from_request(&state, &session).await;
let owner_id = ctx.user_id_i64().unwrap_or(-1);
let total: i64 = {plural}::table
.filter({plural}::{owner_col}.eq(owner_id))
.count()
.get_result(&mut *db)
.await?;
let items: Vec<{pascal_name}> = {plural}::table
.filter({plural}::{owner_col}.eq(owner_id))
.select({pascal_name}::as_select())
.limit(page_req.limit())
.offset(page_req.offset())
.load(&mut *db)
.await?;
let page_data: Page<{pascal_name}> = Page::new(items, total, &page_req);
{index_label_loads}{index_columns_labeled} Ok({layout_fn}("{pascal_name} index", {cp_index}{flash_arg}, html! {{
h1 {{ "{pascal_name}s" }}
(autumn_web::a11y::Link::new("/{plural}/new", "New {pascal_name}"))
{list_render}
(pagination_nav(&page_data, &PagerOptions::new("/{plural}")))
}}))
}}"#
)
} else if sharded {
if live {
format!(
r#"/// `GET /{plural}` — paginated list of {snake_name}s.
///
/// Accepts `?page=N&size=M` query parameters via the [`PageRequest`] extractor.
/// Out-of-range or missing values are clamped silently — list endpoints never
/// return HTTP 400 for bad paging parameters.
#[get("/{plural}")]
pub async fn index(
page_req: PageRequest,
db: ShardedDb,
flash: Flash,
) -> AutumnResult<Markup> {{
let repo = Pg{pascal_name}Repository::from_shard(&db);
let page_data: Page<{pascal_name}> = repo.page(&page_req).await?;
Ok({layout_fn}("{pascal_name} index", {cp_index}{flash_arg}, html! {{
h1 {{ "{pascal_name}s" }}
(autumn_web::a11y::Link::new(paths::new(), "New {pascal_name}"))
{index_list_block}
}}))
}}"#
)
} else {
format!(
r#"/// `GET /{plural}` — paginated, sortable, filterable list of {snake_name}s.
///
/// Accepts `?page=N&size=M` paging plus allowlisted `?sort=col&dir=asc|desc`
/// and `?filter[col]=val` params (the [`ListQuery`] extractor). Unknown or
/// malicious sort/filter keys are ignored against the model's column allowlist,
/// so this never 400s and can never inject SQL. `from_shard` keeps the query
/// pinned to a single shard.
#[get("/{plural}")]
pub async fn index(
list_query: ListQuery,
RawQuery(raw_query): RawQuery,
page_req: PageRequest,
{sharded_index_db},
flash: Flash,
) -> AutumnResult<Markup> {{
let repo = Pg{pascal_name}Repository::from_shard(&db);
let page_data: Page<{pascal_name}> = repo.list(&list_query, &page_req).await?;
let pager_query = raw_query.as_deref().unwrap_or("");
{index_label_loads}{index_columns_labeled} Ok({layout_fn}("{pascal_name} index", {cp_index}{flash_arg}, html! {{
h1 {{ "{pascal_name}s" }}
(autumn_web::a11y::Link::new(paths::new(), "New {pascal_name}"))
{index_list_block}
}}))
}}"#
)
}
} else if live {
format!(
r#"/// `GET /{plural}` — paginated list of {snake_name}s.
///
/// Accepts `?page=N&size=M` query parameters via the [`PageRequest`] extractor.
/// Out-of-range or missing values are clamped silently — list endpoints never
/// return HTTP 400 for bad paging parameters.
#[get("/{plural}")]
pub async fn index(
page_req: PageRequest,
repo: Pg{pascal_name}Repository,
flash: Flash,
) -> AutumnResult<Markup> {{
let page_data: Page<{pascal_name}> = repo.page(&page_req).await?;
Ok({layout_fn}("{pascal_name} index", {cp_index}{flash_arg}, html! {{
h1 {{ "{pascal_name}s" }}
(autumn_web::a11y::Link::new(paths::new(), "New {pascal_name}"))
{index_list_block}
}}))
}}"#
)
} else {
format!(
r#"/// `GET /{plural}` — paginated, sortable, filterable list of {snake_name}s.
///
/// Accepts `?page=N&size=M` paging plus allowlisted `?sort=col&dir=asc|desc`
/// and `?filter[col]=val` params (the [`ListQuery`] extractor). Unknown or
/// malicious sort/filter keys are ignored against the model's column allowlist,
/// so this never 400s and can never inject SQL.
#[get("/{plural}")]
pub async fn index(
list_query: ListQuery,
RawQuery(raw_query): RawQuery,
page_req: PageRequest,
repo: Pg{pascal_name}Repository,
{index_db_param}flash: Flash,
) -> AutumnResult<Markup> {{
let page_data: Page<{pascal_name}> = repo.list(&list_query, &page_req).await?;
let pager_query = raw_query.as_deref().unwrap_or("");
{index_label_loads}{index_columns_labeled} Ok({layout_fn}("{pascal_name} index", {cp_index}{flash_arg}, html! {{
h1 {{ "{pascal_name}s" }}
(autumn_web::a11y::Link::new(paths::new(), "New {pascal_name}"))
{index_list_block}
}}))
}}"#
)
};
let sort_imports = if live {
String::new()
} else {
"use autumn_web::pagination::ListQuery;\n\
use autumn_web::reexports::axum::extract::RawQuery;\n"
.to_owned()
};
let db_import = if sharded {
"use autumn_web::flash::{Flash, flash_messages};\n\
use autumn_web::form::{Changeset, IntoChangeset};\n\
use autumn_web::sharding::ShardedDb;\n\
use autumn_web::{AutumnError, AutumnResult, Markup, get, html, post, secured};"
.to_owned()
} else {
"use autumn_web::flash::{Flash, flash_messages};\n\
use autumn_web::form::{Changeset, IntoChangeset};\n\
use autumn_web::{AutumnError, AutumnResult, Db, Markup, get, html, post, secured};"
.to_owned()
};
let schema_import = {
let mut extra_tables: BTreeSet<String> = display_reference_fields
.iter()
.filter_map(|f| f.reference_table())
.collect();
extra_tables.remove(plural);
if extra_tables.is_empty() {
plural.to_owned()
} else {
let mut list = plural.to_owned();
for table in extra_tables {
let _ = write!(list, ", {table}");
}
format!("{{{list}}}")
}
};
let validate_handlers = if live_validation {
let mut vh = String::new();
for (field_name, rules) in validations {
let rule_comment = rules.join(", ");
let label = humanize_label(field_name);
let field = fields.iter().find(|f| f.name == *field_name);
let constrained = field.and_then(|f| {
html5_constraint_spec(f).and_then(|(input_type, _attrs)| {
matches!(f.kind, FieldKind::String | FieldKind::Text).then_some((f, input_type))
})
});
let _ = write!(
vh,
"\n\n/// `POST /{plural}/validate/{field_name}` — inline validation fragment.\n"
);
let _ = writeln!(
vh,
"///\n/// Decodes the full submitted form, validates it against the `{rule_comment}`\n\
/// rule declared on `{pascal_name}Form`, and returns the `{field_name}` field's\n\
/// wrapper (input + inline error) for htmx's `hx-swap=\"outerHTML\"` on\n\
/// `hx-trigger=\"change\"` to swap in place."
);
let _ = writeln!(vh, "#[post(\"/{plural}/validate/{field_name}\")]");
let _ = writeln!(
vh,
"pub async fn validate_{field_name}(body: autumn_web::reexports::axum::body::Bytes) -> autumn_web::Markup {{"
);
vh.push_str(" let Ok(form) = decode_form(body) else {\n");
vh.push_str(" return autumn_web::html! {};\n");
vh.push_str(" };\n");
vh.push_str(" let changeset = form.into_changeset();\n");
if let Some((f, input_type)) = constrained {
let url = format!("paths::validate_{field_name}()");
let markup = render_live_constrained_field(f, "changeset", input_type, Some(&url));
let _ = writeln!(vh, " autumn_web::html! {{\n {markup}\n }}");
} else {
let helper = if field.is_some_and(|f| f.nullable) {
"text_input_htmx"
} else {
"required_text_input_htmx"
};
let _ = writeln!(
vh,
" autumn_web::form::{helper}(&changeset, \"{field_name}\", \"{label}\", &paths::validate_{field_name}())"
);
}
vh.push_str("}\n");
}
vh
} else {
String::new()
};
let paths_macro = {
let mut names: Vec<String> = vec![
"index".to_owned(),
"show".to_owned(),
"new".to_owned(),
"create".to_owned(),
"edit".to_owned(),
"update".to_owned(),
"delete".to_owned(),
];
if live {
names.push("events".to_owned());
}
if live_validation {
for field_name in validations.keys() {
names.push(format!("validate_{field_name}"));
}
}
for f in &sm_fields {
let field = &f.name;
names.push(format!("transition_{field}"));
}
let mut out = String::from("\n\nautumn_web::paths![\n");
for name in &names {
let _ = writeln!(out, " {name},");
}
out.push_str("];\n");
out
};
let search_handler = if search_enabled && owner_scoped_standard {
let field_list = searchable.join(", ");
format!(
r#"
/// `GET /{plural}/search` — owner-scoped full-text search over {field_list}.
///
/// Powers the `active_search` box on the index list. Ranked by relevance via
/// the repository's owner-scoped `search_page_scoped`; an empty `q` falls back
/// to the owner-scoped `list_scoped` listing. htmx requests get only the
/// results fragment; non-htmx navigations (a bookmarked search URL, or a pager
/// link) get a full page so search still works without JavaScript. Every result
/// is filtered to the authenticated user (owner column `{owner_col}`); this
/// handler never calls the unscoped `search_page`/`page`.
#[derive(serde::Deserialize)]
pub struct {pascal_name}SearchQuery {{
#[serde(default)]
pub q: String,
}}
#[secured]
#[get("/{plural}/search")]
pub async fn search(
autumn_web::extract::Query(query): autumn_web::extract::Query<{pascal_name}SearchQuery>,
autumn_web::reexports::axum::extract::RawQuery(raw_query): autumn_web::reexports::axum::extract::RawQuery,
list_query: ListQuery,
page_req: PageRequest,
hx: autumn_web::htmx::HxRequest,
autumn_web::extract::State(state): autumn_web::extract::State<autumn_web::AppState>,
session: autumn_web::session::Session,
repo: Pg{pascal_name}Repository,
{index_db_param}flash: Flash,
) -> AutumnResult<Markup> {{
let ctx = autumn_web::authorization::PolicyContext::from_request(&state, &session).await;
let owner_id = ctx.user_id_i64().unwrap_or(-1);
let q = query.q.trim();
// Preserve the request's raw query string (already percent-encoded) on pager
// links so `q` survives pagination; `PagerOptions::query` strips the stale
// `page`/`size` params and re-adds them per link.
let pager_query = raw_query.as_deref().unwrap_or("");
let page_data: Page<{pascal_name}> = if q.is_empty() {{
repo.list_scoped(owner_id, &list_query, &page_req).await?
}} else {{
repo.search_page_scoped(owner_id, q, &page_req).await?
}};
{index_label_loads}{index_columns_labeled} let results = html! {{
(autumn_web::widgets::data_table(&page_data.content, &columns, &autumn_web::widgets::DataTableConfig::new("No {plural} found.").base_path("/{plural}/search")))
(pagination_nav(&page_data, &PagerOptions::new("/{plural}/search").query(pager_query)))
}};
if hx.is_htmx {{
return Ok(results);
}}
Ok({layout_fn}("Search {pascal_name}s", {cp_index}{flash_arg}, html! {{
h1 {{ "Search {pascal_name}s" }}
a href="/{plural}" {{ "Back to list" }}
div id="{plural}-search-results" {{ (results) }}
}}))
}}"#
)
} else if search_enabled {
let field_list = searchable.join(", ");
let (repo_extractor, repo_bind) = if sharded {
(
format!(" {sharded_index_db},\n"),
format!(" let repo = Pg{pascal_name}Repository::from_shard(&db);\n"),
)
} else {
(
format!(" repo: Pg{pascal_name}Repository,\n {index_db_param}"),
String::new(),
)
};
format!(
r#"
/// `GET /{plural}/search` — full-text search over {field_list}.
///
/// Powers the `active_search` box on the index list. Ranked by relevance via
/// the repository's `search_page`; an empty `q` falls back to the plain
/// `page(&page_req)` listing. htmx requests get only the results fragment;
/// non-htmx navigations (a bookmarked search URL, or a pager link) get a full
/// page so search still works without JavaScript.
#[derive(serde::Deserialize)]
pub struct {pascal_name}SearchQuery {{
#[serde(default)]
pub q: String,
}}
#[get("/{plural}/search")]
pub async fn search(
autumn_web::extract::Query(query): autumn_web::extract::Query<{pascal_name}SearchQuery>,
autumn_web::reexports::axum::extract::RawQuery(raw_query): autumn_web::reexports::axum::extract::RawQuery,
page_req: PageRequest,
hx: autumn_web::htmx::HxRequest,
{repo_extractor} flash: Flash,
) -> AutumnResult<Markup> {{
{repo_bind} let q = query.q.trim();
// Preserve the request's raw query string (already percent-encoded) on pager
// links so `q` survives pagination; `PagerOptions::query` strips the stale
// `page`/`size` params and re-adds them per link.
let pager_query = raw_query.as_deref().unwrap_or("");
let page_data: Page<{pascal_name}> = if q.is_empty() {{
repo.page(&page_req).await?
}} else {{
repo.search_page(q, &page_req).await?
}};
{index_label_loads}{index_columns_labeled} let results = html! {{
(autumn_web::widgets::data_table(&page_data.content, &columns, &autumn_web::widgets::DataTableConfig::new("No {plural} found.").base_path("/{plural}/search")))
(pagination_nav(&page_data, &PagerOptions::new("/{plural}/search").query(pager_query)))
}};
if hx.is_htmx {{
return Ok(results);
}}
Ok({layout_fn}("Search {pascal_name}s", {cp_index}{flash_arg}, html! {{
h1 {{ "Search {pascal_name}s" }}
(autumn_web::a11y::Link::new("/{plural}", "Back to list"))
div id="{plural}-search-results" {{ (results) }}
}}))
}}"#
)
} else {
String::new()
};
format!(
r"//! Generated by `autumn generate scaffold`.
//!
//! HTML route handlers for the resource. Edit freely — once generated,
//! these are ordinary user code.
{attachment_note}
use autumn_web::extract::Path;
use autumn_web::pagination::{{Page, PageRequest}};
{sort_imports}use autumn_web::reexports::axum::body::Bytes;
use autumn_web::reexports::serde_json;
use autumn_web::security::{{CsrfFormField, CsrfToken, SubmitFormField, SubmitToken}};
use autumn_web::ui::pagination::{{PagerOptions, pagination_nav}};
{db_import}
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
use crate::models::{snake_name}::{{{pascal_name}, New{pascal_name}, Update{pascal_name}{enum_import_suffix}}};
use crate::repositories::{snake_name}::{{{pascal_name}Repository, Pg{pascal_name}Repository}};
use crate::schema::{schema_import};",
attachment_note = if has_attachments {
"//!\n\
//! This scaffold includes file-attachment fields. Uploads work with ZERO\n\
//! JavaScript: the generated `<form>` sets `enctype=\"multipart/form-data\"`,\n\
//! and the `create`/`update` handlers accept an `autumn_web::extract::Multipart`\n\
//! body, stream each file part straight to the configured blob store with\n\
//! `MultipartField::save_to_blob_store` (which enforces\n\
//! `security.upload.max_file_size_bytes`, default 16 MiB), and persist the\n\
//! resulting `Blob` on the record. An edit that doesn't re-upload preserves\n\
//! the existing attachment.\n\
//!\n\
//! SIZE CEILING with CSRF on (the prod-profile default): the CSRF middleware\n\
//! buffers the request body only up to ~2 MiB while scanning for the form\n\
//! token, so a zero-JS multipart upload larger than ~2 MiB is rejected with\n\
//! 403 (token not found in the truncated body) BEFORE the handler's\n\
//! `max_file_size_bytes`/413 check can run. For files above that ceiling, use\n\
//! the advanced presigned direct-upload path below (it doesn't route the file\n\
//! bytes through the CSRF-scanned form body).\n\
//!\n\
//! The `storage` + `multipart` features are enabled on `autumn-web`\n\
//! automatically; configure `[storage]` in `autumn.toml` (local disk for dev,\n\
//! S3 for prod).\n\
//!\n\
//! ADVANCED (opt-in): for very large files you can bypass the app process with\n\
//! presigned direct-to-storage uploads — a CSRF-protected endpoint calls\n\
//! `store.presign_put()`, the browser PUTs directly to storage, then\n\
//! `autumn_web::storage::complete_direct_upload()` confirms the object (see the\n\
//! `direct_upload_input` form helper and the `examples/reddit-clone` app). That\n\
//! path requires JavaScript; the default multipart path above does not."
} else {
""
},
) + &{
let private_layout = if shared_layout {
String::new()
} else {
let live_head_scripts = if live {
"\n script src=(autumn_web::htmx::HTMX_JS_PATH) {};\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20script src=(autumn_web::htmx::HTMX_SSE_JS_PATH) {};\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20script src=(autumn_web::htmx::IDIOMORPH_JS_PATH) {};"
.to_owned()
} else {
"\n script src=(autumn_web::htmx::HTMX_JS_PATH) {};\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20script src=(autumn_web::htmx::HTMX_SSE_JS_PATH) {};"
.to_owned()
};
let live_body_open = if live {
r#"body hx-ext="morph""#
} else {
"body"
};
format!(
r#"
/// Wrap content in a minimal HTML layout for the live view (needs extra
/// `<head>` scripts and a morph-enabled `<body>` the shared layout omits).
///
/// Pass `flash_messages(&flash.consume().await)` for the `flash` argument so
/// one-shot notices (set with `flash.success(...)` before a redirect) appear
/// on the next page.
fn layout(title: &str, flash: Markup, content: Markup) -> Markup {{
html! {{
(autumn_web::PreEscaped("<!DOCTYPE html>"))
html lang="en" {{
head {{
meta charset="utf-8";
title {{ (title) }}
link rel="stylesheet" href=(autumn_web::flash::FLASH_CSS_PATH);{live_head_scripts}
}}
{live_body_open} {{
(flash)
(content)
}}
}}
}}
}}
"#
)
};
let show_section = if has_state_machine {
let show_view_db_param = if show_label_loads.trim().is_empty() {
format!("_db: {db_ty}")
} else {
format!("mut db: {db_ty}")
};
let mut show_transition_controls = String::new();
for f in &sm_fields {
let field = &f.name;
let field_upper = f.name.to_uppercase();
let _ = writeln!(
show_transition_controls,
" (autumn_web::widgets::transition_controls(&paths::transition_{field}(row.id), \"{field}\", &row.{field}, {pascal_name}::__AUTUMN_SM_{field_upper}_TRANSITIONS, |to| row.can_transition_{field}_to(to), csrf, csrf_field))"
);
}
let show_view_fn = format!(
r#"/// Render the {snake_name} detail page — shared by the `show` handler and
/// each state-machine transition handler's 422 re-render, so the detail-page
/// markup has a single source of truth (issue #1326).
async fn show_view(
{show_view_db_param},
row: &{pascal_name},
flash: Markup,
csrf: Option<&CsrfToken>,
csrf_field: Option<&CsrfFormField>,
) -> AutumnResult<Markup> {{
{show_label_loads} let props: Vec<(&str, maud::Markup)> = vec![
{show_rows} ];
Ok({layout_fn}(&format!("{pascal_name} #{{}}", row.id), {cp_show}flash, html! {{
h1 {{ "{pascal_name} #" (row.id) }}
(autumn_web::widgets::property_list(&props))
{show_transition_controls} (autumn_web::a11y::Link::new(paths::index(), "Back to list"))
" "
(autumn_web::a11y::Link::new(paths::edit(row.id), "Edit"))
}}))
}}"#
);
let show_handler = format!(
r#"/// `GET /{plural}/{{id}}` — show one {snake_name}.
#[get("/{plural}/{{id}}")]
pub async fn show(
id: Path<{id_rust}>,
mut db: {db_ty},
flash: Flash,
csrf: Option<CsrfToken>,
csrf_field: Option<CsrfFormField>,
) -> AutumnResult<Markup> {{
let row: {pascal_name} = {plural}::table
.find(*id)
.select({pascal_name}::as_select())
.first(&mut *db)
.await
.map_err(AutumnError::not_found)?;
show_view(db, &row, {flash_arg}, csrf.as_ref(), csrf_field.as_ref()).await
}}"#
);
let transition_authz_params = if authorize {
" autumn_web::extract::State(state): autumn_web::extract::State<autumn_web::AppState>,\n session: autumn_web::session::Session,\n"
} else {
""
};
let transition_authz_call = if authorize {
format!(
" autumn_web::authorization::authorize::<{pascal_name}>(&state, &session, \"update\", &row).await?;\n"
)
} else {
String::new()
};
let mut transition_handlers = String::new();
for f in &sm_fields {
let field = &f.name;
let handler = format!(
r#"/// `POST /{plural}/{{id}}/transitions/{field}` — apply a `{field}` state
/// transition (issue #1326). A legal edge persists the new `{field}` value and
/// redirects to the detail page; an illegal edge (or a rejected guard)
/// re-renders that page at 422 with the record left unchanged.
#[secured]
#[post("/{plural}/{{id}}/transitions/{field}")]
pub async fn transition_{field}(
id: Path<{id_rust}>,
mut db: {db_ty},
flash: Flash,
csrf: Option<CsrfToken>,
csrf_field: Option<CsrfFormField>,
{transition_authz_params} body: Bytes,
) -> AutumnResult<autumn_web::reexports::axum::response::Response> {{
use autumn_web::reexports::axum::response::IntoResponse as _;
let row: {pascal_name} = {plural}::table
.find(*id)
.select({pascal_name}::as_select())
.first(&mut *db)
.await
.map_err(AutumnError::not_found)?;
{transition_authz_call} let submitted: std::collections::HashMap<String, String> =
url::form_urlencoded::parse(body.as_ref()).into_owned().collect();
let target = submitted.get("{field}").cloned().unwrap_or_default();
match row.transition_{field}_to(&target) {{
Ok(new_state) => {{
diesel::update({plural}::table.find(*id))
.set({plural}::{field}.eq(new_state))
.execute(&mut *db)
.await?;
flash.success("{pascal_name} {field} updated").await;
Ok(autumn_web::Redirect::to(&paths::show(*id)).into_response())
}}
Err(err) => {{
flash.error(err.to_string()).await;
let view = show_view(
db,
&row,
flash_messages(&flash.consume().await),
csrf.as_ref(),
csrf_field.as_ref(),
)
.await?;
Ok((autumn_web::reexports::http::StatusCode::UNPROCESSABLE_ENTITY, view).into_response())
}}
}}
}}"#
);
if !transition_handlers.is_empty() {
transition_handlers.push_str("\n\n");
}
transition_handlers.push_str(&handler);
}
format!("\n\n{show_view_fn}\n\n{show_handler}\n\n{transition_handlers}\n")
} else {
format!(
r#"
/// `GET /{plural}/{{id}}` — show one {snake_name}.
#[get("/{plural}/{{id}}")]
pub async fn show(id: Path<{id_rust}>, mut db: {db_ty}, flash: Flash) -> AutumnResult<Markup> {{
let row: {pascal_name} = {plural}::table
.find(*id)
.select({pascal_name}::as_select())
.first(&mut *db)
.await
.map_err(AutumnError::not_found)?;
{show_label_loads} let props: Vec<(&str, maud::Markup)> = vec![
{show_rows} ];
Ok({layout_fn}(&format!("{pascal_name} #{{}}", row.id), {cp_show}{flash_arg}, html! {{
h1 {{ "{pascal_name} #" (row.id) }}
(autumn_web::widgets::property_list(&props))
(autumn_web::a11y::Link::new(paths::index(), "Back to list"))
" "
(autumn_web::a11y::Link::new(paths::edit(row.id), "Edit"))
}}))
}}
"#
)
};
format!(
r#"
fn csrf_input(csrf: Option<&CsrfToken>, field: Option<&CsrfFormField>) -> Markup {{
let csrf_field_name = field.map(|field| field.0.as_str()).unwrap_or("_csrf");
html! {{
@if let Some(csrf) = csrf {{
input type="hidden" name=(csrf_field_name) value=(csrf.token());
}}
}}
}}
/// Emit the hidden one-time submit-token field (issue #1360). Consumed once by
/// the framework's `SubmitTokenLayer`, so a double-click or Back→resubmit
/// replays the first response instead of creating a duplicate row. The field
/// name follows `security.submit_token.field_name` (default `_submit_token`) so
/// a customised name still matches what the layer scans for.
fn submit_token_input(submit_token: Option<&SubmitToken>, field: Option<&SubmitFormField>) -> Markup {{
let submit_field_name = field.map(|field| field.0.as_str()).unwrap_or("_submit_token");
html! {{
@if let Some(submit_token) = submit_token {{
input type="hidden" name=(submit_field_name) value=(submit_token.token());
}}
}}
}}
{private_layout}
{index_handler}{show_section}
/// `GET /{plural}/new` — render the new-{snake_name} form.
#[secured]
#[get("/{plural}/new", name = "new")]
pub async fn new_form(
{new_form_db_param}flash: Flash,
csrf: Option<CsrfToken>,
csrf_field: Option<CsrfFormField>,
submit_token: Option<SubmitToken>,
submit_field: Option<SubmitFormField>,
) -> AutumnResult<Markup> {{
let changeset = Changeset::new({pascal_name}Form::default());
Ok({new_form_body})
}}
{unique_constraints_const}{create_fn}
/// `GET /{plural}/{{id}}/edit` — render the edit form. Submission goes to
/// the `update` handler below as a plain HTML POST (browsers can't submit
/// PUT directly without JS); the auto-generated JSON `PUT /api/{plural}/{{id}}`
/// remains available for API clients.
#[secured]
#[get("/{plural}/{{id}}/edit", name = "edit")]
pub async fn edit_form(
id: Path<{id_rust}>,
mut db: {db_ty},
flash: Flash,
csrf: Option<CsrfToken>,
csrf_field: Option<CsrfFormField>,
submit_token: Option<SubmitToken>,
submit_field: Option<SubmitFormField>,{edit_destroy_authz_params}
) -> AutumnResult<Markup> {{
let row: {pascal_name} = {plural}::table
.find(*id)
.select({pascal_name}::as_select())
.first(&mut *db)
.await
.map_err(AutumnError::not_found)?;
{authz_edit_call}let changeset = Changeset::new({pascal_name}Form::from(&row));
Ok({edit_form_body})
}}
{update_fn}
/// `POST /{plural}/{{id}}/delete` — delete a row, then redirect to the list.
/// Browsers can't submit `DELETE` without JS, so the show page's delete button
/// posts here; the JSON `DELETE /api/{plural}/{{id}}` stays available for API
/// clients via the auto-generated repository handler. Honours the resource's
/// soft-delete configuration (marks `deleted_at` when `--soft-delete` is set).
#[secured]
#[post("/{plural}/{{id}}/delete", name = "delete")]
pub async fn destroy(
id: Path<{id_rust}>,
{destroy_signature_arg},
flash: Flash,
) -> AutumnResult<autumn_web::Redirect> {{
{authz_destroy_preamble}{destroy_stmt}
if deleted == 0 {{
return Err(AutumnError::not_found_msg(format!(
"{pascal_name} with id {{}} not found", *id
)));
}}
flash.success("{pascal_name} deleted").await;
Ok(autumn_web::Redirect::to(&paths::index()))
}}
{form_struct}
{parse_datetime_helper}
{into_new_fn}
{from_row_impl}
{form_model_impl}
{form_for_helper}{decode_form_sig} {{
let pairs: Vec<_> = url::form_urlencoded::parse(body.as_ref())
.filter(|(key, value)| !(value.is_empty() && is_nullable_form_field(key)))
.collect();
let encoded = url::form_urlencoded::Serializer::new(String::new())
.extend_pairs(pairs.iter().map(|(key, value)| (key.as_ref(), value.as_ref())))
.finish();
let form: {pascal_name}Form = serde_urlencoded::from_str(&encoded)
.map_err(|err| AutumnError::bad_request_msg(format!("invalid form submission: {{err}}")))?;
Ok(form)
}}
fn is_nullable_form_field(name: &str) -> bool {{
{nullable_field_match}
}}
"#
)
} + &if live {
format!(
r#"
/// `GET /{plural}/events` — Server-Sent Events stream for live updates.
#[get("/{plural}/events")]
pub async fn events(
state: autumn_web::extract::State<autumn_web::AppState>,
) -> impl autumn_web::reexports::axum::response::IntoResponse {{
autumn_web::sse::stream(&state, "{plural}")
}}"#
)
} else {
String::new()
} + &validate_handlers
+ &paths_macro
+ &search_handler
}
fn render_unique_constraints_const(
plural: &str,
unique_fields: &[&Field],
all_fields: &[Field],
) -> String {
use std::fmt::Write as _;
let mut entries = String::new();
for f in unique_fields {
let index_name = unique_index_name(plural, &f.name, all_fields);
let _ = write!(
entries,
"(\"{index_name}\", \"{name}\", \"has already been taken\"),\n ",
name = f.name
);
}
format!("const UNIQUE_CONSTRAINTS: &[(&str, &str, &str)] = &[\n {entries}];\n")
}
fn render_update_changeset_expr(pascal_name: &str, fields: &[Field]) -> String {
use std::fmt::Write;
let mut out = format!("Update{pascal_name} {{\n");
for f in fields {
let name = &f.name;
writeln!(
out,
" {name}: autumn_web::hooks::Patch::Set(new.{name}.clone()),"
)
.unwrap();
}
out.push_str(" }");
out
}
fn has_attachment_fields(fields: &[Field]) -> bool {
fields.iter().any(|f| f.kind.is_attachment())
}
fn render_form_model_impl(pascal_name: &str) -> String {
format!(
"/// `form_for` field descriptors (issue #1135): delegate to the\n\
/// `#[model]`-derived implementation on `{pascal_name}` so the rendered form\n\
/// always tracks the model's columns.\n\
impl autumn_web::form::FormModel for {pascal_name}Form {{\n \
fn form_fields() -> Vec<autumn_web::form::FormField> {{\n \
<{pascal_name} as autumn_web::form::FormModel>::form_fields()\n \
}}\n\
}}"
)
}
#[allow(
clippy::too_many_lines,
reason = "one per-field match building the form_for builder calls plus the \
constrained-field/attachment append markup — a single cohesive template"
)]
fn render_form_for_helper(
pascal_name: &str,
snake_name: &str,
fields: &[Field],
missing_reference_targets: &BTreeSet<String>,
) -> String {
use std::fmt::Write as _;
let mut extra_params = String::new();
let mut preludes = String::new();
let mut builder_calls = String::new();
let mut appends = String::new();
for f in fields {
let name = &f.name;
match f.kind {
FieldKind::Attachment => {
let _ = write!(
builder_calls,
"\n .override_field(\"{name}\", autumn_web::form::FieldControl::File)"
);
}
FieldKind::Enum => {
let placeholder = if f.nullable {
"— Unset —"
} else {
"— Select —"
};
let mut options = format!("(\"\".into(), \"{placeholder}\".into())");
for v in &f.variants {
let vlabel = humanize_label(v);
let _ = write!(options, ", (\"{v}\".into(), \"{vlabel}\".into())");
}
let _ = write!(
builder_calls,
"\n .override_field(\"{name}\", autumn_web::form::FieldControl::Select {{ options: vec![{options}] }})"
);
}
FieldKind::Decimal { scale, .. } => {
let step = decimal_step(scale);
let _ = write!(
builder_calls,
"\n .override_field(\"{name}\", autumn_web::form::FieldControl::Number {{ step: Some(\"{step}\".into()) }})"
);
}
FieldKind::References => {
if missing_reference_targets.contains(name) {
continue;
}
let placeholder = if f.nullable {
"— Unset —"
} else {
"— Select —"
};
let _ = write!(extra_params, ",\n {name}_options: &[(String, String)]");
let _ = write!(
preludes,
" let mut {name}_select = vec![(String::new(), \"{placeholder}\".to_string())];\n \
{name}_select.extend({name}_options.iter().cloned());\n"
);
let _ = write!(
builder_calls,
"\n .override_field(\"{name}\", autumn_web::form::FieldControl::Select {{ options: {name}_select }})"
);
}
_ => {
if let Some((input_type, _)) = html5_constraint_spec(f) {
let label = humanize_label(name);
let _ = write!(builder_calls, "\n .exclude(\"{name}\")");
if matches!(f.kind, FieldKind::Text) && input_type == "text" {
let constraint_builders = html5_constraint_builder_calls(f)
.map(|(_, calls)| calls)
.unwrap_or_default();
let required_builders = if f.nullable {
""
} else {
".required().aria_required()"
};
let _ = write!(
appends,
"\n .append(html! {{\n \
@let errors = changeset.errors_for(\"{name}\");\n \
div id=\"{name}-field\" class=\"autumn-field\" {{\n \
(autumn_web::a11y::TextArea::new(\"{name}\")\n \
.label(\"{label}\")\n \
.label_class(\"autumn-field__label\")\n \
.value(changeset.field_value(\"{name}\").unwrap_or_default())\n \
{constraint_builders}{required_builders}\n \
.class(if errors.is_empty() {{ \"autumn-field__input\" }} else {{ \"autumn-field__input autumn-field__input--invalid\" }})\n \
.aria_invalid(!errors.is_empty())\n \
.described_by(if errors.is_empty() {{ \"\" }} else {{ \"{name}-error\" }}))\n \
@if !errors.is_empty() {{\n \
div id=\"{name}-error\" role=\"alert\" class=\"autumn-field__errors\" {{\n \
@for error in errors {{ p class=\"autumn-field__error\" {{ (error) }} }}\n \
}}\n \
}}\n \
}}\n \
}})"
);
} else {
let constraint_builders = html5_constraint_builder_calls(f)
.map(|(_, calls)| calls)
.unwrap_or_default();
let required_builders = if f.nullable {
""
} else {
".required().aria_required()"
};
let _ = write!(
appends,
"\n .append(html! {{\n \
@let errors = changeset.errors_for(\"{name}\");\n \
div id=\"{name}-field\" class=\"autumn-field\" {{\n \
(autumn_web::a11y::TextField::new(\"{name}\")\n \
.label(\"{label}\")\n \
.label_class(\"autumn-field__label\")\n \
.input_type(\"{input_type}\")\n \
.value(changeset.field_value(\"{name}\").unwrap_or_default())\n \
{constraint_builders}{required_builders}\n \
.class(if errors.is_empty() {{ \"autumn-field__input\" }} else {{ \"autumn-field__input autumn-field__input--invalid\" }})\n \
.aria_invalid(!errors.is_empty())\n \
.described_by(if errors.is_empty() {{ \"\" }} else {{ \"{name}-error\" }}))\n \
@if !errors.is_empty() {{\n \
div id=\"{name}-error\" role=\"alert\" class=\"autumn-field__errors\" {{\n \
@for error in errors {{ p class=\"autumn-field__error\" {{ (error) }} }}\n \
}}\n \
}}\n \
}}\n \
}})"
);
}
}
}
}
}
let sm_field_names: Vec<&str> = fields
.iter()
.filter(|f| f.state_machine.is_some())
.map(|f| f.name.as_str())
.collect();
let sm_exclude_block = if sm_field_names.is_empty() {
String::new()
} else {
let _ = write!(extra_params, ",\n exclude_state_fields: bool");
let mut excludes = String::new();
for name in &sm_field_names {
let _ = write!(excludes, "\n form = form.exclude(\"{name}\");");
}
format!("\n if exclude_state_fields {{{excludes}\n }}\n ")
};
format!(
"/// Render the create/edit form in a single `form_for` call (issue #1135).\n\
///\n\
/// Controls, labels, pre-filled values, and inline errors all derive from\n\
/// `{pascal_name}Form`'s `FormModel` field descriptors, so adding a model\n\
/// column requires no edits here — any enum/decimal/attachment overrides\n\
/// below are the only schema-specific lines, and regeneration maintains\n\
/// them.\n\
fn {snake_name}_form_for(\n \
changeset: &Changeset<{pascal_name}Form>,\n \
action: String,\n \
submit_label: &str,\n \
csrf: Option<&CsrfToken>,\n \
csrf_field: Option<&CsrfFormField>,\n \
submit_token: Option<&SubmitToken>,\n \
submit_field: Option<&SubmitFormField>{extra_params},\n\
) -> Markup {{\n\
{preludes} \
let mut form = autumn_web::form::form_for(changeset, action, \"post\")\n \
.submit_label(submit_label){builder_calls}{appends};\n{sm_exclude_block} \
// Emit the one-time submit token at the FRONT of the form (issue #1360):\n \
// `SubmitTokenLayer` only scans the first chunk of the URL-encoded body,\n \
// so a large earlier textarea could otherwise push an appended token past\n \
// the scan cap and leave duplicate submits unguarded.\n \
form = form.prepend(submit_token_input(submit_token, submit_field));\n \
if let Some(csrf) = csrf {{\n \
form = form.csrf(csrf.token());\n \
}}\n \
if let Some(field) = csrf_field {{\n \
form = form.csrf_field_name(field.0.as_str());\n \
}}\n \
form.render()\n\
}}\n\n"
)
}
fn render_reference_option_loaders(
reference_fields: &[&Field],
db_ty: &str,
reference_displays: &BTreeMap<String, ReferenceDisplay>,
) -> String {
use std::fmt::Write as _;
let mut out = String::new();
for f in reference_fields {
let name = &f.name;
let Some(table) = f.reference_table() else {
continue;
};
match reference_displays.get(name) {
Some(display) => {
let column = &display.column;
let (load_ty, label_expr) = if display.column_nullable {
("Option<String>", "label.unwrap_or_else(|| id.to_string())")
} else {
("String", "label")
};
let _ = write!(
out,
"/// Load the `(value, label)` select options for the `{name}` reference —\n\
/// one option per `{table}` row (value = id, label = `{column}`), ordered\n\
/// by id (issues #1135, #1146).\n\
async fn {name}_select_options(db: &mut {db_ty}) -> AutumnResult<Vec<(String, String)>> {{\n \
let rows: Vec<(i64, {load_ty})> = {table}::table\n \
.select(({table}::id, {table}::{column}))\n \
.order({table}::id.asc())\n \
.load(&mut **db)\n \
.await?;\n \
Ok(rows.into_iter().map(|(id, label)| (id.to_string(), {label_expr})).collect())\n\
}}\n\n"
);
}
None => {
let _ = write!(
out,
"/// Load the `(value, label)` select options for the `{name}` reference —\n\
/// one option per `{table}` row, ordered by id (issue #1135).\n\
///\n\
/// `{table}` has no string column to label options with, so the id is\n\
/// used as both value and label — swap the `map` below to use a real\n\
/// label column once one exists.\n\
async fn {name}_select_options(db: &mut {db_ty}) -> AutumnResult<Vec<(String, String)>> {{\n \
let ids: Vec<i64> = {table}::table\n \
.select({table}::id)\n \
.order({table}::id.asc())\n \
.load(&mut **db)\n \
.await?;\n \
Ok(ids.into_iter().map(|id| (id.to_string(), id.to_string())).collect())\n\
}}\n\n"
);
}
}
}
out
}
#[derive(Debug, Clone)]
struct ReferenceDisplay {
column: String,
column_nullable: bool,
}
fn target_string_columns(
project_root: &Path,
field: &Field,
self_plural: &str,
own_fields: &[Field],
) -> Vec<(String, bool)> {
if field.reference_table().as_deref() == Some(self_plural) {
own_fields
.iter()
.filter(|f| matches!(f.kind, FieldKind::String | FieldKind::Text))
.map(|f| (f.name.clone(), f.nullable))
.collect()
} else {
let base = field.name.strip_suffix("_id").unwrap_or(&field.name);
super::model::model_string_columns(project_root, base)
}
}
fn resolve_reference_display(
project_root: &Path,
field: &Field,
self_plural: &str,
own_fields: &[Field],
) -> Option<ReferenceDisplay> {
let base = field.name.strip_suffix("_id").unwrap_or(&field.name);
let is_self_ref = field.reference_table().as_deref() == Some(self_plural);
let columns = target_string_columns(project_root, field, self_plural, own_fields);
let heuristic = || {
columns
.iter()
.find(|(c, _)| c == "name")
.or_else(|| columns.iter().find(|(c, _)| c == "title"))
.or_else(|| columns.first())
.map(|(c, n)| (c.clone(), *n))
};
let (column, column_nullable) = if let Some(label) = &field.constraints.label {
if let Some((c, n)) = columns.iter().find(|(c, _)| c == label) {
(c.clone(), *n)
} else if !is_self_ref
&& field
.reference_table()
.is_some_and(|table| !super::model::model_file_exists(project_root, &table, base))
{
(label.clone(), false)
} else {
heuristic()?
}
} else {
heuristic()?
};
Some(ReferenceDisplay {
column,
column_nullable,
})
}
#[allow(clippy::too_many_lines)]
fn render_changeset_form_inputs(
fields: &[Field],
changeset_var: &str,
live_validation: bool,
validated: &[&str],
reference_selects: &[&Field],
) -> String {
use std::fmt::Write as _;
let cv = changeset_var;
let reference_select_names: BTreeSet<&str> =
reference_selects.iter().map(|f| f.name.as_str()).collect();
let mut out = String::with_capacity(fields.len() * 150);
for f in fields {
let name = &f.name;
let label = humanize_label(name);
let constraint = html5_constraint_spec(f);
let line = if f.kind.is_reference() && reference_select_names.contains(name.as_str()) {
render_live_reference_select(f, cv)
} else if let (true, Some((input_type, _attrs))) = (
matches!(
f.kind,
FieldKind::I32 | FieldKind::I64 | FieldKind::F32 | FieldKind::F64
),
&constraint,
) {
render_live_constrained_field(f, cv, input_type, None)
} else if f.kind.is_attachment() {
format!("(autumn_web::a11y::FileField::new(\"{name}\").label(\"{label}\"))")
} else {
match (f.kind, f.nullable) {
(FieldKind::Bool, false) => {
format!("(autumn_web::form::checkbox_input(&{cv}, \"{name}\", \"{label}\"))")
}
(FieldKind::Bool, true) => format!(
"(autumn_web::form::select_input(&{cv}, \"{name}\", \"{label}\", \
&[(\"\", \"— Unset —\"), (\"true\", \"Yes\"), (\"false\", \"No\")]))"
),
(FieldKind::I32 | FieldKind::I64, false) => format!(
"(autumn_web::form::required_number_input(&{cv}, \"{name}\", \"{label}\", Some(\"1\")))"
),
(FieldKind::I32 | FieldKind::I64, true) => format!(
"(autumn_web::form::number_input(&{cv}, \"{name}\", \"{label}\", Some(\"1\")))"
),
(FieldKind::F32 | FieldKind::F64, false) => format!(
"(autumn_web::form::required_number_input(&{cv}, \"{name}\", \"{label}\", Some(\"any\")))"
),
(FieldKind::F32 | FieldKind::F64, true) => format!(
"(autumn_web::form::number_input(&{cv}, \"{name}\", \"{label}\", Some(\"any\")))"
),
(FieldKind::Decimal { scale, .. }, false) => format!(
"(autumn_web::form::required_number_input(&{cv}, \"{name}\", \"{label}\", Some(\"{step}\")))",
step = decimal_step(scale)
),
(FieldKind::Decimal { scale, .. }, true) => format!(
"(autumn_web::form::number_input(&{cv}, \"{name}\", \"{label}\", Some(\"{step}\")))",
step = decimal_step(scale)
),
(FieldKind::NaiveDateTime | FieldKind::DateTime, false) => format!(
"(autumn_web::form::required_datetime_input(&{cv}, \"{name}\", \"{label}\"))"
),
(FieldKind::NaiveDateTime | FieldKind::DateTime, true) => {
format!("(autumn_web::form::datetime_input(&{cv}, \"{name}\", \"{label}\"))")
}
(FieldKind::Enum, _) => {
let placeholder = if f.nullable {
"— Unset —"
} else {
"— Select —"
};
let mut options = format!("(\"\", \"{placeholder}\")");
for v in &f.variants {
let vlabel = humanize_label(v);
let _ = write!(options, ", (\"{v}\", \"{vlabel}\")");
}
let helper = if f.nullable {
"select_input"
} else {
"required_select_input"
};
format!(
"(autumn_web::form::{helper}(&{cv}, \"{name}\", \"{label}\", &[{options}]))"
)
}
_ => {
let validated_htmx = live_validation && validated.contains(&name.as_str());
if let Some((input_type, _attrs)) = &constraint {
let validate_url =
validated_htmx.then(|| format!("paths::validate_{name}()"));
render_live_constrained_field(f, cv, input_type, validate_url.as_deref())
} else if validated_htmx {
let helper = if f.nullable {
"text_input_htmx"
} else {
"required_text_input_htmx"
};
format!(
"(autumn_web::form::{helper}(&{cv}, \"{name}\", \"{label}\", \
&paths::validate_{name}()))"
)
} else if f.nullable {
format!("(autumn_web::form::text_input(&{cv}, \"{name}\", \"{label}\"))")
} else {
format!(
"(autumn_web::form::required_text_input(&{cv}, \"{name}\", \"{label}\"))"
)
}
}
}
};
let _ = writeln!(out, " {line}");
}
out
}
fn decimal_literal_at_scale(scale: u32, digit: u32) -> String {
if scale == 0 {
digit.to_string()
} else {
format!("0.{}{digit}", "0".repeat(scale as usize - 1))
}
}
fn decimal_step(scale: u32) -> String {
decimal_literal_at_scale(scale, 1)
}
enum Html5Constraint {
MinLength(String),
MaxLength(String),
Min(String),
Max(String),
StepAny,
}
impl Html5Constraint {
fn attr(&self) -> String {
match self {
Self::MinLength(v) => format!(" minlength=\"{v}\""),
Self::MaxLength(v) => format!(" maxlength=\"{v}\""),
Self::Min(v) => format!(" min=\"{v}\""),
Self::Max(v) => format!(" max=\"{v}\""),
Self::StepAny => " step=\"any\"".to_owned(),
}
}
fn builder_call(&self) -> String {
match self {
Self::MinLength(v) => format!(".minlength({v}u32)"),
Self::MaxLength(v) => format!(".maxlength({v}u32)"),
Self::Min(v) => format!(".min(\"{v}\")"),
Self::Max(v) => format!(".max(\"{v}\")"),
Self::StepAny => ".step(\"any\")".to_owned(),
}
}
}
fn html5_constraint_parts(field: &Field) -> Option<(&'static str, Vec<Html5Constraint>)> {
let c = &field.constraints;
let mut parts = Vec::new();
let input_type = match field.kind {
FieldKind::String | FieldKind::Text => {
if let Some(min) = &c.min {
parts.push(Html5Constraint::MinLength(min.clone()));
}
if let Some(max) = &c.max {
parts.push(Html5Constraint::MaxLength(max.clone()));
}
if c.email {
"email"
} else if c.url {
"url"
} else {
"text"
}
}
FieldKind::I32 | FieldKind::I64 => {
if let Some(min) = &c.min {
parts.push(Html5Constraint::Min(min.clone()));
}
if let Some(max) = &c.max {
parts.push(Html5Constraint::Max(max.clone()));
}
"number"
}
FieldKind::F32 | FieldKind::F64 => {
if let Some(min) = &c.min {
parts.push(Html5Constraint::Min(min.clone()));
}
if let Some(max) = &c.max {
parts.push(Html5Constraint::Max(max.clone()));
}
if !parts.is_empty() {
parts.push(Html5Constraint::StepAny);
}
"number"
}
_ => return None,
};
if parts.is_empty() && input_type != "email" && input_type != "url" {
return None;
}
Some((input_type, parts))
}
fn html5_constraint_spec(field: &Field) -> Option<(&'static str, String)> {
let (input_type, parts) = html5_constraint_parts(field)?;
let attrs = parts.iter().map(Html5Constraint::attr).collect::<String>();
Some((input_type, attrs))
}
fn html5_constraint_builder_calls(field: &Field) -> Option<(&'static str, String)> {
let (input_type, parts) = html5_constraint_parts(field)?;
let calls = parts
.iter()
.map(Html5Constraint::builder_call)
.collect::<String>();
Some((input_type, calls))
}
fn render_live_constrained_field(
field: &Field,
changeset_var: &str,
input_type: &str,
validate_url: Option<&str>,
) -> String {
let name = &field.name;
let label = humanize_label(name);
let cv = changeset_var;
let constraint_builders = html5_constraint_builder_calls(field)
.map(|(_, calls)| calls)
.unwrap_or_default();
let required_builders = if field.nullable {
""
} else {
".required().aria_required()"
};
let is_textarea = matches!(field.kind, FieldKind::Text) && input_type == "text";
let (wrapper_marker, hx_builders) = validate_url.map_or_else(
|| (String::new(), String::new()),
|url| {
(
format!(" data-autumn-field-wrapper=\"{name}\""),
format!(
"\n .hx(\"post\", {url})\n \
.hx(\"trigger\", \"change\")\n \
.hx(\"target\", \"closest [data-autumn-field-wrapper]\")\n \
.hx(\"swap\", \"outerHTML\")\n \
.hx(\"include\", \"closest form\")\n \
.hx(\"params\", \"not _submit_token\")"
),
)
},
);
let errors_tail = format!(
"\n @if !errors.is_empty() {{\n \
div id=\"{name}-error\" role=\"alert\" class=\"autumn-field__errors\" {{\n \
@for error in errors {{ p class=\"autumn-field__error\" {{ (error) }} }}\n \
}}\n \
}}\n \
}}"
);
if is_textarea {
format!(
"div id=\"{name}-field\" class=\"autumn-field\"{wrapper_marker} {{\n \
@let errors = {cv}.errors_for(\"{name}\");\n \
(autumn_web::a11y::TextArea::new(\"{name}\")\n \
.label(\"{label}\")\n \
.label_class(\"autumn-field__label\")\n \
.value({cv}.field_value(\"{name}\").unwrap_or_default())\n \
{constraint_builders}{required_builders}\n \
.class(if errors.is_empty() {{ \"autumn-field__input\" }} else {{ \"autumn-field__input autumn-field__input--invalid\" }})\n \
.aria_invalid(!errors.is_empty())\n \
.described_by(if errors.is_empty() {{ \"\" }} else {{ \"{name}-error\" }}){hx_builders}){errors_tail}"
)
} else {
format!(
"div id=\"{name}-field\" class=\"autumn-field\"{wrapper_marker} {{\n \
@let errors = {cv}.errors_for(\"{name}\");\n \
(autumn_web::a11y::TextField::new(\"{name}\")\n \
.label(\"{label}\")\n \
.label_class(\"autumn-field__label\")\n \
.input_type(\"{input_type}\")\n \
.value({cv}.field_value(\"{name}\").unwrap_or_default())\n \
{constraint_builders}{required_builders}\n \
.class(if errors.is_empty() {{ \"autumn-field__input\" }} else {{ \"autumn-field__input autumn-field__input--invalid\" }})\n \
.aria_invalid(!errors.is_empty())\n \
.described_by(if errors.is_empty() {{ \"\" }} else {{ \"{name}-error\" }}){hx_builders}){errors_tail}"
)
}
}
fn render_live_reference_select(field: &Field, changeset_var: &str) -> String {
let name = &field.name;
let label = humanize_label(name);
let cv = changeset_var;
let placeholder = if field.nullable {
"— Unset —"
} else {
"— Select —"
};
let required_builders = if field.nullable {
""
} else {
".required().aria_required()"
};
format!(
"div id=\"{name}-field\" class=\"autumn-field\" {{\n \
@let errors = {cv}.errors_for(\"{name}\");\n \
(autumn_web::a11y::Select::new(\"{name}\")\n \
.label(\"{label}\")\n \
.label_class(\"autumn-field__label\")\n \
{required_builders}\n \
.class(if errors.is_empty() {{ \"autumn-field__input\" }} else {{ \"autumn-field__input autumn-field__input--invalid\" }})\n \
.aria_invalid(!errors.is_empty())\n \
.described_by(if errors.is_empty() {{ \"\" }} else {{ \"{name}-error\" }})\n \
.option(\"\", \"{placeholder}\")\n \
.options({name}_options.iter().map(|(opt_value, opt_label)| autumn_web::a11y::SelectOption::new(opt_value.as_str(), opt_label.as_str())))\n \
.selected_value({cv}.field_value(\"{name}\").unwrap_or_default()))\n \
@if !errors.is_empty() {{\n \
div id=\"{name}-error\" role=\"alert\" class=\"autumn-field__errors\" {{\n \
@for error in errors {{ p class=\"autumn-field__error\" {{ (error) }} }}\n \
}}\n \
}}\n \
}}"
)
}
fn cell_value_expr(field: &Field) -> String {
let name = &field.name;
match (field.nullable, field.kind) {
(_, FieldKind::Attachment) => {
format!("if row.{name}.is_some() {{ \"attachment\" }} else {{ \"—\" }}")
}
(true, FieldKind::Bytea) => {
format!(
"row.{name}.as_ref().map(|v| String::from_utf8_lossy(v).to_string()).unwrap_or_default()"
)
}
(true, FieldKind::String | FieldKind::Text) => {
format!("row.{name}.as_deref().unwrap_or_default()")
}
(true, _) => format!("row.{name}.as_ref().map(ToString::to_string).unwrap_or_default()"),
(false, FieldKind::Bytea) => format!("String::from_utf8_lossy(&row.{name})"),
(false, FieldKind::String | FieldKind::Text) => format!("&row.{name}"),
(false, FieldKind::I32 | FieldKind::I64 | FieldKind::F32 | FieldKind::F64) => {
format!("row.{name}")
}
(false, _) => format!("row.{name}.to_string()"),
}
}
const fn kind_is_sortable(kind: FieldKind) -> bool {
!matches!(
kind,
FieldKind::Bytea | FieldKind::Attachment | FieldKind::Enum
)
}
fn render_columns_vec(
pascal_name: &str,
fields: &[Field],
reference_displays: &BTreeMap<String, ReferenceDisplay>,
use_label_maps: bool,
) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(fields.len() * 150 + 300);
let _ = writeln!(
out,
" let columns: Vec<autumn_web::widgets::Column<{pascal_name}>> = vec!["
);
let _ = writeln!(
out,
" autumn_web::widgets::Column::new(\"Id\", |row: &{pascal_name}| maud::html! {{ (row.id) }}).sortable(\"id\"),"
);
for f in fields {
let header = title_case(&f.name);
let sortable_suffix = if kind_is_sortable(f.kind) {
format!(".sortable(\"{}\")", f.name)
} else {
String::new()
};
let cell_expr = if use_label_maps
&& f.kind.is_reference()
&& reference_displays.contains_key(&f.name)
{
let name = &f.name;
if f.nullable {
format!(
"match &row.{name} {{ Some(v) => {name}_labels.get(&v.to_string()).map(String::as_str).unwrap_or(\"—\"), None => \"—\" }}"
)
} else {
format!(
"{name}_labels.get(&row.{name}.to_string()).map(String::as_str).unwrap_or(\"—\")"
)
}
} else {
cell_value_expr(f)
};
let _ = writeln!(
out,
" autumn_web::widgets::Column::new(\"{header}\", |row: &{pascal_name}| maud::html! {{ ({cell_expr}) }}){sortable_suffix},"
);
}
let _ = writeln!(
out,
" autumn_web::widgets::Column::new(\"\", |row: &{pascal_name}| maud::html! {{ (autumn_web::a11y::Link::new(paths::show(row.id), \"Show\")) }}),"
);
let _ = writeln!(out, " ];");
out
}
fn render_show_property_rows(
fields: &[Field],
reference_displays: &BTreeMap<String, ReferenceDisplay>,
) -> String {
let mut out = String::with_capacity(fields.len() * 100 + 150);
out.push_str(" (\"Id\", maud::html! { (row.id) }),\n");
for f in fields {
let label = humanize(&f.name);
let cell_expr = if f.kind.is_reference() && reference_displays.contains_key(&f.name) {
format!("{}_label", f.name)
} else {
cell_value_expr(f)
};
out.push_str(" (\"");
out.push_str(&label);
out.push_str("\", maud::html! { (");
out.push_str(&cell_expr);
out.push_str(") }),\n");
}
out.push_str(" (\"Created at\", maud::html! { (row.created_at.to_string()) }),\n");
out
}
fn render_show_reference_label_loads(
fields: &[Field],
reference_displays: &BTreeMap<String, ReferenceDisplay>,
) -> String {
use std::fmt::Write as _;
let mut out = String::new();
for f in fields {
let Some(display) = reference_displays.get(&f.name) else {
continue;
};
let Some(table) = f.reference_table() else {
continue;
};
let name = &f.name;
let column = &display.column;
let found_expr = if display.column_nullable {
format!(
"{table}::table.find(fk).select({table}::{column}).first::<Option<String>>(&mut *db).await.ok().flatten().unwrap_or_else(|| fk.to_string())"
)
} else {
format!(
"{table}::table.find(fk).select({table}::{column}).first::<String>(&mut *db).await.unwrap_or_else(|_| fk.to_string())"
)
};
if f.nullable {
let _ = writeln!(
out,
" let {name}_label: String = match row.{name} {{ Some(fk) => {found_expr}, None => \"—\".to_string() }};"
);
} else {
let _ = writeln!(
out,
" let {name}_label: String = {{ let fk = row.{name}; {found_expr} }};"
);
}
}
out
}
fn render_index_reference_label_loads(
reference_fields: &[&Field],
reference_displays: &BTreeMap<String, ReferenceDisplay>,
) -> String {
use std::fmt::Write as _;
let mut out = String::new();
for f in reference_fields {
if !reference_displays.contains_key(&f.name) {
continue;
}
let name = &f.name;
let _ = writeln!(
out,
" let {name}_labels: std::collections::HashMap<String, String> =\n \
{name}_select_options(&mut db).await?.into_iter().collect();"
);
}
out
}
fn humanize(s: &str) -> String {
let replaced = s.replace('_', " ");
let mut chars = replaced.chars();
chars.next().map_or_else(String::new, |c| {
c.to_uppercase().to_string() + chars.as_str()
})
}
fn title_case(s: &str) -> String {
s.split('_')
.map(|word| {
let mut chars = word.chars();
chars.next().map_or_else(String::new, |c| {
c.to_uppercase().to_string() + chars.as_str()
})
})
.collect::<Vec<_>>()
.join(" ")
}
const fn is_constrained_required_numeric(f: &Field) -> bool {
!f.nullable
&& matches!(
f.kind,
FieldKind::I32 | FieldKind::I64 | FieldKind::F32 | FieldKind::F64
)
&& (f.constraints.min.is_some() || f.constraints.max.is_some())
}
fn render_nullable_field_match(fields: &[Field]) -> String {
let names = fields
.iter()
.filter(|field| field.nullable || is_constrained_required_numeric(field))
.map(|field| format!("\"{}\"", field.name))
.collect::<Vec<_>>();
if names.is_empty() {
"false".to_owned()
} else {
format!("matches!(name, {})", names.join(" | "))
}
}
fn render_update_columns(plural: &str, fields: &[Field]) -> String {
use std::fmt::Write;
let mut out = String::with_capacity(fields.len() * 50);
for (i, f) in fields
.iter()
.filter(|f| f.state_machine.is_none())
.enumerate()
{
if i > 0 {
out.push_str(", ");
}
write!(
out,
"{plural}::{name}.eq(new.{name}.clone())",
name = f.name
)
.unwrap();
}
out
}
fn render_execute_sql_calls(sql: &str) -> String {
let mut out = String::new();
for statement in crate::migrate::safety::split_statements(sql) {
let escaped = escape_sql_for_rust_literal(&statement);
let _ = writeln!(out, "db.execute_sql(\"{escaped};\").await;");
}
out
}
fn escape_sql_for_rust_literal(sql: &str) -> String {
sql.replace('\\', "\\\\").replace('"', "\\\"")
}
fn render_index_smoke_test(
pascal_name: &str,
plural: &str,
id_schema_type: &str,
setup_calls: &str,
) -> String {
format!(
"//! Smoke test generated by `autumn generate scaffold`.\n\
//!\n\
//! Boots a stand-in for the scaffolded `GET /{plural}` route in-process\n\
//! via `autumn_web::test::{{TestApp, TestClient, TestDb}}` and asserts a\n\
//! real response against a real (throwaway) Postgres database -- no\n\
//! running server, no hard-coded server base URL env var, no silent skip.\n\
//!\n\
//! The handler below is NOT the generated `routes::{plural}::index`\n\
//! handler: a `tests/` integration binary cannot import a project's own\n\
//! code when the project has no `src/lib.rs` (see\n\
//! `docs/guide/tutorial/11-testing.md`), so this test redeclares a\n\
//! minimal handler that runs the same kind of query against the same\n\
//! table instead. That proves the database, migration, and in-process\n\
//! request pipeline all work -- it does NOT exercise the real handler's\n\
//! pagination, `data_table` rendering, CSRF, or flash-message logic. For\n\
//! coverage of that, add unit tests inside `src/routes/{plural}.rs`\n\
//! itself (it's part of the binary crate, so it can call the real code).\n\
//!\n\
//! `cargo test` reports this test as `ignored` (Docker is not assumed to\n\
//! be available in every environment); run it for real with:\n\
//!\n\
//! cargo test -- --ignored\n\
\n\
use autumn_web::prelude::*;\n\
use autumn_web::test::{{TestApp, TestClient, TestDb}};\n\
use diesel::prelude::*;\n\
use diesel_async::RunQueryDsl;\n\
\n\
diesel::table! {{\n\
{plural} (id) {{\n\
id -> {id_schema_type},\n\
}}\n\
}}\n\
\n\
#[get(\"/{plural}\")]\n\
async fn index(mut db: Db) -> AutumnResult<Markup> {{\n\
let total: i64 = {plural}::table.count().get_result(&mut db).await?;\n\
Ok(html! {{\n\
h1 {{ \"{pascal_name}s\" }}\n\
p {{ (total) \" row(s)\" }}\n\
}})\n\
}}\n\
\n\
#[tokio::test]\n\
#[ignore = \"requires Docker (testcontainers) via TestDb; run `cargo test -- --ignored`\"]\n\
async fn {plural}_index_renders_scaffolded_rows() {{\n\
let db = TestDb::shared().await;\n\
{setup_calls}\
db.execute_sql(\"TRUNCATE {plural} RESTART IDENTITY\").await;\n\
\n\
let client: TestClient = TestApp::new().routes(routes![index]).with_db(db.pool()).build();\n\
\n\
client.get(\"/{plural}\").send().await\n\
.assert_ok()\n\
.assert_body_contains(\"{pascal_name}s\");\n\
}}\n"
)
}
fn render_api_smoke_test(
plural: &str,
id_schema_type: &str,
setup_calls: &str,
fields: &[Field],
defaults: &BTreeMap<String, String>,
) -> String {
let seed_calls = render_execute_sql_calls(&render_api_seed_sql(plural, fields, defaults));
format!(
"//! Smoke test generated by `autumn generate scaffold --api`.\n\
//!\n\
//! Boots a stand-in for the scaffolded `GET /api/{plural}` route\n\
//! in-process via `autumn_web::test::{{TestApp, TestClient, TestDb}}`\n\
//! and asserts a real response against a real (throwaway) Postgres\n\
//! database -- no running server, no hard-coded server base URL env\n\
//! var, no silent skip.\n\
//!\n\
//! The handler below is NOT the generated repository's JSON list\n\
//! handler: a `tests/` integration binary cannot import a project's own\n\
//! code when the project has no `src/lib.rs` (see\n\
//! `docs/guide/tutorial/11-testing.md`). It reuses the same shipped\n\
//! `PageRequest` extractor + `Page` envelope the real generated\n\
//! `--api` list handler paginates with (issue #1237), so it proves the\n\
//! self-describing envelope shape and that pages advance -- but it does\n\
//! NOT exercise the real repository handler's serialization or filtering.\n\
//!\n\
//! `cargo test` reports this test as `ignored` (Docker is not assumed to\n\
//! be available in every environment); run it for real with:\n\
//!\n\
//! cargo test -- --ignored\n\
\n\
use autumn_web::prelude::*;\n\
use autumn_web::test::{{TestApp, TestClient, TestDb}};\n\
use diesel::prelude::*;\n\
use diesel::dsl::sql;\n\
use diesel::sql_types::Text;\n\
use diesel_async::RunQueryDsl;\n\
\n\
diesel::table! {{\n\
{plural} (id) {{\n\
id -> {id_schema_type},\n\
}}\n\
}}\n\
\n\
// Stand-in that paginates by default, exactly like the generated\n\
// `--api` list handler: a bounded `LIMIT/OFFSET` window wrapped in the\n\
// `Page` envelope, never an unbounded `find_all()`.\n\
#[get(\"/api/{plural}\")]\n\
async fn api_list(page: PageRequest, mut db: Db) -> AutumnResult<Page<String>> {{\n\
let total: i64 = {plural}::table.count().get_result(&mut db).await?;\n\
let ids: Vec<String> = {plural}::table\n\
.select(sql::<Text>(\"id::text\"))\n\
.order({plural}::id.asc())\n\
.limit(page.limit())\n\
.offset(page.offset())\n\
.load(&mut db)\n\
.await?;\n\
Ok(Page::new(ids, total, &page))\n\
}}\n\
\n\
#[tokio::test]\n\
#[ignore = \"requires Docker (testcontainers) via TestDb; run `cargo test -- --ignored`\"]\n\
async fn {plural}_api_list_paginates_against_a_real_database() {{\n\
let db = TestDb::shared().await;\n\
{setup_calls}\
db.execute_sql(\"TRUNCATE {plural} RESTART IDENTITY\").await;\n\
// Seed 25 rows -- more than one page at size = 2, and more than the\n\
// default page size (20). Every required (NOT NULL, no DEFAULT) column\n\
// gets a valid, per-row-varying sample value, so this works for an\n\
// ordinary scaffold with required fields -- not just an all-default\n\
// table a bare `INSERT ... DEFAULT VALUES` could satisfy.\n\
{seed_calls}\
\n\
let client: TestClient = TestApp::new().routes(routes![api_list]).with_db(db.pool()).build();\n\
\n\
// Page 1 returns the self-describing `Page` envelope, bounded to `size`.\n\
let page1: serde_json::Value = client\n\
.get(\"/api/{plural}?page=1&size=2\")\n\
.send()\n\
.await\n\
.assert_ok()\n\
.json();\n\
assert_eq!(page1[\"page\"], 1);\n\
assert_eq!(page1[\"size\"], 2);\n\
assert_eq!(page1[\"total_elements\"], 25);\n\
assert_eq!(page1[\"total_pages\"], 13);\n\
assert_eq!(page1[\"has_next\"], true);\n\
assert_eq!(page1[\"content\"].as_array().map(Vec::len), Some(2));\n\
\n\
// Page 2 holds different rows -- pagination actually advances.\n\
let page2: serde_json::Value = client\n\
.get(\"/api/{plural}?page=2&size=2\")\n\
.send()\n\
.await\n\
.assert_ok()\n\
.json();\n\
assert_ne!(page1[\"content\"], page2[\"content\"], \"page 2 must differ from page 1\");\n\
}}\n"
)
}
fn render_reference_stub_tables_sql(fields: &[Field], own_table: &str) -> String {
let mut out = String::new();
let mut seen = BTreeSet::new();
for f in fields {
if let Some(target) = f.reference_table()
&& target != own_table
&& seen.insert(target.clone())
{
let _ = writeln!(
out,
"CREATE TABLE IF NOT EXISTS {target} (id BIGSERIAL PRIMARY KEY);"
);
let _ = writeln!(
out,
"INSERT INTO {target} DEFAULT VALUES;\nINSERT INTO {target} DEFAULT VALUES;"
);
}
}
out
}
#[allow(clippy::too_many_arguments)]
fn render_smoke_test(
pascal_name: &str,
plural: &str,
api: bool,
fields: &[Field],
id_type: IdType,
indexes: &BTreeSet<String>,
defaults: &BTreeMap<String, String>,
validations: &BTreeMap<String, Vec<String>>,
authorize: bool,
) -> String {
let stub_tables_sql = render_reference_stub_tables_sql(fields, plural);
let create_table_sql =
create_table_sql_with_metadata_and_id(plural, fields, indexes, defaults, id_type);
let mut setup_calls = render_execute_sql_calls(&stub_tables_sql);
setup_calls.push_str(&render_execute_sql_calls(&create_table_sql));
let id_schema_type = id_type.schema_type();
let base = if api {
render_api_smoke_test(plural, id_schema_type, &setup_calls, fields, defaults)
} else {
render_index_smoke_test(pascal_name, plural, id_schema_type, &setup_calls)
};
let base = if fields.iter().any(Field::is_enum) {
base + &render_enum_rejection_smoke_test(plural, fields, defaults, &setup_calls, api)
} else {
base
};
let unique_fields: Vec<&Field> = fields.iter().filter(|f| f.unique).collect();
let base = if unique_fields.is_empty() {
base
} else {
base + &render_unique_violation_smoke_test(
plural,
fields,
&unique_fields,
defaults,
&setup_calls,
api,
)
};
let base = if !api && !validations.is_empty() {
base + &render_validation_rejection_smoke_test(plural, validations)
} else {
base
};
let base = if api {
base
} else {
base + &render_write_path_smoke_test(pascal_name, plural)
};
let base = if !api && has_attachment_fields(fields) {
base + &render_attachment_multipart_write_path_smoke_test(plural, fields)
} else {
base
};
if authorize {
base + &render_cross_user_forbidden_smoke_test(plural)
} else {
base
}
}
fn invalid_value_violating_rule(rule: &str) -> Option<String> {
if rule == "url" {
return Some("not a valid url".to_owned());
}
if rule == "email" {
return Some("not-an-email".to_owned());
}
if let Some(args) = rule
.strip_prefix("length(")
.and_then(|s| s.strip_suffix(')'))
{
let mut min: Option<u64> = None;
let mut max: Option<u64> = None;
for part in args.split(',') {
let part = part.trim();
if let Some(n) = part.strip_prefix("min = ") {
min = n.trim().parse().ok();
} else if let Some(n) = part.strip_prefix("max = ") {
max = n.trim().parse().ok();
}
}
return match (min, max) {
(Some(min), _) if min > 0 => Some(String::new()),
(_, Some(max)) => Some("x".repeat(usize::try_from(max + 1).unwrap_or(usize::MAX))),
_ => None,
};
}
None
}
const MAX_GENERATED_VALIDATION_SAMPLE_LEN: u64 = 4096;
fn length_bound_exceeds_sample_cap(rule: &str) -> bool {
let Some(args) = rule
.strip_prefix("length(")
.and_then(|s| s.strip_suffix(')'))
else {
return false;
};
args.split(',').any(|part| {
let part = part.trim();
part.strip_prefix("min = ")
.or_else(|| part.strip_prefix("max = "))
.and_then(|n| n.trim().parse::<u64>().ok())
.is_some_and(|bound| bound > MAX_GENERATED_VALIDATION_SAMPLE_LEN)
})
}
fn valid_value_satisfying_rule(rule: &str) -> String {
if rule == "url" {
return "https://example.com".to_owned();
}
if rule == "email" {
return "person@example.com".to_owned();
}
if let Some(args) = rule
.strip_prefix("length(")
.and_then(|s| s.strip_suffix(')'))
{
let mut min: Option<u64> = None;
let mut max: Option<u64> = None;
for part in args.split(',') {
let part = part.trim();
if let Some(n) = part.strip_prefix("min = ") {
min = n.trim().parse().ok();
} else if let Some(n) = part.strip_prefix("max = ") {
max = n.trim().parse().ok();
}
}
let len = match (min, max) {
(Some(min), Some(max)) => min.max(1).min(max),
(Some(min), None) => min.max(1),
(None, Some(max)) => max,
(None, None) => 1,
};
return "a".repeat(usize::try_from(len).unwrap_or(usize::MAX));
}
"a valid value".to_owned()
}
fn render_validation_rejection_smoke_test(
plural: &str,
validations: &BTreeMap<String, Vec<String>>,
) -> String {
let Some((target_name, rule, invalid_value)) = validations.iter().find_map(|(name, rules)| {
let rule = rules.first().cloned().unwrap_or_default();
if length_bound_exceeds_sample_cap(&rule) {
return None;
}
let invalid_value = invalid_value_violating_rule(&rule)?;
Some((name, rule, invalid_value))
}) else {
return String::new();
};
let valid_value = valid_value_satisfying_rule(&rule);
let witness_value = "preserved-witness-value";
format!(
"\n#[tokio::test]\n\
async fn {plural}_rejects_invalid_{target_name}_and_preserves_input() {{\n\
\x20\x20\x20\x20use autumn_web::form::ChangesetForm;\n\
\x20\x20\x20\x20use autumn_web::test::{{TestApp, TestClient}};\n\
\n\
\x20\x20\x20\x20#[derive(serde::Deserialize, serde::Serialize, Default, validator::Validate, Clone)]\n\
\x20\x20\x20\x20struct Form {{\n\
\x20\x20\x20\x20\x20\x20\x20\x20#[validate({rule})]\n\
\x20\x20\x20\x20\x20\x20\x20\x20{target_name}: String,\n\
\x20\x20\x20\x20\x20\x20\x20\x20other_field: String,\n\
\x20\x20\x20\x20}}\n\
\n\
\x20\x20\x20\x20#[post(\"/{plural}\")]\n\
\x20\x20\x20\x20async fn create(form: ChangesetForm<Form>) -> impl IntoResponse {{\n\
\x20\x20\x20\x20\x20\x20\x20\x20match form.into_valid() {{\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20Ok(_) => StatusCode::OK.into_response(),\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20Err(form) => (\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20StatusCode::UNPROCESSABLE_ENTITY,\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20html! {{\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20(autumn_web::form::text_input(&form.changeset, \"{target_name}\", \"{target_name}\"))\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20(autumn_web::form::text_input(&form.changeset, \"other_field\", \"other_field\"))\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20}},\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20).into_response(),\n\
\x20\x20\x20\x20\x20\x20\x20\x20}}\n\
\x20\x20\x20\x20}}\n\
\n\
\x20\x20\x20\x20let client: TestClient = TestApp::new().routes(routes![create]).build();\n\
\n\
\x20\x20\x20\x20// A submission violating the declared rule must respond 422, echo the\n\
\x20\x20\x20\x20// *other* field's submitted value (input is preserved, not lost), and\n\
\x20\x20\x20\x20// carry an inline, accessible error message (AC2, AC3, AC4).\n\
\x20\x20\x20\x20let invalid_body = format!(\"{target_name}={invalid_value}&other_field={witness_value}\");\n\
\x20\x20\x20\x20client.post(\"/{plural}\").form(&invalid_body).send().await\n\
\x20\x20\x20\x20\x20\x20\x20\x20.assert_status(422)\n\
\x20\x20\x20\x20\x20\x20\x20\x20.assert_body_contains(\"{witness_value}\")\n\
\x20\x20\x20\x20\x20\x20\x20\x20.assert_body_contains(\"role=\\\"alert\\\"\");\n\
\n\
\x20\x20\x20\x20// A fully valid submission still succeeds (AC5).\n\
\x20\x20\x20\x20let valid_body = format!(\"{target_name}={valid_value}&other_field={witness_value}\");\n\
\x20\x20\x20\x20client.post(\"/{plural}\").form(&valid_body).send().await\n\
\x20\x20\x20\x20\x20\x20\x20\x20.assert_status(200);\n\
}}\n"
)
}
#[allow(clippy::too_many_lines)] fn render_write_path_smoke_test(pascal_name: &str, plural: &str) -> String {
const TEMPLATE: &str = r#"
// ── write-path CRUD (issue #1127) ─────────────────────────────────────────
//
// Exercises create / update / delete and the validation-failure re-render in
// one in-process test against a stand-in resource. `TestApp::new()` disables
// CSRF, so these same-origin form POSTs need no `_csrf` token (the real
// form_for-rendered forms inject one for the browser; PR #1587).
#[tokio::test]
async fn __PLURAL___write_path_crud() {
use autumn_web::test::{TestApp, TestClient};
// A process-local, in-memory stand-in for the persistence layer: `(id, name)`
// rows shared across requests within this test. No database required.
static STORE: std::sync::Mutex<Vec<(i64, String)>> = std::sync::Mutex::new(Vec::new());
#[derive(serde::Deserialize, serde::Serialize, Default, validator::Validate, Clone)]
struct Form {
#[validate(length(min = 1, message = "must not be blank"))]
name: String,
witness: String,
}
#[get("/__PLURAL__")]
async fn index() -> Markup {
let store = STORE.lock().unwrap();
let count = store.len() as i64;
html! {
h1 { "__PLURAL__" }
p { (count) " row(s)" }
ul { @for (_, name) in store.iter() { li { (name.as_str()) } } }
}
}
#[post("/__PLURAL__")]
async fn create(form: ChangesetForm<Form>) -> impl IntoResponse {
match form.into_valid() {
Ok(valid) => {
let mut store = STORE.lock().unwrap();
let id = store.iter().map(|(i, _)| *i).max().unwrap_or(0) + 1;
store.push((id, valid.name));
Redirect::to("/__PLURAL__").into_response()
}
Err(form) => (
StatusCode::UNPROCESSABLE_ENTITY,
html! {
(autumn_web::form::text_input(&form.changeset, "name", "name"))
(autumn_web::form::text_input(&form.changeset, "witness", "witness"))
},
)
.into_response(),
}
}
#[post("/__PLURAL__/{id}/update")]
async fn update(id: Path<i64>, form: ChangesetForm<Form>) -> impl IntoResponse {
let valid = match form.into_valid() {
Ok(valid) => valid,
Err(_) => return StatusCode::UNPROCESSABLE_ENTITY.into_response(),
};
{
let mut store = STORE.lock().unwrap();
if let Some(row) = store.iter_mut().find(|(i, _)| *i == *id) {
row.1 = valid.name;
}
}
// Mirror the generated handler: update redirects to the show route.
Redirect::to(&format!("/__PLURAL__/{}", *id)).into_response()
}
#[post("/__PLURAL__/{id}/delete")]
async fn destroy(id: Path<i64>) -> impl IntoResponse {
STORE.lock().unwrap().retain(|(i, _)| *i != *id);
Redirect::to("/__PLURAL__")
}
let client: TestClient = TestApp::new()
.routes(routes![index, create, update, destroy])
.build();
// Create (happy path): a valid POST redirects (303 See Other) to the index
// route and the row is persisted — a follow-up read shows it.
client
.post("/__PLURAL__")
.form("name=first-record&witness=w")
.send()
.await
.assert_status(303)
.assert_header("location", "/__PLURAL__");
client
.get("/__PLURAL__")
.send()
.await
.assert_ok()
.assert_body_contains("first-record")
.assert_body_contains("1 row(s)");
// Validation failure: an invalid create re-renders the form at 422 with the
// submitted input preserved and an inline error — it does NOT redirect and
// does NOT persist (the row count stays at 1).
client
.post("/__PLURAL__")
.form("name=&witness=kept-on-error")
.send()
.await
.assert_status(422)
.assert_body_contains("kept-on-error")
.assert_body_contains("role=\"alert\"");
client
.get("/__PLURAL__")
.send()
.await
.assert_body_contains("1 row(s)");
// Update (happy path): a POST of changed fields redirects (303) to the show
// route and the change is observable on a subsequent read. The row count is
// unchanged, so the value was replaced in place, not appended.
client
.post("/__PLURAL__/1/update")
.form("name=second-record&witness=w")
.send()
.await
.assert_status(303)
.assert_header("location", "/__PLURAL__/1");
client
.get("/__PLURAL__")
.send()
.await
.assert_body_contains("second-record")
.assert_body_contains("1 row(s)");
// Delete: the generated HTML delete action removes the row — a POST to the
// `/delete` route redirects and the row is gone on the next index read.
client
.post("/__PLURAL__/1/delete")
.send()
.await
.assert_status(303)
.assert_header("location", "/__PLURAL__");
client
.get("/__PLURAL__")
.send()
.await
.assert_body_contains("0 row(s)");
}
"#;
TEMPLATE
.replace("__PASCAL__", pascal_name)
.replace("__PLURAL__", plural)
}
#[allow(clippy::too_many_lines)] fn render_attachment_multipart_write_path_smoke_test(plural: &str, fields: &[Field]) -> String {
const TEMPLATE: &str = r#"
// ── multipart attachment write-path (issue #1236, AC6) ─────────────────────
//
// Follows the #1127 in-process write-path style (a process-local stand-in for
// the persistence layer, no database required, so it runs without Docker): a
// zero-JS `multipart/form-data` create drives the REAL `Multipart` extractor
// and the REAL `save_to_blob_store` against a REAL `LocalBlobStore`, binds the
// returned `Blob` on the record, and asserts the persisted record's attachment
// column holds a non-empty blob key AND that the uploaded bytes actually landed
// in the store at that key. The handler is an in-test stand-in (a `tests/`
// binary cannot import the project's own routes) that runs the SAME blob path
// the generated `create` handler runs. `TestApp::new()` disables CSRF, so the
// hand-built body carries no `_csrf` part (the real prod form injects one).
#[tokio::test]
async fn __PLURAL___multipart_upload_binds_blob_key() {
use autumn_web::storage::local::SigningKey;
use autumn_web::storage::{BlobStore, BlobStoreState, LocalBlobStore};
// Process-local stand-in for the persistence layer: the attachment `Blob`
// bound on each created record (mirrors the `New{Pascal}.{attachment}`
// column the generated `create` handler sets). No database required.
static STORE: std::sync::Mutex<Vec<autumn_web::storage::Blob>> =
std::sync::Mutex::new(Vec::new());
#[post("/__PLURAL__/upload-probe")]
async fn create(
autumn_web::extract::State(state): autumn_web::extract::State<autumn_web::AppState>,
mut multipart: autumn_web::extract::Multipart,
) -> AutumnResult<Redirect> {
let store = state
.extension::<BlobStoreState>()
.expect("blob store configured")
.store()
.clone();
while let Some(field) = multipart.next_field().await? {
if field.name() == Some("__ATTACH__")
&& field.file_name().is_some_and(|name| !name.is_empty())
{
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default();
let key = format!("__PLURAL__/__ATTACH__/{nanos}");
let blob = field.save_to_blob_store(&*store, key).await?;
STORE.lock().unwrap().push(blob);
}
}
Ok(Redirect::to("/__PLURAL__"))
}
// A real on-disk blob store so `save_to_blob_store` has a backend.
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default();
let blob_root = std::env::temp_dir().join(format!("__PLURAL__-blob-probe-{nanos}"));
let blob_store = std::sync::Arc::new(
LocalBlobStore::new(
"test",
blob_root.clone(),
"/_blobs",
std::time::Duration::from_secs(60),
SigningKey::new(b"test-signing-key".to_vec()),
Vec::new(),
)
.expect("build local blob store"),
);
let store_handle = blob_store.clone();
let client: TestClient = TestApp::new()
.routes(routes![create])
.state_initializer(move |state| {
state.insert_extension(BlobStoreState::new(blob_store.clone()));
})
.build();
// Hand-built multipart body (TestClient has no `.multipart()` helper): a
// text part for a sibling field plus a file part for the attachment field.
let body = "--BOUND\r\n\
Content-Disposition: form-data; name=\"__TEXT_FIELD__\"\r\n\r\n\
hello\r\n\
--BOUND\r\n\
Content-Disposition: form-data; name=\"__ATTACH__\"; filename=\"upload.png\"\r\n\
Content-Type: image/png\r\n\r\n\
not-an-empty-file\r\n\
--BOUND--\r\n";
// The multipart create succeeds and redirects (303), exactly like the real
// generated attachment handler.
client
.post("/__PLURAL__/upload-probe")
.header("content-type", "multipart/form-data; boundary=BOUND")
.body(body.to_owned())
.send()
.await
.assert_status(303)
.assert_header("location", "/__PLURAL__");
// The persisted record bound a non-empty blob key with the expected prefix.
let bound = STORE
.lock()
.unwrap()
.last()
.cloned()
.expect("a record was created with an attachment blob");
assert!(
!bound.key.is_empty(),
"the attachment column must bind a non-empty blob key"
);
assert!(
bound.key.starts_with("__PLURAL__/__ATTACH__/"),
"bound key: {}",
bound.key
);
// The uploaded bytes actually landed in the store at the bound key.
let stored_bytes = store_handle
.get(&bound.key)
.await
.expect("blob present in store at the bound key");
assert_eq!(&stored_bytes[..], b"not-an-empty-file");
let _ = std::fs::remove_dir_all(&blob_root);
}
"#;
let attach = fields
.iter()
.find(|f| f.kind.is_attachment())
.map_or("attachment", |f| f.name.as_str());
let text_field = fields
.iter()
.find(|f| !f.kind.is_attachment())
.map_or("note", |f| f.name.as_str());
TEMPLATE
.replace("__PLURAL__", plural)
.replace("__ATTACH__", attach)
.replace("__TEXT_FIELD__", text_field)
}
#[allow(clippy::too_many_lines)] fn render_cross_user_forbidden_smoke_test(plural: &str) -> String {
const TEMPLATE: &str = r#"
// ── cross-user record-level authorization (issue #1125) ───────────────────
//
// Owner-or-admin update/delete. A row owned by user A is off-limits to user B:
// B's update/delete are forbidden (403) and B's index excludes A's rows, while
// the owner (A) is allowed. `TestApp::new()` disables CSRF; the 403 comes from
// the registered policy + the `ForbiddenResponse::Forbidden403` test override.
#[tokio::test]
async fn __PLURAL___cross_user_mutations_are_forbidden() {
use autumn_web::authorization::{authorize, BoxFuture, Policy, PolicyContext};
use autumn_web::test::{TestApp, TestClient};
#[derive(Clone)]
struct OwnedRow {
id: i64,
owner_id: i64,
}
// Process-local stand-in for the persistence layer.
static STORE: std::sync::Mutex<Vec<OwnedRow>> = std::sync::Mutex::new(Vec::new());
fn find_row(id: i64) -> Option<OwnedRow> {
STORE.lock().unwrap().iter().find(|r| r.id == id).cloned()
}
#[derive(Default)]
struct OwnedRowPolicy;
impl Policy<OwnedRow> for OwnedRowPolicy {
fn can_update<'a>(&'a self, ctx: &'a PolicyContext, row: &'a OwnedRow) -> BoxFuture<'a, bool> {
Box::pin(async move { ctx.has_role("admin") || ctx.user_id_i64() == Some(row.owner_id) })
}
fn can_delete<'a>(&'a self, ctx: &'a PolicyContext, row: &'a OwnedRow) -> BoxFuture<'a, bool> {
Box::pin(async move { ctx.has_role("admin") || ctx.user_id_i64() == Some(row.owner_id) })
}
}
#[secured]
#[get("/owned")]
async fn index(
autumn_web::extract::State(state): autumn_web::extract::State<autumn_web::AppState>,
session: autumn_web::session::Session,
) -> autumn_web::AutumnResult<autumn_web::Markup> {
let ctx = PolicyContext::from_request(&state, &session).await;
let me = ctx.user_id_i64().unwrap_or(-1);
let ids: Vec<i64> = STORE
.lock()
.unwrap()
.iter()
.filter(|r| r.owner_id == me)
.map(|r| r.id)
.collect();
Ok(autumn_web::html! { @for id in &ids { p { "owned-row-" (id) } } })
}
#[secured]
#[post("/owned/{id}/update")]
async fn update(
id: autumn_web::extract::Path<i64>,
autumn_web::extract::State(state): autumn_web::extract::State<autumn_web::AppState>,
session: autumn_web::session::Session,
) -> autumn_web::AutumnResult<autumn_web::Redirect> {
let row = find_row(*id).ok_or_else(|| autumn_web::AutumnError::not_found_msg("not found"))?;
authorize::<OwnedRow>(&state, &session, "update", &row).await?;
Ok(autumn_web::Redirect::to("/owned"))
}
#[secured]
#[post("/owned/{id}/delete")]
async fn destroy(
id: autumn_web::extract::Path<i64>,
autumn_web::extract::State(state): autumn_web::extract::State<autumn_web::AppState>,
session: autumn_web::session::Session,
) -> autumn_web::AutumnResult<autumn_web::Redirect> {
let row = find_row(*id).ok_or_else(|| autumn_web::AutumnError::not_found_msg("not found"))?;
authorize::<OwnedRow>(&state, &session, "delete", &row).await?;
STORE.lock().unwrap().retain(|r| r.id != *id);
Ok(autumn_web::Redirect::to("/owned"))
}
{
let mut store = STORE.lock().unwrap();
store.clear();
// Row 1 is owned by user A (id 1).
store.push(OwnedRow { id: 1, owner_id: 1 });
}
let client: TestClient = TestApp::new()
.routes(routes![index, update, destroy])
.policy::<OwnedRow, _>(OwnedRowPolicy::default())
.forbidden_response(autumn_web::authorization::ForbiddenResponse::Forbidden403)
.build();
// Acting as user B (id 2), who owns nothing.
client.acting_as(2).await;
// B cannot update or delete A's row.
client.post("/owned/1/update").send().await.assert_status(403);
client.post("/owned/1/delete").send().await.assert_status(403);
// B's index excludes A's rows.
let body = client.get("/owned").send().await.assert_ok().text();
assert!(!body.contains("owned-row-1"), "B must not see A's row: {body}");
// The owner (A, id 1) is allowed to update their own row.
client.acting_as(1).await;
client.post("/owned/1/update").send().await.assert_status(303);
}
"#;
TEMPLATE.replace("__PLURAL__", plural)
}
fn sql_sample_literal(kind: FieldKind) -> String {
match kind {
FieldKind::String | FieldKind::Text | FieldKind::Enum => "'sample'".to_owned(),
FieldKind::I32 | FieldKind::I64 | FieldKind::References => "1".to_owned(),
FieldKind::Bool => "TRUE".to_owned(),
FieldKind::F32 | FieldKind::F64 => "1.0".to_owned(),
FieldKind::Decimal { scale, .. } => decimal_literal_at_scale(scale, 1),
FieldKind::Uuid => "gen_random_uuid()".to_owned(),
FieldKind::NaiveDateTime | FieldKind::DateTime => "NOW()".to_owned(),
FieldKind::Bytea => "'\\x00'::bytea".to_owned(),
FieldKind::Attachment => "NULL".to_owned(),
}
}
const API_SMOKE_TEST_SEED_ROWS: usize = 25;
fn sql_seed_value_expr(f: &Field) -> String {
match f.kind {
FieldKind::String | FieldKind::Text => "'sample-' || g".to_owned(),
FieldKind::I32 | FieldKind::I64 => "g".to_owned(),
FieldKind::F32 | FieldKind::F64 => "g::double precision".to_owned(),
FieldKind::NaiveDateTime | FieldKind::DateTime => {
"NOW() + (g || ' seconds')::interval".to_owned()
}
FieldKind::Enum => {
format!("'{}'", f.variants.first().expect("enum field has variants"))
}
_ => sql_sample_literal(f.kind),
}
}
fn render_api_seed_sql(
plural: &str,
fields: &[Field],
defaults: &BTreeMap<String, String>,
) -> String {
let mut columns = Vec::new();
let mut values = Vec::new();
for f in fields {
if !f.nullable && !defaults.contains_key(&f.name) {
columns.push(f.name.clone());
values.push(sql_seed_value_expr(f));
}
}
let n = API_SMOKE_TEST_SEED_ROWS;
if columns.is_empty() {
vec![format!("INSERT INTO {plural} DEFAULT VALUES"); n].join(";\n")
} else {
format!(
"INSERT INTO {plural} ({}) SELECT {} FROM generate_series(1, {n}) AS g",
columns.join(", "),
values.join(", ")
)
}
}
fn enum_rejection_insert_sql(
plural: &str,
fields: &[Field],
target: &Field,
defaults: &BTreeMap<String, String>,
) -> String {
let mut columns = Vec::new();
let mut values = Vec::new();
for f in fields {
if f.name == target.name {
columns.push(f.name.clone());
values.push("'__not_a_real_variant__'".to_owned());
} else if !f.nullable && !defaults.contains_key(&f.name) {
columns.push(f.name.clone());
let value = if f.is_enum() {
format!("'{}'", f.variants.first().expect("enum field has variants"))
} else {
sql_sample_literal(f.kind)
};
values.push(value);
}
}
format!(
"INSERT INTO {plural} ({}) VALUES ({})",
columns.join(", "),
values.join(", ")
)
}
fn render_enum_rejection_smoke_test(
plural: &str,
fields: &[Field],
defaults: &BTreeMap<String, String>,
setup_calls: &str,
api: bool,
) -> String {
let setup_calls = setup_calls.replacen(
&format!("CREATE TABLE {plural} ("),
&format!("CREATE TABLE IF NOT EXISTS {plural} ("),
1,
);
let setup_calls = setup_calls.replace("CREATE INDEX idx_", "CREATE INDEX IF NOT EXISTS idx_");
let setup_calls = setup_calls.as_str();
let mut out = String::new();
for target in fields.iter().filter(|f| f.is_enum()) {
let field_name = &target.name;
let insert_sql = enum_rejection_insert_sql(plural, fields, target, defaults);
let escaped_insert = escape_sql_for_rust_literal(&insert_sql);
let allowed_values = target
.variants
.iter()
.map(|v| format!("\"{v}\""))
.collect::<Vec<_>>()
.join(" | ");
let request_boundary_check = if api {
String::new()
} else {
format!(
"#[post(\"/{plural}\")]\n\
async fn create(body: autumn_web::reexports::axum::body::Bytes) -> AutumnResult<&'static str> {{\n\
\x20\x20\x20\x20let value = url::form_urlencoded::parse(body.as_ref())\n\
\x20\x20\x20\x20\x20\x20\x20\x20.find(|(k, _)| k == \"{field_name}\")\n\
\x20\x20\x20\x20\x20\x20\x20\x20.map(|(_, v)| v.into_owned())\n\
\x20\x20\x20\x20\x20\x20\x20\x20.unwrap_or_default();\n\
\x20\x20\x20\x20if !matches!(value.as_str(), {allowed_values}) {{\n\
\x20\x20\x20\x20\x20\x20\x20\x20return Err(AutumnError::bad_request_msg(format!(\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\"{field_name}: must be one of {variants_display}\"\n\
\x20\x20\x20\x20\x20\x20\x20\x20)));\n\
\x20\x20\x20\x20}}\n\
\x20\x20\x20\x20Ok(\"ok\")\n\
}}\n\
\n\
\x20\x20\x20\x20let client: TestClient = TestApp::new().routes(routes![create]).with_db(db.pool()).build();\n\
\n\
\x20\x20\x20\x20client.post(\"/{plural}\").form(\"{field_name}=__not_a_real_variant__\").send().await\n\
\x20\x20\x20\x20\x20\x20\x20\x20.assert_status(400)\n\
\x20\x20\x20\x20\x20\x20\x20\x20.assert_body_contains(\"{field_name}\");\n",
field_name = field_name,
allowed_values = allowed_values,
variants_display = target.variants.join(", "),
)
};
let _ = write!(
out,
"\n#[tokio::test]\n\
#[ignore = \"requires Docker (testcontainers) via TestDb; run `cargo test -- --ignored`\"]\n\
async fn {plural}_rejects_out_of_set_{field_name}() {{\n\
\x20\x20\x20\x20let db = TestDb::shared().await;\n\
{setup_calls}\
\x20\x20\x20\x20db.execute_sql(\"TRUNCATE {plural} RESTART IDENTITY\").await;\n\
\n\
{request_boundary_check}\
\n\
\x20\x20\x20\x20let mut conn = db.pool().get().await.expect(\"failed to get db connection\");\n\
\x20\x20\x20\x20let result = diesel::sql_query(\"{escaped_insert}\").execute(&mut *conn).await;\n\
\x20\x20\x20\x20assert!(result.is_err(), \"out-of-set {field_name} must violate the CHECK constraint\");\n\
\n\
\x20\x20\x20\x20let count: i64 = {plural}::table.count().get_result(&mut *conn).await.unwrap();\n\
\x20\x20\x20\x20assert_eq!(count, 0, \"the rejected row must not have been written\");\n\
}}\n",
);
}
out
}
fn unique_sample_literal(kind: FieldKind) -> String {
match kind {
FieldKind::String | FieldKind::Text | FieldKind::Enum => "'dup_value'".to_owned(),
FieldKind::I32 | FieldKind::I64 => "424242".to_owned(),
FieldKind::References => "1".to_owned(),
FieldKind::Bool => "TRUE".to_owned(),
FieldKind::F32 | FieldKind::F64 => "1.5".to_owned(),
FieldKind::Decimal { scale, .. } => decimal_literal_at_scale(scale, 2),
FieldKind::Uuid => "'00000000-0000-0000-0000-000000000001'::uuid".to_owned(),
FieldKind::NaiveDateTime => "'2024-01-01 00:00:00'::timestamp".to_owned(),
FieldKind::DateTime => "'2024-01-01 00:00:00+00'::timestamptz".to_owned(),
FieldKind::Bytea => "'\\xDEADBEEF'::bytea".to_owned(),
FieldKind::Attachment => "NULL".to_owned(),
}
}
fn unique_sample_literal_variant(kind: FieldKind) -> String {
match kind {
FieldKind::String | FieldKind::Text | FieldKind::Enum => "'dup_value_2'".to_owned(),
FieldKind::I32 | FieldKind::I64 => "424243".to_owned(),
FieldKind::References => "2".to_owned(),
FieldKind::Bool => "FALSE".to_owned(),
FieldKind::F32 | FieldKind::F64 => "2.5".to_owned(),
FieldKind::Decimal { scale, .. } => decimal_literal_at_scale(scale, 3),
FieldKind::Uuid => "'00000000-0000-0000-0000-000000000002'::uuid".to_owned(),
FieldKind::NaiveDateTime => "'2024-01-02 00:00:00'::timestamp".to_owned(),
FieldKind::DateTime => "'2024-01-02 00:00:00+00'::timestamptz".to_owned(),
FieldKind::Bytea => "'\\xBEEFDEAD'::bytea".to_owned(),
FieldKind::Attachment => "NULL".to_owned(),
}
}
fn unique_violation_insert_sql(
plural: &str,
fields: &[Field],
target: &Field,
defaults: &BTreeMap<String, String>,
is_duplicate_insert: bool,
) -> String {
let mut columns = Vec::new();
let mut values = Vec::new();
for f in fields {
if f.name == target.name {
columns.push(f.name.clone());
let value = if f.is_enum() {
format!("'{}'", f.variants.first().expect("enum field has variants"))
} else {
unique_sample_literal(f.kind)
};
values.push(value);
} else if !f.nullable && !defaults.contains_key(&f.name) {
columns.push(f.name.clone());
let value = if is_duplicate_insert && f.unique && f.is_enum() {
let variant = f.variants.get(1).unwrap_or(&f.variants[0]);
format!("'{variant}'")
} else if is_duplicate_insert && f.unique {
unique_sample_literal_variant(f.kind)
} else if f.is_enum() {
format!("'{}'", f.variants.first().expect("enum field has variants"))
} else {
sql_sample_literal(f.kind)
};
values.push(value);
}
}
format!(
"INSERT INTO {plural} ({}) VALUES ({})",
columns.join(", "),
values.join(", ")
)
}
fn render_unique_violation_smoke_test(
plural: &str,
fields: &[Field],
unique_fields: &[&Field],
defaults: &BTreeMap<String, String>,
setup_calls: &str,
api: bool,
) -> String {
let setup_calls = setup_calls.replacen(
&format!("CREATE TABLE {plural} ("),
&format!("CREATE TABLE IF NOT EXISTS {plural} ("),
1,
);
let setup_calls = setup_calls.replace("CREATE INDEX idx_", "CREATE INDEX IF NOT EXISTS idx_");
let setup_calls = setup_calls.replace(
"CREATE UNIQUE INDEX idx_",
"CREATE UNIQUE INDEX IF NOT EXISTS idx_",
);
let setup_calls = setup_calls.as_str();
let mut out = String::new();
for target in unique_fields {
if target.kind.is_attachment() {
continue;
}
let field_name = &target.name;
let insert_sql = unique_violation_insert_sql(plural, fields, target, defaults, false);
let escaped_insert = escape_sql_for_rust_literal(&insert_sql);
let insert_sql_dup = unique_violation_insert_sql(plural, fields, target, defaults, true);
let escaped_insert_dup = escape_sql_for_rust_literal(&insert_sql_dup);
let constraint_name = unique_index_name(plural, field_name, fields);
let request_boundary_check = if api {
String::new()
} else {
format!(
"#[post(\"/{plural}\")]\n\
async fn create(mut db: Db) -> AutumnResult<autumn_web::reexports::axum::response::Response> {{\n\
\x20\x20\x20\x20use autumn_web::reexports::axum::response::IntoResponse as _;\n\
\x20\x20\x20\x20let result: AutumnResult<()> = async {{\n\
\x20\x20\x20\x20\x20\x20\x20\x20// The client sends this same request twice (see below) to prove\n\
\x20\x20\x20\x20\x20\x20\x20\x20// the 200-then-422 path — this one compiled handler runs for both\n\
\x20\x20\x20\x20\x20\x20\x20\x20// calls, so it can't statically bake in \"first insert\" vs.\n\
\x20\x20\x20\x20\x20\x20\x20\x20// \"duplicate insert\" the way the DB-level check above does.\n\
\x20\x20\x20\x20\x20\x20\x20\x20// Reading the row count instead tells the two calls apart at\n\
\x20\x20\x20\x20\x20\x20\x20\x20// runtime: 0 means this is the first (must succeed) call, so\n\
\x20\x20\x20\x20\x20\x20\x20\x20// every unique column gets its plain sample value; any other\n\
\x20\x20\x20\x20\x20\x20\x20\x20// count means this is the second (must collide) call, so\n\
\x20\x20\x20\x20\x20\x20\x20\x20// non-target unique columns switch to their distinct variant —\n\
\x20\x20\x20\x20\x20\x20\x20\x20// otherwise a scaffold with more than one unique field would\n\
\x20\x20\x20\x20\x20\x20\x20\x20// duplicate a *different* column too and Postgres could report\n\
\x20\x20\x20\x20\x20\x20\x20\x20// that one instead of {field_name}'s.\n\
\x20\x20\x20\x20\x20\x20\x20\x20let existing: i64 = {plural}::table.count().get_result(&mut *db).await?;\n\
\x20\x20\x20\x20\x20\x20\x20\x20let insert_sql = if existing == 0 {{ \"{escaped_insert}\" }} else {{ \"{escaped_insert_dup}\" }};\n\
\x20\x20\x20\x20\x20\x20\x20\x20diesel::sql_query(insert_sql).execute(&mut *db).await?;\n\
\x20\x20\x20\x20\x20\x20\x20\x20Ok(())\n\
\x20\x20\x20\x20}}.await;\n\
\x20\x20\x20\x20if let Err(err) = result {{\n\
\x20\x20\x20\x20\x20\x20\x20\x20if let Some((field, message)) = autumn_web::error::unique_violation_field(\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20&err,\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20&[(\"{constraint_name}\", \"{field_name}\", \"has already been taken\")],\n\
\x20\x20\x20\x20\x20\x20\x20\x20) {{\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20return Ok((\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20autumn_web::reexports::http::StatusCode::UNPROCESSABLE_ENTITY,\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20format!(\"{{field}}: {{message}}\"),\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20).into_response());\n\
\x20\x20\x20\x20\x20\x20\x20\x20}}\n\
\x20\x20\x20\x20\x20\x20\x20\x20return Err(err);\n\
\x20\x20\x20\x20}}\n\
\x20\x20\x20\x20Ok(autumn_web::reexports::http::StatusCode::OK.into_response())\n\
}}\n\
\n\
\x20\x20\x20\x20db.execute_sql(\"TRUNCATE {plural} RESTART IDENTITY\").await;\n\
\x20\x20\x20\x20let client: TestClient = TestApp::new().routes(routes![create]).with_db(db.pool()).build();\n\
\n\
\x20\x20\x20\x20client.post(\"/{plural}\").send().await.assert_status(200);\n\
\x20\x20\x20\x20client.post(\"/{plural}\").send().await\n\
\x20\x20\x20\x20\x20\x20\x20\x20.assert_status(422)\n\
\x20\x20\x20\x20\x20\x20\x20\x20.assert_body_contains(\"{field_name}\");\n"
)
};
let _ = write!(
out,
"\n#[tokio::test]\n\
#[ignore = \"requires Docker (testcontainers) via TestDb; run `cargo test -- --ignored`\"]\n\
async fn {plural}_rejects_duplicate_{field_name}() {{\n\
\x20\x20\x20\x20let db = TestDb::shared().await;\n\
{setup_calls}\
\x20\x20\x20\x20db.execute_sql(\"TRUNCATE {plural} RESTART IDENTITY\").await;\n\
\n\
\x20\x20\x20\x20let mut conn = db.pool().get().await.expect(\"failed to get db connection\");\n\
\x20\x20\x20\x20diesel::sql_query(\"{escaped_insert}\").execute(&mut *conn).await.expect(\"first insert must succeed\");\n\
\x20\x20\x20\x20let dup_result = diesel::sql_query(\"{escaped_insert_dup}\").execute(&mut *conn).await;\n\
\x20\x20\x20\x20let dup_err = dup_result.expect_err(\"a duplicate {field_name} must violate the UNIQUE index\");\n\
\n\
\x20\x20\x20\x20let mapped: AutumnResult<()> = Err(dup_err.into());\n\
\x20\x20\x20\x20let mapped_err = mapped.unwrap_err();\n\
\x20\x20\x20\x20let (field, _message) = autumn_web::error::unique_violation_field(\n\
\x20\x20\x20\x20\x20\x20\x20\x20&mapped_err,\n\
\x20\x20\x20\x20\x20\x20\x20\x20&[(\"{constraint_name}\", \"{field_name}\", \"has already been taken\")],\n\
\x20\x20\x20\x20).expect(\"a real Postgres unique_violation error must be classified by field\");\n\
\x20\x20\x20\x20assert_eq!(field, \"{field_name}\");\n\
\n\
{request_boundary_check}\
}}\n",
);
}
out
}
fn main_route_entries(
plural: &str,
snake_name: &str,
api: bool,
live: bool,
search: bool,
validated_field_names: &[String],
sm_field_names: &[String],
) -> Vec<String> {
if api {
let mut entries = vec![
format!("repositories::{snake_name}::{snake_name}_api_list"),
format!("repositories::{snake_name}::{snake_name}_api_get"),
format!("repositories::{snake_name}::{snake_name}_api_create"),
format!("repositories::{snake_name}::{snake_name}_api_update"),
format!("repositories::{snake_name}::{snake_name}_api_delete"),
];
if live {
entries.push(format!("repositories::{snake_name}::stream"));
}
entries
} else {
let mut entries = vec![
format!("routes::{plural}::index"),
format!("routes::{plural}::show"),
format!("routes::{plural}::new_form"),
format!("routes::{plural}::create"),
format!("routes::{plural}::edit_form"),
format!("routes::{plural}::update"),
format!("routes::{plural}::destroy"),
];
if live {
entries.push(format!("routes::{plural}::events"));
}
if search {
entries.push(format!("routes::{plural}::search"));
}
for field_name in validated_field_names {
entries.push(format!("routes::{plural}::validate_{field_name}"));
}
for field_name in sm_field_names {
entries.push(format!("routes::{plural}::transition_{field_name}"));
}
entries.push(format!("repositories::{snake_name}::{snake_name}_api_list"));
entries.push(format!("repositories::{snake_name}::{snake_name}_api_get"));
entries
}
}
fn submit_token_validate_exempt_prefix(plural: &str) -> String {
format!("/{plural}/validate")
}
fn toml_table_block_range(lines: &[&str], header: &str) -> Option<(usize, usize)> {
let start = lines.iter().position(|l| l.trim() == header)?;
let end = lines[start + 1..]
.iter()
.position(|l| l.trim_start().starts_with('['))
.map_or(lines.len(), |rel| start + 1 + rel);
Some((start, end))
}
fn append_submit_token_validate_exempt_to_toml(existing: &str, plural: &str) -> Option<String> {
use toml_edit::{Array, DocumentMut, Item, Table, Value};
let exempt = submit_token_validate_exempt_prefix(plural);
let mut doc = existing.parse::<DocumentMut>().ok()?;
let security_missing = !doc.as_table().contains_key("security");
let security = doc
.as_table_mut()
.entry("security")
.or_insert_with(|| Item::Table(Table::new()))
.as_table_mut()?;
if security_missing {
security.set_implicit(true);
}
let submit_token = security
.entry("submit_token")
.or_insert_with(|| Item::Table(Table::new()))
.as_table_mut()?;
let array = submit_token
.entry("exempt_paths")
.or_insert_with(|| Item::Value(Value::Array(Array::new())))
.as_array_mut()?;
if array.iter().any(|v| v.as_str() == Some(exempt.as_str())) {
return None;
}
array.push(exempt.as_str());
Some(doc.to_string())
}
pub(super) fn remove_submit_token_validate_exempt_from_toml(
existing: &str,
plural: &str,
) -> String {
const HEADER: &str = "[security.submit_token]";
let exempt = submit_token_validate_exempt_prefix(plural);
let quoted = format!("\"{exempt}\"");
let lines: Vec<&str> = existing.lines().collect();
let Some((start, end)) = toml_table_block_range(&lines, HEADER) else {
return existing.to_owned();
};
let Some(key_idx) = lines[start + 1..end]
.iter()
.position(|l| l.trim_start().starts_with("exempt_paths"))
.map(|rel| start + 1 + rel)
else {
return existing.to_owned();
};
let close_idx = (key_idx..end)
.find(|&i| lines[i].contains(']'))
.unwrap_or(key_idx);
let joined: String = lines[key_idx..=close_idx].join("\n");
let (Some(open), Some(close)) = (joined.find('['), joined.rfind(']')) else {
return existing.to_owned();
};
let inner = &joined[open + 1..close];
if !inner.contains("ed) {
return existing.to_owned();
}
let remaining: Vec<String> = inner
.split(',')
.map(str::trim)
.filter(|item| !item.is_empty() && *item != quoted)
.map(str::to_owned)
.collect();
let mut out_lines: Vec<String> = lines.iter().map(|l| (*l).to_owned()).collect();
if remaining.is_empty() {
let block_only_our_key = (start + 1..end).all(|i| {
i == key_idx || (key_idx..=close_idx).contains(&i) || lines[i].trim().is_empty()
});
if block_only_our_key {
out_lines.splice(start..end, std::iter::empty::<String>());
} else {
out_lines.splice(key_idx..=close_idx, std::iter::empty::<String>());
}
} else {
let rebuilt = format!(
"{}[{}]{}",
&joined[..open],
remaining.join(", "),
&joined[close + 1..]
);
out_lines.splice(key_idx..=close_idx, std::iter::once(rebuilt));
}
let mut out = out_lines.join("\n");
while out.contains("\n\n\n") {
out = out.replace("\n\n\n", "\n\n");
}
if existing.ends_with('\n') && !out.ends_with('\n') {
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate::Flags;
use crate::generate::dsl::FieldConstraints;
use std::fs;
use tempfile::TempDir;
fn project_with_main(template: &str) -> TempDir {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("Cargo.toml"), "[package]\nname=\"x\"\n").unwrap();
fs::create_dir_all(tmp.path().join("src")).unwrap();
fs::write(tmp.path().join("src/main.rs"), template).unwrap();
tmp
}
fn write_target_model(tmp: &TempDir, base: &str, pascal: &str) {
fs::create_dir_all(tmp.path().join("src/models")).unwrap();
fs::write(
tmp.path().join(format!("src/models/{base}.rs")),
format!(
"#[autumn_web::model]\npub struct {pascal} {{\n #[id]\n pub id: i64,\n}}\n"
),
)
.unwrap();
}
fn default_main() -> &'static str {
r#"use autumn_web::prelude::*;
pub fn layout(title: &str, current_path: &str, flash: maud::Markup, content: maud::Markup) -> maud::Markup {
let _ = (current_path, flash);
maud::html! {
title { (title) }
(content)
}
}
#[get("/")]
async fn index() -> &'static str { "ok" }
#[autumn_web::main]
async fn main() {
autumn_web::app()
.routes(routes![index])
.run()
.await;
}
"#
}
fn main_without_layout() -> &'static str {
r#"use autumn_web::prelude::*;
#[get("/")]
async fn index() -> &'static str { "ok" }
#[autumn_web::main]
async fn main() {
autumn_web::app()
.routes(routes![index])
.run()
.await;
}
"#
}
#[test]
fn plan_creates_full_scaffold() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&[
"title:String".into(),
"body:Text".into(),
"published:bool".into(),
],
"20260427000000",
)
.unwrap();
let paths: Vec<String> = plan
.actions
.iter()
.map(|a| {
a.path()
.strip_prefix(&plan.project_root)
.unwrap()
.display()
.to_string()
.replace('\\', "/")
})
.collect();
for expected in [
"src/models/post.rs",
"src/models/mod.rs",
"migrations/20260427000000_create_posts/up.sql",
"migrations/20260427000000_create_posts/down.sql",
"src/schema.rs",
"src/repositories/post.rs",
"src/repositories/mod.rs",
"src/routes/posts.rs",
"src/routes/mod.rs",
"tests/post.rs",
"src/main.rs",
] {
assert!(
paths.iter().any(|p| p == expected),
"missing expected action for {expected}; got {paths:?}"
);
}
}
#[test]
fn generate_then_destroy_scaffold_round_trips_to_original_project_state() {
temp_env::with_vars(
[
("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
("AUTUMN_DATABASE__URL", None::<&str>),
("DATABASE_URL", None::<&str>),
],
|| {
let tmp = project_with_main(default_main());
let cargo_path = tmp.path().join("Cargo.toml");
fs::write(
&cargo_path,
"[package]\nname = \"x\"\n\n\
[dependencies]\n\
autumn-web = \"0.6.0\"\n\
maud = { version = \"0.27\", features = [\"axum\"] }\n\
diesel_migrations = \"2\"\n\n\
[dev-dependencies]\n\
tokio = { version = \"1\", features = [\"rt\", \"macros\"] }\n",
)
.unwrap();
let main_path = tmp.path().join("src/main.rs");
let original_cargo = fs::read_to_string(&cargo_path).unwrap();
let original_main = fs::read_to_string(&main_path).unwrap();
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
assert!(tmp.path().join("src/models/post.rs").exists());
assert!(tmp.path().join("src/routes/posts.rs").exists());
let destroy_plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into()],
"99999999999999",
)
.unwrap();
destroy_plan.revert(Flags::default()).unwrap();
for gone in [
"src/models/post.rs",
"src/models/mod.rs",
"src/schema.rs",
"src/repositories/post.rs",
"src/repositories/mod.rs",
"src/routes/posts.rs",
"src/routes/mod.rs",
"tests/post.rs",
] {
assert!(!tmp.path().join(gone).exists(), "{gone} should be removed");
}
assert!(
fs::read_dir(tmp.path().join("migrations"))
.map_or(true, |mut d| d.next().is_none()),
"migration directory must be removed"
);
assert_eq!(fs::read_to_string(&main_path).unwrap(), original_main);
assert_eq!(fs::read_to_string(&cargo_path).unwrap(), original_cargo);
},
);
}
#[test]
fn plan_errors_when_main_rs_missing() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
let err = plan_scaffold(tmp.path(), "Post", &[], "20260427000000").unwrap_err();
match err {
GenerateError::Config(msg) => {
assert!(
msg.contains("pub fn layout") && msg.contains("autumn new"),
"missing main.rs must yield the actionable shared-layout error: {msg}"
);
}
other => panic!("expected an actionable Config error, got: {other:?}"),
}
}
#[test]
fn execute_writes_form_for_helper_with_formmodel_delegation() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("impl autumn_web::form::FormModel for PostForm"),
"PostForm must implement FormModel: {routes}"
);
assert!(
routes.contains("<Post as autumn_web::form::FormModel>::form_fields()"),
"PostForm must delegate its descriptors to the derived model impl: {routes}"
);
assert!(routes.contains("fn post_form_for("), "{routes}");
assert!(
routes.contains("autumn_web::form::form_for(changeset, action, \"post\")"),
"the shared helper must render through form_for: {routes}"
);
assert!(
!routes.contains("autumn_web::form::required_text_input(&changeset"),
"no per-field input helpers may remain in the views: {routes}"
);
}
#[test]
fn execute_references_column_renders_select_with_loaded_options() {
let tmp = project_with_main(default_main());
write_target_model(&tmp, "post", "Post");
let plan = plan_scaffold(
tmp.path(),
"Comment",
&["body:Text".into(), "post:references".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/comments.rs")).unwrap();
assert!(
routes.contains("post_id_options: &[(String, String)],"),
"helper must take the reference options: {routes}"
);
assert!(
routes.contains(
"let mut post_id_select = vec![(String::new(), \"— Select —\".to_string())];"
),
"non-nullable reference must get a blank 'please select' placeholder: {routes}"
);
assert!(
routes.contains(
".override_field(\"post_id\", autumn_web::form::FieldControl::Select { options: post_id_select })"
),
"reference column must be promoted to a Select: {routes}"
);
assert!(
routes.contains(
"async fn post_id_select_options(db: &mut Db) -> AutumnResult<Vec<(String, String)>>"
),
"must emit an options loader: {routes}"
);
assert!(
routes.contains("posts::table") && routes.contains(".select(posts::id)"),
"loader must query the referenced table's ids: {routes}"
);
assert!(
routes.contains("use crate::schema::{comments, posts};"),
"referenced table's schema module must be imported: {routes}"
);
assert_eq!(
routes
.matches("let post_id_options = post_id_select_options(&mut db).await?;")
.count(),
4,
"new_form, edit_form, create 422, and update 422 must all load options: {routes}"
);
assert!(
routes.contains("pub async fn new_form(\n mut db: Db,"),
"new_form must take a DB handle to load reference options: {routes}"
);
assert!(
routes.contains(
"csrf_field.as_ref(), submit_token.as_ref(), submit_field.as_ref(), &post_id_options))"
),
"view bodies must pass the loaded options to the helper: {routes}"
);
}
#[test]
fn execute_nullable_references_column_gets_unset_placeholder() {
let tmp = project_with_main(default_main());
write_target_model(&tmp, "post", "Post");
let plan = plan_scaffold(
tmp.path(),
"Comment",
&["post:Option<references>".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/comments.rs")).unwrap();
assert!(
routes.contains(
"let mut post_id_select = vec![(String::new(), \"— Unset —\".to_string())];"
),
"nullable reference must get an '— Unset —' placeholder: {routes}"
);
}
#[test]
fn execute_references_column_with_missing_target_falls_back_to_number_input() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Comment",
&["body:Text".into(), "post:references".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/comments.rs")).unwrap();
assert!(
routes.contains("use crate::schema::comments;"),
"only the resource's own schema module may be imported: {routes}"
);
assert!(
!routes.contains("posts::"),
"the missing target's schema module must never be referenced: {routes}"
);
assert!(
!routes.contains("post_id_select_options"),
"no options loader may be emitted for a missing target: {routes}"
);
assert!(
!routes.contains(".override_field(\"post_id\""),
"the column must keep the derived number input, not a Select: {routes}"
);
assert!(
!routes.contains("post_id_options"),
"the form helper must not take options for a missing target: {routes}"
);
assert!(
!routes.contains("pub async fn new_form(\n mut db: Db,"),
"new_form must not gain a DB handle when no options are loaded: {routes}"
);
}
#[test]
fn execute_mixed_reference_targets_keeps_select_only_for_present_target() {
let tmp = project_with_main(default_main());
write_target_model(&tmp, "author", "Author");
let plan = plan_scaffold(
tmp.path(),
"Comment",
&["author:references".into(), "post:references".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/comments.rs")).unwrap();
assert!(
routes.contains("use crate::schema::{comments, authors};")
|| routes.contains("use crate::schema::{authors, comments};"),
"only the present target may join the schema import: {routes}"
);
assert!(
routes.contains(
".override_field(\"author_id\", autumn_web::form::FieldControl::Select { options: author_id_select })"
),
"the present target must keep its Select promotion: {routes}"
);
assert!(
routes.contains("async fn author_id_select_options"),
"the present target must keep its options loader: {routes}"
);
assert!(
!routes.contains("posts::") && !routes.contains("post_id_select_options"),
"the missing target must not produce select machinery: {routes}"
);
assert!(
!routes.contains(".override_field(\"post_id\""),
"the missing target's column must keep the derived number input: {routes}"
);
}
#[test]
fn execute_self_referential_reference_keeps_select_without_target_model() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Category",
&["name:String".into(), "category:references".into()],
"20260427000000",
)
.unwrap();
assert!(
plan.warnings.is_empty(),
"a self-reference must not warn: {:?}",
plan.warnings
);
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/categories.rs")).unwrap();
assert!(
routes.contains(
".override_field(\"category_id\", autumn_web::form::FieldControl::Select { options: category_id_select })"
),
"self-referential column must keep the Select promotion: {routes}"
);
assert!(
routes.contains("async fn category_id_select_options"),
"self-referential column must keep its options loader: {routes}"
);
assert!(
routes.contains("use crate::schema::categories;"),
"own table needs no extra schema import: {routes}"
);
}
#[test]
fn execute_writes_a_routes_file_referencing_model() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(routes.contains("use crate::models::post::{Post, NewPost, UpdatePost};"));
assert!(routes.contains("#[get(\"/posts\")]"));
assert!(routes.contains("#[get(\"/posts/{id}\")]"));
assert!(
!routes.contains("#[secured]\n#[get(\"/posts\")]"),
"index should be reachable by the five-command scaffold smoke test"
);
assert!(
!routes.contains("#[secured]\n#[get(\"/posts/{id}\")]"),
"read-only show pages should stay public when generated"
);
assert!(routes.contains("#[get(\"/posts/new\", name = \"new\")]"));
assert!(routes.contains("#[post(\"/posts\")]"));
assert!(routes.contains("#[get(\"/posts/{id}/edit\", name = \"edit\")]"));
assert!(routes.contains("#[post(\"/posts/{id}/update\")]"));
assert!(routes.contains("pub async fn new_form("));
assert!(routes.contains("layout(\"New Post\""));
assert!(routes.contains("posts::title.eq(new.title.clone())"));
assert!(routes.contains("if updated == 0"));
assert!(routes.contains("AutumnError::not_found_msg"));
assert!(routes.contains("paths::update(*id)"));
assert!(!routes.contains("/api/posts/{}\""));
assert!(!routes.contains("#[put("));
assert!(!routes.contains("#[delete("));
assert!(routes.contains(r#"#[post("/posts/{id}/delete", name = "delete")]"#));
}
fn plan_and_execute_post_scaffold_with_status_enum(tmp: &TempDir) {
let plan = plan_scaffold(
tmp.path(),
"Post",
&[
"title:String".into(),
"status:enum{draft,published,archived}".into(),
],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
}
#[test]
fn scaffold_create_form_renders_enum_select() {
let tmp = project_with_main(default_main());
plan_and_execute_post_scaffold_with_status_enum(&tmp);
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains(
".override_field(\"status\", autumn_web::form::FieldControl::Select { options: vec![(\"\".into(), \"— Select —\".into()), (\"draft\".into(), \"Draft\".into()), (\"published\".into(), \"Published\".into()), (\"archived\".into(), \"Archived\".into())] })"
),
"got:\n{routes}"
);
}
#[test]
fn scaffold_edit_form_marks_current_enum_variant_selected() {
let tmp = project_with_main(default_main());
plan_and_execute_post_scaffold_with_status_enum(&tmp);
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("Status::Draft => \"draft\".to_string()"),
"got:\n{routes}"
);
assert!(
routes.contains("Status::Published => \"published\".to_string()"),
"got:\n{routes}"
);
}
#[test]
fn scaffold_form_parses_enum_with_field_error() {
let tmp = project_with_main(default_main());
plan_and_execute_post_scaffold_with_status_enum(&tmp);
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(routes.contains("pub status: String,"), "got:\n{routes}");
assert!(
routes.contains("form.status.parse::<Status>()"),
"got:\n{routes}"
);
assert!(
routes.contains("AutumnError::bad_request_msg(format!(\"status: {err}\"))"),
"got:\n{routes}"
);
}
#[test]
fn scaffold_routes_import_enum_type() {
let tmp = project_with_main(default_main());
plan_and_execute_post_scaffold_with_status_enum(&tmp);
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("use crate::models::post::{Post, NewPost, UpdatePost, Status};"),
"got:\n{routes}"
);
}
#[test]
fn scaffold_update_columns_set_enum_directly() {
let tmp = project_with_main(default_main());
plan_and_execute_post_scaffold_with_status_enum(&tmp);
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("posts::status.eq(new.status.clone())"),
"got:\n{routes}"
);
}
#[test]
fn scaffold_default_enum_field_is_dropped_from_forms() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&[
"title:String".into(),
"status:enum{draft,published,archived}".into(),
],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
defaults: vec!["status=draft".into()],
..Default::default()
},
..Default::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
!routes.contains(".override_field(\"status\""),
"a --default field must be excluded from the create/edit forms: {routes}"
);
}
#[test]
fn scaffold_query_on_enum_field_is_rejected() {
let tmp = project_with_main(default_main());
let err = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into(), "status:enum{draft,published}".into()],
"20260427000000",
&ScaffoldOptions {
queries: vec!["find_by_status:status".into()],
..Default::default()
},
)
.unwrap_err();
assert!(matches!(err, GenerateError::InvalidField { .. }));
}
#[test]
fn scaffold_enum_field_colliding_with_routes_import_is_rejected() {
for field_name in ["path", "page", "bytes", "flash", "db"] {
let tmp = project_with_main(default_main());
let err = plan_scaffold(
tmp.path(),
"Post",
&[format!("{field_name}:enum{{a,b}}")],
"20260427000000",
)
.unwrap_err();
assert!(
matches!(err, GenerateError::InvalidField { .. }),
"expected '{field_name}' to be rejected"
);
}
}
#[test]
fn scaffold_enum_field_named_to_string_is_rejected() {
let tmp = project_with_main(default_main());
let err = plan_scaffold(
tmp.path(),
"Post",
&["to_string:Option<enum{a,b}>".into()],
"20260427000000",
)
.unwrap_err();
assert!(matches!(err, GenerateError::InvalidField { .. }));
}
#[test]
fn scaffold_enum_field_named_changeset_is_rejected() {
for field_name in ["changeset", "into_changeset"] {
let tmp = project_with_main(default_main());
let err = plan_scaffold(
tmp.path(),
"Post",
&[format!("{field_name}:enum{{a,b}}")],
"20260427000000",
)
.unwrap_err();
assert!(
matches!(err, GenerateError::InvalidField { .. }),
"expected '{field_name}' to be rejected"
);
}
}
#[test]
fn scaffold_enum_field_named_submit_token_is_rejected() {
for field_name in ["submit_token", "submit_form_field"] {
let tmp = project_with_main(default_main());
let err = plan_scaffold(
tmp.path(),
"Post",
&[format!("{field_name}:enum{{a,b}}")],
"20260427000000",
)
.unwrap_err();
assert!(
matches!(err, GenerateError::InvalidField { .. }),
"expected '{field_name}' to be rejected"
);
}
}
#[test]
fn scaffold_enum_field_colliding_with_generated_form_struct_is_rejected() {
let tmp = project_with_main(default_main());
let err = plan_scaffold(
tmp.path(),
"Post",
&["post_form:enum{a,b}".into()],
"20260427000000",
)
.unwrap_err();
assert!(matches!(err, GenerateError::InvalidField { .. }));
}
#[test]
fn scaffold_enum_field_colliding_with_routes_import_is_accepted_for_api_only() {
let tmp = project_with_main(default_main());
plan_scaffold_with_options(
tmp.path(),
"Post",
&["path:enum{a,b}".into()],
"20260427000000",
&ScaffoldOptions {
api: true,
..Default::default()
},
)
.unwrap();
}
#[test]
fn scaffold_enum_field_named_redirect_is_accepted_and_routes_fully_qualify_redirect() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["redirect:enum{a,b}".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("Redirect}"),
"expected the enum `Redirect` to be imported from crate::models: {routes}"
);
assert!(
!routes.contains("use autumn_web::Redirect;"),
"routes must not import the framework Redirect (would collide): {routes}"
);
assert!(
routes.contains("autumn_web::Redirect::to("),
"redirect handlers must fully-qualify autumn_web::Redirect::to: {routes}"
);
assert!(
routes.contains("-> AutumnResult<autumn_web::Redirect>"),
"destroy handler must fully-qualify its return type: {routes}"
);
}
fn plan_and_execute_post_scaffold_with_nullable_status_enum(tmp: &TempDir) {
let plan = plan_scaffold(
tmp.path(),
"Post",
&[
"title:String".into(),
"status:Option<enum{draft,published}>".into(),
],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
}
#[test]
fn scaffold_nullable_enum_create_form_has_unset_placeholder() {
let tmp = project_with_main(default_main());
plan_and_execute_post_scaffold_with_nullable_status_enum(&tmp);
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains(
".override_field(\"status\", autumn_web::form::FieldControl::Select { options: vec![(\"\".into(), \"— Unset —\".into()), (\"draft\".into(), \"Draft\".into()), (\"published\".into(), \"Published\".into())] })"
),
"got:\n{routes}"
);
}
#[test]
fn scaffold_nullable_enum_edit_form_seed_maps_option_variant() {
let tmp = project_with_main(default_main());
plan_and_execute_post_scaffold_with_nullable_status_enum(&tmp);
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains(
"status: row.status.as_ref().map(|value| match value { Status::Draft => \"draft\".to_string(), Status::Published => \"published\".to_string(), }),"
),
"got:\n{routes}"
);
}
#[test]
fn scaffold_nullable_enum_form_is_option_string_with_transpose() {
let tmp = project_with_main(default_main());
plan_and_execute_post_scaffold_with_nullable_status_enum(&tmp);
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("pub status: Option<String>,"),
"got:\n{routes}"
);
assert!(
routes.contains("form.status.as_deref()") && routes.contains(".transpose()"),
"got:\n{routes}"
);
}
#[test]
fn execute_writes_csrf_aware_form_handlers() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(routes.contains(
"use autumn_web::security::{CsrfFormField, CsrfToken, SubmitFormField, SubmitToken};"
));
assert!(routes.contains("fn csrf_input("));
assert!(routes.contains("input type=\"hidden\" name=(csrf_field_name"));
assert!(routes.contains("value=(csrf.token());"));
assert!(routes.contains("pub async fn new_form("));
assert!(routes.contains("csrf: Option<CsrfToken>"));
assert!(routes.contains("csrf_field: Option<CsrfFormField>"));
assert!(routes.contains("(csrf_input(csrf.as_ref(), csrf_field.as_ref()))"));
assert!(routes.contains("pub async fn edit_form("));
assert!(routes.contains("fn submit_token_input("));
assert!(routes.contains(
"let submit_field_name = field.map(|field| field.0.as_str()).unwrap_or(\"_submit_token\");"
));
assert!(routes.contains(
"input type=\"hidden\" name=(submit_field_name) value=(submit_token.token());"
));
assert!(!routes.contains("name=\"_submit_token\""));
assert!(routes.contains("submit_token: Option<SubmitToken>"));
assert!(routes.contains("submit_field: Option<SubmitFormField>"));
assert!(routes.contains("submit_token.as_ref()"));
assert!(routes.contains("submit_field.as_ref()"));
assert!(routes.contains("submit_token_input(submit_token, submit_field)"));
assert!(
routes.contains("form.prepend(submit_token_input(submit_token, submit_field))"),
"standard form must prepend the submit token, not append it:\n{routes}"
);
assert!(
!routes.contains("form.append(submit_token_input"),
"submit token must not be appended after derived fields:\n{routes}"
);
}
#[test]
fn execute_writes_model_form_struct() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into(), "body:String".into()],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
validations: vec!["title=length:min=1".into()],
..ModelOptions::default()
},
..ScaffoldOptions::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("pub struct PostForm"),
"generator must emit a named, public form struct: {routes}"
);
assert!(
routes.contains("serde::Serialize"),
"PostForm must derive Serialize so Changeset::field_value can \
repopulate inputs: {routes}"
);
assert!(
routes.contains("validator::Validate"),
"PostForm must derive Validate so into_changeset() validates: {routes}"
);
assert!(
routes.contains("Default"),
"PostForm must derive Default for the blank new-form changeset: {routes}"
);
assert!(
routes.contains("#[validate(length(min = 1))]"),
"PostForm must echo the declared validation rule: {routes}"
);
assert!(
routes.contains("impl From<&Post> for PostForm"),
"generator must emit a From<&Row> to seed the edit-form changeset: {routes}"
);
}
#[test]
fn execute_writes_changeset_validated_create_handler() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
validations: vec!["title=length:min=1".into()],
..ModelOptions::default()
},
..ScaffoldOptions::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("into_changeset()"),
"create/update must build a changeset from the submission: {routes}"
);
assert!(
routes.contains("is_valid()"),
"handler must branch on changeset validity: {routes}"
);
assert!(
routes.contains("StatusCode::UNPROCESSABLE_ENTITY"),
"handler must respond 422 on a rejected submission: {routes}"
);
assert!(
routes.contains("into_response()"),
"the changeset re-render returns a Response, not a bare Markup 400: {routes}"
);
}
#[test]
fn execute_new_and_edit_forms_render_via_form_for() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&[
"title:String".into(),
"rank:i32".into(),
"published:bool".into(),
"published_at:NaiveDateTime".into(),
],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
validations: vec!["title=length:min=1".into()],
..ModelOptions::default()
},
..ScaffoldOptions::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
let new_form_start = routes
.find("pub async fn new_form")
.expect("new_form handler");
let create_start = routes.find("pub async fn create(").expect("create handler");
let new_form_body = &routes[new_form_start..create_start];
assert_eq!(
new_form_body.matches("post_form_for(&changeset").count(),
1,
"the new view must render via exactly one form_for call: {new_form_body}"
);
let edit_form_start = routes
.find("pub async fn edit_form")
.expect("edit_form handler");
let update_start = routes.find("pub async fn update(").expect("update handler");
let edit_form_body = &routes[edit_form_start..update_start];
assert_eq!(
edit_form_body.matches("post_form_for(&changeset").count(),
1,
"the edit view must render via exactly one form_for call: {edit_form_body}"
);
for helper in [
"required_text_input",
"required_number_input",
"checkbox_input",
"required_datetime_input",
] {
assert!(
!routes.contains(&format!("autumn_web::form::{helper}(&changeset")),
"no per-field {helper} lines may remain: {routes}"
);
}
assert!(
!routes.contains(r#"input type="text" name="title""#),
"standard fields must no longer be hand-rolled: {routes}"
);
}
#[test]
fn execute_live_validation_inline_posts_drop_the_submit_token() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String{min=3,max=120}".into()],
"20260427000000",
&ScaffoldOptions {
live_validation: true,
..ScaffoldOptions::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains(".hx(\"post\", paths::validate_title())"),
"title must keep its htmx inline-validation wiring via `.hx()`:\n{routes}"
);
assert!(
routes.contains(".hx(\"params\", \"not _submit_token\")"),
"live-validation inputs must drop _submit_token from the inline POST:\n{routes}"
);
let validate_title = &routes[routes
.find("pub async fn validate_title(")
.expect("validate_title handler")..];
assert!(
validate_title[..validate_title[1..]
.find("#[post(")
.map_or(validate_title.len(), |rel| rel + 1)]
.contains(".hx(\"params\", \"not _submit_token\")"),
"the validate_title fragment must also drop the submit token:\n{validate_title}"
);
assert!(
routes.contains("submit_token_input(submit_token.as_ref(), submit_field.as_ref())"),
"the create/update form must still carry the submit token:\n{routes}"
);
let extract_block = |src: &str, start: usize| -> String {
let open = src[start..].find('{').expect("wrapper `{`") + start;
let mut depth = 0usize;
for (i, ch) in src[open..].char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return src[start..=open + i].to_owned();
}
}
_ => {}
}
}
panic!("unbalanced wrapper block");
};
let normalize =
|block: &str| -> String { block.lines().map(str::trim).collect::<Vec<_>>().join("\n") };
let form_start = routes
.find("div id=\"title-field\"")
.expect("form title-field");
let form_block = extract_block(&routes, form_start);
let frag_offset = routes
.find("pub async fn validate_title(")
.expect("validate_title handler");
let frag_start = frag_offset
+ routes[frag_offset..]
.find("div id=\"title-field\"")
.expect("fragment title-field");
let frag_block = extract_block(&routes, frag_start);
assert_eq!(
normalize(&form_block),
normalize(&frag_block),
"the initial-rendered field and the validate_title fragment must be \
byte-identical (modulo insignificant whitespace) for the htmx \
outerHTML swap"
);
}
#[test]
fn execute_writes_edit_form_with_prefilled_values_and_nullable_optional_inputs() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&[
"title:String".into(),
"subtitle:Option<String>".into(),
"views:Option<i64>".into(),
],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("impl From<&Post> for PostForm"),
"edit form must seed a changeset from the loaded row: {routes}"
);
assert!(
routes.contains("title: row.title.clone(),"),
"seed must copy required fields from the loaded row: {routes}"
);
assert!(
routes.contains("subtitle: row.subtitle.clone(),"),
"seed must copy nullable text fields from the loaded row: {routes}"
);
assert!(
routes.contains("views: row.views.clone(),"),
"seed must copy nullable numeric fields from the loaded row: {routes}"
);
assert!(
!routes.contains(".override_field(\"views\""),
"plain numeric fields need no form_for override: {routes}"
);
}
#[test]
fn execute_writes_form_decoder_that_drops_blank_nullable_fields() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&[
"title:String".into(),
"nickname:Option<String>".into(),
"views:Option<i64>".into(),
"published_at:Option<NaiveDateTime>".into(),
"token:Option<Uuid>".into(),
],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("use autumn_web::reexports::axum::body::Bytes;"),
"generated routes must be able to inspect raw form bytes: {routes}"
);
assert!(
routes.contains("pub async fn create(flash: Flash, csrf: Option<CsrfToken>, csrf_field: Option<CsrfFormField>, submit_token: Option<SubmitToken>, submit_field: Option<SubmitFormField>, mut db: Db, \n autumn_web::extract::State(state): autumn_web::extract::State<autumn_web::AppState>,\n session: autumn_web::session::Session,\n body: Bytes)"),
"create must decode after blank nullable normalization: {routes}"
);
assert!(
routes.contains(
"pub async fn update(\n flash: Flash,\n csrf: Option<CsrfToken>,\n csrf_field: Option<CsrfFormField>,\n submit_token: Option<SubmitToken>,\n submit_field: Option<SubmitFormField>,\n id: Path<i64>,\n mut db: Db,\n autumn_web::extract::State(state): autumn_web::extract::State<autumn_web::AppState>,\n session: autumn_web::session::Session,\n body: Bytes,\n)"
),
"update must decode after blank nullable normalization: {routes}"
);
assert!(
routes.contains("let form = decode_form(body)?;"),
"handlers must use the generated decoder: {routes}"
);
assert!(
routes.contains(r#"matches!(name, "nickname" | "views" | "published_at" | "token")"#),
"decoder must drop blank submissions for every nullable field: {routes}"
);
}
#[test]
fn execute_writes_a_repository_with_json_api_attribute() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(tmp.path(), "Post", &[], "20260427000000").unwrap();
plan.execute(Flags::default()).unwrap();
let repo = fs::read_to_string(tmp.path().join("src/repositories/post.rs")).unwrap();
assert!(repo.contains("#[autumn_web::repository(Post, api = \"/api/posts\")]"));
assert!(repo.contains("pub trait PostRepository"));
}
#[test]
fn execute_updates_main_rs_with_mods_and_routes() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let main = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
assert!(main.contains("mod models;"));
assert!(main.contains("mod routes;"));
assert!(main.contains("mod schema;"));
assert!(main.contains("mod repositories;"));
assert!(main.contains("routes::posts::index"));
assert!(main.contains("routes::posts::show"));
assert!(main.contains("routes::posts::new_form"));
assert!(main.contains("routes::posts::create"));
assert!(main.contains("routes::posts::edit_form"));
assert!(main.contains("routes::posts::update"));
assert!(main.contains("routes::posts::destroy"));
assert!(main.contains("repositories::post::post_api_list"));
assert!(main.contains("repositories::post::post_api_get"));
assert!(!main.contains("repositories::post::post_api_create"));
assert!(!main.contains("repositories::post::post_api_update"));
assert!(!main.contains("repositories::post::post_api_delete"));
}
#[test]
fn scaffold_emits_flash_messages_and_destroy_handler() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("use autumn_web::flash::{Flash, flash_messages};"),
"routes file must import Flash and flash_messages: {routes}"
);
assert!(routes.contains(r#"flash.success("Post created")"#));
assert!(routes.contains(r#"flash.success("Post updated")"#));
assert!(routes.contains(r#"flash.success("Post deleted")"#));
assert!(routes.contains("pub async fn destroy("));
assert!(routes.contains(r#"#[post("/posts/{id}/delete", name = "delete")]"#));
assert!(routes.contains("paths::delete(*id)"));
assert!(
!routes.contains("fn layout(title: &str, flash: Markup, content: Markup)"),
"standard scaffold must not define a private layout stub: {routes}"
);
assert!(
!routes.contains("Replace with your real layout"),
"the private-layout doc comment must be gone: {routes}"
);
assert!(
routes.contains("crate::layout("),
"views must render through the shared crate::layout: {routes}"
);
assert!(
!routes.contains("flash.render().await"),
"flash must not use the old flash.render() path: {routes}"
);
assert!(
routes.contains("flash_messages(&flash.consume().await)"),
"flash must render through the shared flash_messages() helper: {routes}"
);
let main = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
assert!(
main.contains("routes::posts::destroy"),
"main.rs must register the destroy route: {main}"
);
}
#[test]
fn scaffold_views_render_through_shared_layout() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
!routes.contains("fn layout("),
"no private layout may be defined in the routes file: {routes}"
);
assert!(
!routes.contains("Replace with your real layout"),
"the private-layout doc comment must be gone: {routes}"
);
assert!(
routes.contains(
"crate::layout(\"Post index\", \"/posts\", flash_messages(&flash.consume().await),"
),
"index must render through crate::layout with current_path: {routes}"
);
assert!(
routes.contains("crate::layout(&format!(\"Post #{}\", row.id), \"/posts\", flash_messages(&flash.consume().await),"),
"show must render through crate::layout with current_path: {routes}"
);
assert!(
routes.contains("crate::layout(\"New Post\", \"/posts/new\", flash_messages(&flash.consume().await),"),
"new_form must render through crate::layout with current_path: {routes}"
);
assert!(
routes.contains("crate::layout(&format!(\"Edit Post #{}\", *id), \"/posts\", flash_messages(&flash.consume().await),"),
"edit_form must render through crate::layout with current_path: {routes}"
);
}
#[test]
fn scaffold_create_update_render_flash_via_shared_helper() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String:unique".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("use autumn_web::flash::{Flash, flash_messages};"),
"routes must import the shared flash_messages helper: {routes}"
);
assert!(
!routes.contains("flash.render()"),
"no view may use the older flash.render(): {routes}"
);
let occurrences = routes
.matches("flash_messages(&flash.consume().await)")
.count();
assert!(
occurrences >= 4,
"index/show/new/edit (and the create/update re-renders) must all use \
flash_messages; found {occurrences} occurrences: {routes}"
);
}
#[test]
fn scaffold_multiple_resources_have_no_duplicate_layout() {
let tmp = project_with_main(default_main());
for (name, ts) in [("Post", "20260427000000"), ("Comment", "20260427000001")] {
let plan = plan_scaffold(tmp.path(), name, &["title:String".into()], ts).unwrap();
plan.execute(Flags::default()).unwrap();
}
let posts = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
let comments = fs::read_to_string(tmp.path().join("src/routes/comments.rs")).unwrap();
assert!(
!posts.contains("fn layout(") && !comments.contains("fn layout("),
"no generated routes file may define its own layout"
);
}
#[test]
fn scaffold_without_shared_layout_errors() {
let tmp = project_with_main(main_without_layout());
let err = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap_err();
match err {
GenerateError::Config(msg) => {
assert!(
msg.contains("pub fn layout") && msg.contains("autumn new"),
"error must be actionable about the missing shared layout: {msg}"
);
}
other => panic!("expected an actionable Config error, got: {other:?}"),
}
}
#[test]
fn scaffold_for_revert_succeeds_without_shared_layout() {
let tmp = project_with_main(main_without_layout());
let plan = plan_scaffold_with_options_for_revert(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ScaffoldOptions::default(),
)
.expect("destroy scaffold must build its revert plan without a shared layout");
assert!(
plan.actions.iter().any(|a| {
a.path()
.display()
.to_string()
.replace('\\', "/")
.ends_with("src/routes/posts.rs")
}),
"revert plan should still include the HTML routes file it will remove"
);
let err = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap_err();
assert!(
matches!(err, GenerateError::Config(ref msg) if msg.contains("pub fn layout")),
"generate path must still reject a missing shared layout: {err:?}"
);
}
#[test]
fn scaffold_live_without_shared_layout_succeeds() {
let tmp = project_with_main(main_without_layout());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ScaffoldOptions {
live: true,
..Default::default()
},
)
.expect("live scaffold is self-contained and needs no shared layout");
assert!(
plan.actions.iter().any(|a| {
a.path()
.display()
.to_string()
.replace('\\', "/")
.ends_with("src/routes/posts.rs")
}),
"live scaffold must still plan an HTML routes file"
);
}
#[test]
fn scaffold_with_wrong_arity_layout_errors() {
let wrong_arity_main = r#"use autumn_web::prelude::*;
pub fn layout(title: &str, content: maud::Markup) -> maud::Markup {
maud::html! {
title { (title) }
(content)
}
}
#[get("/")]
async fn index() -> &'static str { "ok" }
#[autumn_web::main]
async fn main() {
autumn_web::app()
.routes(routes![index])
.run()
.await;
}
"#;
let tmp = project_with_main(wrong_arity_main);
let err = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap_err();
match err {
GenerateError::Config(msg) => {
assert!(
msg.contains("pub fn layout") && msg.contains("autumn new"),
"a 2-arg `pub fn layout` must not satisfy the shared-layout \
check; error must stay actionable: {msg}"
);
}
other => panic!("expected an actionable Config error, got: {other:?}"),
}
}
#[test]
fn scaffold_with_decoy_layout_prefix_errors() {
let decoy_main = r#"use autumn_web::prelude::*;
pub fn layout_sidebar(title: &str) -> maud::Markup {
maud::html! { title { (title) } }
}
#[get("/")]
async fn index() -> &'static str { "ok" }
#[autumn_web::main]
async fn main() {
autumn_web::app()
.routes(routes![index])
.run()
.await;
}
"#;
let tmp = project_with_main(decoy_main);
let err = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap_err();
match err {
GenerateError::Config(msg) => {
assert!(
msg.contains("pub fn layout") && msg.contains("autumn new"),
"a `layout_sidebar` decoy must not satisfy the shared-layout \
check; error must stay actionable: {msg}"
);
}
other => panic!("expected an actionable Config error, got: {other:?}"),
}
}
#[test]
fn scaffold_layout_commented_decoy_above_real_is_accepted() {
let commented_then_real = r#"use autumn_web::prelude::*;
// pub fn layout(title, content) -- old 2-arg signature, kept for reference
pub fn layout(title: &str, current_path: &str, flash: maud::Markup, content: maud::Markup) -> maud::Markup {
let _ = (current_path, flash);
maud::html! {
title { (title) }
(content)
}
}
#[get("/")]
async fn index() -> &'static str { "ok" }
#[autumn_web::main]
async fn main() {
autumn_web::app()
.routes(routes![index])
.run()
.await;
}
"#;
let tmp = project_with_main(commented_then_real);
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.expect("a real 4-arg layout below a commented decoy must satisfy the check");
assert!(
plan.actions.iter().any(|a| {
a.path()
.display()
.to_string()
.replace('\\', "/")
.ends_with("src/routes/posts.rs")
}),
"scaffold must plan an HTML routes file when a real shared layout exists"
);
}
#[test]
fn scaffold_layout_only_in_comment_errors() {
let only_comment_main = r#"use autumn_web::prelude::*;
// pub fn layout(title, current_path, flash, content) -- TODO: add a shared layout
#[get("/")]
async fn index() -> &'static str { "ok" }
#[autumn_web::main]
async fn main() {
autumn_web::app()
.routes(routes![index])
.run()
.await;
}
"#;
let tmp = project_with_main(only_comment_main);
let err = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap_err();
match err {
GenerateError::Config(msg) => {
assert!(
msg.contains("pub fn layout") && msg.contains("autumn new"),
"a commented-only `pub fn layout` must not satisfy the \
shared-layout check; error must stay actionable: {msg}"
);
}
other => panic!("expected an actionable Config error, got: {other:?}"),
}
}
#[test]
fn scaffold_api_only_without_layout_succeeds() {
let tmp = project_with_main(main_without_layout());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ScaffoldOptions {
api: true,
..Default::default()
},
)
.expect("api-only scaffold needs no shared layout");
plan.execute(Flags::default()).unwrap();
assert!(
!tmp.path().join("src/routes/posts.rs").exists(),
"api-only scaffold must not emit an HTML routes file"
);
}
#[test]
fn execute_writes_smoke_test() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(tmp.path(), "Post", &[], "20260427000000").unwrap();
plan.execute(Flags::default()).unwrap();
let test = fs::read_to_string(tmp.path().join("tests/post.rs")).unwrap();
assert!(test.contains("posts_index_renders_scaffolded_rows"));
assert!(!test.contains("AUTUMN_TEST_SESSION_COOKIE"));
assert!(!test.contains("Cookie: {session_cookie}"));
assert!(test.contains("/posts"));
}
#[test]
fn smoke_test_emits_write_path_crud_and_validation_rerender() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(tmp.path(), "Post", &[], "20260427000000").unwrap();
plan.execute(Flags::default()).unwrap();
let test = fs::read_to_string(tmp.path().join("tests/post.rs")).unwrap();
assert!(
test.contains("async fn posts_write_path_crud()"),
"write-path CRUD test must be emitted: {test}"
);
assert!(
test.contains(".post(\"/posts\")"),
"must POST to the create route: {test}"
);
assert!(
test.contains(".assert_status(303)"),
"create/update/delete must assert a 302/303 redirect: {test}"
);
assert!(
test.contains(".assert_header(\"location\", \"/posts\")"),
"redirects must be asserted to target the index route: {test}"
);
assert!(
test.contains(".post(\"/posts/1/update\")"),
"must POST to the update route: {test}"
);
assert!(
test.contains(".post(\"/posts/1/delete\")"),
"must POST to the HTML delete action: {test}"
);
assert!(
test.contains(".assert_status(422)"),
"invalid create must re-render at 422: {test}"
);
assert!(
test.contains("role=\\\"alert\\\""),
"invalid create must assert an inline (role=\"alert\") error: {test}"
);
}
#[test]
fn write_path_smoke_test_emits_redirect_persist_and_validation_shapes() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(tmp.path(), "Post", &[], "20260427000000").unwrap();
plan.execute(Flags::default()).unwrap();
let test = fs::read_to_string(tmp.path().join("tests/post.rs")).unwrap();
assert!(
test.contains("Redirect::to(\"/posts\")"),
"create/update/delete stand-ins must issue a real redirect: {test}"
);
assert!(
test.contains("STORE.lock().unwrap()"),
"handlers must read/write the shared store so persistence is observable: {test}"
);
assert!(
test.contains("store.push(") && test.contains(".retain("),
"create must persist and delete must remove from the store: {test}"
);
assert!(
test.contains("form.into_valid()"),
"create must validate the submission through the real changeset round-trip: {test}"
);
assert!(
test.contains(".assert_body_contains(\"1 row(s)\")")
&& test.contains(".assert_body_contains(\"0 row(s)\")"),
"reads must assert the row count so persistence/removal is observable: {test}"
);
}
#[test]
fn write_path_smoke_test_emits_csrf_posture_note() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(tmp.path(), "Post", &[], "20260427000000").unwrap();
plan.execute(Flags::default()).unwrap();
let test = fs::read_to_string(tmp.path().join("tests/post.rs")).unwrap();
assert!(
test.contains("CSRF"),
"the write-path test must document its CSRF posture: {test}"
);
}
#[test]
fn smoke_test_uses_in_process_test_app_not_tcp_stream() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(tmp.path(), "Post", &[], "20260427000000").unwrap();
plan.execute(Flags::default()).unwrap();
let test = fs::read_to_string(tmp.path().join("tests/post.rs")).unwrap();
assert!(
test.contains("autumn_web::test::{TestApp, TestClient, TestDb}"),
"smoke test must use the TestApp/TestClient/TestDb harness: {test}"
);
assert!(
!test.contains("TcpStream"),
"smoke test must not hand-roll a raw TCP request: {test}"
);
assert!(
!test.contains("AUTUMN_TEST_BASE_URL"),
"smoke test must not gate on a running server's base URL: {test}"
);
assert!(
!test.contains("eprintln!(\"skipping"),
"smoke test must not silently skip via an env-gated return: {test}"
);
assert!(
test.contains("#[ignore = \"requires Docker"),
"DB-backed smoke test must be explicitly #[ignore]d with a reason: {test}"
);
assert!(test.contains(".assert_ok()"));
assert!(test.contains(".assert_body_contains(\"Posts\")"));
}
#[test]
fn smoke_test_index_handler_returns_wrong_status_fails_the_assertion() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(tmp.path(), "Post", &[], "20260427000000").unwrap();
plan.execute(Flags::default()).unwrap();
let test = fs::read_to_string(tmp.path().join("tests/post.rs")).unwrap();
assert!(
test.contains("Ok(html! {"),
"handler must actually render Markup that assert_body_contains can inspect: {test}"
);
assert!(
test.contains("h1 { \"Posts\" }"),
"handler must render the heading the test asserts on: {test}"
);
assert!(test.contains(".get_result(&mut db).await?;"));
}
#[test]
fn smoke_test_wires_dev_dependency_test_support() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(tmp.path(), "Post", &[], "20260427000000").unwrap();
plan.execute(Flags::default()).unwrap();
let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
assert!(
cargo.contains("[dev-dependencies]"),
"Cargo.toml must have a [dev-dependencies] section: {cargo}"
);
assert!(
cargo.contains("test-support"),
"Cargo.toml must enable autumn-web's test-support feature for TestDb: {cargo}"
);
let deps_section = cargo.split("[dev-dependencies]").next().unwrap();
assert!(
!deps_section.contains("test-support"),
"test-support must stay out of [dependencies]: {cargo}"
);
}
#[test]
fn destroying_the_only_scaffold_keeps_pre_existing_test_support_another_test_still_uses() {
let tmp = project_with_main(default_main());
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname=\"x\"\n\n[dev-dependencies]\nautumn-web = { version = \"0.6\", features = [\"test-support\"] }\n",
)
.unwrap();
fs::create_dir_all(tmp.path().join("tests")).unwrap();
fs::write(
tmp.path().join("tests/hand_written.rs"),
"#[tokio::test]\nasync fn it_works() {\n let _db = autumn_web::test::TestDb::shared().await;\n}\n",
)
.unwrap();
let plan = plan_scaffold(tmp.path(), "Post", &[], "20260427000000").unwrap();
plan.execute(Flags::default()).unwrap();
let destroy_plan = plan_scaffold(tmp.path(), "Post", &[], "99999999999999").unwrap();
destroy_plan.revert(Flags::default()).unwrap();
let cargo_after = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
assert!(
cargo_after.contains("test-support"),
"pre-existing test-support must survive — the hand-written test still uses \
TestDb: {cargo_after}"
);
}
#[test]
fn smoke_test_wires_dev_dependency_tokio_test_features() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(tmp.path(), "Post", &[], "20260427000000").unwrap();
plan.execute(Flags::default()).unwrap();
let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
let tokio_line = cargo
.lines()
.find(|l| l.trim_start().starts_with("tokio"))
.unwrap_or_else(|| panic!("Cargo.toml must have a tokio dev-dependency: {cargo}"));
assert!(
tokio_line.contains("\"rt\"") && tokio_line.contains("\"macros\""),
"tokio dev-dependency must enable rt and macros for #[tokio::test]: {tokio_line}"
);
}
#[test]
fn delete_button_has_destructive_confirmation() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains(r#"onclick="return confirm("#) || routes.contains("hx-confirm="),
"delete button must have an onclick confirm or hx-confirm: {routes}"
);
}
#[test]
fn smoke_test_write_path_stays_in_process_never_over_a_socket() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let test = fs::read_to_string(tmp.path().join("tests/post.rs")).unwrap();
assert!(
!test.contains("TcpStream"),
"write-path tests must not hand-roll a raw TCP request: {test}"
);
assert!(
!test.contains("AUTUMN_TEST_BASE_URL"),
"write-path tests must not gate on a running server's base URL: {test}"
);
assert_eq!(
test.matches("#[tokio::test]").count(),
2,
"expected the read smoke test plus the write-path CRUD test: {test}"
);
assert!(test.contains("posts_index_renders_scaffolded_rows"));
assert!(test.contains("posts_write_path_crud"));
}
#[test]
fn dry_run_does_not_modify_main() {
let tmp = project_with_main(default_main());
let original = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
let plan = plan_scaffold(tmp.path(), "Post", &[], "20260427000000").unwrap();
plan.execute(Flags {
dry_run: true,
force: false,
})
.unwrap();
let after = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
assert_eq!(original, after);
}
#[test]
fn collision_lists_existing_files_without_force() {
let tmp = project_with_main(default_main());
let dir = tmp.path().join("src/models");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("post.rs"), "// existing").unwrap();
let plan = plan_scaffold(tmp.path(), "Post", &[], "20260427000000").unwrap();
let err = plan.execute(Flags::default()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("post.rs"));
}
#[test]
fn scaffold_soft_delete_destroy_handler_marks_deleted_at_not_physical_delete() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
soft_delete: true,
..Default::default()
},
..Default::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("posts::deleted_at.eq(Some(chrono::Utc::now().naive_utc()))"),
"soft-delete destroy must mark deleted_at: {routes}"
);
assert!(
routes.contains("posts::deleted_at.is_null()"),
"soft-delete destroy must skip already-deleted rows so a repeat delete 404s: {routes}"
);
assert!(
!routes.contains("diesel::delete(posts::table.find(*id))"),
"soft-delete destroy must not physically delete the row: {routes}"
);
}
#[test]
fn scaffold_without_soft_delete_destroy_handler_physically_deletes() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("diesel::delete(posts::table.find(*id))"),
"non-soft-delete destroy must issue a physical delete: {routes}"
);
assert!(
!routes.contains("deleted_at.eq("),
"non-soft-delete destroy must not mark deleted_at: {routes}"
);
}
#[test]
fn scaffold_soft_delete_repository_annotation_includes_soft_delete() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
soft_delete: true,
..Default::default()
},
..Default::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let repo = fs::read_to_string(tmp.path().join("src/repositories/post.rs")).unwrap();
assert!(
repo.contains("soft_delete"),
"repository file must include soft_delete in the #[repository] annotation: {repo}"
);
}
#[test]
fn scaffold_soft_delete_model_includes_deleted_at_field() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
soft_delete: true,
..Default::default()
},
..Default::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/post.rs")).unwrap();
assert!(
model.contains("deleted_at"),
"model struct must include deleted_at field when soft_delete is enabled: {model}"
);
assert!(
model.contains("#[default]\n pub deleted_at:"),
"deleted_at must be #[default] (DB-managed, excluded from NewX/UpdateX): {model}"
);
let deleted_at_pos = model.find("pub deleted_at:").expect("deleted_at field");
let created_at_pos = model.find("pub created_at:").expect("created_at field");
assert!(
deleted_at_pos < created_at_pos,
"deleted_at must be declared before created_at, matching schema.rs's column order: {model}"
);
let schema = fs::read_to_string(tmp.path().join("src/schema.rs")).unwrap();
let schema_deleted_at_pos = schema.find("deleted_at ->").expect("deleted_at column");
let schema_created_at_pos = schema.find("created_at ->").expect("created_at column");
assert!(
schema_deleted_at_pos < schema_created_at_pos,
"schema.rs must declare deleted_at before created_at: {schema}"
);
}
#[test]
fn scaffold_soft_delete_smoke_test_table_includes_deleted_at() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
soft_delete: true,
..Default::default()
},
..Default::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let migration = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_posts/up.sql"),
)
.unwrap();
let test = fs::read_to_string(tmp.path().join("tests/post.rs")).unwrap();
assert!(
migration.contains("deleted_at"),
"real migration must include deleted_at: {migration}"
);
assert!(
test.contains("deleted_at"),
"smoke test's throwaway table must match the real migration schema \
and include deleted_at for a --soft-delete model: {test}"
);
}
#[test]
fn scaffold_without_soft_delete_does_not_include_soft_delete_annotation() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let repo = fs::read_to_string(tmp.path().join("src/repositories/post.rs")).unwrap();
assert!(
!repo.contains("soft_delete"),
"repository without soft_delete must not include soft_delete annotation: {repo}"
);
}
#[test]
fn execute_writes_zero_js_multipart_attachment_handlers() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "avatar:Attachment".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains(".override_field(\"avatar\", autumn_web::form::FieldControl::File)"),
"{routes}"
);
assert!(!routes.contains(".exclude(\"avatar\")"), "{routes}");
assert!(
!routes.contains(
"input type=\"hidden\" name=\"avatar\" value=(changeset.field_value(\"avatar\")"
),
"no hidden presign-key input any more: {routes}"
);
assert!(
routes.contains("mut multipart: autumn_web::extract::Multipart"),
"{routes}"
);
assert!(
routes.contains(".save_to_blob_store(&*store, key).await?"),
"{routes}"
);
assert!(
!routes.contains("complete_direct_upload(&"),
"default path must not call the presign helper: {routes}"
);
assert!(routes.contains("multipart.next_field().await?"), "{routes}");
assert!(
routes.contains("new.avatar = avatar_blob.or(current.avatar);"),
"{routes}"
);
assert!(routes.contains("new.avatar = avatar_blob;"), "{routes}");
assert!(routes.contains("pub struct PostForm"));
assert!(!routes.contains("pub avatar: Option<String>"), "{routes}");
assert!(!routes.contains("async fn into_new("), "{routes}");
assert!(routes.contains("fn into_new(form: &PostForm)"), "{routes}");
assert!(routes.contains("avatar: None,"), "{routes}");
assert!(routes.contains("ZERO"), "{routes}");
assert!(routes.contains("save_to_blob_store"), "{routes}");
assert!(routes.contains("ADVANCED (opt-in)"), "{routes}");
}
#[test]
fn execute_writes_checkbox_for_bool_field() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "active:bool".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
!routes.contains("autumn_web::form::checkbox_input(&changeset"),
"{routes}"
);
assert!(
!routes.contains(".override_field(\"active\""),
"bool fields need no form_for override: {routes}"
);
assert!(
!routes.contains("input type=\"text\" name=\"active\""),
"bool field must not render input type=\"text\": {routes}"
);
assert!(
routes.contains("#[serde(default)]\n pub active: bool,"),
"{routes}"
);
}
#[test]
fn execute_writes_select_for_nullable_bool_field() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "archived:Option<bool>".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
!routes.contains("input type=\"checkbox\" name=\"archived\""),
"nullable bool must not render a checkbox: {routes}"
);
assert!(
!routes.contains("autumn_web::form::select_input(&changeset"),
"{routes}"
);
assert!(
!routes.contains(".override_field(\"archived\""),
"nullable bool fields need no form_for override: {routes}"
);
assert!(routes.contains("pub archived: Option<bool>,"), "{routes}");
}
#[test]
fn execute_writes_number_input_for_integer_fields() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "views:i64".into(), "rank:i32".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
!routes.contains("autumn_web::form::required_number_input(&changeset"),
"{routes}"
);
for field in ["views", "rank"] {
assert!(
!routes.contains(&format!(".override_field(\"{field}\"")),
"integer fields need no form_for override: {routes}"
);
}
assert!(
!routes.contains("input type=\"text\" name=\"views\""),
"{routes}"
);
}
#[test]
fn execute_writes_number_input_with_any_step_for_float_fields() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&[
"title:String".into(),
"price:f64".into(),
"weight:f32".into(),
],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
!routes.contains("autumn_web::form::required_number_input(&changeset"),
"{routes}"
);
for field in ["price", "weight"] {
assert!(
!routes.contains(&format!(".override_field(\"{field}\"")),
"float fields need no form_for override: {routes}"
);
}
}
#[test]
fn execute_writes_number_input_reads_value_from_changeset() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&[
"title:String".into(),
"price:f64".into(),
"weight:Option<f32>".into(),
],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(routes.contains("pub price: String,"), "{routes}");
assert!(
routes.contains(
"price: form.price.parse::<f64>()\n .map_err(|err| autumn_web::AutumnError::bad_request_msg(format!(\"price: {err}\")))?,"
),
"{routes}"
);
assert!(
routes.contains("price: row.price.to_string(),"),
"the edit-form seed copies the float from the row: {routes}"
);
assert!(routes.contains("pub weight: Option<f32>,"), "{routes}");
}
#[test]
fn execute_writes_required_references_field_as_string_on_form() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Comment",
&["body:String".into(), "post:references".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/comments.rs")).unwrap();
assert!(routes.contains("pub post_id: String,"), "{routes}");
assert!(
routes.contains(
"post_id: form.post_id.parse::<i64>()\n .map_err(|err| autumn_web::AutumnError::bad_request_msg(format!(\"post_id: {err}\")))?,"
),
"{routes}"
);
assert!(
routes.contains("post_id: row.post_id.to_string(),"),
"{routes}"
);
}
#[test]
fn execute_writes_required_bytea_field_as_string_on_form() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "payload:Bytea".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(routes.contains("pub payload: String,"), "{routes}");
assert!(
routes.contains("payload: form.payload.clone().into_bytes(),"),
"{routes}"
);
assert!(
routes.contains("payload: String::from_utf8_lossy(&row.payload).into_owned(),"),
"{routes}"
);
}
#[test]
fn execute_writes_nullable_bytea_field_as_option_string_on_form() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "payload:Option<Bytea>".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(routes.contains("pub payload: Option<String>,"), "{routes}");
assert!(
routes.contains(
"payload: form.payload.as_ref().map(|value| value.clone().into_bytes()),"
),
"{routes}"
);
assert!(
routes.contains(
"payload: row.payload.as_ref().map(|value| String::from_utf8_lossy(value).into_owned()),"
),
"{routes}"
);
}
#[test]
fn execute_writes_number_input_value_on_edit_form() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "views:i64".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(routes.contains("views: row.views.to_string(),"), "{routes}");
}
#[test]
fn execute_writes_datetime_local_input_for_naive_datetime_field() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "published_at:NaiveDateTime".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
!routes.contains("autumn_web::form::required_datetime_input(&changeset"),
"{routes}"
);
assert!(
!routes.contains(".override_field(\"published_at\""),
"datetime fields need no form_for override: {routes}"
);
assert!(
!routes.contains("input type=\"text\" name=\"published_at\""),
"{routes}"
);
assert!(routes.contains("pub published_at: String,"), "{routes}");
assert!(
routes.contains("published_at: parse_local_datetime(&form.published_at)?,"),
"{routes}"
);
assert!(
routes.contains(
"published_at: row.published_at.format(\"%Y-%m-%dT%H:%M:%S%.f\").to_string(),"
),
"{routes}"
);
}
#[test]
fn execute_writes_datetime_local_input_for_tz_datetime_field() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "scheduled_at:DateTime".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
!routes.contains(".override_field(\"scheduled_at\""),
"{routes}"
);
assert!(routes.contains("pub scheduled_at: String,"), "{routes}");
assert!(
routes.contains("scheduled_at: parse_local_datetime(&form.scheduled_at)?.and_utc(),"),
"{routes}"
);
}
#[test]
fn execute_writes_datetime_local_input_for_nullable_datetime_field() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&[
"title:String".into(),
"published_at:Option<NaiveDateTime>".into(),
],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("pub published_at: Option<String>,"),
"{routes}"
);
assert!(
routes.contains("form.published_at.as_deref().filter(|value| !value.is_empty())"),
"{routes}"
);
assert!(
!routes.contains("fn deserialize_option_naive_datetime_local"),
"{routes}"
);
}
#[test]
fn execute_writes_text_input_only_for_genuine_string_fields() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&[
"title:String".into(),
"body:Text".into(),
"active:bool".into(),
"views:i64".into(),
"published_at:NaiveDateTime".into(),
],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(routes.contains("fn post_form_for("), "{routes}");
for helper in [
"text_input",
"required_text_input",
"checkbox_input",
"required_number_input",
"required_datetime_input",
] {
assert!(
!routes.contains(&format!("autumn_web::form::{helper}(&changeset")),
"no per-field {helper} lines may remain: {routes}"
);
}
assert!(!routes.contains(".override_field("), "{routes}");
assert!(
!routes.contains("input type=\"text\" name=\"active\""),
"{routes}"
);
assert!(
!routes.contains("input type=\"text\" name=\"views\""),
"{routes}"
);
assert!(
!routes.contains("input type=\"text\" name=\"published_at\""),
"{routes}"
);
}
#[test]
fn plan_scaffold_api_only_skips_html() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ScaffoldOptions {
api: true,
..Default::default()
},
)
.unwrap();
let paths: Vec<String> = plan
.actions
.iter()
.map(|a| {
a.path()
.strip_prefix(&plan.project_root)
.unwrap()
.display()
.to_string()
.replace('\\', "/")
})
.collect();
assert!(!paths.iter().any(|p| p.contains("src/routes/posts.rs")));
assert!(!paths.iter().any(|p| p.contains("src/routes/mod.rs")));
}
#[test]
fn plan_scaffold_api_only_mounts_all_five_json_endpoints() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ScaffoldOptions {
api: true,
..Default::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let main = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
assert!(main.contains("repositories::post::post_api_create"));
assert!(main.contains("repositories::post::post_api_update"));
assert!(main.contains("repositories::post::post_api_delete"));
assert!(main.contains("repositories::post::post_api_list"));
assert!(main.contains("repositories::post::post_api_get"));
assert!(!main.contains("routes::posts::index"));
}
fn sharded_options_with_key(key: &str) -> ScaffoldOptions {
ScaffoldOptions {
model: ModelOptions {
sharded: true,
shard_key: Some(key.into()),
..Default::default()
},
..Default::default()
}
}
#[test]
fn resolves_shard_key_explicit_field() {
let fields = parse_fields(&["tenant_id:i64".into(), "name:String".into()]).unwrap();
let opts = ModelOptions {
sharded: true,
shard_key: Some("tenant_id".into()),
..Default::default()
};
let key = resolve_shard_key(&fields, &opts).unwrap();
assert_eq!(key, Some("tenant_id".to_owned()));
}
#[test]
fn resolves_shard_key_explicit_id() {
let fields = parse_fields(&["name:String".into()]).unwrap();
let opts = ModelOptions {
sharded: true,
shard_key: Some("id".into()),
..Default::default()
};
let key = resolve_shard_key(&fields, &opts).unwrap();
assert_eq!(key, Some("id".to_owned()));
}
#[test]
fn resolves_shard_key_invalid_field_errors() {
let fields = parse_fields(&["name:String".into()]).unwrap();
let opts = ModelOptions {
sharded: true,
shard_key: Some("bogus".into()),
..Default::default()
};
assert!(
resolve_shard_key(&fields, &opts).is_err(),
"unknown shard_key field must return an error"
);
}
#[test]
fn resolves_shard_key_defaults_to_tenant_id_when_present() {
let fields = parse_fields(&["tenant_id:i64".into(), "name:String".into()]).unwrap();
let opts = ModelOptions {
sharded: true,
shard_key: None,
..Default::default()
};
let key = resolve_shard_key(&fields, &opts).unwrap();
assert_eq!(key, Some("tenant_id".to_owned()));
}
#[test]
fn resolves_shard_key_defaults_to_id_when_no_tenant_id() {
let fields = parse_fields(&["name:String".into()]).unwrap();
let opts = ModelOptions {
sharded: true,
shard_key: None,
..Default::default()
};
let key = resolve_shard_key(&fields, &opts).unwrap();
assert_eq!(key, Some("id".to_owned()));
}
#[test]
fn resolves_shard_key_none_when_not_sharded() {
let fields = parse_fields(&["tenant_id:i64".into()]).unwrap();
let opts = ModelOptions {
sharded: false,
shard_key: None,
..Default::default()
};
let key = resolve_shard_key(&fields, &opts).unwrap();
assert!(key.is_none());
}
#[test]
fn routes_use_sharded_db_when_sharded() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Account",
&["tenant_id:i64".into(), "name:String".into()],
"20260427000000",
&sharded_options_with_key("tenant_id"),
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/accounts.rs")).unwrap();
assert!(
routes.contains("use autumn_web::sharding::ShardedDb;"),
"sharded routes must import ShardedDb from autumn_web::sharding: {routes}"
);
assert!(
!routes.contains("mut db: Db"),
"sharded routes must not use Db extractor: {routes}"
);
assert!(
routes.contains("mut db: ShardedDb"),
"sharded routes must use ShardedDb in handler signatures: {routes}"
);
assert!(
routes.contains("from_shard(&db)"),
"sharded index handler must call from_shard(&db): {routes}"
);
}
#[test]
fn scaffold_sharded_on_sqlite_app_is_rejected_as_postgres_only() {
temp_env::with_vars(
[
("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
("AUTUMN_DATABASE__URL", None::<&str>),
("DATABASE_URL", None::<&str>),
],
|| {
let tmp = project_with_main(default_main());
fs::write(
tmp.path().join("autumn.toml"),
"[database]\nprimary_url = \"sqlite://app.db\"\n",
)
.unwrap();
let err = plan_scaffold_with_options(
tmp.path(),
"Account",
&["tenant_id:i64".into(), "name:String".into()],
"20260427000000",
&sharded_options_with_key("tenant_id"),
)
.unwrap_err();
let msg = err.to_string();
assert!(
matches!(err, GenerateError::Config(_)),
"expected Config error, got: {err:?}"
);
assert!(
msg.contains("Postgres") && msg.contains("--sharded"),
"scaffold --sharded on SQLite must be rejected as Postgres-only: {msg}"
);
},
);
}
#[test]
fn routes_use_db_when_not_sharded() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("mut db: Db"),
"non-sharded routes must still use Db"
);
assert!(
!routes.contains("ShardedDb"),
"non-sharded routes must not reference ShardedDb"
);
}
#[test]
fn repository_notes_sharded() {
let rendered = render_repository_file(
"Account",
"account",
&[],
false,
false,
true,
false,
false,
None,
);
assert!(
rendered.contains("shard-aware"),
"sharded repository doc must mention shard-aware: {rendered}"
);
assert!(
rendered.contains("from_shard"),
"sharded repository doc must mention from_shard: {rendered}"
);
}
#[test]
fn repository_notes_api_sharded_caveat() {
let rendered = render_repository_file(
"Account",
"account",
&[],
false,
true,
true,
false,
false,
None,
);
assert!(
rendered.contains("control pool"),
"sharded api repository doc must note control pool: {rendered}"
);
}
#[test]
fn repository_no_sharded_note_when_not_sharded() {
let rendered =
render_repository_file("Post", "post", &[], false, false, false, false, false, None);
assert!(
!rendered.contains("shard-aware"),
"non-sharded repository must not mention shard-aware: {rendered}"
);
}
#[test]
fn plan_scaffold_api_only_emits_json_smoke_test() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into(), "published:bool".into()],
"20260427000000",
&ScaffoldOptions {
api: true,
..Default::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let test_file = fs::read_to_string(tmp.path().join("tests/post.rs")).unwrap();
assert!(test_file.contains("/api/posts"));
assert!(
test_file.contains("autumn_web::test::{TestApp, TestClient, TestDb}"),
"api smoke test must use the in-process harness: {test_file}"
);
assert!(!test_file.contains("TcpStream"));
assert!(!test_file.contains("AUTUMN_TEST_BASE_URL"));
assert!(
test_file.contains("#[ignore = \"requires Docker"),
"DB-backed api smoke test must be explicitly ignored with a reason: {test_file}"
);
assert!(!test_file.contains("contains(\"Posts\")"));
assert!(
test_file.contains("FROM generate_series(1, 25) AS g"),
"required-column seed must use generate_series: {test_file}"
);
assert!(
!test_file.contains("INSERT INTO posts DEFAULT VALUES"),
"required-column seed must not fall back to DEFAULT VALUES: {test_file}"
);
}
#[test]
fn plan_scaffold_api_only_seeds_default_rows_when_no_required_columns() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Widget",
&["label:Option<String>".into()],
"20260427000000",
&ScaffoldOptions {
api: true,
..Default::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let test_file = fs::read_to_string(tmp.path().join("tests/widget.rs")).unwrap();
assert!(test_file.contains("/api/widgets"));
assert!(
!test_file.contains("SELECT FROM generate_series"),
"must not emit the invalid targetless generate_series seed: {test_file}"
);
assert!(
!test_file.contains("generate_series"),
"no-required-column seed must not use generate_series at all: {test_file}"
);
assert_eq!(
test_file
.matches("db.execute_sql(\"INSERT INTO widgets DEFAULT VALUES;\").await;")
.count(),
25,
"expected 25 DEFAULT VALUES inserts (one per row): {test_file}"
);
assert!(test_file.contains("total_elements\"], 25"));
assert!(test_file.contains("total_pages\"], 13"));
}
#[test]
fn index_uses_data_table_not_ul() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&[
"title:String".into(),
"body:Text".into(),
"published:bool".into(),
],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(routes.contains("data_table("), "{routes}");
assert!(routes.contains("DataTableConfig::new("), "{routes}");
assert!(routes.contains("Column::new(\"Title\""), "{routes}");
assert!(
!routes.contains("ul id=\"posts-list\""),
"still uses ul: {routes}"
);
}
#[test]
fn searchable_omitted_is_byte_identical_to_default() {
let fields = ["title:String".to_string(), "body:Text".to_string()];
let tmp_default = project_with_main(default_main());
plan_scaffold(tmp_default.path(), "Post", &fields, "20260427000000")
.unwrap()
.execute(Flags::default())
.unwrap();
let tmp_empty = project_with_main(default_main());
plan_scaffold_with_options(
tmp_empty.path(),
"Post",
&fields,
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
searchable: Vec::new(),
..Default::default()
},
..Default::default()
},
)
.unwrap()
.execute(Flags::default())
.unwrap();
for rel in [
"src/routes/posts.rs",
"src/models/post.rs",
"src/repositories/post.rs",
"migrations/20260427000000_create_posts/up.sql",
"migrations/20260427000000_create_posts/down.sql",
"src/main.rs",
] {
let a = fs::read_to_string(tmp_default.path().join(rel)).unwrap();
let b = fs::read_to_string(tmp_empty.path().join(rel)).unwrap();
assert_eq!(a, b, "empty --searchable must not change `{rel}`");
assert!(
!b.contains("searchable")
&& !b.contains("active_search")
&& !b.contains("search_vector"),
"non-searchable `{rel}` must carry no FTS artifacts:\n{b}"
);
}
}
#[test]
fn searchable_non_text_field_is_rejected() {
let tmp = project_with_main(default_main());
let err = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into(), "views:i64".into()],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
searchable: vec!["views".into()],
..Default::default()
},
..Default::default()
},
)
.unwrap_err();
let msg = err.to_string();
assert!(
matches!(err, GenerateError::Config(_)),
"expected Config error, got: {err:?}"
);
assert!(
msg.contains("views") && (msg.contains("text") || msg.contains("String")),
"error must name the offending field and mention text-only: {msg}"
);
}
#[test]
fn searchable_with_uuid_primary_key_is_rejected() {
let tmp = project_with_main(default_main());
let err = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into(), "body:Text".into()],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
searchable: vec!["title".into(), "body".into()],
id_type: IdType::Uuid,
..Default::default()
},
..Default::default()
},
)
.unwrap_err();
assert!(
matches!(err, GenerateError::Config(_)),
"expected Config error, got: {err:?}"
);
assert!(
err.to_string().to_lowercase().contains("uuid"),
"error must name uuid as unsupported: {err}"
);
assert!(
!tmp.path().join("src/models/post.rs").exists()
&& !tmp.path().join("src/repositories/post.rs").exists(),
"rejected uuid+searchable scaffold must not write any files"
);
}
#[test]
fn searchable_with_owner_scoped_model_emits_scoped_wiring() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&[
"user_id:i64".into(),
"title:String".into(),
"body:Text".into(),
],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
searchable: vec!["title".into(), "body".into()],
..Default::default()
},
..Default::default()
},
)
.expect("standard owner-scoped + searchable must be allowed (#1841)");
plan.execute(Flags::default()).unwrap();
let repo = fs::read_to_string(tmp.path().join("src/repositories/post.rs")).unwrap();
assert!(
repo.contains(", owner = user_id)"),
"owner-scoped repository must carry `owner = user_id` on the attr: {repo}"
);
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("repo.search_page_scoped(owner_id, q, &page_req)"),
"owner-scoped /search must call search_page_scoped: {routes}"
);
assert!(
routes.contains("repo.list_scoped(owner_id, &list_query, &page_req)"),
"owner-scoped index/search must call list_scoped: {routes}"
);
assert!(
routes.contains(".active_sort(list_query.sort().unwrap_or_default())"),
"owner-scoped standard index must render the sort/filter data_table: {routes}"
);
assert!(
routes.contains("active_search_input"),
"owner-scoped searchable index must render the search box: {routes}"
);
}
#[test]
fn owner_scoped_searchable_never_calls_unscoped_repo_methods() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&[
"user_id:i64".into(),
"title:String".into(),
"body:Text".into(),
],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
searchable: vec!["title".into(), "body".into()],
..Default::default()
},
..Default::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
!routes.contains("repo.search_page(") && !routes.contains("repo.page("),
"owner-scoped searchable routes must NEVER call the unscoped \
repo.search_page(/repo.page( (cross-user leak): {routes}"
);
}
#[test]
fn searchable_with_owner_column_but_no_policy_is_allowed() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&[
"user_id:i64".into(),
"title:String".into(),
"body:Text".into(),
],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
searchable: vec!["title".into(), "body".into()],
..Default::default()
},
no_policy: true,
..Default::default()
},
);
assert!(
plan.is_ok(),
"--searchable + owner column + --no-policy must be allowed: {plan:?}"
);
}
#[test]
fn searchable_unknown_field_is_rejected() {
let tmp = project_with_main(default_main());
let err = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
searchable: vec!["nope".into()],
..Default::default()
},
..Default::default()
},
)
.unwrap_err();
assert!(
matches!(err, GenerateError::Config(_)),
"expected Config error, got: {err:?}"
);
assert!(
err.to_string().contains("nope"),
"must name the field: {err}"
);
}
#[test]
fn searchable_scaffold_wires_model_repo_and_index() {
let tmp = project_with_main(default_main());
plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into(), "body:Text".into()],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
searchable: vec!["title".into(), "body".into()],
..Default::default()
},
..Default::default()
},
)
.unwrap()
.execute(Flags::default())
.unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/post.rs")).unwrap();
assert!(
model.contains("#[searchable(language = \"english\")]"),
"model must opt into search: {model}"
);
assert!(
model.contains("#[searchable(weight = \"A\")]")
&& model.contains("#[searchable(weight = \"B\")]"),
"each named text field must be weighted: {model}"
);
let repo = fs::read_to_string(tmp.path().join("src/repositories/post.rs")).unwrap();
assert!(
repo.contains("api = \"/api/posts\", searchable)"),
"repository must carry `searchable`: {repo}"
);
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_posts/up.sql"),
)
.unwrap();
assert!(
up.contains("search_vector tsvector") && up.contains("USING gin(search_vector)"),
"migration must add the generated column + GIN index: {up}"
);
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("active_search_input(\"post-search\"")
&& !routes.contains("widgets::active_search(\"post-search\"")
&& routes.contains("\"/posts/search\", \"#posts-search-results\""),
"index must render the input-only search box wired to the results route: {routes}"
);
assert!(
routes.contains("div id=\"posts-search-results\"")
&& !routes.contains(".initial_load(")
&& !routes.contains("noscript"),
"index must render the results container server-side without initial_load/noscript: {routes}"
);
let index_fn = routes
.split("pub async fn index(")
.nth(1)
.and_then(|s| s.split("pub async fn ").next())
.expect("an index handler must be emitted");
assert_eq!(
index_fn.matches("id=\"posts-search-results\"").count(),
1,
"the index page must render the results container exactly once: {index_fn}"
);
assert!(
routes.contains("pub async fn search(")
&& routes.contains("repo.search_page(q, &page_req)"),
"a search results handler must call search_page: {routes}"
);
assert!(
routes.contains("PagerOptions::new(\"/posts/search\").query(pager_query)"),
"search pager must preserve the query string across pages: {routes}"
);
assert!(
routes.contains("repo.page(&page_req).await?"),
"empty q must fall back to page(): {routes}"
);
let main_rs = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
assert!(
main_rs.contains("routes::posts::search"),
"search route must be registered in main.rs: {main_rs}"
);
}
#[test]
fn searchable_column_is_not_in_schema_or_struct() {
let tmp = project_with_main(default_main());
plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into(), "body:Text".into()],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
searchable: vec!["title".into(), "body".into()],
..Default::default()
},
..Default::default()
},
)
.unwrap()
.execute(Flags::default())
.unwrap();
let schema = fs::read_to_string(tmp.path().join("src/schema.rs")).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/post.rs")).unwrap();
assert!(
!schema.contains("search_vector"),
"search_vector must not be in schema.rs: {schema}"
);
assert!(
!model.contains("pub search_vector"),
"search_vector must not be a struct field: {model}"
);
}
#[test]
fn searchable_search_box_gated_off_for_live() {
let tmp = project_with_main(default_main());
plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into(), "body:Text".into()],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
searchable: vec!["title".into(), "body".into()],
..Default::default()
},
live: true,
..Default::default()
},
)
.unwrap()
.execute(Flags::default())
.unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
!routes.contains("active_search(")
&& !routes.contains("active_search_input(")
&& !routes.contains("pub async fn search("),
"live variant must not emit a search box: {routes}"
);
let repo = fs::read_to_string(tmp.path().join("src/repositories/post.rs")).unwrap();
assert!(
repo.contains(", searchable)"),
"live repo must still carry searchable: {repo}"
);
}
#[test]
fn kind_is_sortable_excludes_blobs_and_enums() {
assert!(kind_is_sortable(FieldKind::String));
assert!(kind_is_sortable(FieldKind::Text));
assert!(kind_is_sortable(FieldKind::I32));
assert!(kind_is_sortable(FieldKind::I64));
assert!(kind_is_sortable(FieldKind::Bool));
assert!(kind_is_sortable(FieldKind::F64));
assert!(kind_is_sortable(FieldKind::Uuid));
assert!(kind_is_sortable(FieldKind::DateTime));
assert!(kind_is_sortable(FieldKind::References));
assert!(kind_is_sortable(FieldKind::Decimal {
precision: 12,
scale: 2
}));
assert!(!kind_is_sortable(FieldKind::Bytea));
assert!(!kind_is_sortable(FieldKind::Attachment));
assert!(!kind_is_sortable(FieldKind::Enum));
}
#[test]
fn index_wires_allowlisted_sort_filter() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&[
"title:String".into(),
"body:Text".into(),
"published:bool".into(),
],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(routes.contains("list_query: ListQuery,"), "{routes}");
assert!(
routes.contains("repo.list(&list_query, &page_req)"),
"{routes}"
);
assert!(routes.contains(".sortable(\"id\")"), "{routes}");
assert!(routes.contains(".sortable(\"title\")"), "{routes}");
assert!(
routes.contains(".active_dir(list_query.direction())"),
"{routes}"
);
}
#[test]
fn index_data_table_cell_handles_nullable_field() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:Option<String>".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(routes.contains("unwrap_or_default"), "{routes}");
}
#[test]
fn index_data_table_has_show_link_column() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(routes.contains("paths::show(row.id)"), "{routes}");
assert!(routes.contains("row.id"), "{routes}");
}
#[test]
fn sharded_index_uses_data_table() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["tenant_id:i64".into(), "title:String".into()],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
sharded: true,
shard_key: Some("tenant_id".into()),
..Default::default()
},
..Default::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(routes.contains("data_table("), "{routes}");
assert!(routes.contains("from_shard"), "{routes}");
}
#[test]
fn live_index_keeps_sse_list_container() {
let tmp = project_with_main(default_main());
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = \"0.5.0\"\n",
)
.unwrap();
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ScaffoldOptions {
live: true,
..Default::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(routes.contains("ul id=\"posts-list\""), "{routes}");
assert!(routes.contains("sse-connect=(paths::events())"), "{routes}");
}
#[test]
fn plan_scaffold_live_views() {
let tmp = project_with_main(default_main());
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = \"0.5.0\"\n",
)
.unwrap();
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ScaffoldOptions {
live: true,
..Default::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(routes.contains("/posts/events"));
assert!(routes.contains("autumn_web::sse::stream"));
assert!(routes.contains("hx-ext=\"sse\""));
assert!(routes.contains("sse-connect=(paths::events())"));
assert!(routes.contains("hx-swap=\"none\""));
assert!(routes.contains("autumn_web::htmx::HTMX_JS_PATH"));
assert!(routes.contains("autumn_web::htmx::HTMX_SSE_JS_PATH"));
assert!(routes.contains("title: autumn_web::hooks::Patch::Set(new.title.clone())"));
let main_rs = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
assert!(main_rs.contains("routes::posts::events"));
let repo = fs::read_to_string(tmp.path().join("src/repositories/post.rs")).unwrap();
assert!(repo.contains("broadcasts = true"));
let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
assert!(cargo.contains("\"ws\""));
assert!(cargo.contains("\"maud\""));
assert!(cargo.contains("\"htmx\""));
}
#[test]
fn show_uses_property_list_widget_with_declared_fields() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&[
"title:String".into(),
"body:Text".into(),
"published:bool".into(),
],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("autumn_web::widgets::property_list"),
"show must use property_list widget: {routes}"
);
assert!(
routes.contains("\"Title\""),
"show must list 'title' field: {routes}"
);
assert!(
routes.contains("\"Body\""),
"show must list 'body' field: {routes}"
);
assert!(
routes.contains("\"Published\""),
"show must list 'published' field: {routes}"
);
assert!(routes.contains("\"Id\""), "show must include id: {routes}");
assert!(
routes.contains("\"Created at\""),
"show must include created_at: {routes}"
);
}
#[test]
fn show_property_list_label_humanization() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&[
"published_at:NaiveDateTime".into(),
"user_name:String".into(),
],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("\"Published at\""),
"humanize must produce 'Published at': {routes}"
);
assert!(
routes.contains("\"User name\""),
"humanize must produce 'User name': {routes}"
);
}
#[test]
fn show_includes_defaulted_fields_in_property_list() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into(), "views:i64".into()],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
defaults: vec!["views=0".to_string()],
..Default::default()
},
..Default::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("\"Views\""),
"show must include defaulted field 'views': {routes}"
);
}
#[test]
fn scaffold_without_state_machine_emits_no_transition_wiring() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "body:Text".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
!routes.contains("/transitions/"),
"no-state-machine scaffold must not emit any transition route: {routes}"
);
assert!(
!routes.contains("fn show_view"),
"no-state-machine scaffold must not extract a show_view helper: {routes}"
);
assert!(
!routes.contains("transition_controls"),
"no-state-machine scaffold must not render transition controls: {routes}"
);
assert!(
routes.contains(
"pub async fn show(id: Path<i64>, mut db: Db, flash: Flash) -> AutumnResult<Markup> {"
),
"no-state-machine `show` signature must be unchanged: {routes}"
);
}
#[test]
fn scaffold_with_state_machine_emits_transition_route_and_controls() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Order",
&[
"status:String:states(draft -> published: can_publish, published -> archived)"
.into(),
],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/orders.rs")).unwrap();
assert!(
routes.contains("#[post(\"/orders/{id}/transitions/status\")]"),
"must emit the transition POST route: {routes}"
);
assert!(
routes.contains("pub async fn transition_status("),
"must emit the transition handler: {routes}"
);
assert!(
routes.contains("row.transition_status_to(&target)"),
"transition handler must call transition_status_to: {routes}"
);
assert!(
routes.contains("Ok(autumn_web::Redirect::to(&paths::show(*id)).into_response())"),
"transition handler must 303-redirect on a legal edge: {routes}"
);
assert!(
routes.contains("autumn_web::reexports::http::StatusCode::UNPROCESSABLE_ENTITY"),
"transition handler must 422 on an illegal edge: {routes}"
);
assert!(
routes.contains("async fn show_view("),
"must extract a shared show_view helper: {routes}"
);
assert!(
routes.contains("autumn_web::widgets::transition_controls(&paths::transition_status(row.id), \"status\", &row.status, Order::__AUTUMN_SM_STATUS_TRANSITIONS,"),
"show view must render transition_controls for the state-machine field: {routes}"
);
assert!(
routes.contains("transition_status,"),
"paths! must register the transition route name: {routes}"
);
assert!(
routes.contains(
"autumn_web::extract::State(state): autumn_web::extract::State<autumn_web::AppState>,"
),
"transition handler must thread the State extractor when policy wiring is on: {routes}"
);
assert!(
routes.contains("session: autumn_web::session::Session,"),
"transition handler must thread the Session extractor when policy wiring is on: {routes}"
);
assert!(
routes.contains(
"autumn_web::authorization::authorize::<Order>(&state, &session, \"update\", &row).await?;"
),
"transition handler must record-authorize the actor against the loaded row: {routes}"
);
}
#[test]
fn scaffold_with_state_machine_no_policy_emits_no_authz_preamble() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Order",
&[
"status:String:states(draft -> published: can_publish, published -> archived)"
.into(),
],
"20260427000000",
&ScaffoldOptions {
no_policy: true,
..Default::default()
},
)
.unwrap();
let routes = action_contents(&plan, "src/routes/orders.rs");
assert!(
routes.contains("pub async fn transition_status("),
"--no-policy state-machine scaffold must still emit the transition handler: {routes}"
);
assert!(
!routes.contains("authorize::<Order>"),
"--no-policy transition handler must not wire authorize: {routes}"
);
assert!(
!routes.contains("session: autumn_web::session::Session"),
"--no-policy transition handler must not thread a Session extractor: {routes}"
);
}
#[test]
fn scaffold_with_state_machine_mounts_transition_route_in_main() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Order",
&[
"status:String:states(draft -> published, published -> archived)".into(),
"title:String".into(),
],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let main = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
assert!(
main.contains("routes::orders::transition_status"),
"main.rs must MOUNT the transition handler, not just declare it in paths!: {main}"
);
assert!(
main.contains("routes::orders::update") && main.contains("routes::orders::destroy"),
"standard CRUD handlers must still be mounted: {main}"
);
}
#[test]
fn scaffold_without_state_machine_mounts_no_transition_route_in_main() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "body:Text".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let main = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
assert!(
!main.contains("transition_"),
"no-state-machine scaffold must mount no transition route: {main}"
);
assert!(
!main.contains("/transitions/"),
"no-state-machine scaffold must reference no transition path: {main}"
);
}
#[test]
fn scaffold_state_machine_field_is_transition_only_on_update() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Order",
&[
"status:String:states(draft -> published, published -> archived)".into(),
"title:String".into(),
],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/orders.rs")).unwrap();
assert!(
!routes.contains("orders::status.eq(new.status.clone())"),
"the update `.set((…))` tuple must exclude the state-machine column: {routes}"
);
assert!(
routes.contains("orders::title.eq(new.title.clone())"),
"the update `.set((…))` tuple must still set non-state columns: {routes}"
);
assert!(
routes.contains("exclude_state_fields: bool"),
"the shared form helper must take an exclude_state_fields flag: {routes}"
);
assert!(
routes.contains("form = form.exclude(\"status\");"),
"the form helper must exclude the state column when asked: {routes}"
);
assert!(
routes.contains(
"order_form_for(&changeset, paths::update(*id), \"Save\", csrf.as_ref(), csrf_field.as_ref(), submit_token.as_ref(), submit_field.as_ref(), true)"
),
"the EDIT form call must exclude the state field (pass `true`): {routes}"
);
assert!(
routes.contains(
"order_form_for(&changeset, paths::create(), \"Create\", csrf.as_ref(), csrf_field.as_ref(), submit_token.as_ref(), submit_field.as_ref(), false)"
),
"the CREATE form call must keep the state field (pass `false`): {routes}"
);
assert!(
routes.contains("#[serde(default)]\n pub status: String,"),
"OrderForm must keep a serde-defaulted state column: {routes}"
);
assert!(
routes.contains("status: form.status.clone(),"),
"into_new must seed the initial state on create: {routes}"
);
assert!(
routes.contains("transition_controls(&paths::transition_status(row.id), \"status\""),
"the show view must still render the state column's transition controls: {routes}"
);
}
#[test]
fn scaffold_without_state_machine_keeps_field_on_update_surfaces() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "body:Text".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
!routes.contains("exclude_state_fields"),
"no-state-machine form helper must not gain an exclude flag: {routes}"
);
assert!(
routes.contains("posts::title.eq(new.title.clone())")
&& routes.contains("posts::body.eq(new.body.clone())"),
"no-state-machine update must set every column: {routes}"
);
}
#[test]
fn smoke_test_default_value_containing_semicolon_does_not_corrupt_sql() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
defaults: vec!["title=hello;world".to_string()],
..Default::default()
},
..Default::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let test = fs::read_to_string(tmp.path().join("tests/post.rs")).unwrap();
assert!(
test.contains("DEFAULT 'hello;world'"),
"generated default literal must survive intact: {test}"
);
assert_eq!(
test.matches("CREATE TABLE").count(),
1,
"CREATE TABLE must appear in a single execute_sql call, not split across two: {test}"
);
}
#[test]
fn scaffold_smoke_test_asserts_out_of_set_post_rejected() {
let tmp = project_with_main(default_main());
plan_and_execute_post_scaffold_with_status_enum(&tmp);
let test = fs::read_to_string(tmp.path().join("tests/post.rs")).unwrap();
assert!(
test.contains("CHECK (status IN ('draft', 'published', 'archived'))"),
"the raw-INSERT test needs the real CHECK constraint in its setup SQL: {test}"
);
assert!(
test.contains("fn posts_rejects_out_of_set_status"),
"got:\n{test}"
);
assert!(test.contains(".assert_status(400)"), "got:\n{test}");
assert!(
test.contains(".assert_body_contains(\"status\")"),
"got:\n{test}"
);
assert!(
test.contains("is_err()"),
"must assert the raw out-of-set INSERT fails at the DB layer: {test}"
);
assert!(
test.contains("posts::table.count()"),
"must assert zero rows were written: {test}"
);
}
#[test]
fn scaffold_enum_rejection_test_uses_idempotent_create_table() {
let tmp = project_with_main(default_main());
plan_and_execute_post_scaffold_with_status_enum(&tmp);
let test = fs::read_to_string(tmp.path().join("tests/post.rs")).unwrap();
let enum_test_pos = test
.find("fn posts_rejects_out_of_set_status")
.unwrap_or_else(|| panic!("expected the enum rejection test: {test}"));
let enum_test_setup = &test[enum_test_pos..];
assert!(
enum_test_setup.contains("CREATE TABLE IF NOT EXISTS posts ("),
"the enum-rejection test's own CREATE TABLE must be idempotent: {enum_test_setup}"
);
let base_test_setup = &test[..enum_test_pos];
assert!(
base_test_setup.contains("CREATE TABLE posts (")
&& !base_test_setup.contains("CREATE TABLE IF NOT EXISTS posts ("),
"the base index test's own setup must be unchanged (plain CREATE TABLE, \
matching the real migration exactly): {base_test_setup}"
);
}
#[test]
fn scaffold_enum_rejection_test_uses_idempotent_create_index() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&[
"title:String".into(),
"status:enum{draft,published,archived}".into(),
],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
indexes: vec!["title".into()],
..Default::default()
},
..Default::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let test = fs::read_to_string(tmp.path().join("tests/post.rs")).unwrap();
let enum_test_pos = test
.find("fn posts_rejects_out_of_set_status")
.unwrap_or_else(|| panic!("expected the enum rejection test: {test}"));
let enum_test_setup = &test[enum_test_pos..];
assert!(
enum_test_setup.contains("CREATE INDEX IF NOT EXISTS idx_posts_title"),
"the enum-rejection test's own CREATE INDEX must be idempotent: {enum_test_setup}"
);
let base_test_setup = &test[..enum_test_pos];
assert!(
base_test_setup.contains("CREATE INDEX idx_posts_title")
&& !base_test_setup.contains("CREATE INDEX IF NOT EXISTS idx_posts_title"),
"the base index test's own setup must be unchanged (plain CREATE INDEX, \
matching the real migration exactly): {base_test_setup}"
);
}
#[test]
fn scaffold_api_smoke_test_asserts_check_constraint_but_no_http_assertion() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&[
"title:String".into(),
"status:enum{draft,published,archived}".into(),
],
"20260427000000",
&ScaffoldOptions {
api: true,
..Default::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let test = fs::read_to_string(tmp.path().join("tests/post.rs")).unwrap();
assert!(
test.contains("fn posts_rejects_out_of_set_status"),
"got:\n{test}"
);
assert!(
test.contains("is_err()"),
"must assert the raw out-of-set INSERT fails at the DB layer: {test}"
);
assert!(
!test.contains(".assert_status(400)"),
"the --api smoke test has no HTML form route to POST to: {test}"
);
}
#[test]
fn scaffold_smoke_test_absent_when_no_enum_fields() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let test = fs::read_to_string(tmp.path().join("tests/post.rs")).unwrap();
assert!(
!test.contains("rejects_out_of_set"),
"no enum field means no rejection test: {test}"
);
}
#[test]
fn scaffold_validation_smoke_test_max_only_rule_exceeds_bound() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
validations: vec!["title=length:max=5".into()],
..ModelOptions::default()
},
..ScaffoldOptions::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let test = fs::read_to_string(tmp.path().join("tests/post.rs")).unwrap();
let invalid_line = test
.lines()
.find(|l| l.contains("let invalid_body"))
.expect("invalid_body line present");
let invalid_title = invalid_line
.split("title=")
.nth(1)
.and_then(|s| s.split('&').next())
.expect("title value in invalid_body");
assert!(
invalid_title.len() > 5,
"invalid value must exceed the declared max=5 bound: {invalid_line}"
);
let valid_line = test
.lines()
.find(|l| l.contains("let valid_body"))
.expect("valid_body line present");
let valid_title = valid_line
.split("title=")
.nth(1)
.and_then(|s| s.split('&').next())
.expect("title value in valid_body");
assert!(
valid_title.len() <= 5,
"valid value must satisfy the declared max=5 bound: {valid_line}"
);
}
#[test]
fn scaffold_validation_smoke_test_skips_absurdly_large_bound() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
validations: vec!["title=length:min=1000000000".into()],
..ModelOptions::default()
},
..ScaffoldOptions::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let test = fs::read_to_string(tmp.path().join("tests/post.rs")).unwrap();
assert!(
!test.contains("rejects_invalid_title_and_preserves_input"),
"an absurdly large bound must not generate a smoke test: {test}"
);
}
#[test]
fn scaffold_validation_smoke_test_skips_noop_min_zero_rule() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
validations: vec!["title=length:min=0".into()],
..ModelOptions::default()
},
..ScaffoldOptions::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let test = fs::read_to_string(tmp.path().join("tests/post.rs")).unwrap();
assert!(
!test.contains("rejects_invalid_title_and_preserves_input"),
"a no-op length:min=0 rule must not generate a smoke test: {test}"
);
}
#[test]
fn scaffold_validation_smoke_test_falls_back_past_noop_rule() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into(), "body:String".into()],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
validations: vec![
"title=length:min=0".into(),
"body=length:min=1,max=200".into(),
],
..ModelOptions::default()
},
..ScaffoldOptions::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let test = fs::read_to_string(tmp.path().join("tests/post.rs")).unwrap();
assert!(
test.contains("rejects_invalid_body_and_preserves_input"),
"the violatable body rule must be used instead of the no-op title rule: {test}"
);
assert!(
!test.contains("rejects_invalid_title_and_preserves_input"),
"the no-op title rule must not be used for the smoke test: {test}"
);
}
#[test]
fn scaffold_validation_rejects_min_greater_than_max() {
let tmp = project_with_main(default_main());
let err = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
validations: vec!["title=length:min=5,max=3".into()],
..ModelOptions::default()
},
..ScaffoldOptions::default()
},
)
.unwrap_err();
assert!(
matches!(err, GenerateError::InvalidField { .. }),
"expected min > max to be rejected, got {err:?}"
);
}
#[test]
fn scaffold_multiple_enum_fields_each_use_own_variant_as_other_columns_sample() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&[
"status:enum{draft,published,archived}".into(),
"priority:enum{low,high}".into(),
],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let test = fs::read_to_string(tmp.path().join("tests/post.rs")).unwrap();
assert!(
test.contains(
"INSERT INTO posts (status, priority) VALUES ('__not_a_real_variant__', 'low')"
),
"got:\n{test}"
);
assert!(
test.contains(
"INSERT INTO posts (status, priority) VALUES ('draft', '__not_a_real_variant__')"
),
"got:\n{test}"
);
}
#[test]
fn scaffold_enum_rejection_test_isolates_check_failure_from_required_references_field() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&[
"author:references".into(),
"status:enum{draft,published,archived}".into(),
],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let test = fs::read_to_string(tmp.path().join("tests/post.rs")).unwrap();
assert!(
test.contains("INSERT INTO authors DEFAULT VALUES;"),
"the stub `authors` table must be seeded so author_id=1 is a valid FK: {test}"
);
assert!(
test.contains(
"INSERT INTO posts (author_id, status) VALUES (1, '__not_a_real_variant__')"
),
"got:\n{test}"
);
}
#[test]
fn scaffold_references_field_emits_fk_column_constraint_and_index() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Comment",
&["body:Text".into(), "post:references".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_comments/up.sql"),
)
.unwrap();
assert!(
up.contains("post_id BIGINT NOT NULL REFERENCES posts(id)"),
"up.sql: {up}"
);
assert!(
up.contains("CREATE INDEX idx_comments_post_id ON comments (post_id);"),
"up.sql: {up}"
);
}
#[test]
fn scaffold_references_field_warns_when_target_model_missing() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Comment",
&["post:references".into()],
"20260427000000",
)
.unwrap();
assert_eq!(plan.warnings.len(), 2, "warnings: {:?}", plan.warnings);
assert!(plan.warnings[0].contains("posts"));
assert!(
plan.warnings[1].contains("numeric id input"),
"warnings: {:?}",
plan.warnings
);
assert!(
plan.warnings[1].contains("Generate the 'post' model first"),
"warnings: {:?}",
plan.warnings
);
}
#[test]
fn scaffold_smoke_test_creates_a_stub_referenced_table_before_the_real_one() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Comment",
&["body:Text".into(), "post:references".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let test = fs::read_to_string(tmp.path().join("tests/comment.rs")).unwrap();
let stub_pos = test
.find("CREATE TABLE IF NOT EXISTS posts")
.unwrap_or_else(|| panic!("expected a stub `posts` table in the smoke test: {test}"));
let real_pos = test.find("CREATE TABLE comments").unwrap_or_else(|| {
panic!("expected the real `comments` table in the smoke test: {test}")
});
assert!(
stub_pos < real_pos,
"the stub referenced table must be created before the table under test: {test}"
);
}
#[test]
fn scaffold_smoke_test_emits_one_stub_per_distinct_reference_target() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Follow",
&["follower:references".into(), "followee:references".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let test = fs::read_to_string(tmp.path().join("tests/follow.rs")).unwrap();
assert!(test.contains("CREATE TABLE IF NOT EXISTS followers"));
assert!(test.contains("CREATE TABLE IF NOT EXISTS followees"));
}
#[test]
fn render_reference_stub_tables_sql_dedupes_identical_targets() {
let fields = vec![
Field {
name: "author_id".to_string(),
kind: FieldKind::References,
nullable: false,
variants: Vec::new(),
unique: false,
constraints: FieldConstraints::default(),
state_machine: None,
},
Field {
name: "author_id".to_string(),
kind: FieldKind::References,
nullable: true,
variants: Vec::new(),
unique: false,
constraints: FieldConstraints::default(),
state_machine: None,
},
];
assert_eq!(
fields[0].reference_table(),
fields[1].reference_table(),
"test setup: both fields must target the same table"
);
let sql = render_reference_stub_tables_sql(&fields, "unrelated_table");
assert_eq!(
sql.matches("CREATE TABLE IF NOT EXISTS").count(),
1,
"identical targets must be de-duplicated: {sql}"
);
}
#[test]
fn render_reference_stub_tables_sql_seeds_a_row_so_fk_id_1_is_valid() {
let fields = super::super::dsl::parse_fields(&["author:references".into()]).unwrap();
let sql = render_reference_stub_tables_sql(&fields, "posts");
assert_eq!(
sql,
"CREATE TABLE IF NOT EXISTS authors (id BIGSERIAL PRIMARY KEY);\n\
INSERT INTO authors DEFAULT VALUES;\n\
INSERT INTO authors DEFAULT VALUES;\n",
"got:\n{sql}"
);
}
#[test]
fn render_reference_stub_tables_sql_seeds_only_once_per_distinct_target() {
let fields = vec![
Field {
name: "author_id".to_string(),
kind: FieldKind::References,
nullable: false,
variants: Vec::new(),
unique: false,
constraints: FieldConstraints::default(),
state_machine: None,
},
Field {
name: "author_id".to_string(),
kind: FieldKind::References,
nullable: true,
variants: Vec::new(),
unique: false,
constraints: FieldConstraints::default(),
state_machine: None,
},
];
let sql = render_reference_stub_tables_sql(&fields, "unrelated_table");
assert_eq!(
sql.matches("CREATE TABLE IF NOT EXISTS authors").count(),
1,
"two fields pointing at the same target must still only stub \
(and seed) that table once, not once per field; got:\n{sql}"
);
assert_eq!(
sql.matches("INSERT INTO authors DEFAULT VALUES;").count(),
2,
"got:\n{sql}"
);
}
#[test]
fn render_reference_stub_tables_sql_skips_own_table() {
let fields = super::super::dsl::parse_fields(&["category:references".into()]).unwrap();
assert_eq!(fields[0].reference_table().as_deref(), Some("categories"));
let sql = render_reference_stub_tables_sql(&fields, "categories");
assert!(sql.is_empty(), "must not stub the model's own table: {sql}");
}
#[test]
fn scaffold_self_referential_reference_compiles_one_create_table() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Category",
&["name:String".into(), "category:references".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let test = fs::read_to_string(tmp.path().join("tests/category.rs")).unwrap();
assert_eq!(
test.matches("CREATE TABLE").count(),
1,
"a self-referential FK must not stub its own table before creating it for real: {test}"
);
assert!(test.contains("category_id BIGINT NOT NULL REFERENCES categories(id)"));
}
#[test]
fn decimal_literal_at_scale_never_reaches_one() {
for scale in 1..=6 {
for digit in [1, 2, 3] {
let literal = decimal_literal_at_scale(scale, digit);
let value: f64 = literal.parse().unwrap();
assert!(
value.abs() < 1.0,
"decimal_literal_at_scale({scale}, {digit}) = {literal} must be < 1"
);
}
}
}
#[test]
fn decimal_sample_literals_are_pairwise_distinct_at_every_scale() {
for scale in 0..=6 {
let a = sql_sample_literal(FieldKind::Decimal {
precision: scale.max(1) + 1,
scale,
});
let b = unique_sample_literal(FieldKind::Decimal {
precision: scale.max(1) + 1,
scale,
});
let c = unique_sample_literal_variant(FieldKind::Decimal {
precision: scale.max(1) + 1,
scale,
});
assert!(a != b && b != c && a != c, "scale {scale}: {a}, {b}, {c}");
}
}
#[test]
fn enum_rejection_insert_sql_fits_tightly_scaled_decimal_column() {
let status = Field {
name: "status".to_string(),
kind: FieldKind::Enum,
nullable: false,
variants: vec!["a".to_string(), "b".to_string()],
unique: false,
constraints: FieldConstraints::default(),
state_machine: None,
};
let weight = Field {
name: "weight".to_string(),
kind: FieldKind::Decimal {
precision: 1,
scale: 1,
},
nullable: false,
variants: Vec::new(),
unique: false,
constraints: FieldConstraints::default(),
state_machine: None,
};
let fields = vec![status.clone(), weight];
let sql = enum_rejection_insert_sql("items", &fields, &status, &BTreeMap::new());
assert!(
sql.contains("weight) VALUES ('__not_a_real_variant__', 0.1)"),
"expected a scale-fitting 0.1 literal for NUMERIC(1,1), got: {sql}"
);
}
#[test]
fn scaffold_decimal_field_warns_when_existing_rust_decimal_dep_lacks_diesel_feature() {
let tmp = project_with_main(default_main());
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname=\"x\"\n\n[dependencies]\nrust_decimal = \"1\"\n",
)
.unwrap();
let plan = plan_scaffold(
tmp.path(),
"Product",
&["price:decimal".into()],
"20260427000000",
)
.unwrap();
assert_eq!(plan.warnings.len(), 1, "warnings: {:?}", plan.warnings);
assert!(plan.warnings[0].contains("rust_decimal"));
assert!(plan.warnings[0].contains("db-diesel2-postgres"));
}
fn scaffold_paths(plan: &Plan) -> Vec<String> {
plan.actions
.iter()
.map(|a| {
a.path()
.strip_prefix(&plan.project_root)
.unwrap()
.display()
.to_string()
.replace('\\', "/")
})
.collect()
}
fn action_contents(plan: &Plan, suffix: &str) -> String {
plan.actions
.iter()
.find(|a| {
a.path()
.to_string_lossy()
.replace('\\', "/")
.ends_with(suffix)
})
.map(|a| match a {
Action::Create { contents, .. } | Action::Modify { contents, .. } => {
contents.clone()
}
_ => String::new(),
})
.unwrap_or_default()
}
#[test]
fn default_scaffold_emits_policy_file_and_registration() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "user_id:i64".into()],
"20260427000000",
)
.unwrap();
let paths = scaffold_paths(&plan);
assert!(
paths.iter().any(|p| p == "src/policies/post.rs"),
"{paths:?}"
);
assert!(
paths.iter().any(|p| p == "src/policies/mod.rs"),
"{paths:?}"
);
let policy = action_contents(&plan, "src/policies/post.rs");
assert!(
policy.contains("impl Policy<Post> for PostPolicy"),
"{policy}"
);
assert!(policy.contains("Some(post.user_id)"), "{policy}");
let main = action_contents(&plan, "src/main.rs");
assert!(
main.contains(".policy::<crate::models::post::Post, _>"),
"{main}"
);
assert!(
main.contains(".scope::<crate::models::post::Post, _>"),
"{main}"
);
assert!(main.contains("mod policies;"), "{main}");
}
#[test]
fn owner_scaffold_authorizes_mutating_handlers_and_scopes_index() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "user_id:i64".into()],
"20260427000000",
)
.unwrap();
let routes = action_contents(&plan, "src/routes/posts.rs");
assert_eq!(
routes
.matches("autumn_web::authorization::authorize::<Post>")
.count(),
3,
"edit/update/destroy must each authorize: {routes}"
);
assert!(routes.contains("\"edit\", &row"), "{routes}");
assert!(routes.contains("\"update\", ¤t"), "{routes}");
assert!(routes.contains("\"delete\", ¤t"), "{routes}");
assert!(
routes
.contains("autumn_web::authorization::authorize_create::<Post>(&state, &session)"),
"create must authorize_create: {routes}"
);
assert!(routes.contains("pub async fn create("), "{routes}");
assert!(
routes.contains("repo.list_scoped(owner_id, &list_query, &page_req)"),
"index must scope through list_scoped: {routes}"
);
assert!(
!routes.contains("repo.list(&list_query") && !routes.contains("repo.page("),
"owner-scoped index must not call the unscoped list/page: {routes}"
);
assert!(
routes.contains("PolicyContext::from_request(&state, &session)"),
"{routes}"
);
let smoke = action_contents(&plan, "tests/post.rs");
assert!(
smoke.contains("cross_user_mutations_are_forbidden"),
"{smoke}"
);
assert!(smoke.contains("ForbiddenResponse::Forbidden403"), "{smoke}");
}
#[test]
fn live_owner_scaffold_authorizes_mutating_handlers_and_scopes_index() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into(), "author_id:i64".into()],
"20260427000000",
&ScaffoldOptions {
live: true,
..Default::default()
},
)
.unwrap();
let routes = action_contents(&plan, "src/routes/posts.rs");
assert_eq!(
routes
.matches("autumn_web::authorization::authorize::<Post>")
.count(),
3,
"live edit/update/destroy must each authorize: {routes}"
);
assert!(routes.contains("\"edit\", &row"), "{routes}");
assert!(routes.contains("\"update\", ¤t"), "{routes}");
assert!(routes.contains("\"delete\", ¤t"), "{routes}");
assert!(
routes.contains("let current: Post = repo.find_by_id(*id).await?"),
"live variant must load current row via the repository: {routes}"
);
assert!(
routes
.contains("autumn_web::authorization::authorize_create::<Post>(&state, &session)"),
"live create must authorize_create: {routes}"
);
assert!(
routes.contains("posts::author_id.eq(owner_id)"),
"live index must filter by owner: {routes}"
);
assert!(
routes.contains("PolicyContext::from_request(&state, &session)"),
"{routes}"
);
assert!(
routes.contains("ul id=\"posts-list\"")
&& routes.contains("sse-connect=(paths::events())"),
"live owner index must keep the SSE ul island: {routes}"
);
}
#[test]
fn sharded_owner_scaffold_authorizes_mutating_handlers_and_scopes_index() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into(), "author_id:i64".into()],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
sharded: true,
..Default::default()
},
..Default::default()
},
)
.unwrap();
let routes = action_contents(&plan, "src/routes/posts.rs");
assert_eq!(
routes
.matches("autumn_web::authorization::authorize::<Post>")
.count(),
3,
"sharded edit/update/destroy must each authorize: {routes}"
);
assert!(routes.contains("\"edit\", &row"), "{routes}");
assert!(routes.contains("\"update\", ¤t"), "{routes}");
assert!(routes.contains("\"delete\", ¤t"), "{routes}");
assert!(
routes.contains(
"let current: Post = PgPostRepository::from_shard(&db).find_by_id(*id).await?"
),
"sharded variant must load current row via from_shard: {routes}"
);
assert!(
routes
.contains("autumn_web::authorization::authorize_create::<Post>(&state, &session)"),
"sharded create must authorize_create: {routes}"
);
assert!(
routes.contains("posts::author_id.eq(owner_id)"),
"sharded index must filter by owner: {routes}"
);
assert!(
routes.contains("mut db: ShardedDb") && !routes.contains("mut db: Db"),
"sharded handlers must use ShardedDb, not Db: {routes}"
);
}
#[test]
fn attachment_owner_scaffold_authorizes_reusing_state_wrapper() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&[
"title:String".into(),
"author_id:i64".into(),
"avatar:Attachment".into(),
],
"20260427000000",
)
.unwrap();
let routes = action_contents(&plan, "src/routes/posts.rs");
assert!(
routes
.contains("autumn_web::authorization::authorize_create::<Post>(&*state, &session)"),
"attachment create must authorize_create via &*state: {routes}"
);
assert!(
routes.contains(
"autumn_web::authorization::authorize::<Post>(&*state, &session, \"update\", ¤t)"
),
"attachment update must authorize via &*state: {routes}"
);
assert!(
routes.contains(
"autumn_web::authorization::authorize::<Post>(&state, &session, \"delete\", ¤t)"
),
"attachment destroy must authorize via &state: {routes}"
);
assert!(
routes.contains(
"autumn_web::authorization::authorize::<Post>(&state, &session, \"edit\", &row)"
),
"attachment edit must authorize via &state: {routes}"
);
assert!(
!routes.contains(
"autumn_web::extract::State(state): autumn_web::extract::State<autumn_web::AppState>,\n body: Bytes"
) && !routes.contains(
"autumn_web::extract::State(state): autumn_web::extract::State<autumn_web::AppState>,\n mut multipart"
),
"attachment create/update must not inject a duplicate State extractor: {routes}"
);
assert!(
routes.contains("repo.list_scoped(owner_id, &list_query, &page_req)"),
"attachment index must scope through list_scoped: {routes}"
);
assert!(
routes.contains("PolicyContext::from_request(&state, &session)"),
"attachment index must resolve owner_id from the policy context: {routes}"
);
}
#[test]
fn no_policy_flag_reverts_to_secured_only() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into(), "user_id:i64".into()],
"20260427000000",
&ScaffoldOptions {
no_policy: true,
..Default::default()
},
)
.unwrap();
let paths = scaffold_paths(&plan);
assert!(
!paths.iter().any(|p| p.starts_with("src/policies/")),
"--no-policy must not emit a policies file: {paths:?}"
);
let routes = action_contents(&plan, "src/routes/posts.rs");
assert!(
!routes.contains("authorize::<Post>"),
"--no-policy must not wire authorize: {routes}"
);
assert!(
!routes.contains("authorize_create::<Post>"),
"--no-policy must not wire authorize_create: {routes}"
);
let main = action_contents(&plan, "src/main.rs");
assert!(
!main.contains(".policy::<"),
"--no-policy must not register a policy: {main}"
);
}
#[test]
fn no_owner_scaffold_authorizes_via_authenticated_policy_without_scoping_index() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "body:Text".into()],
"20260427000000",
)
.unwrap();
let policy = action_contents(&plan, "src/policies/post.rs");
assert!(
policy.contains("// SECURITY TODO: this only checks authentication"),
"{policy}"
);
assert!(
policy.contains("Box::pin(async move { ctx.is_authenticated() })"),
"no-owner can_update/can_delete must authenticate-check: {policy}"
);
let main = action_contents(&plan, "src/main.rs");
assert!(
main.contains(".policy::<crate::models::post::Post, _>"),
"{main}"
);
let routes = action_contents(&plan, "src/routes/posts.rs");
assert_eq!(
routes
.matches("autumn_web::authorization::authorize::<Post>")
.count(),
3,
"no-owner edit/update/destroy must each authorize: {routes}"
);
assert!(
routes
.contains("autumn_web::authorization::authorize_create::<Post>(&state, &session)"),
"no-owner create must authorize_create: {routes}"
);
assert!(
!routes.contains(".eq(owner_id)"),
"no-owner index must not be owner-scoped: {routes}"
);
assert!(
!routes.contains("PolicyContext::from_request(&state, &session)"),
"no-owner index must not build a PolicyContext for owner filtering: {routes}"
);
}
#[test]
fn api_scaffold_never_generates_policy() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into(), "user_id:i64".into()],
"20260427000000",
&ScaffoldOptions {
api: true,
..Default::default()
},
)
.unwrap();
let paths = scaffold_paths(&plan);
assert!(
!paths.iter().any(|p| p.starts_with("src/policies/")),
"--api must not emit a policy: {paths:?}"
);
}
#[test]
fn policy_registration_survives_scaffold_destroy_round_trip() {
let tmp = project_with_main(default_main());
let main_path = tmp.path().join("src/main.rs");
let original_main = fs::read_to_string(&main_path).unwrap();
plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "user_id:i64".into()],
"20260427000000",
)
.unwrap()
.execute(Flags::default())
.unwrap();
assert!(tmp.path().join("src/policies/post.rs").exists());
assert!(
fs::read_to_string(&main_path)
.unwrap()
.contains(".policy::<")
);
plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "user_id:i64".into()],
"20260427000000",
)
.unwrap()
.revert(Flags::default())
.unwrap();
assert!(!tmp.path().join("src/policies/post.rs").exists());
assert!(!tmp.path().join("src/policies").exists());
assert_eq!(fs::read_to_string(&main_path).unwrap(), original_main);
}
#[test]
fn submit_token_exempt_appends_fresh_block_when_absent() {
let existing = "[server]\nport = 3000\n";
let out = append_submit_token_validate_exempt_to_toml(existing, "posts")
.expect("absent exemption must be inserted");
assert!(out.contains("[security.submit_token]"), "{out}");
assert!(
out.contains("exempt_paths = [\"/posts/validate\"]"),
"{out}"
);
assert!(out.contains("[server]"), "{out}");
}
#[test]
fn submit_token_exempt_is_idempotent() {
let existing = "[security.submit_token]\nexempt_paths = [\"/posts/validate\"]\n";
let out = append_submit_token_validate_exempt_to_toml(existing, "posts");
assert_eq!(
out, None,
"re-adding the same prefix must be a no-op returning None"
);
}
#[test]
fn submit_token_exempt_merges_into_existing_array() {
let existing =
"[security.submit_token]\nttl_secs = 900\nexempt_paths = [\"/webhooks/x\"]\n";
let out = append_submit_token_validate_exempt_to_toml(existing, "posts")
.expect("a new prefix must be merged into the existing array");
assert!(out.contains("\"/webhooks/x\""), "{out}");
assert!(out.contains("\"/posts/validate\""), "{out}");
assert!(out.contains("ttl_secs = 900"), "{out}");
assert_eq!(out.matches("exempt_paths").count(), 1, "{out}");
}
#[test]
fn submit_token_exempt_merges_into_commented_multiline_array() {
let existing = "\
[security.submit_token]
exempt_paths = [
\"/api\", # public api, never guarded
\"/webhooks/x\",
]
";
let out = append_submit_token_validate_exempt_to_toml(existing, "posts")
.expect("a new prefix must be merged into the commented array");
let parsed: toml::Value =
toml::from_str(&out).unwrap_or_else(|e| panic!("merged TOML must parse: {e}\n{out}"));
let paths = parsed["security"]["submit_token"]["exempt_paths"]
.as_array()
.expect("exempt_paths must be an array");
let values: Vec<&str> = paths.iter().filter_map(toml::Value::as_str).collect();
assert!(values.contains(&"/api"), "{out}");
assert!(values.contains(&"/webhooks/x"), "{out}");
assert!(values.contains(&"/posts/validate"), "{out}");
assert!(out.contains("# public api, never guarded"), "{out}");
assert_eq!(out.matches("exempt_paths").count(), 1, "{out}");
}
#[test]
fn submit_token_exempt_inserts_key_when_block_lacks_it() {
let existing = "[security.submit_token]\nttl_secs = 900\n";
let out = append_submit_token_validate_exempt_to_toml(existing, "posts")
.expect("a block lacking the key must gain one");
assert!(
out.contains("exempt_paths = [\"/posts/validate\"]"),
"{out}"
);
assert!(out.contains("ttl_secs = 900"), "{out}");
}
#[test]
fn submit_token_exempt_prefix_covers_only_validation_routes() {
assert_eq!(
submit_token_validate_exempt_prefix("posts"),
"/posts/validate"
);
}
#[test]
fn remove_submit_token_exempt_drops_fresh_block() {
let existing = "[server]\nport = 3000\n";
let added = append_submit_token_validate_exempt_to_toml(existing, "posts")
.expect("fresh block must be added");
let removed = remove_submit_token_validate_exempt_from_toml(&added, "posts");
assert!(!removed.contains("/posts/validate"), "{removed}");
assert!(!removed.contains("[security.submit_token]"), "{removed}");
assert!(removed.contains("[server]"), "{removed}");
}
#[test]
fn remove_submit_token_exempt_keeps_other_entries() {
let existing =
"[security.submit_token]\nexempt_paths = [\"/webhooks/x\", \"/posts/validate\"]\n";
let removed = remove_submit_token_validate_exempt_from_toml(existing, "posts");
assert!(removed.contains("\"/webhooks/x\""), "{removed}");
assert!(!removed.contains("/posts/validate"), "{removed}");
assert!(removed.contains("[security.submit_token]"), "{removed}");
}
#[test]
fn remove_submit_token_exempt_ignores_hand_edited_value() {
let existing = "[security.submit_token]\nexempt_paths = [\"/custom/only\"]\n";
let removed = remove_submit_token_validate_exempt_from_toml(existing, "posts");
assert_eq!(removed, existing);
}
#[test]
fn live_validation_scaffold_exempts_validation_routes_in_autumn_toml() {
let tmp = project_with_main(default_main());
fs::write(
tmp.path().join("autumn.toml"),
"[security.submit_token]\nfield_name = \"_confirm\"\n",
)
.unwrap();
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260712000000",
&ScaffoldOptions {
live_validation: true,
..Default::default()
},
)
.unwrap();
let toml_action = plan
.actions
.iter()
.rev()
.find_map(|a| match a {
Action::Modify { path, contents } if path.ends_with("autumn.toml") => {
Some(contents.clone())
}
_ => None,
})
.expect("live-validation scaffold must modify autumn.toml");
assert!(
toml_action.contains("\"/posts/validate\""),
"autumn.toml must exempt the validation route prefix:\n{toml_action}"
);
assert!(
toml_action.contains("field_name = \"_confirm\""),
"{toml_action}"
);
}
#[test]
fn non_live_validation_scaffold_does_not_touch_autumn_toml_exempt() {
let tmp = project_with_main(default_main());
fs::write(tmp.path().join("autumn.toml"), "[server]\nport = 3000\n").unwrap();
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into()],
"20260712000000",
)
.unwrap();
let touches_toml = plan
.actions
.iter()
.any(|a| a.path().ends_with("autumn.toml"));
assert!(
!touches_toml,
"a plain scaffold (no --live-validation) must not add submit-token exemptions"
);
}
#[test]
fn live_validation_scaffold_records_revert_only_when_exemption_inserted() {
let tmp = project_with_main(default_main());
fs::write(
tmp.path().join("autumn.toml"),
"[security.submit_token]\nfield_name = \"_confirm\"\n",
)
.unwrap();
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260712000000",
&ScaffoldOptions {
live_validation: true,
..Default::default()
},
)
.unwrap();
let touches_toml = plan
.actions
.iter()
.any(|a| a.path().ends_with("autumn.toml"));
assert!(
touches_toml,
"an absent exemption must be inserted via a modify action"
);
let has_revert = plan.reverts.iter().any(|r| {
matches!(
r,
Revert::SubmitTokenValidateExempt { plural, .. } if plural == "posts"
)
});
assert!(
has_revert,
"inserting the exemption must record a SubmitTokenValidateExempt revert"
);
}
#[test]
fn live_validation_scaffold_skips_revert_when_exemption_preexists() {
let tmp = project_with_main(default_main());
fs::write(
tmp.path().join("autumn.toml"),
"[security.submit_token]\nfield_name = \"_confirm\"\nexempt_paths = [\"/posts/validate\"]\n",
)
.unwrap();
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260712000000",
&ScaffoldOptions {
live_validation: true,
..Default::default()
},
)
.unwrap();
let touches_toml = plan
.actions
.iter()
.any(|a| a.path().ends_with("autumn.toml"));
assert!(
!touches_toml,
"a preexisting exemption must not be re-modified"
);
let has_revert = plan
.reverts
.iter()
.any(|r| matches!(r, Revert::SubmitTokenValidateExempt { .. }));
assert!(
!has_revert,
"a preexisting, user-owned exemption must not be claimed via a revert"
);
}
}