#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
clippy::string_slice,
clippy::arithmetic_side_effects,
)
)]
#[cfg(feature = "maud")]
use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::OnceLock;
use axum::extract::{FromRequest, Request};
use axum::response::IntoResponse;
use serde::Serialize;
pub struct Changeset<T> {
data: T,
errors: HashMap<String, Vec<String>>,
field_value_cache: OnceLock<Box<serde_json::Value>>,
}
#[allow(
clippy::missing_fields_in_debug,
reason = "field_value_cache is deliberately omitted — see its doc comment"
)]
impl<T: std::fmt::Debug> std::fmt::Debug for Changeset<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Changeset")
.field("data", &self.data)
.field("errors", &self.errors)
.finish()
}
}
impl<T> Changeset<T> {
pub fn new(data: T) -> Self {
Self {
data,
errors: HashMap::new(),
field_value_cache: OnceLock::new(),
}
}
pub const fn from_errors(data: T, errors: HashMap<String, Vec<String>>) -> Self {
Self {
data,
errors,
field_value_cache: OnceLock::new(),
}
}
pub fn is_valid(&self) -> bool {
self.errors.is_empty()
}
pub fn errors_for(&self, field: &str) -> &[String] {
self.errors.get(field).map_or(&[], Vec::as_slice)
}
#[must_use]
pub const fn all_errors(&self) -> &HashMap<String, Vec<String>> {
&self.errors
}
pub fn into_inner(self) -> T {
self.data
}
pub fn into_valid(self) -> Result<T, Self> {
if self.is_valid() {
Ok(self.data)
} else {
Err(self)
}
}
pub const fn data(&self) -> &T {
&self.data
}
pub const fn errors(&self) -> &HashMap<String, Vec<String>> {
&self.errors
}
pub fn add_error(&mut self, field: impl Into<String>, message: impl Into<String>) {
self.errors
.entry(field.into())
.or_default()
.push(message.into());
}
}
impl<T: Serialize> Changeset<T> {
pub fn field_value(&self, field: &str) -> Option<String> {
let json = self.field_value_cache.get_or_init(|| {
Box::new(serde_json::to_value(&self.data).unwrap_or(serde_json::Value::Null))
});
json_scalar_to_string(json.get(field)?)
}
pub fn field_values(&self, field: &str) -> Vec<String> {
let json = self.field_value_cache.get_or_init(|| {
Box::new(serde_json::to_value(&self.data).unwrap_or(serde_json::Value::Null))
});
match json.get(field) {
Some(serde_json::Value::Array(items)) => {
items.iter().filter_map(json_scalar_to_string).collect()
}
Some(value) => json_scalar_to_string(value).into_iter().collect(),
None => Vec::new(),
}
}
}
fn json_scalar_to_string(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
pub const NUL_CHARACTER_FIELD_ERROR: &str = "Cannot contain the NUL character (0x00)";
pub const NUL_CHARACTER_MESSAGE_KEY: &str = "common.error.nul_character";
#[cfg(feature = "i18n")]
pub(crate) async fn nul_field_message<S: Send + Sync>(
req: Request,
state: &S,
) -> (Request, String) {
use axum::extract::FromRequestParts as _;
let Some(bundle) = req
.extensions()
.get::<std::sync::Arc<crate::i18n::Bundle>>()
.cloned()
else {
return (req, NUL_CHARACTER_FIELD_ERROR.to_owned());
};
let (mut parts, body) = req.into_parts();
let message = match crate::i18n::Locale::from_request_parts(&mut parts, state).await {
Ok(locale) => bundle.lookup(locale.tag(), NUL_CHARACTER_MESSAGE_KEY),
Err(never) => match never {},
};
(
Request::from_parts(parts, body),
message.unwrap_or_else(|| NUL_CHARACTER_FIELD_ERROR.to_owned()),
)
}
#[cfg(not(feature = "i18n"))]
#[allow(clippy::unused_async)]
pub(crate) async fn nul_field_message<S: Send + Sync>(
req: Request,
_state: &S,
) -> (Request, String) {
(req, NUL_CHARACTER_FIELD_ERROR.to_owned())
}
const PLUMBING_FIELDS: &[&str] = &["_method", "_csrf", "_submit_token"];
#[cfg_attr(not(feature = "nested-form"), allow(dead_code))]
pub(crate) fn is_plumbing_field(name: &str) -> bool {
PLUMBING_FIELDS.contains(&name)
}
pub(crate) fn strip_nul_from_pairs(pairs: &mut [(String, String)]) -> Vec<String> {
let mut offenders: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for (key, value) in pairs {
if value.as_bytes().contains(&0) {
*value = crate::normalize::strip_nul(value);
if !offenders.contains(key.as_str()) {
offenders.insert(key.clone());
}
}
}
offenders.into_iter().collect()
}
pub trait IntoChangeset: Sized {
fn into_changeset(self) -> Changeset<Self>;
fn into_changeset_with(
self,
_resolve: impl Fn(&str, &str) -> Option<String>,
) -> Changeset<Self> {
self.into_changeset()
}
}
impl<T: validator::Validate> IntoChangeset for T {
fn into_changeset(self) -> Changeset<Self> {
match validator::Validate::validate(&self) {
Ok(()) => Changeset::new(self),
Err(errors) => Changeset::from_errors(self, validation_errors_to_map(&errors)),
}
}
fn into_changeset_with(
self,
resolve: impl Fn(&str, &str) -> Option<String>,
) -> Changeset<Self> {
match validator::Validate::validate(&self) {
Ok(()) => Changeset::new(self),
Err(errors) => {
Changeset::from_errors(self, validation_errors_to_map_with(&errors, &resolve))
}
}
}
}
pub struct ChangesetForm<T> {
pub changeset: Changeset<T>,
pub(crate) csrf_token: Option<String>,
pub(crate) csrf_field: String,
#[allow(
clippy::box_collection,
reason = "a `Box<Vec>` is one pointer wide; a `Vec` or `Box<[T]>` pushes `into_valid`'s `Err` over `result_large_err`"
)]
pub(crate) files: Option<Box<Vec<FormFile>>>,
}
impl<T> ChangesetForm<T> {
pub fn blank(data: T, csrf_token: &str) -> Self {
Self {
changeset: Changeset::new(data),
csrf_token: Some(csrf_token.to_owned()),
csrf_field: "_csrf".to_owned(),
files: None,
}
}
#[must_use]
pub fn without_csrf(data: T) -> Self {
Self {
changeset: Changeset::new(data),
csrf_token: None,
csrf_field: "_csrf".to_owned(),
files: None,
}
}
#[must_use]
pub fn from_changeset(changeset: Changeset<T>) -> Self {
Self {
changeset,
csrf_token: None,
csrf_field: "_csrf".to_owned(),
files: None,
}
}
#[must_use]
pub fn with_csrf_field(mut self, field: impl Into<String>) -> Self {
self.csrf_field = field.into();
self
}
pub fn csrf_token(&self) -> Option<&str> {
self.csrf_token.as_deref()
}
pub fn file(&self, field: &str) -> Option<&FormFile> {
self.all_files().iter().find(|file| file.field == field)
}
pub fn files<'a>(&'a self, field: &'a str) -> impl Iterator<Item = &'a FormFile> {
self.all_files()
.iter()
.filter(move |file| file.field == field)
}
pub fn take_files(&mut self) -> Vec<FormFile> {
self.files.take().map(|files| *files).unwrap_or_default()
}
#[must_use]
pub fn with_files(mut self, files: Vec<FormFile>) -> Self {
self.files = (!files.is_empty()).then(|| Box::new(files));
self
}
fn all_files(&self) -> &[FormFile] {
self.files.as_deref().map_or(&[], Vec::as_slice)
}
pub fn into_changeset(self) -> Changeset<T> {
self.changeset
}
pub fn into_valid(self) -> Result<T, Self> {
if self.changeset.is_valid() {
Ok(self.changeset.into_inner())
} else {
Err(self)
}
}
}
impl<T> std::ops::Deref for ChangesetForm<T> {
type Target = Changeset<T>;
fn deref(&self) -> &Self::Target {
&self.changeset
}
}
#[cfg(feature = "maud")]
impl<T: Serialize> ChangesetForm<T> {
#[must_use]
#[allow(clippy::needless_pass_by_value)]
pub fn form_tag(&self, action: &str, method: &str, content: maud::Markup) -> maud::Markup {
form_tag_inner(
action,
method,
&self.csrf_field,
self.csrf_token.as_deref(),
None,
content,
)
}
pub fn text_input(&self, field: &str, label: &str) -> maud::Markup {
text_input(&self.changeset, field, label)
}
pub fn text_input_htmx(&self, field: &str, label: &str, validate_url: &str) -> maud::Markup {
text_input_htmx(&self.changeset, field, label, validate_url)
}
pub fn text_input_htmx_with_token_field(
&self,
field: &str,
label: &str,
validate_url: &str,
token_field: &str,
) -> maud::Markup {
text_input_htmx_with_token_field(&self.changeset, field, label, validate_url, token_field)
}
pub fn rich_text_area(&self, field: &str, label: &str) -> maud::Markup {
rich_text_area(&self.changeset, field, label)
}
pub fn rich_text_area_with_labels(
&self,
field: &str,
label: &str,
labels: &RichTextLabels<'_>,
) -> maud::Markup {
rich_text_area_with_labels(&self.changeset, field, label, labels)
}
pub fn rich_text_area_htmx(&self, field: &str, label: &str, preview_url: &str) -> maud::Markup {
rich_text_area_htmx(&self.changeset, field, label, preview_url)
}
pub fn rich_text_area_htmx_with_labels(
&self,
field: &str,
label: &str,
preview_url: &str,
labels: &RichTextLabels<'_>,
) -> maud::Markup {
rich_text_area_htmx_with_labels(&self.changeset, field, label, preview_url, labels)
}
pub fn rich_text_area_htmx_with_token_field(
&self,
field: &str,
label: &str,
preview_url: &str,
token_field: &str,
) -> maud::Markup {
rich_text_area_htmx_with_token_field(
&self.changeset,
field,
label,
preview_url,
token_field,
)
}
pub fn rich_text_area_htmx_with_token_field_with_labels(
&self,
field: &str,
label: &str,
preview_url: &str,
token_field: &str,
labels: &RichTextLabels<'_>,
) -> maud::Markup {
rich_text_area_htmx_with_token_field_with_labels(
&self.changeset,
field,
label,
preview_url,
token_field,
labels,
)
}
pub fn submit_button(&self, label: &str) -> maud::Markup {
submit_button(label)
}
}
impl<S, T> FromRequest<S> for ChangesetForm<T>
where
S: Send + Sync,
T: serde::de::DeserializeOwned + validator::Validate + Send,
{
type Rejection = axum::response::Response;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
let csrf_token = req
.extensions()
.get::<crate::security::CsrfToken>()
.map(|t| t.token().to_string());
let csrf_field = req
.extensions()
.get::<crate::security::csrf::CsrfFormField>()
.map_or_else(|| "_csrf".to_owned(), |f| f.0.clone());
let submit_field = req
.extensions()
.get::<crate::security::SubmitFormField>()
.map_or_else(|| "_submit_token".to_owned(), |f| f.0.clone());
let (req, nul_message) = nul_field_message(req, state).await;
let (data, nul_fields, files) = decode_form_body::<T, S>(req, state).await?;
let mut changeset = data.into_changeset();
for field in nul_fields {
if field == csrf_field
|| field == submit_field
|| PLUMBING_FIELDS.contains(&field.as_str())
{
continue;
}
changeset.add_error(field, nul_message.as_str());
}
Ok(Self {
changeset,
csrf_token,
csrf_field,
files: (!files.is_empty()).then(|| Box::new(files)),
})
}
}
#[allow(clippy::result_large_err)]
async fn decode_form_body<T, S>(
req: Request,
state: &S,
) -> Result<(T, Vec<String>, Vec<FormFile>), axum::response::Response>
where
T: serde::de::DeserializeOwned + validator::Validate + Send,
S: Send + Sync,
{
let content_type = req
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
#[cfg(feature = "multipart")]
if crate::media_type::is_media_type(&content_type, "multipart/form-data") {
return decode_multipart(req, state).await;
}
if !crate::media_type::is_media_type(&content_type, "application/x-www-form-urlencoded") {
return Err((
axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
"Form requests must have `Content-Type: application/x-www-form-urlencoded`",
)
.into_response());
}
let (parts, body) = req.into_parts();
let bytes_req = Request::from_parts(parts, body);
let bytes = axum::body::Bytes::from_request(bytes_req, state)
.await
.map_err(IntoResponse::into_response)?;
let mut pairs = parse_urlencoded_pairs(&bytes);
let nul_fields = strip_nul_from_pairs(&mut pairs);
let data = decode_pairs_dropping_blank_optional_fields::<T>(pairs)
.map_err(|e| (e.status(), e.to_string()).into_response())?;
Ok((data, nul_fields, Vec::new()))
}
pub(crate) fn decode_urlencoded_dropping_blank_optional_fields<T: serde::de::DeserializeOwned>(
bytes: &[u8],
) -> Result<T, FormDecodeError> {
decode_pairs_dropping_blank_optional_fields(parse_urlencoded_pairs(bytes))
}
pub(crate) fn parse_urlencoded_pairs(bytes: &[u8]) -> Vec<(String, String)> {
url::form_urlencoded::parse(bytes)
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect()
}
pub(crate) fn decode_pairs_dropping_blank_optional_fields<T: serde::de::DeserializeOwned>(
mut pairs: Vec<(String, String)>,
) -> Result<T, FormDecodeError> {
loop {
let encoded = url::form_urlencoded::Serializer::new(String::new())
.extend_pairs(pairs.iter().map(|(k, v)| (k.as_str(), v.as_str())))
.finish();
let deserializer = serde_html_form::Deserializer::from_bytes(encoded.as_bytes());
match serde_path_to_error::deserialize(deserializer) {
Ok(data) => return Ok(data),
Err(err) => match next_retry(&pairs, err.path()) {
Retry::DropPair(pos) => {
pairs.remove(pos);
}
Retry::DropBlanksOf(field) => {
pairs.retain(|(k, v)| !(*k == field && v.is_empty()));
}
Retry::Repeated(field) => return Err(FormDecodeError::repeated(field)),
Retry::Stop => return Err(FormDecodeError::invalid(err)),
},
}
}
}
enum Retry {
DropPair(usize),
DropBlanksOf(String),
Repeated(String),
Stop,
}
fn next_retry(pairs: &[(String, String)], path: &serde_path_to_error::Path) -> Retry {
let mut segments = path.iter();
let Some(serde_path_to_error::Segment::Map { key: field }) = segments.next() else {
return Retry::Stop;
};
let mut same_key = pairs.iter().enumerate().filter(|(_, (k, _))| k == field);
match (segments.next(), segments.next()) {
(None, _) => {
if pairs.iter().filter(|(k, _)| k == field).nth(1).is_some() {
return Retry::Repeated(field.clone());
}
same_key
.find(|(_, (_, v))| v.is_empty())
.map_or(Retry::Stop, |(i, _)| Retry::DropPair(i))
}
(Some(serde_path_to_error::Segment::Seq { index }), None) => {
if same_key.nth(*index).is_some_and(|(_, (_, v))| v.is_empty()) {
Retry::DropBlanksOf(field.clone())
} else {
Retry::Stop
}
}
_ => Retry::Stop,
}
}
pub fn from_urlencoded<T: serde::de::DeserializeOwned>(body: &[u8]) -> Result<T, FormDecodeError> {
decode_urlencoded_dropping_blank_optional_fields(body)
}
const MAX_REPORTED_FIELD_CHARS: usize = 64;
#[derive(Debug, Clone)]
pub struct FormDecodeError {
field: Option<String>,
kind: FormDecodeErrorKind,
}
#[derive(Debug, Clone)]
enum FormDecodeErrorKind {
Repeated,
Invalid(serde_path_to_error::Error<serde_html_form::de::Error>),
}
impl FormDecodeError {
const fn repeated(field: String) -> Self {
Self {
field: Some(field),
kind: FormDecodeErrorKind::Repeated,
}
}
fn invalid(err: serde_path_to_error::Error<serde_html_form::de::Error>) -> Self {
let field = err.path().iter().find_map(|segment| match segment {
serde_path_to_error::Segment::Map { key } => Some(key.clone()),
_ => None,
});
Self {
field,
kind: FormDecodeErrorKind::Invalid(err),
}
}
#[must_use]
pub const fn is_repeated_field(&self) -> bool {
matches!(self.kind, FormDecodeErrorKind::Repeated)
}
#[must_use]
pub fn field(&self) -> Option<&str> {
self.field.as_deref()
}
#[must_use]
pub const fn status(&self) -> http::StatusCode {
match self.kind {
FormDecodeErrorKind::Repeated => http::StatusCode::UNPROCESSABLE_ENTITY,
FormDecodeErrorKind::Invalid(_) => http::StatusCode::BAD_REQUEST,
}
}
#[cfg_attr(not(feature = "nested-form"), allow(dead_code))]
pub(crate) const fn invalid_error(
&self,
) -> Option<&serde_path_to_error::Error<serde_html_form::de::Error>> {
match &self.kind {
FormDecodeErrorKind::Invalid(err) => Some(err),
FormDecodeErrorKind::Repeated => None,
}
}
}
impl std::fmt::Display for FormDecodeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.kind {
FormDecodeErrorKind::Repeated => {
let field = self.field.as_deref().unwrap_or_default();
let mut shown: String = field
.chars()
.filter(|c| is_printable_key_char(*c))
.take(MAX_REPORTED_FIELD_CHARS)
.collect();
if field
.chars()
.filter(|c| is_printable_key_char(*c))
.nth(MAX_REPORTED_FIELD_CHARS)
.is_some()
{
shown.push('…');
}
write!(
f,
"form field `{shown}` takes one value, but the form sent it more than once"
)
}
FormDecodeErrorKind::Invalid(err) => err.fmt(f),
}
}
}
impl std::error::Error for FormDecodeError {}
const fn is_printable_key_char(c: char) -> bool {
!c.is_control()
&& !matches!(
c,
'\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{FEFF}'
)
}
#[cfg(fuzzing)]
pub fn __fuzz_decode_urlencoded(bytes: &[u8]) {
let _ = decode_urlencoded_dropping_blank_optional_fields::<
std::collections::HashMap<String, String>,
>(bytes);
}
#[cfg(feature = "multipart")]
#[allow(clippy::result_large_err)]
async fn decode_multipart<T, S>(
req: Request,
state: &S,
) -> Result<(T, Vec<String>, Vec<FormFile>), axum::response::Response>
where
T: serde::de::DeserializeOwned,
S: Send + Sync,
{
let mut multipart = crate::extract::Multipart::from_request_with_route_limit(req, state)
.await
.map_err(IntoResponse::into_response)?;
let mut pairs: Vec<(String, String)> = Vec::new();
let mut files: Vec<FormFile> = Vec::new();
while let Some(field) = multipart
.next_field()
.await
.map_err(IntoResponse::into_response)?
{
let Some(name) = field.name().map(str::to_owned) else {
continue;
};
if field.is_empty_optional_input() {
continue;
}
let file_name = field.file_name().map(str::to_owned);
let content_type = field.content_type().map(str::to_owned);
let sniffed_content_type = field.sniffed_content_type().map(str::to_owned);
let mut bytes = field
.bytes_limited()
.await
.map_err(IntoResponse::into_response)?;
if let Some(file_name) = file_name {
if file_name.is_empty() && bytes.is_empty() {
continue;
}
bytes.shrink_to_fit();
files.push(FormFile {
field: name,
file_name,
content_type,
sniffed_content_type,
bytes,
});
} else {
let value = String::from_utf8(bytes).map_err(|_| {
(
axum::http::StatusCode::BAD_REQUEST,
"a multipart text field is not valid UTF-8",
)
.into_response()
})?;
pairs.push((name, value));
}
}
let nul_fields = strip_nul_from_pairs(&mut pairs);
let data = decode_pairs_dropping_blank_optional_fields::<T>(pairs)
.map_err(|e| (e.status(), e.to_string()).into_response())?;
Ok((data, nul_fields, files))
}
#[derive(Clone)]
pub struct FormFile {
field: String,
file_name: String,
content_type: Option<String>,
sniffed_content_type: Option<String>,
bytes: Vec<u8>,
}
impl FormFile {
#[must_use]
pub fn new(
field: impl Into<String>,
file_name: impl Into<String>,
content_type: Option<String>,
bytes: Vec<u8>,
) -> Self {
Self {
field: field.into(),
file_name: file_name.into(),
content_type,
sniffed_content_type: None,
bytes,
}
}
#[must_use]
pub fn field(&self) -> &str {
&self.field
}
#[must_use]
pub fn file_name(&self) -> &str {
&self.file_name
}
#[must_use]
pub fn content_type(&self) -> Option<&str> {
self.content_type.as_deref()
}
#[must_use]
pub fn sniffed_content_type(&self) -> Option<&str> {
self.sniffed_content_type.as_deref()
}
#[must_use]
pub fn bytes(&self) -> &[u8] {
&self.bytes
}
#[must_use]
pub fn into_bytes(self) -> Vec<u8> {
self.bytes
}
#[must_use]
pub const fn len(&self) -> usize {
self.bytes.len()
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.bytes.is_empty()
}
}
impl std::fmt::Debug for FormFile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FormFile")
.field("field", &self.field)
.field("file_name", &self.file_name)
.field("content_type", &self.content_type)
.field("sniffed_content_type", &self.sniffed_content_type)
.field("len", &self.bytes.len())
.finish()
}
}
pub(crate) fn validation_errors_to_map(
errors: &validator::ValidationErrors,
) -> HashMap<String, Vec<String>> {
let mut map = HashMap::new();
collect_errors(errors, "", &mut map, &|_, _| None);
map
}
pub(crate) fn validation_errors_to_map_with(
errors: &validator::ValidationErrors,
resolve: &dyn Fn(&str, &str) -> Option<String>,
) -> HashMap<String, Vec<String>> {
let mut map = HashMap::new();
collect_errors(errors, "", &mut map, resolve);
map
}
fn collect_errors(
errors: &validator::ValidationErrors,
prefix: &str,
map: &mut HashMap<String, Vec<String>>,
resolve: &dyn Fn(&str, &str) -> Option<String>,
) {
for (field, kind) in errors.errors() {
let key = if prefix.is_empty() {
(*field).to_string()
} else {
format!("{prefix}.{field}")
};
match kind {
validator::ValidationErrorsKind::Field(errs) => {
let messages: Vec<String> = errs
.iter()
.map(|e| {
e.message.as_ref().map_or_else(
|| {
resolve(&key, &e.code)
.unwrap_or_else(|| format!("validation failed: {}", e.code))
},
ToString::to_string,
)
})
.collect();
map.entry(key).or_default().extend(messages);
}
validator::ValidationErrorsKind::Struct(nested) => {
collect_errors(nested, &key, map, resolve);
}
validator::ValidationErrorsKind::List(list) => {
for (idx, nested) in list {
let indexed_key = format!("{key}[{idx}]");
collect_errors(nested, &indexed_key, map, resolve);
}
}
}
}
}
#[cfg(feature = "maud")]
#[must_use]
#[allow(clippy::needless_pass_by_value)]
pub fn form_tag(
action: &str,
method: &str,
csrf_token: Option<&str>,
content: maud::Markup,
) -> maud::Markup {
form_tag_inner(action, method, "_csrf", csrf_token, None, content)
}
#[cfg(feature = "maud")]
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn form_tag_inner(
action: &str,
method: &str,
csrf_field: &str,
csrf_token: Option<&str>,
enctype: Option<&str>,
content: maud::Markup,
) -> maud::Markup {
let (browser_method, override_value) = browser_method_and_override(method);
maud::html! {
form action=(action) method=(browser_method) enctype=[enctype] {
@if let Some(override_method) = override_value {
input
type="hidden"
name=(crate::middleware::DEFAULT_METHOD_OVERRIDE_FIELD)
value=(override_method);
}
@if let Some(token) = csrf_token {
input type="hidden" name=(csrf_field) value=(token);
}
(content)
}
}
}
#[cfg(feature = "maud")]
fn browser_method_and_override(method: &str) -> (&'static str, Option<&'static str>) {
let trimmed = method.trim();
if trimmed.eq_ignore_ascii_case("PUT") {
("post", Some("PUT"))
} else if trimmed.eq_ignore_ascii_case("PATCH") {
("post", Some("PATCH"))
} else if trimmed.eq_ignore_ascii_case("DELETE") {
("post", Some("DELETE"))
} else if trimmed.eq_ignore_ascii_case("GET") {
("get", None)
} else {
("post", None)
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn method_input(method: &str) -> maud::Markup {
let normalized = method.trim();
let value = if normalized.eq_ignore_ascii_case("PUT") {
"PUT"
} else if normalized.eq_ignore_ascii_case("PATCH") {
"PATCH"
} else if normalized.eq_ignore_ascii_case("DELETE") {
"DELETE"
} else {
return maud::html! {};
};
maud::html! {
input
type="hidden"
name=(crate::middleware::DEFAULT_METHOD_OVERRIDE_FIELD)
value=(value);
}
}
#[cfg(feature = "maud")]
#[allow(
clippy::string_slice,
reason = "every slice index below comes from a position() match on a single-byte ASCII \
value (&, <, >, \"), which can never land inside a multi-byte UTF-8 sequence — \
always a char boundary. Proven for arbitrary input by the \
fast_escape_matches_naive_reference proptest."
)]
#[allow(
clippy::arithmetic_side_effects,
reason = "`i + 1` where `i` is a byte index already `< input.len()` (from `bytes.iter().\
enumerate()`), and `input.len() <= isize::MAX` is a `str` invariant — cannot \
overflow `usize`."
)]
fn write_escaped(input: &str, out: &mut String) {
let bytes = input.as_bytes();
let mut start = 0;
for (i, &b) in bytes.iter().enumerate() {
let replacement = match b {
b'&' => "&",
b'<' => "<",
b'>' => ">",
b'"' => """,
_ => continue,
};
out.push_str(&input[start..i]);
out.push_str(replacement);
start = i + 1;
}
out.push_str(&input[start..]);
}
#[cfg(feature = "maud")]
fn fast_escape(input: &str) -> Cow<'_, str> {
if !input
.as_bytes()
.iter()
.any(|b| matches!(b, b'&' | b'<' | b'>' | b'"'))
{
return Cow::Borrowed(input);
}
let mut out = String::with_capacity(input.len());
write_escaped(input, &mut out);
Cow::Owned(out)
}
#[cfg(feature = "maud")]
fn concat_suffix(base: &str, suffix: &str) -> String {
let mut s = String::with_capacity(base.len().saturating_add(suffix.len()));
s.push_str(base);
s.push_str(suffix);
s
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[allow(
clippy::struct_excessive_bools,
reason = "each bool is one independent HTML attribute or class"
)]
pub struct FieldOptions<'a> {
hint: Option<Cow<'a, str>>,
required: bool,
disabled: bool,
readonly: bool,
placeholder: Option<Cow<'a, str>>,
autocomplete: Option<Cow<'a, str>>,
inputmode: Option<Cow<'a, str>>,
pattern: Option<Cow<'a, str>>,
min: Option<Cow<'a, str>>,
max: Option<Cow<'a, str>>,
minlength: Option<usize>,
maxlength: Option<usize>,
auto_grow: bool,
reveal: Option<Cow<'a, str>>,
strength: Option<[Cow<'a, str>; 4]>,
multiple: bool,
styled_select: bool,
extra: Vec<(&'static str, Cow<'a, str>, Cow<'a, str>)>,
}
#[cfg(feature = "maud")]
impl<'a> FieldOptions<'a> {
#[must_use]
pub const fn new() -> Self {
Self {
hint: None,
required: false,
disabled: false,
readonly: false,
placeholder: None,
autocomplete: None,
inputmode: None,
pattern: None,
min: None,
max: None,
minlength: None,
maxlength: None,
auto_grow: false,
reveal: None,
strength: None,
multiple: false,
styled_select: false,
extra: Vec::new(),
}
}
#[must_use]
pub fn hint(mut self, text: impl Into<Cow<'a, str>>) -> Self {
self.hint = Some(text.into());
self
}
#[must_use]
pub const fn required(mut self) -> Self {
self.required = true;
self
}
#[must_use]
pub const fn disabled(mut self) -> Self {
self.disabled = true;
self
}
#[must_use]
pub const fn readonly(mut self) -> Self {
self.readonly = true;
self
}
#[must_use]
pub fn placeholder(mut self, text: impl Into<Cow<'a, str>>) -> Self {
self.placeholder = Some(text.into());
self
}
#[must_use]
pub fn autocomplete(mut self, token: impl Into<Cow<'a, str>>) -> Self {
self.autocomplete = Some(token.into());
self
}
#[must_use]
pub fn inputmode(mut self, mode: impl Into<Cow<'a, str>>) -> Self {
self.inputmode = Some(mode.into());
self
}
#[must_use]
pub fn pattern(mut self, regex: impl Into<Cow<'a, str>>) -> Self {
self.pattern = Some(regex.into());
self
}
#[must_use]
pub fn min(mut self, min: impl Into<Cow<'a, str>>) -> Self {
self.min = Some(min.into());
self
}
#[must_use]
pub fn max(mut self, max: impl Into<Cow<'a, str>>) -> Self {
self.max = Some(max.into());
self
}
#[must_use]
pub const fn minlength(mut self, chars: usize) -> Self {
self.minlength = Some(chars);
self
}
#[must_use]
pub const fn maxlength(mut self, chars: usize) -> Self {
self.maxlength = Some(chars);
self
}
#[must_use]
pub fn current_password(self) -> Self {
self.autocomplete("current-password")
}
#[must_use]
pub fn new_password(self) -> Self {
self.autocomplete("new-password")
}
#[must_use]
pub fn reveal(self) -> Self {
self.reveal_label("Show password")
}
#[must_use]
pub fn reveal_label(mut self, text: impl Into<Cow<'a, str>>) -> Self {
self.reveal = Some(text.into());
self
}
#[must_use]
pub fn strength_hint(self) -> Self {
self.strength_labels([
"Strength: weak",
"Strength: fair",
"Strength: good",
"Strength: strong",
])
}
#[must_use]
pub fn strength_labels(mut self, labels: [&'a str; 4]) -> Self {
self.strength = Some(labels.map(Cow::Borrowed));
self
}
#[must_use]
pub const fn auto_grow(mut self) -> Self {
self.auto_grow = true;
self
}
#[must_use]
pub const fn multiple(mut self) -> Self {
self.multiple = true;
self
}
#[must_use]
pub const fn styled_select(mut self) -> Self {
self.styled_select = true;
self
}
#[must_use]
pub fn data(self, name: impl Into<Cow<'a, str>>, value: impl Into<Cow<'a, str>>) -> Self {
let name = name.into();
if name.starts_with("autumn-") || !is_valid_extra_name(&name, false) {
return self;
}
self.with_extra("data-", name, value.into())
}
#[must_use]
pub fn hx(self, name: impl Into<Cow<'a, str>>, value: impl Into<Cow<'a, str>>) -> Self {
let name = name.into();
if !is_valid_extra_name(&name, true) {
return self;
}
self.with_extra("hx-", name, value.into())
}
#[must_use]
pub const fn is_required(&self) -> bool {
self.required
}
#[must_use]
pub const fn is_disabled(&self) -> bool {
self.disabled
}
#[must_use]
pub const fn is_readonly(&self) -> bool {
self.readonly
}
#[must_use]
pub const fn is_auto_grow(&self) -> bool {
self.auto_grow
}
#[must_use]
pub const fn is_multiple(&self) -> bool {
self.multiple
}
#[must_use]
pub const fn is_styled_select(&self) -> bool {
self.styled_select
}
#[must_use]
pub fn hint_text(&self) -> Option<&str> {
self.hint.as_deref()
}
#[cfg_attr(not(feature = "nested-form"), allow(dead_code))]
pub(crate) const fn optional(mut self) -> Self {
self.required = false;
self
}
fn with_extra(mut self, prefix: &'static str, name: Cow<'a, str>, value: Cow<'a, str>) -> Self {
if let Some(slot) = self
.extra
.iter_mut()
.find(|(p, n, _)| *p == prefix && *n == name)
{
slot.2 = value;
} else {
self.extra.push((prefix, name, value));
}
self
}
}
#[cfg(feature = "maud")]
fn is_valid_extra_name(name: &str, allow_colon: bool) -> bool {
!name.is_empty()
&& name.bytes().all(|b| {
b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || (allow_colon && b == b':')
})
}
#[cfg(feature = "maud")]
const HTMX_OWNED: &[&str] = &["post", "trigger", "target", "swap", "include", "params"];
#[cfg(feature = "maud")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FieldKind {
Text,
Password,
Textarea,
Checkbox,
Number,
Date,
DateTime,
Select,
File,
Email,
Tel,
Url,
Time,
Month,
Color,
Range,
Switch,
Hidden,
Otp,
}
#[cfg(feature = "maud")]
impl FieldKind {
const fn input_type(self) -> Option<&'static str> {
match self {
Self::Text | Self::Otp => Some("text"),
Self::Password => Some("password"),
Self::Checkbox | Self::Switch => Some("checkbox"),
Self::Number => Some("number"),
Self::Date => Some("date"),
Self::DateTime => Some("datetime-local"),
Self::File => Some("file"),
Self::Email => Some("email"),
Self::Tel => Some("tel"),
Self::Url => Some("url"),
Self::Time => Some("time"),
Self::Month => Some("month"),
Self::Color => Some("color"),
Self::Range => Some("range"),
Self::Hidden => Some("hidden"),
Self::Textarea | Self::Select => None,
}
}
const fn takes_placeholder(self) -> bool {
self.takes_text_entry() || matches!(self, Self::Otp)
}
const fn takes_text_entry(self) -> bool {
matches!(
self,
Self::Text
| Self::Password
| Self::Textarea
| Self::Number
| Self::Email
| Self::Tel
| Self::Url
)
}
const fn takes_pattern(self) -> bool {
matches!(
self,
Self::Text | Self::Password | Self::Email | Self::Tel | Self::Url
)
}
const fn takes_minlength(self) -> bool {
self.takes_length() || matches!(self, Self::Otp)
}
const fn takes_length(self) -> bool {
matches!(
self,
Self::Text | Self::Password | Self::Textarea | Self::Email | Self::Tel | Self::Url
)
}
const fn takes_range(self) -> bool {
matches!(
self,
Self::Number | Self::Date | Self::DateTime | Self::Time | Self::Month | Self::Range
)
}
const fn takes_readonly(self) -> bool {
!matches!(
self,
Self::Checkbox
| Self::Switch
| Self::Select
| Self::File
| Self::Range
| Self::Color
| Self::Hidden
)
}
const fn takes_autocomplete(self) -> bool {
!matches!(
self,
Self::Checkbox | Self::Switch | Self::File | Self::Hidden | Self::Otp
)
}
const fn takes_required(self) -> bool {
!matches!(self, Self::Range | Self::Color | Self::Hidden)
}
const fn takes_multiple(self) -> bool {
matches!(self, Self::Select | Self::File)
}
const fn is_box(self) -> bool {
matches!(self, Self::Checkbox | Self::Switch)
}
const fn wrapper_class(self) -> &'static str {
match self {
Self::Checkbox => "autumn-field autumn-field--checkbox",
Self::Switch => "autumn-field autumn-field--checkbox autumn-field--switch",
Self::Range => "autumn-field autumn-field--range",
Self::Color => "autumn-field autumn-field--color",
Self::Otp => "autumn-field autumn-field--otp",
_ => "autumn-field",
}
}
}
#[cfg(feature = "maud")]
#[derive(Clone, Copy)]
pub(crate) enum SelectChoices<'a> {
None,
Borrowed(&'a [(&'a str, &'a str)]),
Owned(&'a [(String, String)]),
}
#[cfg(feature = "maud")]
impl SelectChoices<'_> {
fn for_each(self, mut f: impl FnMut(&str, &str)) {
match self {
Self::None => {}
Self::Borrowed(choices) => {
for (value, label) in choices {
f(value, label);
}
}
Self::Owned(choices) => {
for (value, label) in choices {
f(value, label);
}
}
}
}
}
#[cfg(feature = "maud")]
#[derive(Clone, Copy)]
pub(crate) enum SelectGroups<'a> {
None,
Borrowed(&'a [(&'a str, &'a [(&'a str, &'a str)])]),
Owned(&'a [(String, Vec<(String, String)>)]),
}
#[cfg(feature = "maud")]
impl SelectGroups<'_> {
fn for_each(self, mut f: impl FnMut(&str, SelectChoices<'_>)) {
match self {
Self::None => {}
Self::Borrowed(groups) => {
for (label, choices) in groups {
f(label, SelectChoices::Borrowed(choices));
}
}
Self::Owned(groups) => {
for (label, choices) in groups {
f(label, SelectChoices::Owned(choices));
}
}
}
}
}
#[cfg(feature = "maud")]
pub(crate) struct FieldSpec<'a> {
pub(crate) kind: FieldKind,
pub(crate) field: &'a str,
pub(crate) name: Option<&'a str>,
pub(crate) label: &'a str,
pub(crate) value: Option<&'a str>,
pub(crate) selected: &'a [String],
pub(crate) checked: bool,
pub(crate) step: Option<&'a str>,
pub(crate) choices: SelectChoices<'a>,
pub(crate) groups: SelectGroups<'a>,
pub(crate) htmx: Option<(&'a str, &'a str)>,
pub(crate) otp: OtpFormat,
}
#[cfg(feature = "maud")]
impl<'a> FieldSpec<'a> {
pub(crate) const fn new(kind: FieldKind, field: &'a str, label: &'a str) -> Self {
Self {
kind,
field,
name: None,
label,
value: None,
selected: &[],
checked: false,
step: None,
choices: SelectChoices::None,
groups: SelectGroups::None,
htmx: None,
otp: OtpFormat::digits(6),
}
}
}
#[cfg(feature = "maud")]
pub(crate) fn push_attr(out: &mut String, name: &str, value: &str) {
out.push(' ');
out.push_str(name);
out.push_str("=\"");
write_escaped(value, out);
out.push('"');
}
#[cfg(feature = "maud")]
const REQUIRED_MARKER: &str = r#"<span class="autumn-field__required" aria-hidden="true">*</span>"#;
#[cfg(feature = "maud")]
fn push_label(out: &mut String, field_html: &str, label: &str, required: bool) {
out.push_str(r#"<label for=""#);
out.push_str(field_html);
out.push_str(r#"" class="autumn-field__label">"#);
write_escaped(label, out);
if required {
out.push_str(REQUIRED_MARKER);
}
out.push_str("</label>");
}
#[cfg(feature = "maud")]
fn push_option_attrs(
out: &mut String,
kind: Option<FieldKind>,
opts: &FieldOptions<'_>,
reserved: &[&str],
) {
let takes = |accepts: fn(FieldKind) -> bool| kind.is_none_or(accepts);
let typed = [
(
"placeholder",
opts.placeholder.as_deref(),
takes(FieldKind::takes_placeholder),
),
(
"autocomplete",
opts.autocomplete.as_deref(),
takes(FieldKind::takes_autocomplete),
),
(
"inputmode",
opts.inputmode.as_deref(),
takes(FieldKind::takes_text_entry),
),
(
"pattern",
opts.pattern.as_deref(),
takes(FieldKind::takes_pattern),
),
("min", opts.min.as_deref(), takes(FieldKind::takes_range)),
("max", opts.max.as_deref(), takes(FieldKind::takes_range)),
];
for (name, value, accepted) in typed {
if accepted && let Some(value) = value {
push_attr(out, name, value);
}
}
let lengths = [
(
"minlength",
opts.minlength,
takes(FieldKind::takes_minlength),
),
("maxlength", opts.maxlength, takes(FieldKind::takes_length)),
];
for (name, value, accepted) in lengths {
if accepted && let Some(value) = value {
push_attr(out, name, &value.to_string());
}
}
if opts.disabled {
out.push_str(" disabled");
}
if opts.readonly && takes(FieldKind::takes_readonly) {
out.push_str(" readonly");
}
for (prefix, name, value) in &opts.extra {
if *prefix == "hx-" && reserved.contains(&name.as_ref()) {
continue;
}
out.push(' ');
out.push_str(prefix);
out.push_str(name);
out.push_str("=\"");
write_escaped(value, out);
out.push('"');
}
}
#[cfg(feature = "maud")]
fn push_hint(out: &mut String, field_html: &str, hint: &str) {
out.push_str(r#"<p id=""#);
out.push_str(field_html);
out.push_str(r#"-hint" class="autumn-field__hint">"#);
write_escaped(hint, out);
out.push_str("</p>");
}
#[cfg(feature = "maud")]
fn push_errors(out: &mut String, field_html: &str, errors: &[String]) {
out.push_str(r#"<div id=""#);
out.push_str(field_html);
out.push_str(r#"-error" role="alert" class="autumn-field__errors">"#);
for error in errors {
out.push_str(r#"<p class="autumn-field__error">"#);
write_escaped(error, out);
out.push_str("</p>");
}
out.push_str("</div>");
}
#[cfg(feature = "maud")]
fn described_by(field_html: &str, has_hint: bool, has_errors: bool) -> Option<String> {
described_by_parts(field_html, &[(has_hint, "-hint"), (has_errors, "-error")])
}
#[cfg(feature = "maud")]
fn described_by_parts(field_html: &str, parts: &[(bool, &str)]) -> Option<String> {
let on = parts.iter().filter(|(on, _)| *on);
let len: usize = on
.clone()
.map(|(_, suffix)| {
field_html
.len()
.saturating_add(suffix.len())
.saturating_add(1)
})
.sum();
if len == 0 {
return None;
}
let mut ids = String::with_capacity(len);
for (_, suffix) in on {
if !ids.is_empty() {
ids.push(' ');
}
ids.push_str(field_html);
ids.push_str(suffix);
}
Some(ids)
}
#[cfg(feature = "maud")]
fn push_otp_attrs(out: &mut String, format: OtpFormat, opts: &FieldOptions<'_>) {
let digits = format.digit_count().to_string();
let pattern = format!("[0-9]{{{digits}}}");
push_attr(
out,
"autocomplete",
opts.autocomplete.as_deref().unwrap_or("one-time-code"),
);
push_attr(
out,
"inputmode",
opts.inputmode.as_deref().unwrap_or("numeric"),
);
push_attr(out, "pattern", opts.pattern.as_deref().unwrap_or(&pattern));
let maxlength = opts.maxlength.map(|n| n.to_string());
push_attr(out, "maxlength", maxlength.as_deref().unwrap_or(&digits));
out.push_str(r#" data-autumn-otp=""#);
out.push_str(&digits);
out.push('"');
if let Some(group) = format.group_size() {
out.push_str(r#" data-autumn-otp-group=""#);
out.push_str(&group.to_string());
out.push('"');
}
}
#[cfg(feature = "maud")]
fn push_otp_cells(out: &mut String, format: OtpFormat) {
out.push_str(r#"<span class="autumn-otp__cells" aria-hidden="true">"#);
let group = format.group_size().unwrap_or(0);
for digit in 1..=format.digit_count() {
out.push_str(r#"<span class="autumn-otp__cell"></span>"#);
if digit.checked_rem(group) == Some(0) && digit < format.digit_count() {
out.push_str(r#"<span class="autumn-otp__sep"></span>"#);
}
}
out.push_str("</span></div>");
}
#[cfg(feature = "maud")]
fn push_reveal_button(out: &mut String, field_html: &str, label: &str, disabled: bool) {
out.push_str(r#"<button type="button" class="autumn-password__reveal" aria-controls=""#);
out.push_str(field_html);
out.push_str(r#"" aria-pressed="false" data-autumn-reveal hidden"#);
if disabled {
out.push_str(" disabled");
}
out.push('>');
write_escaped(label, out);
out.push_str("</button></div>");
}
#[cfg(feature = "maud")]
fn push_strength(out: &mut String, field_html: &str, labels: &[Cow<'_, str>; 4]) {
out.push_str(r#"<p id=""#);
out.push_str(field_html);
out.push_str(r#"-strength" class="autumn-field__strength" aria-live="polite""#);
let json = serde_json::to_string(labels).unwrap_or_else(|_| "[]".to_owned());
push_attr(out, "data-autumn-strength", &json);
out.push_str("></p>");
}
#[cfg(feature = "maud")]
#[allow(
clippy::too_many_lines,
reason = "one linear writer for the whole field; splitting it hides the attribute order"
)]
pub(crate) fn render_field(
spec: &FieldSpec<'_>,
errors: &[String],
opts: &FieldOptions<'_>,
) -> maud::Markup {
let kind = spec.kind;
let has_errors = !errors.is_empty();
let required = opts.required && kind.takes_required();
let hint = opts.hint.as_deref();
let field_html = fast_escape(spec.field);
let field_html = field_html.as_ref();
let password = kind == FieldKind::Password;
let reveal = opts.reveal.as_deref().filter(|_| password);
let strength = opts.strength.as_ref().filter(|_| password);
let describedby = described_by_parts(
field_html,
&[
(hint.is_some(), "-hint"),
(strength.is_some(), "-strength"),
(has_errors, "-error"),
],
);
let errors_len: usize = errors.iter().map(|e| e.len().saturating_add(40)).sum();
let mut out = String::with_capacity(
360usize
.saturating_add(field_html.len().saturating_mul(6))
.saturating_add(spec.label.len())
.saturating_add(spec.value.map_or(0, str::len))
.saturating_add(hint.map_or(0, str::len))
.saturating_add(errors_len),
);
out.push_str(r#"<div id=""#);
out.push_str(field_html);
out.push_str(r#"-field" class=""#);
out.push_str(kind.wrapper_class());
out.push('"');
if spec.htmx.is_some() {
out.push_str(r#" data-autumn-field-wrapper=""#);
out.push_str(field_html);
out.push('"');
}
out.push('>');
if !kind.is_box() {
push_label(&mut out, field_html, spec.label, required);
if let Some(hint) = hint {
push_hint(&mut out, field_html, hint);
}
}
let tag = match kind {
FieldKind::Textarea => "textarea",
FieldKind::Select => "select",
_ => "input",
};
if reveal.is_some() {
out.push_str(r#"<div class="autumn-password">"#);
} else if kind == FieldKind::Otp {
out.push_str(r#"<div class="autumn-otp">"#);
}
out.push('<');
out.push_str(tag);
if let Some(input_type) = kind.input_type() {
push_attr(&mut out, "type", input_type);
}
out.push_str(r#" id=""#);
out.push_str(field_html);
out.push('"');
if let Some(name) = spec.name {
push_attr(&mut out, "name", name);
} else {
out.push_str(r#" name=""#);
out.push_str(field_html);
out.push('"');
}
let multiple = opts.multiple && kind.takes_multiple();
if multiple {
out.push_str(" multiple");
}
if kind.is_box() {
out.push_str(r#" value="true""#);
if kind == FieldKind::Switch {
out.push_str(r#" role="switch""#);
}
if spec.checked {
out.push_str(" checked");
}
} else if kind.input_type().is_some()
&& let Some(value) = spec.value
{
push_attr(&mut out, "value", value);
}
if let Some(step) = spec.step {
push_attr(&mut out, "step", step);
}
if required {
out.push_str(r#" required aria-required="true""#);
}
out.push_str(r#" class="autumn-field__input"#);
if kind == FieldKind::Otp {
out.push_str(" autumn-otp__input");
}
if has_errors {
out.push_str(" autumn-field__input--invalid");
}
if kind == FieldKind::Textarea && opts.auto_grow {
out.push_str(" autumn-auto-grow");
}
if kind == FieldKind::Select && opts.styled_select {
out.push_str(" autumn-select");
}
out.push_str(if has_errors {
r#"" aria-invalid="true""#
} else {
r#"" aria-invalid="false""#
});
if let Some(ids) = &describedby {
out.push_str(r#" aria-describedby=""#);
out.push_str(ids);
out.push('"');
}
if kind == FieldKind::Otp {
push_otp_attrs(&mut out, spec.otp, opts);
}
if reveal.is_some() {
out.push_str(r#" spellcheck="false" autocapitalize="off""#);
}
let reserved: &[&str] = if let Some((validate_url, token_field)) = spec.htmx {
push_attr(&mut out, "hx-post", validate_url);
out.push_str(
r#" hx-trigger="change" hx-target="closest [data-autumn-field-wrapper]" hx-swap="outerHTML" hx-include="closest form" hx-params="not "#,
);
write_escaped(token_field, &mut out);
out.push('"');
HTMX_OWNED
} else {
&[]
};
push_option_attrs(&mut out, Some(kind), opts, reserved);
out.push('>');
match kind {
FieldKind::Textarea => {
write_escaped(spec.value.unwrap_or_default(), &mut out);
out.push_str("</textarea>");
}
FieldKind::Select => {
push_select_options(&mut out, spec, multiple);
out.push_str("</select>");
}
_ => {}
}
if let Some(label) = reveal {
push_reveal_button(&mut out, field_html, label, opts.disabled);
} else if kind == FieldKind::Otp {
push_otp_cells(&mut out, spec.otp);
}
if let Some(labels) = strength {
push_strength(&mut out, field_html, labels);
}
if kind.is_box() {
push_label(&mut out, field_html, spec.label, required);
if let Some(hint) = hint {
push_hint(&mut out, field_html, hint);
}
}
if has_errors {
push_errors(&mut out, field_html, errors);
}
out.push_str("</div>");
maud::PreEscaped(out)
}
#[cfg(feature = "maud")]
fn push_select_options(out: &mut String, spec: &FieldSpec<'_>, multiple: bool) {
let mut found = false;
let mut push_option = |out: &mut String, value: &str, label: &str| {
let hit = spec.selected.iter().any(|s| s == value);
out.push_str("<option");
push_attr(out, "value", value);
if hit && (multiple || !found) {
out.push_str(" selected");
}
found |= hit;
out.push('>');
write_escaped(label, out);
out.push_str("</option>");
};
spec.choices
.for_each(|value, label| push_option(out, value, label));
spec.groups.for_each(|group, choices| {
out.push_str("<optgroup");
push_attr(out, "label", group);
out.push('>');
choices.for_each(|value, label| push_option(out, value, label));
out.push_str("</optgroup>");
});
}
#[cfg(feature = "maud")]
fn select_values<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
options: &FieldOptions<'_>,
) -> Vec<String> {
if options.multiple {
changeset.field_values(field)
} else {
vec![changeset.field_value(field).unwrap_or_default()]
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn text_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
text_input_with(changeset, field, label, &FieldOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn text_input_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
options: &FieldOptions<'_>,
) -> maud::Markup {
let value = changeset.field_value(field).unwrap_or_default();
let spec = FieldSpec {
value: Some(&value),
..FieldSpec::new(FieldKind::Text, field, label)
};
render_field(&spec, changeset.errors_for(field), options)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn text_input_htmx<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
validate_url: &str,
) -> maud::Markup {
text_input_htmx_with_token_field(
changeset,
field,
label,
validate_url,
DEFAULT_SUBMIT_TOKEN_FIELD,
)
}
#[cfg(feature = "maud")]
const DEFAULT_SUBMIT_TOKEN_FIELD: &str = "_submit_token";
#[cfg(feature = "maud")]
#[must_use]
pub fn text_input_htmx_with_token_field<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
validate_url: &str,
token_field: &str,
) -> maud::Markup {
text_input_htmx_with(
changeset,
field,
label,
validate_url,
token_field,
&FieldOptions::new(),
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn text_input_htmx_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
validate_url: &str,
token_field: &str,
options: &FieldOptions<'_>,
) -> maud::Markup {
let value = changeset.field_value(field).unwrap_or_default();
let spec = FieldSpec {
value: Some(&value),
htmx: Some((validate_url, token_field)),
..FieldSpec::new(FieldKind::Text, field, label)
};
render_field(&spec, changeset.errors_for(field), options)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_text_input_htmx<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
validate_url: &str,
) -> maud::Markup {
required_text_input_htmx_with_token_field(
changeset,
field,
label,
validate_url,
DEFAULT_SUBMIT_TOKEN_FIELD,
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_text_input_htmx_with_token_field<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
validate_url: &str,
token_field: &str,
) -> maud::Markup {
text_input_htmx_with(
changeset,
field,
label,
validate_url,
token_field,
&FieldOptions::new().required(),
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn submit_button(label: &str) -> maud::Markup {
maud::html! {
button type="submit" class="autumn-submit" { (label) }
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn password_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
password_input_with(changeset, field, label, &FieldOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_password_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
password_input_with(changeset, field, label, &FieldOptions::new().required())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn password_input_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
options: &FieldOptions<'_>,
) -> maud::Markup {
let spec = FieldSpec::new(FieldKind::Password, field, label);
render_field(&spec, changeset.errors_for(field), options)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn otp_input<T: Serialize>(changeset: &Changeset<T>, field: &str, label: &str) -> maud::Markup {
otp_input_with(
changeset,
field,
label,
OtpFormat::default(),
&FieldOptions::new(),
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_otp_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
otp_input_with(
changeset,
field,
label,
OtpFormat::default(),
&FieldOptions::new().required(),
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn otp_input_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
format: OtpFormat,
options: &FieldOptions<'_>,
) -> maud::Markup {
let spec = FieldSpec {
otp: format,
..FieldSpec::new(FieldKind::Otp, field, label)
};
render_field(&spec, changeset.errors_for(field), options)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn textarea_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
textarea_input_with(changeset, field, label, &FieldOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_textarea_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
textarea_input_with(changeset, field, label, &FieldOptions::new().required())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn textarea_input_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
options: &FieldOptions<'_>,
) -> maud::Markup {
let value = changeset.field_value(field).unwrap_or_default();
let spec = FieldSpec {
value: Some(&value),
..FieldSpec::new(FieldKind::Textarea, field, label)
};
render_field(&spec, changeset.errors_for(field), options)
}
#[must_use]
pub fn field_from_urlencoded(body: &[u8], field: &str) -> Option<String> {
url::form_urlencoded::parse(body)
.find(|(key, _)| key.as_ref() == field)
.map(|(_, value)| value.into_owned())
}
#[cfg(feature = "maud")]
const RICH_TEXT_HINT: &str = "Markdown supported. HTML is not allowed and is shown as plain text.";
#[cfg(feature = "maud")]
const RICH_TEXT_TOOLBAR: &[(&str, &str)] = &[
("Bold", "**bold**"),
("Italic", "_italic_"),
("Link", "[text](url)"),
("Code", "`code`"),
("List", "- item"),
("Heading", "# Heading"),
("Quote", "> quote"),
];
#[cfg(feature = "maud")]
#[derive(Clone, Copy)]
pub struct RichTextLabels<'a> {
toolbar_group: &'a str,
controls: &'a [(&'a str, &'a str)],
hint: &'a str,
preview_heading: &'a str,
}
#[cfg(feature = "maud")]
impl<'a> RichTextLabels<'a> {
#[must_use]
pub const fn new() -> Self {
Self {
toolbar_group: "Markdown formatting",
controls: RICH_TEXT_TOOLBAR,
hint: RICH_TEXT_HINT,
preview_heading: "Preview",
}
}
#[must_use]
pub const fn toolbar_group(mut self, label: &'a str) -> Self {
self.toolbar_group = label;
self
}
#[must_use]
pub const fn controls(mut self, controls: &'a [(&'a str, &'a str)]) -> Self {
self.controls = controls;
self
}
#[must_use]
pub const fn hint(mut self, hint: &'a str) -> Self {
self.hint = hint;
self
}
#[must_use]
pub const fn preview_heading(mut self, heading: &'a str) -> Self {
self.preview_heading = heading;
self
}
}
#[cfg(feature = "maud")]
impl Default for RichTextLabels<'static> {
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn rich_text_area<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
rich_text_area_inner(
changeset,
field,
label,
None,
&RichTextLabels::new(),
&FieldOptions::new(),
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn rich_text_area_with_labels<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
labels: &RichTextLabels<'_>,
) -> maud::Markup {
rich_text_area_inner(changeset, field, label, None, labels, &FieldOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn rich_text_area_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
labels: &RichTextLabels<'_>,
options: &FieldOptions<'_>,
) -> maud::Markup {
rich_text_area_inner(changeset, field, label, None, labels, options)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn rich_text_area_htmx_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
preview_url: &str,
token_field: &str,
labels: &RichTextLabels<'_>,
options: &FieldOptions<'_>,
) -> maud::Markup {
rich_text_area_inner(
changeset,
field,
label,
Some((preview_url, token_field)),
labels,
options,
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn rich_text_area_htmx<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
preview_url: &str,
) -> maud::Markup {
rich_text_area_htmx_with_token_field(
changeset,
field,
label,
preview_url,
DEFAULT_SUBMIT_TOKEN_FIELD,
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn rich_text_area_htmx_with_labels<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
preview_url: &str,
labels: &RichTextLabels<'_>,
) -> maud::Markup {
rich_text_area_htmx_with_token_field_with_labels(
changeset,
field,
label,
preview_url,
DEFAULT_SUBMIT_TOKEN_FIELD,
labels,
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn rich_text_area_htmx_with_token_field<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
preview_url: &str,
token_field: &str,
) -> maud::Markup {
rich_text_area_inner(
changeset,
field,
label,
Some((preview_url, token_field)),
&RichTextLabels::new(),
&FieldOptions::new(),
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn rich_text_area_htmx_with_token_field_with_labels<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
preview_url: &str,
token_field: &str,
labels: &RichTextLabels<'_>,
) -> maud::Markup {
rich_text_area_inner(
changeset,
field,
label,
Some((preview_url, token_field)),
labels,
&FieldOptions::new(),
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_rich_text_area<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
rich_text_area_inner(
changeset,
field,
label,
None,
&RichTextLabels::new(),
&FieldOptions::new().required(),
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_rich_text_area_with_labels<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
labels: &RichTextLabels<'_>,
) -> maud::Markup {
rich_text_area_inner(
changeset,
field,
label,
None,
labels,
&FieldOptions::new().required(),
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_rich_text_area_htmx<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
preview_url: &str,
) -> maud::Markup {
required_rich_text_area_htmx_with_token_field(
changeset,
field,
label,
preview_url,
DEFAULT_SUBMIT_TOKEN_FIELD,
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_rich_text_area_htmx_with_labels<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
preview_url: &str,
labels: &RichTextLabels<'_>,
) -> maud::Markup {
required_rich_text_area_htmx_with_token_field_with_labels(
changeset,
field,
label,
preview_url,
DEFAULT_SUBMIT_TOKEN_FIELD,
labels,
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_rich_text_area_htmx_with_token_field<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
preview_url: &str,
token_field: &str,
) -> maud::Markup {
rich_text_area_inner(
changeset,
field,
label,
Some((preview_url, token_field)),
&RichTextLabels::new(),
&FieldOptions::new().required(),
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_rich_text_area_htmx_with_token_field_with_labels<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
preview_url: &str,
token_field: &str,
labels: &RichTextLabels<'_>,
) -> maud::Markup {
rich_text_area_inner(
changeset,
field,
label,
Some((preview_url, token_field)),
labels,
&FieldOptions::new().required(),
)
}
#[cfg(feature = "maud")]
fn rich_text_area_inner<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
preview: Option<(&str, &str)>,
labels: &RichTextLabels<'_>,
options: &FieldOptions<'_>,
) -> maud::Markup {
let required = options.required;
let errors = changeset.errors_for(field);
let has_errors = !errors.is_empty();
let value = changeset.field_value(field).unwrap_or_default();
let error_id = has_errors.then(|| format!("{field}-error"));
let hint_id = format!("{field}-hint");
let wrapper_id = format!("{field}-field");
let preview_id = format!("{field}-preview");
let described_by = if has_errors {
format!("{hint_id} {}", error_id.as_deref().unwrap_or_default())
} else {
hint_id.clone()
};
let hint = options.hint.as_deref().unwrap_or(labels.hint);
let mut editor = String::with_capacity(320usize.saturating_add(value.len()));
editor.push_str("<textarea");
push_attr(&mut editor, "id", field);
push_attr(&mut editor, "name", field);
editor.push_str(r#" rows="12""#);
editor.push_str(r#" class="autumn-field__input autumn-rich-text__editor"#);
if has_errors {
editor.push_str(" autumn-field__input--invalid");
}
if options.auto_grow {
editor.push_str(" autumn-auto-grow");
}
editor.push_str(if has_errors {
r#"" aria-invalid="true""#
} else {
r#"" aria-invalid="false""#
});
push_attr(&mut editor, "aria-describedby", &described_by);
if required {
editor.push_str(r#" required aria-required="true""#);
}
let reserved: &[&str] = if let Some((url, token_field)) = preview {
push_attr(&mut editor, "hx-post", url);
editor.push_str(r##" hx-trigger="keyup changed delay:400ms, change" hx-target="#"##);
write_escaped(&preview_id, &mut editor);
editor.push_str(r#"" hx-swap="innerHTML" hx-include="closest form" hx-params="not "#);
write_escaped(token_field, &mut editor);
editor.push('"');
HTMX_OWNED
} else {
&[]
};
push_option_attrs(&mut editor, Some(FieldKind::Textarea), options, reserved);
editor.push('>');
write_escaped(&value, &mut editor);
editor.push_str("</textarea>");
maud::html! {
div id=(wrapper_id) class="autumn-field autumn-rich-text" data-autumn-field-wrapper=(field) {
label for=(field) class="autumn-field__label" {
(label)
@if required { (maud::PreEscaped(REQUIRED_MARKER)) }
}
div class="autumn-rich-text__toolbar" role="group" aria-label=(labels.toolbar_group) {
@for (control, syntax) in labels.controls {
span class="autumn-rich-text__toolbar-item" {
span class="autumn-rich-text__toolbar-label" { (control) }
code class="autumn-rich-text__toolbar-syntax" { (syntax) }
}
}
}
(maud::PreEscaped(editor))
p id=(hint_id) class="autumn-rich-text__hint" { (hint) }
@if has_errors {
div id=(error_id.as_deref().unwrap_or_default()) role="alert" class="autumn-field__errors" {
@for error in errors {
p class="autumn-field__error" { (error) }
}
}
}
@if preview.is_some() {
div class="autumn-rich-text__preview-wrapper" {
span class="autumn-rich-text__preview-label" id=(format!("{field}-preview-label")) { (labels.preview_heading) }
div
id=(preview_id)
class="autumn-rich-text__preview"
role="region"
aria-labelledby=(format!("{field}-preview-label"))
{ (rich_text_preview(&value)) }
}
}
}
}
}
#[cfg(all(feature = "maud", feature = "markdown"))]
fn rich_text_preview(value: &str) -> maud::Markup {
crate::markdown::render_user_content(value)
}
#[cfg(all(feature = "maud", not(feature = "markdown")))]
fn rich_text_preview(_value: &str) -> maud::Markup {
maud::html! {}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_text_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
text_input_with(changeset, field, label, &FieldOptions::new().required())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn checkbox_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
checkbox_input_with(changeset, field, label, &FieldOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_checkbox_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
checkbox_input_with(changeset, field, label, &FieldOptions::new().required())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn checkbox_input_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
options: &FieldOptions<'_>,
) -> maud::Markup {
let spec = FieldSpec {
checked: changeset.field_value(field).as_deref() == Some("true"),
..FieldSpec::new(FieldKind::Checkbox, field, label)
};
render_field(&spec, changeset.errors_for(field), options)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn number_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
step: Option<&str>,
) -> maud::Markup {
number_input_with(changeset, field, label, step, &FieldOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn number_input_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
step: Option<&str>,
options: &FieldOptions<'_>,
) -> maud::Markup {
let value = changeset.field_value(field).unwrap_or_default();
let spec = FieldSpec {
value: Some(&value),
step,
..FieldSpec::new(FieldKind::Number, field, label)
};
render_field(&spec, changeset.errors_for(field), options)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_number_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
step: Option<&str>,
) -> maud::Markup {
number_input_with(
changeset,
field,
label,
step,
&FieldOptions::new().required(),
)
}
#[cfg(feature = "maud")]
fn normalize_date_value(raw: &str) -> String {
if raw.is_empty() {
return String::new();
}
let normalized = raw.replace(' ', "T");
if let Ok(date) = chrono::NaiveDate::parse_from_str(&normalized, "%Y-%m-%d") {
return date.to_string();
}
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&normalized) {
return dt.format("%Y-%m-%d").to_string();
}
if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(&normalized, "%Y-%m-%dT%H:%M:%S%.f") {
return ndt.format("%Y-%m-%d").to_string();
}
raw.to_owned()
}
#[cfg(feature = "maud")]
fn normalize_datetime_local_value(raw: &str) -> String {
if raw.is_empty() {
return String::new();
}
let normalized = raw.replace(' ', "T");
if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(&normalized, "%Y-%m-%dT%H:%M:%S%.f") {
return ndt.format("%Y-%m-%dT%H:%M:%S%.f").to_string();
}
if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(&normalized, "%Y-%m-%dT%H:%M") {
return ndt.format("%Y-%m-%dT%H:%M:%S%.f").to_string();
}
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&normalized) {
return dt.naive_local().format("%Y-%m-%dT%H:%M:%S%.f").to_string();
}
raw.to_owned()
}
fn pad_datetime_local_seconds(raw: &str) -> String {
if raw.chars().count() == 16 {
format!("{raw}:00")
} else {
raw.to_owned()
}
}
fn parse_datetime_local_or_rfc3339_utc(
raw: &str,
) -> Result<chrono::DateTime<chrono::Utc>, chrono::ParseError> {
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(raw) {
return Ok(dt.with_timezone(&chrono::Utc));
}
chrono::NaiveDateTime::parse_from_str(&pad_datetime_local_seconds(raw), "%Y-%m-%dT%H:%M:%S%.f")
.map(|ndt| ndt.and_utc())
}
fn parse_datetime_local_or_rfc3339_local(
raw: &str,
) -> Result<chrono::DateTime<chrono::Local>, String> {
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(raw) {
return Ok(dt.with_timezone(&chrono::Local));
}
let ndt = chrono::NaiveDateTime::parse_from_str(
&pad_datetime_local_seconds(raw),
"%Y-%m-%dT%H:%M:%S%.f",
)
.map_err(|e| e.to_string())?;
match ndt.and_local_timezone(chrono::Local) {
chrono::LocalResult::Single(dt) => Ok(dt),
chrono::LocalResult::Ambiguous(earliest, _) => Ok(earliest),
chrono::LocalResult::None => Err(format!(
"local time {ndt} does not exist in the server's timezone (skipped by a DST transition)"
)),
}
}
pub fn deserialize_datetime_local_utc<'de, D>(
deserializer: D,
) -> Result<chrono::DateTime<chrono::Utc>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = <String as serde::Deserialize>::deserialize(deserializer)?;
parse_datetime_local_or_rfc3339_utc(&raw).map_err(serde::de::Error::custom)
}
pub fn deserialize_datetime_local_utc_option<'de, D>(
deserializer: D,
) -> Result<Option<chrono::DateTime<chrono::Utc>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = <Option<String> as serde::Deserialize>::deserialize(deserializer)?;
match raw {
Some(s) if !s.is_empty() => parse_datetime_local_or_rfc3339_utc(&s)
.map(Some)
.map_err(serde::de::Error::custom),
_ => Ok(None),
}
}
pub fn deserialize_datetime_local_local<'de, D>(
deserializer: D,
) -> Result<chrono::DateTime<chrono::Local>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = <String as serde::Deserialize>::deserialize(deserializer)?;
parse_datetime_local_or_rfc3339_local(&raw).map_err(serde::de::Error::custom)
}
pub fn deserialize_datetime_local_local_option<'de, D>(
deserializer: D,
) -> Result<Option<chrono::DateTime<chrono::Local>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = <Option<String> as serde::Deserialize>::deserialize(deserializer)?;
match raw {
Some(s) if !s.is_empty() => parse_datetime_local_or_rfc3339_local(&s)
.map(Some)
.map_err(serde::de::Error::custom),
_ => Ok(None),
}
}
pub fn deserialize_naive_datetime_local<'de, D>(
deserializer: D,
) -> Result<chrono::NaiveDateTime, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = <String as serde::Deserialize>::deserialize(deserializer)?;
chrono::NaiveDateTime::parse_from_str(&pad_datetime_local_seconds(&raw), "%Y-%m-%dT%H:%M:%S%.f")
.map_err(serde::de::Error::custom)
}
pub fn deserialize_naive_datetime_local_option<'de, D>(
deserializer: D,
) -> Result<Option<chrono::NaiveDateTime>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = <Option<String> as serde::Deserialize>::deserialize(deserializer)?;
match raw {
Some(s) if !s.is_empty() => chrono::NaiveDateTime::parse_from_str(
&pad_datetime_local_seconds(&s),
"%Y-%m-%dT%H:%M:%S%.f",
)
.map(Some)
.map_err(serde::de::Error::custom),
_ => Ok(None),
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn date_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
date_input_with(changeset, field, label, &FieldOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn date_input_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
options: &FieldOptions<'_>,
) -> maud::Markup {
let value = normalize_date_value(&changeset.field_value(field).unwrap_or_default());
let spec = FieldSpec {
value: Some(&value),
..FieldSpec::new(FieldKind::Date, field, label)
};
render_field(&spec, changeset.errors_for(field), options)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_date_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
date_input_with(changeset, field, label, &FieldOptions::new().required())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn datetime_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
datetime_input_with(changeset, field, label, &FieldOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn datetime_input_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
options: &FieldOptions<'_>,
) -> maud::Markup {
let value = normalize_datetime_local_value(&changeset.field_value(field).unwrap_or_default());
let spec = FieldSpec {
value: Some(&value),
step: Some("any"),
..FieldSpec::new(FieldKind::DateTime, field, label)
};
render_field(&spec, changeset.errors_for(field), options)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_datetime_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
datetime_input_with(changeset, field, label, &FieldOptions::new().required())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn select_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
options: &[(&str, &str)],
) -> maud::Markup {
select_input_with(changeset, field, label, options, &FieldOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn select_input_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
choices: &[(&str, &str)],
options: &FieldOptions<'_>,
) -> maud::Markup {
render_select(
changeset,
field,
label,
SelectChoices::Borrowed(choices),
SelectGroups::None,
options,
)
}
#[cfg(feature = "maud")]
fn render_select<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
choices: SelectChoices<'_>,
groups: SelectGroups<'_>,
options: &FieldOptions<'_>,
) -> maud::Markup {
let selected = select_values(changeset, field, options);
let spec = FieldSpec {
selected: &selected,
choices,
groups,
..FieldSpec::new(FieldKind::Select, field, label)
};
render_field(&spec, changeset.errors_for(field), options)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_select_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
options: &[(&str, &str)],
) -> maud::Markup {
select_input_with(
changeset,
field,
label,
options,
&FieldOptions::new().required(),
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn grouped_select_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
choices: &[(&str, &str)],
groups: &[(&str, &[(&str, &str)])],
) -> maud::Markup {
grouped_select_input_with(
changeset,
field,
label,
choices,
groups,
&FieldOptions::new(),
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn grouped_select_input_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
choices: &[(&str, &str)],
groups: &[(&str, &[(&str, &str)])],
options: &FieldOptions<'_>,
) -> maud::Markup {
render_select(
changeset,
field,
label,
SelectChoices::Borrowed(choices),
SelectGroups::Borrowed(groups),
options,
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_grouped_select_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
choices: &[(&str, &str)],
groups: &[(&str, &[(&str, &str)])],
) -> maud::Markup {
grouped_select_input_with(
changeset,
field,
label,
choices,
groups,
&FieldOptions::new().required(),
)
}
#[cfg(feature = "maud")]
fn native_input<T: Serialize>(
kind: FieldKind,
changeset: &Changeset<T>,
field: &str,
label: &str,
options: &FieldOptions<'_>,
) -> maud::Markup {
let value = changeset.field_value(field).unwrap_or_default();
let spec = FieldSpec {
value: Some(&value),
..FieldSpec::new(kind, field, label)
};
render_field(&spec, changeset.errors_for(field), options)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn switch_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
switch_input_with(changeset, field, label, &FieldOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_switch_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
switch_input_with(changeset, field, label, &FieldOptions::new().required())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn switch_input_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
options: &FieldOptions<'_>,
) -> maud::Markup {
let spec = FieldSpec {
checked: changeset.field_value(field).as_deref() == Some("true"),
..FieldSpec::new(FieldKind::Switch, field, label)
};
render_field(&spec, changeset.errors_for(field), options)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn range_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
step: Option<&str>,
) -> maud::Markup {
range_input_with(changeset, field, label, step, &FieldOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn range_input_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
step: Option<&str>,
options: &FieldOptions<'_>,
) -> maud::Markup {
let value = changeset.field_value(field).unwrap_or_default();
let spec = FieldSpec {
value: Some(&value),
step,
..FieldSpec::new(FieldKind::Range, field, label)
};
render_field(&spec, changeset.errors_for(field), options)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn radio_group<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
choices: &[(&str, &str)],
) -> maud::Markup {
radio_group_with(changeset, field, label, choices, &FieldOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_radio_group<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
choices: &[(&str, &str)],
) -> maud::Markup {
radio_group_with(
changeset,
field,
label,
choices,
&FieldOptions::new().required(),
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn radio_group_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
choices: &[(&str, &str)],
options: &FieldOptions<'_>,
) -> maud::Markup {
render_choice_group(
ChoiceStyle::Radio,
changeset,
field,
label,
SelectChoices::Borrowed(choices),
options,
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn checkbox_group<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
choices: &[(&str, &str)],
) -> maud::Markup {
checkbox_group_with(changeset, field, label, choices, &FieldOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn checkbox_group_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
choices: &[(&str, &str)],
options: &FieldOptions<'_>,
) -> maud::Markup {
render_choice_group(
ChoiceStyle::Checkbox,
changeset,
field,
label,
SelectChoices::Borrowed(choices),
options,
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn segmented_control<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
choices: &[(&str, &str)],
) -> maud::Markup {
segmented_control_with(changeset, field, label, choices, &FieldOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_segmented_control<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
choices: &[(&str, &str)],
) -> maud::Markup {
segmented_control_with(
changeset,
field,
label,
choices,
&FieldOptions::new().required(),
)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn segmented_control_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
choices: &[(&str, &str)],
options: &FieldOptions<'_>,
) -> maud::Markup {
render_choice_group(
ChoiceStyle::Segmented,
changeset,
field,
label,
SelectChoices::Borrowed(choices),
options,
)
}
#[cfg(feature = "maud")]
macro_rules! native_input_helpers {
($(
$kind:ident, $html:literal, $plain:ident, $required:ident, $with:ident, $what:literal;
)*) => {$(
#[doc = concat!("Render a labeled `<input type=\"", $html, "\">` tied to a changeset field.")]
#[doc = concat!($what, ". The wrapper, label, hint and errors are the same as [`text_input`].")]
#[cfg(feature = "maud")]
#[must_use]
pub fn $plain<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
$with(changeset, field, label, &FieldOptions::new())
}
#[doc = concat!("[`", stringify!($plain), "`] for a required field. See [`required_text_input`].")]
#[cfg(feature = "maud")]
#[must_use]
pub fn $required<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
$with(changeset, field, label, &FieldOptions::new().required())
}
#[doc = concat!("[`", stringify!($plain), "`] with a hint and typed attributes. See [`FieldOptions`].")]
#[cfg(feature = "maud")]
#[must_use]
pub fn $with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
options: &FieldOptions<'_>,
) -> maud::Markup {
native_input(FieldKind::$kind, changeset, field, label, options)
}
)*};
}
#[cfg(feature = "maud")]
native_input_helpers! {
Email, "email", email_input, required_email_input, email_input_with,
"The browser checks the address shape. Check it on the server too";
Tel, "tel", tel_input, required_tel_input, tel_input_with,
"For a phone number. The browser does not check the format";
Url, "url", url_input, required_url_input, url_input_with,
"For an absolute URL. The browser checks the URL shape";
}
#[cfg(feature = "maud")]
#[must_use]
pub fn time_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
time_input_with(changeset, field, label, &FieldOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_time_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
time_input_with(changeset, field, label, &FieldOptions::new().required())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn time_input_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
options: &FieldOptions<'_>,
) -> maud::Markup {
let raw = changeset.field_value(field).unwrap_or_default();
let (value, step) = normalize_time_value(&raw);
let spec = FieldSpec {
value: Some(&value),
step,
..FieldSpec::new(FieldKind::Time, field, label)
};
render_field(&spec, changeset.errors_for(field), options)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn month_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
month_input_with(changeset, field, label, &FieldOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn required_month_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
month_input_with(changeset, field, label, &FieldOptions::new().required())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn month_input_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
options: &FieldOptions<'_>,
) -> maud::Markup {
let value = normalize_month_value(&changeset.field_value(field).unwrap_or_default());
let spec = FieldSpec {
value: Some(&value),
..FieldSpec::new(FieldKind::Month, field, label)
};
render_field(&spec, changeset.errors_for(field), options)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn color_input<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
) -> maud::Markup {
color_input_with(changeset, field, label, &FieldOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn color_input_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
options: &FieldOptions<'_>,
) -> maud::Markup {
let value = changeset
.field_value(field)
.and_then(|raw| normalize_color_value(&raw));
let spec = FieldSpec {
value: value.as_deref(),
..FieldSpec::new(FieldKind::Color, field, label)
};
render_field(&spec, changeset.errors_for(field), options)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn hidden_input<T: Serialize>(changeset: &Changeset<T>, field: &str) -> maud::Markup {
hidden_input_with(changeset, field, &FieldOptions::new())
}
#[cfg(feature = "maud")]
#[must_use]
pub fn hidden_input_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
options: &FieldOptions<'_>,
) -> maud::Markup {
let value = changeset.field_value(field).unwrap_or_default();
render_hidden(field, &value, options)
}
#[cfg(feature = "maud")]
fn render_hidden(field: &str, value: &str, options: &FieldOptions<'_>) -> maud::Markup {
let field_html = fast_escape(field);
let mut out = String::with_capacity(
60usize
.saturating_add(field_html.len().saturating_mul(2))
.saturating_add(value.len()),
);
out.push_str(r#"<input type="hidden" id=""#);
out.push_str(&field_html);
out.push_str(r#"" name=""#);
out.push_str(&field_html);
out.push('"');
push_attr(&mut out, "value", value);
push_option_attrs(&mut out, Some(FieldKind::Hidden), options, &[]);
out.push('>');
maud::PreEscaped(out)
}
#[cfg(feature = "maud")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ChoiceStyle {
Radio,
Checkbox,
Segmented,
}
#[cfg(feature = "maud")]
fn render_choice_group<T: Serialize>(
style: ChoiceStyle,
changeset: &Changeset<T>,
field: &str,
label: &str,
choices: SelectChoices<'_>,
options: &FieldOptions<'_>,
) -> maud::Markup {
let selected: Vec<String> = if style == ChoiceStyle::Checkbox {
changeset.field_values(field)
} else {
vec![changeset.field_value(field).unwrap_or_default()]
};
let context = FieldContext {
field,
label,
value: None,
values: &selected,
errors: changeset.errors_for(field),
options,
grouped: true,
};
let mut items = String::new();
push_choice_items(&mut items, style, &context, choices);
render_custom_field(&context, &maud::PreEscaped(items))
}
#[cfg(feature = "maud")]
fn push_choice_items(
out: &mut String,
style: ChoiceStyle,
context: &FieldContext<'_>,
choices: SelectChoices<'_>,
) {
let field_html = fast_escape(context.field);
let (container, item_open, item_close, input_class, label_class) = match style {
ChoiceStyle::Radio | ChoiceStyle::Checkbox => (
"autumn-choices",
r#"<div class="autumn-choice">"#,
"</div>",
"autumn-choice__input",
"autumn-choice__label",
),
ChoiceStyle::Segmented => (
"autumn-segmented",
"",
"",
"autumn-segmented__input",
"autumn-segmented__option",
),
};
let input_type = if style == ChoiceStyle::Checkbox {
"checkbox"
} else {
"radio"
};
let required = context.is_required() && style != ChoiceStyle::Checkbox;
out.push_str(r#"<div class=""#);
out.push_str(container);
out.push_str(r#"">"#);
let mut push_item = |index: usize, value: &str, label: &str| {
let mut id = String::with_capacity(field_html.len().saturating_add(4));
id.push_str(&field_html);
if index > 0 {
id.push('-');
id.push_str(&index.to_string());
}
out.push_str(item_open);
out.push_str(r#"<input type=""#);
out.push_str(input_type);
out.push_str(r#"" id=""#);
out.push_str(&id);
out.push_str(r#"" name=""#);
out.push_str(&field_html);
out.push('"');
push_attr(out, "value", value);
if context.values.iter().any(|v| v == value) {
out.push_str(" checked");
}
if required {
out.push_str(" required");
}
out.push_str(r#" class=""#);
out.push_str(input_class);
out.push('"');
if context.is_invalid() {
out.push_str(r#" aria-invalid="true""#);
}
push_option_attrs(out, Some(FieldKind::Checkbox), context.options, &[]);
out.push_str(r#"><label for=""#);
out.push_str(&id);
out.push_str(r#"" class=""#);
out.push_str(label_class);
out.push_str(r#"">"#);
write_escaped(label, out);
out.push_str("</label>");
out.push_str(item_close);
};
let mut index = 0;
choices.for_each(|value, label| {
push_item(index, value, label);
index = index.saturating_add(1);
});
out.push_str("</div>");
}
#[cfg(feature = "maud")]
fn normalize_time_value(raw: &str) -> (String, Option<&'static str>) {
let digits = |range: std::ops::Range<usize>| {
raw.get(range)
.is_some_and(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()))
};
let colon = |at: usize| raw.as_bytes().get(at) == Some(&b':');
let (Some(hh_mm), true, true, true) = (raw.get(0..5), digits(0..2), colon(2), digits(3..5))
else {
return (raw.to_owned(), None);
};
let rest = raw.get(5..).unwrap_or_default();
if rest.is_empty() {
return (raw.to_owned(), None);
}
let (Some(seconds), true, true) = (raw.get(6..8), colon(5), digits(6..8)) else {
return (raw.to_owned(), None);
};
let tail = raw.get(8..).unwrap_or_default();
let fraction = match tail.strip_prefix('.') {
None if tail.is_empty() => "",
Some(f) if !f.is_empty() && f.bytes().all(|b| b.is_ascii_digit()) => {
f.get(..3).unwrap_or(f).trim_end_matches('0')
}
_ => return (raw.to_owned(), None),
};
if fraction.is_empty() {
if seconds == "00" {
(hh_mm.to_owned(), None)
} else {
(format!("{hh_mm}:{seconds}"), Some("1"))
}
} else {
(format!("{hh_mm}:{seconds}.{fraction}"), Some("0.001"))
}
}
#[cfg(feature = "maud")]
fn normalize_month_value(raw: &str) -> String {
let bytes = raw.as_bytes();
let is_digit = |at: usize| bytes.get(at).is_some_and(u8::is_ascii_digit);
let shape = (0..4).all(is_digit)
&& bytes.get(4) == Some(&b'-')
&& is_digit(5)
&& is_digit(6)
&& matches!(bytes.get(7), None | Some(b'-'));
match raw.get(..7) {
Some(month) if shape => month.to_owned(),
_ => raw.to_owned(),
}
}
#[cfg(feature = "maud")]
fn normalize_color_value(raw: &str) -> Option<String> {
let hex = raw.strip_prefix('#')?;
if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
match hex.len() {
6 => Some(raw.to_ascii_lowercase()),
3 => {
let mut out = String::with_capacity(7);
out.push('#');
for c in hex.chars().map(|c| c.to_ascii_lowercase()) {
out.push(c);
out.push(c);
}
Some(out)
}
_ => None,
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn aria_live_region(id: &str, message: &str) -> maud::Markup {
maud::html! {
div id=(id) role="status" aria-live="polite" aria-atomic="true" {
(message)
}
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn skip_link(target: &str, label: &str) -> maud::Markup {
maud::html! {
a href=(target) class="skip-link" { (label) }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum FieldControl {
Text,
Textarea,
Password,
Number {
step: Option<String>,
},
Checkbox,
Date,
DateTime,
Select {
options: Vec<(String, String)>,
},
GroupedSelect {
options: Vec<(String, String)>,
groups: Vec<(String, Vec<(String, String)>)>,
},
File,
Switch,
Range {
step: Option<String>,
},
Radio {
options: Vec<(String, String)>,
},
CheckboxGroup {
options: Vec<(String, String)>,
},
Segmented {
options: Vec<(String, String)>,
},
Email,
Tel,
Url,
Time,
Month,
Color,
Hidden,
Otp(OtpFormat),
#[cfg(feature = "maud")]
Custom(CustomControl),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct OtpFormat {
digits: u8,
group: u8,
}
impl OtpFormat {
pub const MIN_DIGITS: u8 = 4;
pub const MAX_DIGITS: u8 = 10;
#[must_use]
pub const fn digits(count: u8) -> Self {
let digits = if count < Self::MIN_DIGITS {
Self::MIN_DIGITS
} else if count > Self::MAX_DIGITS {
Self::MAX_DIGITS
} else {
count
};
Self { digits, group: 0 }
}
#[must_use]
pub const fn group(mut self, size: u8) -> Self {
self.group = if size >= 2 && size <= 5 && size < self.digits {
size
} else {
0
};
self
}
#[must_use]
pub const fn digit_count(self) -> u8 {
self.digits
}
#[must_use]
pub const fn group_size(self) -> Option<u8> {
if self.group == 0 {
None
} else {
Some(self.group)
}
}
#[must_use]
pub const fn cells(self) -> u8 {
match self.digits.saturating_sub(1).checked_div(self.group) {
Some(separators) => self.digits.saturating_add(separators),
None => self.digits,
}
}
}
impl Default for OtpFormat {
fn default() -> Self {
Self::digits(6)
}
}
#[cfg(feature = "maud")]
impl FieldControl {
#[must_use]
pub fn custom(renderer: impl FieldRenderer) -> Self {
Self::Custom(CustomControl::new(renderer))
}
fn is_multipart(&self) -> bool {
match self {
Self::File => true,
Self::Custom(control) => control.renderer().multipart(),
_ => false,
}
}
}
#[derive(Debug, Clone)]
pub struct FormField {
pub name: String,
pub label: String,
pub control: FieldControl,
pub required: bool,
pub value_name: Option<String>,
}
impl FormField {
#[must_use]
pub fn new(
name: impl Into<String>,
label: impl Into<String>,
control: FieldControl,
required: bool,
) -> Self {
Self {
name: name.into(),
label: label.into(),
control,
required,
value_name: None,
}
}
#[must_use]
pub fn with_value_name(mut self, value_name: impl Into<String>) -> Self {
self.value_name = Some(value_name.into());
self
}
}
pub trait FormModel {
fn form_fields() -> Vec<FormField>;
}
#[cfg(feature = "maud")]
#[must_use]
pub fn form_for<T>(
changeset: &Changeset<T>,
action: impl Into<String>,
method: impl Into<String>,
) -> FormFor<'_, T>
where
T: Serialize + FormModel,
{
FormFor {
changeset,
action: action.into(),
method: method.into(),
csrf_token: None,
csrf_field_name: "_csrf".to_string(),
excluded: Vec::new(),
field_overrides: Vec::new(),
label_overrides: Vec::new(),
field_options: Vec::new(),
prepended: Vec::new(),
appended: Vec::new(),
submit_label: "Save".to_string(),
force_multipart: false,
}
}
#[cfg(feature = "maud")]
pub struct FormFor<'a, T: Serialize + FormModel> {
changeset: &'a Changeset<T>,
action: String,
method: String,
csrf_token: Option<String>,
csrf_field_name: String,
excluded: Vec<String>,
field_overrides: Vec<(String, FieldControl)>,
label_overrides: Vec<(String, String)>,
field_options: Vec<(String, FieldOptions<'a>)>,
prepended: Vec<maud::Markup>,
appended: Vec<maud::Markup>,
submit_label: String,
force_multipart: bool,
}
#[cfg(feature = "maud")]
impl<'a, T: Serialize + FormModel> FormFor<'a, T> {
#[must_use]
pub fn csrf(mut self, token: impl Into<String>) -> Self {
self.csrf_token = Some(token.into());
self
}
#[must_use]
pub fn csrf_field_name(mut self, name: impl Into<String>) -> Self {
self.csrf_field_name = name.into();
self
}
#[must_use]
pub fn exclude(mut self, field: impl Into<String>) -> Self {
self.excluded.push(field.into());
self
}
#[must_use]
pub fn override_field(mut self, field: impl Into<String>, control: FieldControl) -> Self {
self.field_overrides.push((field.into(), control));
self
}
#[must_use]
pub fn override_label(mut self, field: impl Into<String>, label: impl Into<String>) -> Self {
self.label_overrides.push((field.into(), label.into()));
self
}
#[must_use]
pub fn field_options(mut self, field: impl Into<String>, options: FieldOptions<'a>) -> Self {
self.field_options.push((field.into(), options));
self
}
#[must_use]
pub fn append(mut self, markup: maud::Markup) -> Self {
self.appended.push(markup);
self
}
#[must_use]
pub fn prepend(mut self, markup: maud::Markup) -> Self {
self.prepended.push(markup);
self
}
#[must_use]
pub fn submit_label(mut self, label: impl Into<String>) -> Self {
self.submit_label = label.into();
self
}
#[must_use]
pub const fn multipart(mut self) -> Self {
self.force_multipart = true;
self
}
#[must_use]
pub fn render(self) -> maud::Markup {
let Self {
changeset,
action,
method,
csrf_token,
csrf_field_name,
excluded,
field_overrides,
label_overrides,
field_options,
prepended,
appended,
submit_label,
force_multipart,
} = self;
let no_options = FieldOptions::new();
let fields = effective_form_fields::<T>(&excluded, &field_overrides, &label_overrides);
let is_multipart = force_multipart || fields.iter().any(|f| f.control.is_multipart());
let inner = maud::html! {
@for markup in prepended {
(markup)
}
@for field in &fields {
(render_form_field(
changeset,
field,
field_options
.iter()
.rev()
.find(|(name, _)| *name == field.name)
.map_or(&no_options, |(_, options)| options),
))
}
@for markup in appended {
(markup)
}
button type="submit" { (submit_label) }
};
form_tag_inner(
&action,
&method,
&csrf_field_name,
csrf_token.as_deref(),
is_multipart.then_some("multipart/form-data"),
inner,
)
}
}
#[cfg(feature = "maud")]
fn effective_form_fields<T: FormModel>(
excluded: &[String],
field_overrides: &[(String, FieldControl)],
label_overrides: &[(String, String)],
) -> Vec<FormField> {
T::form_fields()
.into_iter()
.filter(|field| !excluded.contains(&field.name))
.map(|mut field| {
if let Some((_, control)) = field_overrides
.iter()
.rev()
.find(|(name, _)| *name == field.name)
{
field.control = control.clone();
}
if let Some((_, label)) = label_overrides
.iter()
.rev()
.find(|(name, _)| *name == field.name)
{
field.label = label.clone();
}
field
})
.collect()
}
#[cfg(feature = "maud")]
struct PrefillAlias<'a, T> {
data: &'a T,
serialized_name: &'a str,
exposed_name: &'a str,
}
#[cfg(feature = "maud")]
impl<T: Serialize> Serialize for PrefillAlias<'_, T> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::{Error as _, SerializeMap as _};
let value = serde_json::to_value(self.data).map_err(S::Error::custom)?;
let field_value = value
.get(self.serialized_name)
.cloned()
.unwrap_or(serde_json::Value::Null);
let mut map = serializer.serialize_map(Some(1))?;
map.serialize_entry(self.exposed_name, &field_value)?;
map.end()
}
}
#[cfg(feature = "maud")]
fn render_form_field<T: Serialize>(
changeset: &Changeset<T>,
field: &FormField,
options: &FieldOptions<'_>,
) -> maud::Markup {
if let Some(value_name) = field
.value_name
.as_deref()
.filter(|value_name| *value_name != field.name)
{
let aliased = Changeset::from_errors(
PrefillAlias {
data: changeset.data(),
serialized_name: value_name,
exposed_name: &field.name,
},
changeset.errors().clone(),
);
return render_form_control(&aliased, field, options);
}
render_form_control(changeset, field, options)
}
#[cfg(feature = "maud")]
fn render_form_control<T: Serialize>(
changeset: &Changeset<T>,
field: &FormField,
options: &FieldOptions<'_>,
) -> maud::Markup {
let multiple_select = options.multiple
&& matches!(
field.control,
FieldControl::Select { .. } | FieldControl::GroupedSelect { .. }
);
let required_by_model = field.required
&& !multiple_select
&& !matches!(
field.control,
FieldControl::Checkbox
| FieldControl::Password
| FieldControl::Switch
| FieldControl::Range { .. }
| FieldControl::Color
| FieldControl::Hidden
| FieldControl::CheckboxGroup { .. }
);
let merged;
let options = if required_by_model && !options.required {
merged = options.clone().required();
&merged
} else {
options
};
let (name, label) = (field.name.as_str(), field.label.as_str());
match &field.control {
FieldControl::Text => text_input_with(changeset, name, label, options),
FieldControl::Textarea => textarea_input_with(changeset, name, label, options),
FieldControl::Password => password_input_with(changeset, name, label, options),
FieldControl::Number { step } => {
number_input_with(changeset, name, label, step.as_deref(), options)
}
FieldControl::Checkbox => checkbox_input_with(changeset, name, label, options),
FieldControl::Date => date_input_with(changeset, name, label, options),
FieldControl::DateTime => datetime_input_with(changeset, name, label, options),
FieldControl::Select { options: choices } => render_select(
changeset,
name,
label,
SelectChoices::Owned(choices),
SelectGroups::None,
options,
),
FieldControl::GroupedSelect {
options: choices,
groups,
} => render_select(
changeset,
name,
label,
SelectChoices::Owned(choices),
SelectGroups::Owned(groups),
options,
),
FieldControl::File => {
let spec = FieldSpec::new(FieldKind::File, name, label);
render_field(&spec, changeset.errors_for(name), options)
}
FieldControl::Switch => switch_input_with(changeset, name, label, options),
FieldControl::Range { step } => {
range_input_with(changeset, name, label, step.as_deref(), options)
}
FieldControl::Radio { options: choices } => render_choice_group(
ChoiceStyle::Radio,
changeset,
name,
label,
SelectChoices::Owned(choices),
options,
),
FieldControl::CheckboxGroup { options: choices } => render_choice_group(
ChoiceStyle::Checkbox,
changeset,
name,
label,
SelectChoices::Owned(choices),
options,
),
FieldControl::Segmented { options: choices } => render_choice_group(
ChoiceStyle::Segmented,
changeset,
name,
label,
SelectChoices::Owned(choices),
options,
),
FieldControl::Email => email_input_with(changeset, name, label, options),
FieldControl::Tel => tel_input_with(changeset, name, label, options),
FieldControl::Url => url_input_with(changeset, name, label, options),
FieldControl::Time => time_input_with(changeset, name, label, options),
FieldControl::Month => month_input_with(changeset, name, label, options),
FieldControl::Color => color_input_with(changeset, name, label, options),
FieldControl::Hidden => hidden_input_with(changeset, name, options),
FieldControl::Otp(format) => otp_input_with(changeset, name, label, *format, options),
FieldControl::Custom(control) => {
custom_input_with(changeset, name, label, control.renderer(), options)
}
}
}
#[cfg(feature = "maud")]
pub trait FieldRenderer: Send + Sync + 'static {
fn name(&self) -> &'static str;
fn render(&self, field: &FieldContext<'_>) -> maud::Markup;
fn multipart(&self) -> bool {
false
}
fn grouped(&self) -> bool {
false
}
}
#[cfg(feature = "maud")]
#[derive(Clone)]
pub struct CustomControl(std::sync::Arc<dyn FieldRenderer>);
#[cfg(feature = "maud")]
impl CustomControl {
#[must_use]
pub fn new(renderer: impl FieldRenderer) -> Self {
Self(std::sync::Arc::new(renderer))
}
#[must_use]
pub fn renderer(&self) -> &dyn FieldRenderer {
&*self.0
}
}
#[cfg(feature = "maud")]
impl std::fmt::Debug for CustomControl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("CustomControl")
.field(&self.0.name())
.finish()
}
}
#[cfg(feature = "maud")]
impl PartialEq for CustomControl {
fn eq(&self, other: &Self) -> bool {
std::sync::Arc::ptr_eq(&self.0, &other.0)
}
}
#[cfg(feature = "maud")]
impl Eq for CustomControl {}
#[cfg(feature = "maud")]
#[derive(Debug)]
pub struct FieldContext<'a> {
field: &'a str,
label: &'a str,
value: Option<&'a str>,
values: &'a [String],
errors: &'a [String],
options: &'a FieldOptions<'a>,
grouped: bool,
}
#[cfg(feature = "maud")]
impl<'a> FieldContext<'a> {
#[must_use]
pub const fn id(&self) -> &'a str {
self.field
}
#[must_use]
pub const fn name(&self) -> &'a str {
self.field
}
#[must_use]
pub const fn label(&self) -> &'a str {
self.label
}
#[must_use]
pub fn label_id(&self) -> String {
concat_suffix(self.field, "-label")
}
#[must_use]
pub const fn value(&self) -> Option<&'a str> {
self.value
}
#[must_use]
pub const fn values(&self) -> &'a [String] {
self.values
}
#[must_use]
pub const fn errors(&self) -> &'a [String] {
self.errors
}
#[must_use]
pub const fn is_invalid(&self) -> bool {
!self.errors.is_empty()
}
#[must_use]
pub const fn is_required(&self) -> bool {
self.options.required
}
#[must_use]
pub const fn options(&self) -> &'a FieldOptions<'a> {
self.options
}
#[must_use]
pub fn described_by(&self) -> Option<String> {
described_by(self.field, self.options.hint.is_some(), self.is_invalid())
}
#[must_use]
pub fn control(
&self,
tag: &'static str,
attrs: &[(&str, &str)],
body: maud::Markup,
) -> maud::Markup {
assert!(
is_valid_control_tag(tag),
"control tag {tag:?} is not [a-z][a-z0-9]* or loads code"
);
let attrs = clean_attrs(attrs, CONTROL_OWNED);
let find = |wanted: &str| attrs.iter().find(|(name, _)| *name == wanted).map(|a| a.1);
let form_element = is_form_element(tag);
let input_type = find("type").or_else(|| (tag == "button").then_some("button"));
let mut out = String::with_capacity(240usize.saturating_add(body.0.len()));
out.push('<');
out.push_str(tag);
if let Some(input_type) = input_type {
push_attr(&mut out, "type", input_type);
}
push_attr(&mut out, "id", self.field);
if form_element {
push_attr(&mut out, "name", self.field);
}
let takes_multiple = tag == "select"
|| (tag == "input" && input_type.is_some_and(|t| t.eq_ignore_ascii_case("file")));
if self.options.multiple && takes_multiple && find("multiple").is_none() {
out.push_str(" multiple");
}
let prefill = tag == "input"
&& !input_type.is_some_and(|t| {
["password", "file", "checkbox", "radio"]
.iter()
.any(|no| t.eq_ignore_ascii_case(no))
});
match (find("value"), self.value) {
(Some(value), _) => push_attr(&mut out, "value", value),
(None, Some(value)) if prefill => push_attr(&mut out, "value", value),
_ => {}
}
if self.is_required() {
let native = matches!(tag, "input" | "select" | "textarea");
if native {
out.push_str(" required");
}
if native || find("role").is_some() {
out.push_str(r#" aria-required="true""#);
}
}
out.push_str(if self.is_invalid() {
r#" class="autumn-field__input autumn-field__input--invalid"#
} else {
r#" class="autumn-field__input"#
});
if let Some(class) = find("class") {
out.push(' ');
write_escaped(class, &mut out);
}
out.push('"');
if !self.grouped {
out.push_str(if self.is_invalid() {
r#" aria-invalid="true""#
} else {
r#" aria-invalid="false""#
});
if let Some(ids) = self.described_by() {
push_attr(&mut out, "aria-describedby", &ids);
}
if !form_element && find("aria-label").is_none() && find("aria-labelledby").is_none() {
push_attr(&mut out, "aria-labelledby", &self.label_id());
}
}
push_option_attrs(&mut out, None, self.options, &[]);
let skip = option_attr_names(self.options);
for (name, value) in &attrs {
if !matches!(*name, "type" | "value" | "class") && !skip.iter().any(|s| s == name) {
push_attr(&mut out, name, value);
}
}
close_element(&mut out, tag, body);
maud::PreEscaped(out)
}
#[must_use]
pub fn group_item(
&self,
tag: &'static str,
suffix: &str,
attrs: &[(&str, &str)],
body: maud::Markup,
) -> maud::Markup {
assert!(
is_valid_control_tag(tag),
"control tag {tag:?} is not [a-z][a-z0-9]* or loads code"
);
let attrs = clean_attrs(attrs, CONTROL_OWNED);
let mut out = String::with_capacity(160usize.saturating_add(body.0.len()));
out.push('<');
out.push_str(tag);
if let Some((_, input_type)) = attrs.iter().find(|(name, _)| *name == "type") {
push_attr(&mut out, "type", input_type);
}
let mut id = String::with_capacity(
self.field
.len()
.saturating_add(suffix.len())
.saturating_add(1),
);
id.push_str(self.field);
id.push('-');
id.push_str(suffix);
push_attr(&mut out, "id", &id);
if is_form_element(tag) {
push_attr(&mut out, "name", self.field);
}
if self.is_required() && matches!(tag, "input" | "select" | "textarea") {
out.push_str(" required");
}
if self.is_invalid() {
out.push_str(r#" aria-invalid="true""#);
}
push_option_attrs(&mut out, None, self.options, &[]);
let skip = option_attr_names(self.options);
for (name, value) in &attrs {
if *name != "type" && !skip.iter().any(|s| s == name) {
push_attr(&mut out, name, value);
}
}
close_element(&mut out, tag, body);
maud::PreEscaped(out)
}
}
#[cfg(feature = "maud")]
pub(crate) fn widget_element(
tag: &'static str,
attrs: &[(&str, &str)],
options: &FieldOptions<'_>,
body: maud::Markup,
) -> maud::Markup {
assert!(
is_valid_control_tag(tag),
"widget tag {tag:?} is not [a-z][a-z0-9]*"
);
let attrs = clean_attrs(attrs, &[]);
let mut out = String::with_capacity(120usize.saturating_add(body.0.len()));
out.push('<');
out.push_str(tag);
for (name, value) in &attrs {
push_attr(&mut out, name, value);
}
for (prefix, name, value) in &options.extra {
let full = format!("{prefix}{name}");
if !attrs.iter().any(|(n, _)| *n == full) {
push_attr(&mut out, &full, value);
}
}
close_element(&mut out, tag, body);
maud::PreEscaped(out)
}
#[cfg(feature = "maud")]
const CONTROL_OWNED: &[&str] = &[
"id",
"name",
"required",
"aria-required",
"aria-invalid",
"aria-describedby",
];
#[cfg(feature = "maud")]
fn is_form_element(tag: &str) -> bool {
matches!(tag, "input" | "select" | "textarea" | "button")
}
#[cfg(feature = "maud")]
fn close_element(out: &mut String, tag: &str, body: maud::Markup) {
out.push('>');
if !is_void_tag(tag) {
out.push_str(&body.into_string());
out.push_str("</");
out.push_str(tag);
out.push('>');
}
}
#[cfg(feature = "maud")]
fn option_attr_names(opts: &FieldOptions<'_>) -> Vec<String> {
let typed = [
("placeholder", opts.placeholder.is_some()),
("autocomplete", opts.autocomplete.is_some()),
("inputmode", opts.inputmode.is_some()),
("pattern", opts.pattern.is_some()),
("min", opts.min.is_some()),
("max", opts.max.is_some()),
("minlength", opts.minlength.is_some()),
("maxlength", opts.maxlength.is_some()),
("disabled", opts.disabled),
("readonly", opts.readonly),
];
typed
.into_iter()
.filter(|(_, set)| *set)
.map(|(name, _)| name.to_owned())
.chain(
opts.extra
.iter()
.map(|(prefix, name, _)| format!("{prefix}{name}")),
)
.collect()
}
#[cfg(feature = "maud")]
fn is_valid_control_tag(tag: &str) -> bool {
tag.as_bytes().first().is_some_and(u8::is_ascii_lowercase)
&& tag
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
&& !matches!(
tag,
"script"
| "style"
| "iframe"
| "frame"
| "object"
| "embed"
| "base"
| "link"
| "meta"
| "template"
)
}
#[cfg(feature = "maud")]
fn is_void_tag(tag: &str) -> bool {
matches!(
tag,
"area"
| "base"
| "br"
| "col"
| "embed"
| "hr"
| "img"
| "input"
| "link"
| "meta"
| "source"
| "track"
| "wbr"
)
}
#[cfg(feature = "maud")]
pub(crate) fn clean_attrs<'v>(
attrs: &[(&'v str, &'v str)],
owned: &[&str],
) -> Vec<(&'v str, &'v str)> {
let mut kept: Vec<(&str, &str)> = Vec::with_capacity(attrs.len());
for &(name, value) in attrs {
let well_formed = name.as_bytes().first().is_some_and(u8::is_ascii_lowercase)
&& name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b':');
let handler =
name.starts_with("on") || name.starts_with("hx-on") || name.starts_with("data-hx-on");
if well_formed
&& !handler
&& name != "style"
&& !owned.contains(&name)
&& !kept.iter().any(|(k, _)| *k == name)
{
kept.push((name, value));
}
}
kept
}
#[cfg(feature = "maud")]
#[must_use]
pub fn custom_input_with<T: Serialize>(
changeset: &Changeset<T>,
field: &str,
label: &str,
renderer: &dyn FieldRenderer,
options: &FieldOptions<'_>,
) -> maud::Markup {
let value = changeset.field_value(field);
let values = changeset.field_values(field);
let context = FieldContext {
field,
label,
value: value.as_deref(),
values: &values,
errors: changeset.errors_for(field),
options,
grouped: renderer.grouped(),
};
let control = renderer.render(&context);
render_custom_field(&context, &control)
}
#[cfg(feature = "maud")]
fn render_custom_field(context: &FieldContext<'_>, control: &maud::Markup) -> maud::Markup {
let field_html = fast_escape(context.field);
let field_html = field_html.as_ref();
let hint = context.options.hint.as_deref();
let mut out = String::with_capacity(
240usize
.saturating_add(field_html.len().saturating_mul(4))
.saturating_add(context.label.len())
.saturating_add(control.0.len()),
);
let wrapper = if context.grouped { "fieldset" } else { "div" };
out.push('<');
out.push_str(wrapper);
out.push_str(r#" id=""#);
out.push_str(field_html);
out.push_str(r#"-field" class="autumn-field"#);
if context.grouped {
out.push_str(r#" autumn-field--group""#);
if context.is_invalid() {
out.push_str(r#" aria-invalid="true""#);
}
if let Some(ids) = context.described_by() {
push_attr(&mut out, "aria-describedby", &ids);
}
out.push_str(r#"><legend class="autumn-field__label">"#);
write_escaped(context.label, &mut out);
if context.is_required() {
out.push_str(REQUIRED_MARKER);
}
out.push_str("</legend>");
} else {
out.push_str(r#""><label id=""#);
out.push_str(field_html);
out.push_str(r#"-label" for=""#);
out.push_str(field_html);
out.push_str(r#"" class="autumn-field__label">"#);
write_escaped(context.label, &mut out);
if context.is_required() {
out.push_str(REQUIRED_MARKER);
}
out.push_str("</label>");
}
if let Some(hint) = hint {
push_hint(&mut out, field_html, hint);
}
out.push_str(&control.0);
if context.is_invalid() {
push_errors(&mut out, field_html, context.errors);
}
out.push_str("</");
out.push_str(wrapper);
out.push('>');
maud::PreEscaped(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fast_escape_no_special_chars_borrows() {
let input = "ART-1042";
match fast_escape(input) {
std::borrow::Cow::Borrowed(s) => assert_eq!(s, input),
std::borrow::Cow::Owned(_) => panic!("expected a borrow, no allocation needed"),
}
}
#[test]
fn fast_escape_empty_string_borrows() {
assert!(matches!(fast_escape(""), std::borrow::Cow::Borrowed("")));
}
#[test]
fn fast_escape_escapes_all_four_chars() {
assert_eq!(fast_escape(r#"&<>""#), "&<>"");
}
#[test]
fn fast_escape_leading_trailing_and_adjacent_specials() {
assert_eq!(fast_escape("&start"), "&start");
assert_eq!(fast_escape("end&"), "end&");
assert_eq!(fast_escape("a&&b"), "a&&b");
assert_eq!(
fast_escape("<script>alert('x')</script>"),
"<script>alert('x')</script>"
);
}
#[test]
fn fast_escape_preserves_multibyte_utf8() {
assert_eq!(fast_escape("café <3"), "café <3");
assert_eq!(fast_escape("日本語"), "日本語");
}
fn naive_escape_reference(input: &str) -> String {
let mut out = Vec::with_capacity(input.len());
for b in input.bytes() {
match b {
b'&' => out.extend_from_slice(b"&"),
b'<' => out.extend_from_slice(b"<"),
b'>' => out.extend_from_slice(b">"),
b'"' => out.extend_from_slice(b"""),
_ => out.push(b),
}
}
String::from_utf8(out).expect("escaping ASCII-only replacements preserves valid UTF-8")
}
proptest::proptest! {
#[test]
fn fast_escape_matches_naive_reference(s in ".*") {
proptest::prop_assert_eq!(fast_escape(&s).into_owned(), naive_escape_reference(&s));
}
}
mod repeated_keys {
use super::super::*;
use std::collections::HashSet;
#[derive(Debug, serde::Deserialize)]
struct Numbers {
#[serde(default)]
a: Vec<u32>,
}
#[test]
fn repeated_key_decodes_into_vec() {
let form: Numbers = from_urlencoded(b"a=1&a=2").unwrap();
assert_eq!(form.a, vec![1, 2]);
}
#[test]
fn no_key_decodes_into_empty_vec() {
let form: Numbers = from_urlencoded(b"").unwrap();
assert!(form.a.is_empty());
}
#[test]
fn single_key_decodes_into_one_element_vec() {
let form: Numbers = from_urlencoded(b"a=7").unwrap();
assert_eq!(form.a, vec![7]);
}
#[test]
fn repeated_key_decodes_into_hash_set() {
#[derive(serde::Deserialize)]
struct Tags {
#[serde(default)]
tag: HashSet<String>,
}
let form: Tags = from_urlencoded(b"tag=a&tag=b&tag=a").unwrap();
assert_eq!(form.tag, HashSet::from(["a".to_owned(), "b".to_owned()]));
}
#[test]
fn option_vec_is_none_when_absent_and_some_when_present() {
#[derive(serde::Deserialize)]
struct F {
ids: Option<Vec<i64>>,
}
let absent: F = from_urlencoded(b"").unwrap();
assert_eq!(absent.ids, None);
let one: F = from_urlencoded(b"ids=3").unwrap();
assert_eq!(one.ids, Some(vec![3]));
let two: F = from_urlencoded(b"ids=3&ids=4").unwrap();
assert_eq!(two.ids, Some(vec![3, 4]));
}
#[test]
fn percent_encoded_values_decode_into_vec() {
#[derive(serde::Deserialize)]
struct F {
#[serde(default)]
q: Vec<String>,
}
let form: F = from_urlencoded(b"q=a%20b&q=c+d&q=%26%3D&q=%C3%A9").unwrap();
assert_eq!(form.q, vec!["a b", "c d", "&=", "é"]);
}
#[test]
fn repeated_key_into_scalar_is_a_repeated_field_error() {
#[derive(Debug, serde::Deserialize)]
struct F {
#[allow(dead_code)]
name: String,
}
let err = from_urlencoded::<F>(b"name=a&name=b").unwrap_err();
assert!(err.is_repeated_field(), "{err}");
assert_eq!(err.field(), Some("name"));
assert_eq!(err.status(), http::StatusCode::UNPROCESSABLE_ENTITY);
assert!(err.to_string().contains("`name`"), "{err}");
}
#[test]
fn repeated_blank_and_filled_scalar_is_still_repeated() {
#[derive(Debug, serde::Deserialize)]
struct F {
#[allow(dead_code)]
count: Option<i32>,
}
let err = from_urlencoded::<F>(b"count=&count=5").unwrap_err();
assert!(err.is_repeated_field(), "{err}");
}
#[test]
fn malformed_element_is_a_400_not_a_repeat() {
let err = from_urlencoded::<Numbers>(b"a=1&a=x").unwrap_err();
assert!(!err.is_repeated_field(), "{err}");
assert_eq!(err.status(), http::StatusCode::BAD_REQUEST);
assert_eq!(err.field(), Some("a"));
}
#[test]
fn blank_element_of_a_typed_vec_is_dropped() {
let form: Numbers = from_urlencoded(b"a=&a=2&a=").unwrap();
assert_eq!(form.a, vec![2]);
}
#[test]
fn blank_element_of_a_string_vec_is_kept() {
#[derive(serde::Deserialize)]
struct F {
#[serde(default)]
q: Vec<String>,
}
let form: F = from_urlencoded(b"q=&q=x").unwrap();
assert_eq!(form.q, vec!["", "x"]);
}
#[test]
fn repeated_field_error_bounds_a_hostile_key() {
let key = format!("k{}\u{7}", "x".repeat(500));
let body = format!("{key}=1&{key}=2");
let err = from_urlencoded::<std::collections::HashMap<String, String>>(body.as_bytes())
.unwrap_err();
assert!(err.is_repeated_field(), "{err}");
let text = err.to_string();
assert!(text.len() < 300, "{text}");
assert!(!text.contains('\u{7}'), "{text}");
}
#[test]
fn single_value_fields_decode_as_before() {
#[derive(Debug, PartialEq, serde::Deserialize)]
struct F {
name: String,
age: Option<i32>,
note: Option<String>,
#[serde(default)]
active: bool,
}
let form: F = from_urlencoded(b"name=Ann&age=¬e=&active=true").unwrap();
assert_eq!(
form,
F {
name: "Ann".to_owned(),
age: None,
note: Some(String::new()),
active: true,
}
);
}
#[test]
fn repeated_field_error_converts_to_a_422_autumn_error() {
#[derive(Debug, serde::Deserialize)]
struct F {
#[allow(dead_code)]
name: String,
}
let err: crate::AutumnError =
from_urlencoded::<F>(b"name=a&name=b").unwrap_err().into();
assert_eq!(err.status(), http::StatusCode::UNPROCESSABLE_ENTITY);
}
#[test]
fn many_blank_elements_decode_in_one_retry() {
let mut body = "ids=&".repeat(50_000);
body.push_str("ids=1");
let started = std::time::Instant::now();
let form: Numbers = from_urlencoded(body.replace("ids", "a").as_bytes()).unwrap();
assert_eq!(form.a, vec![1]);
assert!(
started.elapsed() < std::time::Duration::from_secs(10),
"{:?}",
started.elapsed()
);
}
#[test]
fn invalid_body_converts_to_a_400_autumn_error() {
let err: crate::AutumnError = from_urlencoded::<Numbers>(b"a=x").unwrap_err().into();
assert_eq!(err.status(), http::StatusCode::BAD_REQUEST);
}
#[test]
fn brackets_are_part_of_the_key_unless_aliased() {
#[derive(serde::Deserialize)]
struct Plain {
#[serde(default)]
ids: Vec<i64>,
}
#[derive(Debug, serde::Deserialize)]
struct Aliased {
#[serde(default, alias = "ids[]")]
ids: Vec<i64>,
}
let plain: Plain = from_urlencoded(b"ids[]=1").unwrap();
assert!(plain.ids.is_empty());
let aliased: Aliased = from_urlencoded(b"ids[]=1&ids[]=2").unwrap();
assert_eq!(aliased.ids, vec![1, 2]);
let mixed = from_urlencoded::<Aliased>(b"ids=1&ids[]=2").unwrap_err();
assert_eq!(mixed.status(), http::StatusCode::BAD_REQUEST);
}
#[test]
fn bool_accepts_on() {
#[derive(serde::Deserialize)]
struct F {
#[serde(default)]
active: bool,
}
let form: F = from_urlencoded(b"active=on").unwrap();
assert!(form.active);
}
#[test]
fn form_file_accessors_and_debug_hide_the_bytes() {
let file = FormFile::new(
"avatar",
"me.png",
Some("image/png".to_owned()),
b"secret".to_vec(),
);
assert_eq!(file.field(), "avatar");
assert_eq!(file.file_name(), "me.png");
assert_eq!(file.content_type(), Some("image/png"));
assert_eq!(file.sniffed_content_type(), None);
assert_eq!(file.len(), 6);
assert!(!file.is_empty());
let debug = format!("{file:?}");
assert!(debug.contains("len: 6"), "{debug}");
assert!(!debug.contains("secret"), "{debug}");
assert_eq!(file.into_bytes(), b"secret");
}
#[test]
fn with_files_attaches_files_to_a_form() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let mut form = ChangesetForm::without_csrf(F {
name: "x".to_owned(),
})
.with_files(vec![FormFile::new("a", "a.txt", None, Vec::new())]);
assert!(form.file("a").is_some_and(FormFile::is_empty));
assert_eq!(form.take_files().len(), 1);
assert!(form.file("a").is_none());
}
#[test]
fn field_values_lists_every_element_of_a_sequence_field() {
#[derive(serde::Serialize)]
struct F {
tags: Vec<String>,
ids: Vec<i64>,
name: String,
}
let cs = Changeset::new(F {
tags: vec!["a".to_owned(), "b".to_owned()],
ids: vec![1, 2],
name: "x".to_owned(),
});
assert_eq!(cs.field_values("tags"), vec!["a", "b"]);
assert_eq!(cs.field_values("ids"), vec!["1", "2"]);
assert_eq!(cs.field_values("name"), vec!["x"]);
assert!(cs.field_values("missing").is_empty());
}
#[test]
fn field_values_of_a_none_field_is_empty() {
#[derive(serde::Serialize)]
struct G {
tags: Option<Vec<String>>,
}
assert!(
Changeset::new(G { tags: None })
.field_values("tags")
.is_empty()
);
}
proptest::proptest! {
#[test]
fn vec_of_strings_round_trips(
values in proptest::collection::vec(proptest::prelude::any::<String>(), 0..8)
) {
#[derive(serde::Deserialize)]
struct F {
#[serde(default)]
v: Vec<String>,
}
let body = url::form_urlencoded::Serializer::new(String::new())
.extend_pairs(values.iter().map(|value| ("v", value.as_str())))
.finish();
let form: F = from_urlencoded(body.as_bytes()).unwrap();
proptest::prop_assert_eq!(form.v, values);
}
}
}
#[test]
fn new_changeset_is_valid() {
let cs = Changeset::new(42_i32);
assert!(cs.is_valid());
}
#[test]
fn new_changeset_has_no_errors() {
let cs = Changeset::new("hello");
assert!(cs.errors().is_empty());
}
#[test]
fn new_changeset_into_inner() {
let cs = Changeset::new(99_u8);
assert_eq!(cs.into_inner(), 99);
}
#[test]
fn new_changeset_data_ref() {
let cs = Changeset::new(vec![1, 2, 3]);
assert_eq!(cs.data(), &vec![1, 2, 3]);
}
#[test]
fn from_errors_changeset_is_invalid() {
let mut errors = HashMap::new();
errors.insert("name".to_string(), vec!["too short".to_string()]);
let cs = Changeset::from_errors("data", errors);
assert!(!cs.is_valid());
}
#[test]
fn from_errors_returns_correct_field_errors() {
let mut errors = HashMap::new();
errors.insert("email".to_string(), vec!["invalid email".to_string()]);
let cs = Changeset::from_errors("data", errors);
assert_eq!(cs.errors_for("email"), &["invalid email"]);
}
#[test]
fn errors_for_unknown_field_returns_empty_slice() {
let cs = Changeset::new("data");
assert!(cs.errors_for("nonexistent").is_empty());
}
#[test]
fn from_errors_multiple_messages_per_field() {
let mut errors = HashMap::new();
errors.insert(
"password".to_string(),
vec!["too short".to_string(), "must contain a digit".to_string()],
);
let cs = Changeset::from_errors("data", errors);
let msgs = cs.errors_for("password");
assert_eq!(msgs.len(), 2);
assert!(msgs.contains(&"too short".to_string()));
assert!(msgs.contains(&"must contain a digit".to_string()));
}
#[test]
fn into_valid_returns_ok_when_valid() {
let cs = Changeset::new(42_i32);
assert_eq!(cs.into_valid().unwrap(), 42);
}
#[test]
fn into_valid_returns_err_when_invalid() {
let mut errors = HashMap::new();
errors.insert("x".to_string(), vec!["err".to_string()]);
let cs = Changeset::from_errors(42_i32, errors);
assert!(cs.into_valid().is_err());
}
#[test]
fn into_valid_err_preserves_changeset() {
let mut errors = HashMap::new();
errors.insert("name".to_string(), vec!["required".to_string()]);
let cs = Changeset::from_errors(7_i32, errors);
let err_cs = cs.into_valid().unwrap_err();
assert_eq!(err_cs.into_inner(), 7);
}
#[test]
fn field_value_returns_string_field() {
#[derive(serde::Serialize)]
struct Form {
name: String,
}
let cs = Changeset::new(Form {
name: "Alice".into(),
});
assert_eq!(cs.field_value("name"), Some("Alice".to_string()));
}
#[test]
fn field_value_returns_number_as_string() {
#[derive(serde::Serialize)]
struct Form {
age: u32,
}
let cs = Changeset::new(Form { age: 30 });
assert_eq!(cs.field_value("age"), Some("30".to_string()));
}
#[test]
fn field_value_returns_bool_as_string() {
#[derive(serde::Serialize)]
struct Form {
active: bool,
}
let cs = Changeset::new(Form { active: true });
assert_eq!(cs.field_value("active"), Some("true".to_string()));
}
#[test]
fn field_value_returns_none_for_missing_field() {
#[derive(serde::Serialize)]
struct Form {
name: String,
}
let cs = Changeset::new(Form {
name: "Alice".into(),
});
assert_eq!(cs.field_value("email"), None);
}
#[test]
fn field_value_after_errors_uses_submitted_data() {
#[derive(serde::Serialize)]
struct Form {
name: String,
}
let mut errors = HashMap::new();
errors.insert("name".to_string(), vec!["too short".to_string()]);
let cs = Changeset::from_errors(Form { name: "ab".into() }, errors);
assert_eq!(cs.field_value("name"), Some("ab".to_string()));
}
#[test]
fn into_changeset_valid_input_produces_no_errors() {
#[derive(validator::Validate)]
struct F {
#[validate(length(min = 3))]
name: String,
}
let cs = F {
name: "Alice".into(),
}
.into_changeset();
assert!(cs.is_valid());
assert!(cs.errors_for("name").is_empty());
}
#[test]
fn into_changeset_invalid_input_populates_errors() {
#[derive(validator::Validate)]
struct F {
#[validate(length(min = 5))]
name: String,
}
let cs = F { name: "ab".into() }.into_changeset();
assert!(!cs.is_valid());
assert!(!cs.errors_for("name").is_empty());
}
#[test]
fn into_changeset_with_resolves_a_translated_message_for_an_unmessaged_code() {
#[derive(validator::Validate)]
struct F {
#[validate(email)]
email: String,
}
let cs = F {
email: "not-an-email".into(),
}
.into_changeset_with(|field, code| {
if field == "email" && code == "email" {
Some("not a valid address".to_string())
} else {
None
}
});
assert!(!cs.is_valid());
assert_eq!(cs.errors_for("email"), ["not a valid address".to_string()]);
}
#[test]
fn into_changeset_with_falls_back_to_the_default_message_when_resolve_returns_none() {
#[derive(validator::Validate)]
struct F {
#[validate(email)]
email: String,
}
let without_resolver = F {
email: "not-an-email".into(),
}
.into_changeset();
let with_noop_resolver = F {
email: "not-an-email".into(),
}
.into_changeset_with(|_, _| None);
assert_eq!(
with_noop_resolver.errors_for("email"),
without_resolver.errors_for("email")
);
}
#[test]
fn into_changeset_with_never_overrides_an_explicit_message() {
#[derive(validator::Validate)]
struct F {
#[validate(length(min = 5, message = "too short"))]
name: String,
}
let cs = F { name: "ab".into() }.into_changeset_with(|_, _| {
Some("the resolver's message, which must never win".to_string())
});
assert_eq!(cs.errors_for("name"), ["too short".to_string()]);
}
#[test]
fn into_changeset_preserves_data_on_failure() {
#[derive(validator::Validate)]
struct F {
#[validate(length(min = 5))]
name: String,
}
let cs = F { name: "ab".into() }.into_changeset();
assert_eq!(cs.data().name, "ab");
}
#[test]
fn into_changeset_multiple_fields_errors() {
#[derive(validator::Validate)]
struct F {
#[validate(length(min = 3))]
name: String,
#[validate(email)]
email: String,
}
let cs = F {
name: "a".into(),
email: "not-email".into(),
}
.into_changeset();
assert!(!cs.is_valid());
assert!(!cs.errors_for("name").is_empty());
assert!(!cs.errors_for("email").is_empty());
}
mod nested_validation {
use super::*;
use validator::Validate as _;
#[derive(validator::Validate)]
struct NestedAddress {
#[validate(length(min = 3, message = "street too short"))]
street: String,
}
#[derive(validator::Validate)]
struct PersonWithAddress {
#[validate(nested)]
address: NestedAddress,
}
#[test]
fn nested_struct_errors_are_flattened_with_dot_notation() {
let cs = PersonWithAddress {
address: NestedAddress { street: "x".into() },
}
.into_changeset();
assert!(!cs.is_valid());
assert!(!cs.errors_for("address.street").is_empty());
}
#[derive(validator::Validate)]
struct NestedContact {
#[validate(email)]
email: String,
}
#[derive(validator::Validate)]
struct PersonWithContact {
#[validate(nested)]
contact: NestedContact,
}
#[test]
fn into_changeset_with_resolver_sees_the_dotted_nested_key() {
use std::cell::RefCell;
let seen_keys: RefCell<Vec<String>> = RefCell::new(Vec::new());
let cs = PersonWithContact {
contact: NestedContact {
email: "not-an-email".into(),
},
}
.into_changeset_with(|field, code| {
seen_keys.borrow_mut().push(field.to_string());
(field == "contact.email" && code == "email")
.then(|| "not a valid address".to_string())
});
assert!(!cs.is_valid());
assert_eq!(
cs.errors_for("contact.email"),
["not a valid address".to_string()]
);
assert_eq!(seen_keys.into_inner(), vec!["contact.email".to_string()]);
}
}
#[test]
fn changeset_form_blank_is_valid() {
#[derive(validator::Validate, serde::Serialize)]
struct F {
#[validate(length(min = 1))]
name: String,
}
let form = ChangesetForm::blank(F { name: "ok".into() }, "tok");
assert!(form.is_valid()); assert_eq!(form.csrf_token(), Some("tok"));
}
#[test]
fn changeset_form_deref_exposes_changeset_methods() {
#[derive(validator::Validate)]
struct F {
#[validate(length(min = 3))]
name: String,
}
let changeset = F { name: "ab".into() }.into_changeset();
let form = ChangesetForm {
changeset,
csrf_token: None,
csrf_field: "_csrf".into(),
files: None,
};
assert!(!form.is_valid());
assert!(!form.errors_for("name").is_empty());
}
#[test]
fn changeset_form_into_valid_ok() {
#[derive(validator::Validate)]
struct F {
#[validate(length(min = 1))]
name: String,
}
let form = ChangesetForm {
changeset: F { name: "ok".into() }.into_changeset(),
csrf_token: None,
csrf_field: "_csrf".into(),
files: None,
};
assert!(form.into_valid().is_ok());
}
#[test]
fn changeset_form_into_valid_err_preserves_csrf() {
#[derive(Debug, validator::Validate)]
struct F {
#[validate(length(min = 5))]
name: String,
}
let form = ChangesetForm {
changeset: F { name: "ab".into() }.into_changeset(),
csrf_token: Some("tok123".into()),
csrf_field: "_csrf".into(),
files: None,
};
let err_form = form.into_valid().unwrap_err();
assert_eq!(err_form.csrf_token(), Some("tok123"));
}
#[cfg(feature = "maud")]
#[test]
fn form_tag_renders_action_and_method() {
let html = form_tag("/users", "post", None, maud::html! { "" }).into_string();
assert!(html.contains(r#"action="/users""#), "{html}");
assert!(html.contains(r#"method="post""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_tag_emits_csrf_hidden_input_when_token_provided() {
let html = form_tag("/users", "post", Some("tok123"), maud::html! { "" }).into_string();
assert!(html.contains(r#"name="_csrf""#), "{html}");
assert!(html.contains(r#"value="tok123""#), "{html}");
assert!(html.contains(r#"type="hidden""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_tag_omits_csrf_input_when_none() {
let html = form_tag("/users", "post", None, maud::html! { "" }).into_string();
assert!(!html.contains("_csrf"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_tag_includes_content() {
let html = form_tag("/x", "post", None, maud::html! { span { "inner" } }).into_string();
assert!(html.contains("inner"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_tag_emits_method_override_for_delete() {
let html = form_tag("/posts/42", "delete", None, maud::html! { "" }).into_string();
assert!(html.contains(r#"method="post""#), "{html}");
assert!(!html.contains(r#"method="delete""#), "{html}");
assert!(html.contains(r#"name="_method""#), "{html}");
assert!(html.contains(r#"value="DELETE""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_tag_emits_method_override_for_put_and_patch() {
let put_html = form_tag("/p/1", "put", None, maud::html! { "" }).into_string();
assert!(put_html.contains(r#"method="post""#));
assert!(put_html.contains(r#"value="PUT""#));
let patch_html = form_tag("/p/1", "PATCH", None, maud::html! { "" }).into_string();
assert!(patch_html.contains(r#"method="post""#));
assert!(patch_html.contains(r#"value="PATCH""#));
}
#[cfg(feature = "maud")]
#[test]
fn form_tag_no_override_for_get_or_post() {
let get_html = form_tag("/p", "get", None, maud::html! { "" }).into_string();
assert!(!get_html.contains("_method"), "{get_html}");
let post_html = form_tag("/p", "post", None, maud::html! { "" }).into_string();
assert!(!post_html.contains("_method"), "{post_html}");
}
#[cfg(feature = "maud")]
#[test]
fn method_input_emits_hidden_field_for_mutating_methods() {
for method in ["PUT", "PATCH", "DELETE", "delete"] {
let html = method_input(method).into_string();
assert!(html.contains(r#"name="_method""#), "{html}");
assert!(html.contains(r#"type="hidden""#), "{html}");
}
}
#[cfg(feature = "maud")]
#[test]
fn method_input_is_empty_for_safe_or_unknown_methods() {
assert_eq!(method_input("GET").into_string(), "");
assert_eq!(method_input("POST").into_string(), "");
assert_eq!(method_input("BREW").into_string(), "");
}
#[cfg(feature = "maud")]
#[test]
fn changeset_form_form_tag_injects_stored_csrf() {
#[derive(validator::Validate, serde::Serialize)]
struct F {
name: String,
}
let form = ChangesetForm::blank(
F {
name: String::new(),
},
"secret-token",
);
let html = form
.form_tag("/x", "post", maud::html! { "" })
.into_string();
assert!(html.contains(r#"value="secret-token""#), "{html}");
assert!(html.contains(r#"name="_csrf""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn changeset_form_form_tag_honours_custom_csrf_field_name() {
#[derive(validator::Validate, serde::Serialize)]
struct F {
name: String,
}
let form = ChangesetForm {
changeset: Changeset::new(F {
name: String::new(),
}),
csrf_token: Some("tok".into()),
csrf_field: "authenticity_token".into(),
files: None,
};
let html = form
.form_tag("/x", "post", maud::html! { "" })
.into_string();
assert!(html.contains(r#"name="authenticity_token""#), "{html}");
assert!(!html.contains(r#"name="_csrf""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn text_input_renders_label_name_and_value() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let cs = Changeset::new(F {
name: "Alice".into(),
});
let html = text_input(&cs, "name", "Full Name").into_string();
assert!(html.contains(r#"name="name""#), "{html}");
assert!(html.contains(r#"value="Alice""#), "{html}");
assert!(html.contains("Full Name"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn text_input_aria_invalid_false_when_no_errors() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let cs = Changeset::new(F {
name: "Alice".into(),
});
let html = text_input(&cs, "name", "Name").into_string();
assert!(html.contains(r#"aria-invalid="false""#), "{html}");
assert!(!html.contains(r#"role="alert""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn text_input_aria_invalid_true_and_error_block_on_failure() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let mut errors = HashMap::new();
errors.insert("name".to_string(), vec!["too short".to_string()]);
let cs = Changeset::from_errors(F { name: "ab".into() }, errors);
let html = text_input(&cs, "name", "Name").into_string();
assert!(html.contains(r#"aria-invalid="true""#), "{html}");
assert!(html.contains(r#"role="alert""#), "{html}");
assert!(html.contains("too short"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn text_input_error_block_has_describedby_link() {
#[derive(serde::Serialize)]
struct F {
email: String,
}
let mut errors = HashMap::new();
errors.insert("email".to_string(), vec!["invalid".to_string()]);
let cs = Changeset::from_errors(F { email: "x".into() }, errors);
let html = text_input(&cs, "email", "Email").into_string();
assert!(html.contains("email-error"), "{html}");
assert!(html.contains(r#"aria-describedby="email-error""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn text_input_multiple_errors_all_rendered() {
#[derive(serde::Serialize)]
struct F {
password: String,
}
let mut errors = HashMap::new();
errors.insert(
"password".to_string(),
vec!["too short".to_string(), "needs digit".to_string()],
);
let cs = Changeset::from_errors(
F {
password: "x".into(),
},
errors,
);
let html = text_input(&cs, "password", "Password").into_string();
assert!(html.contains("too short"), "{html}");
assert!(html.contains("needs digit"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn submit_button_renders_button_with_label() {
let html = submit_button("Save").into_string();
assert!(html.contains(r#"type="submit""#), "{html}");
assert!(html.contains("Save"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn password_input_renders_type_password() {
#[derive(serde::Serialize)]
struct F {
password: String,
}
let cs = Changeset::new(F {
password: String::new(),
});
let html = password_input(&cs, "password", "Password").into_string();
assert!(html.contains(r#"type="password""#), "{html}");
assert!(html.contains(r#"name="password""#), "{html}");
assert!(html.contains("Password"), "{html}");
assert!(!html.contains(r#"value=""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn password_input_emits_aria_invalid_on_error() {
#[derive(serde::Serialize)]
struct F {
password: String,
}
let mut errors = HashMap::new();
errors.insert("password".to_string(), vec!["too short".to_string()]);
let cs = Changeset::from_errors(
F {
password: "x".into(),
},
errors,
);
let html = password_input(&cs, "password", "Password").into_string();
assert!(html.contains(r#"aria-invalid="true""#), "{html}");
assert!(html.contains(r#"role="alert""#), "{html}");
assert!(html.contains("too short"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn textarea_input_renders_textarea_element() {
#[derive(serde::Serialize)]
struct F {
bio: String,
}
let cs = Changeset::new(F {
bio: "Hello world".into(),
});
let html = textarea_input(&cs, "bio", "Bio").into_string();
assert!(html.contains("<textarea"), "{html}");
assert!(html.contains(r#"name="bio""#), "{html}");
assert!(html.contains(r#"id="bio""#), "{html}");
assert!(html.contains("Bio"), "{html}");
assert!(html.contains("Hello world"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn auto_grow_marks_a_textarea_and_no_other_control() {
#[derive(serde::Serialize)]
struct F {
bio: String,
name: String,
}
let cs = Changeset::new(F {
bio: "Hi".into(),
name: "Ada".into(),
});
let grow = FieldOptions::new().auto_grow();
assert!(grow.is_auto_grow());
assert!(!FieldOptions::new().is_auto_grow());
let html = textarea_input_with(&cs, "bio", "Bio", &grow).into_string();
assert!(
html.contains(r#"class="autumn-field__input autumn-auto-grow" aria-invalid="false""#),
"{html}"
);
let plain = textarea_input(&cs, "bio", "Bio").into_string();
assert!(!plain.contains("autumn-auto-grow"), "{plain}");
let text = text_input_with(&cs, "name", "Name", &grow).into_string();
assert!(!text.contains("autumn-auto-grow"), "{text}");
}
#[cfg(feature = "maud")]
#[test]
fn auto_grow_keeps_the_invalid_class() {
#[derive(serde::Serialize)]
struct F {
bio: String,
}
let mut cs = Changeset::new(F { bio: String::new() });
cs.add_error("bio", "Too short");
let html =
textarea_input_with(&cs, "bio", "Bio", &FieldOptions::new().auto_grow()).into_string();
assert!(
html.contains(
r#"class="autumn-field__input autumn-field__input--invalid autumn-auto-grow" aria-invalid="true""#
),
"{html}"
);
}
#[cfg(feature = "maud")]
#[test]
fn auto_grow_reaches_the_rich_text_editor() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: "# Hi".into(),
});
let html = rich_text_area_with(
&cs,
"body",
"Body",
&RichTextLabels::new(),
&FieldOptions::new().auto_grow(),
)
.into_string();
assert!(
html.contains(
r#"class="autumn-field__input autumn-rich-text__editor autumn-auto-grow""#
),
"{html}"
);
}
#[cfg(feature = "maud")]
#[test]
fn textarea_input_aria_invalid_on_error() {
#[derive(serde::Serialize)]
struct F {
bio: String,
}
let mut errors = HashMap::new();
errors.insert("bio".to_string(), vec!["required".to_string()]);
let cs = Changeset::from_errors(F { bio: String::new() }, errors);
let html = textarea_input(&cs, "bio", "Bio").into_string();
assert!(html.contains(r#"aria-invalid="true""#), "{html}");
assert!(html.contains(r#"role="alert""#), "{html}");
assert!(html.contains("required"), "{html}");
}
#[test]
fn strict_form_decode_fails_on_a_partially_filled_form() {
#[derive(serde::Deserialize)]
struct PostForm {
#[allow(dead_code)]
body: String,
#[allow(dead_code)]
count: i64,
}
let partial = b"body=hello+%2A%2Aworld%2A%2A&count=";
assert!(
serde_urlencoded::from_bytes::<PostForm>(partial).is_err(),
"whole-form decode must fail here — that is the bug being fixed"
);
assert_eq!(
field_from_urlencoded(partial, "body").as_deref(),
Some("hello **world**")
);
}
#[test]
fn field_from_urlencoded_decodes_percent_and_plus_escapes() {
let body = b"title=hi&body=a+%2B+b+%26+%3Cc%3E&other=x";
assert_eq!(
field_from_urlencoded(body, "body").as_deref(),
Some("a + b & <c>")
);
assert_eq!(field_from_urlencoded(body, "title").as_deref(), Some("hi"));
assert_eq!(field_from_urlencoded(body, "absent"), None);
}
#[test]
fn field_from_urlencoded_handles_empty_and_repeated_keys() {
assert_eq!(
field_from_urlencoded(b"body=&x=1", "body").as_deref(),
Some("")
);
assert_eq!(
field_from_urlencoded(b"body=first&body=second", "body").as_deref(),
Some("first")
);
assert_eq!(field_from_urlencoded(b"", "body"), None);
assert_eq!(field_from_urlencoded(b"body_extra=no", "body"), None);
}
#[cfg(feature = "maud")]
#[test]
fn rich_text_area_with_labels_overrides_the_hint() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: String::new(),
});
let labels = RichTextLabels::new().hint("Se admite Markdown.");
let html = rich_text_area_with_labels(&cs, "body", "Body", &labels).into_string();
assert!(html.contains("Se admite Markdown."), "{html}");
assert!(!html.contains("Markdown supported"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn rich_text_area_with_labels_overrides_the_toolbar_group_label() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: String::new(),
});
let labels = RichTextLabels::new().toolbar_group("Formato Markdown");
let html = rich_text_area_with_labels(&cs, "body", "Body", &labels).into_string();
assert!(html.contains(r#"aria-label="Formato Markdown""#), "{html}");
assert!(!html.contains("Markdown formatting"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn rich_text_area_with_labels_overrides_the_controls() {
const CUSTOM_CONTROLS: &[(&str, &str)] = &[("Negrita", "**negrita**")];
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: String::new(),
});
let labels = RichTextLabels::new().controls(CUSTOM_CONTROLS);
let html = rich_text_area_with_labels(&cs, "body", "Body", &labels).into_string();
assert!(html.contains("Negrita"), "{html}");
assert!(html.contains("**negrita**"), "{html}");
assert!(!html.contains(">Bold<"), "{html}");
assert!(!html.contains("**bold**"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn rich_text_area_htmx_with_labels_overrides_the_preview_heading() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: String::new(),
});
let labels = RichTextLabels::new().preview_heading("Vista previa");
let html =
rich_text_area_htmx_with_labels(&cs, "body", "Body", "/p", &labels).into_string();
assert!(html.contains("Vista previa"), "{html}");
assert!(!html.contains(">Preview<"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn rich_text_area_htmx_with_token_field_with_labels_overrides_the_preview_heading() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: String::new(),
});
let labels = RichTextLabels::new().preview_heading("Vista previa");
let html = rich_text_area_htmx_with_token_field_with_labels(
&cs,
"body",
"Body",
"/p",
"_one_time",
&labels,
)
.into_string();
assert!(html.contains("Vista previa"), "{html}");
assert!(html.contains(r#"hx-params="not _one_time""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn required_rich_text_area_with_labels_keeps_the_required_signal_and_labels() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: String::new(),
});
let labels = RichTextLabels::new().hint("Se admite Markdown.");
let html = required_rich_text_area_with_labels(&cs, "body", "Body", &labels).into_string();
assert!(html.contains(r#"aria-required="true""#), "{html}");
assert!(html.contains("Se admite Markdown."), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn required_rich_text_area_htmx_with_labels_keeps_the_required_signal_and_labels() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: String::new(),
});
let labels = RichTextLabels::new().preview_heading("Vista previa");
let html = required_rich_text_area_htmx_with_labels(&cs, "body", "Body", "/p", &labels)
.into_string();
assert!(html.contains(r#"aria-required="true""#), "{html}");
assert!(html.contains("Vista previa"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn required_rich_text_area_htmx_with_token_field_with_labels_keeps_the_required_signal_and_labels()
{
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: String::new(),
});
let labels = RichTextLabels::new().preview_heading("Vista previa");
let html = required_rich_text_area_htmx_with_token_field_with_labels(
&cs,
"body",
"Body",
"/p",
"_one_time",
&labels,
)
.into_string();
assert!(html.contains(r#"aria-required="true""#), "{html}");
assert!(html.contains("Vista previa"), "{html}");
assert!(html.contains(r#"hx-params="not _one_time""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn rich_text_area_with_labels_default_labels_match_rich_text_area() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: "Hello **world**".into(),
});
assert_eq!(
rich_text_area_with_labels(&cs, "body", "Body", &RichTextLabels::new()).into_string(),
rich_text_area(&cs, "body", "Body").into_string()
);
}
#[cfg(feature = "maud")]
#[test]
fn rich_text_area_htmx_with_labels_default_labels_match_rich_text_area_htmx() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F { body: "hi".into() });
assert_eq!(
rich_text_area_htmx_with_labels(&cs, "body", "Body", "/p", &RichTextLabels::new())
.into_string(),
rich_text_area_htmx(&cs, "body", "Body", "/p").into_string()
);
}
#[cfg(feature = "maud")]
#[test]
fn changeset_form_exposes_the_rich_text_labels_helpers() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let form = ChangesetForm {
changeset: Changeset::new(F { body: "hi".into() }),
csrf_token: None,
csrf_field: "_csrf".to_owned(),
files: None,
};
let labels = RichTextLabels::new().hint("Se admite Markdown.");
assert_eq!(
form.rich_text_area_with_labels("body", "Body", &labels)
.into_string(),
rich_text_area_with_labels(&form.changeset, "body", "Body", &labels).into_string()
);
assert_eq!(
form.rich_text_area_htmx_with_labels("body", "Body", "/p", &labels)
.into_string(),
rich_text_area_htmx_with_labels(&form.changeset, "body", "Body", "/p", &labels)
.into_string()
);
assert_eq!(
form.rich_text_area_htmx_with_token_field_with_labels(
"body",
"Body",
"/p",
"_one_time",
&labels
)
.into_string(),
rich_text_area_htmx_with_token_field_with_labels(
&form.changeset,
"body",
"Body",
"/p",
"_one_time",
&labels
)
.into_string()
);
}
#[cfg(feature = "maud")]
#[test]
fn required_rich_text_area_signals_a_required_field() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: String::new(),
});
let html = required_rich_text_area(&cs, "body", "Body").into_string();
assert!(html.contains("required"), "{html}");
assert!(html.contains(r#"aria-required="true""#), "{html}");
assert!(html.contains("<textarea"), "{html}");
assert!(html.contains("autumn-rich-text"), "{html}");
let optional = rich_text_area(&cs, "body", "Body").into_string();
assert!(!optional.contains(r#"aria-required="true""#), "{optional}");
}
#[cfg(feature = "maud")]
#[test]
fn required_rich_text_area_htmx_keeps_both_the_preview_and_the_signal() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: String::new(),
});
let html =
required_rich_text_area_htmx(&cs, "body", "Body", "/posts/preview/body").into_string();
assert!(html.contains(r#"aria-required="true""#), "{html}");
assert!(html.contains(r#"hx-post="/posts/preview/body""#), "{html}");
assert!(html.contains(r##"hx-target="#body-preview""##), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn rich_text_area_renders_a_labeled_markdown_editor() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: "Hello **world**".into(),
});
let html = rich_text_area(&cs, "body", "Body").into_string();
assert!(html.contains("<textarea"), "{html}");
assert!(html.contains(r#"name="body""#), "{html}");
assert!(html.contains(r#"id="body""#), "{html}");
assert!(html.contains(r#"<label for="body""#), "{html}");
assert!(html.contains("Body"), "{html}");
assert!(html.contains("Hello **world**"), "{html}");
assert!(html.to_lowercase().contains("markdown"), "{html}");
assert!(html.contains("autumn-rich-text"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn rich_text_area_renders_a_minimal_markdown_toolbar() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: String::new(),
});
let html = rich_text_area(&cs, "body", "Body").into_string();
assert!(html.contains(r#"role="group""#), "{html}");
assert!(html.contains("autumn-rich-text__toolbar"), "{html}");
assert!(
html.to_lowercase()
.contains(r#"aria-label="markdown formatting"#),
"{html}"
);
for token in [
"**bold**",
"_italic_",
"[text](url)",
"- item",
"# Heading",
"> quote",
] {
assert!(
html.contains(&maud::html! { (token) }.into_string()),
"toolbar is missing the {token:?} affordance:\n{html}"
);
}
assert!(!html.contains("<script"), "{html}");
assert!(!html.contains("<button"), "{html}");
assert!(!html.contains("onclick"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn rich_text_area_escapes_the_editor_value() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: "</textarea><script>alert(1)</script>".into(),
});
let html = rich_text_area(&cs, "body", "Body").into_string();
assert!(!html.contains("<script"), "{html}");
assert!(!html.contains("</textarea><script"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn rich_text_area_describes_itself_with_hint_and_errors() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: String::new(),
});
let ok = rich_text_area(&cs, "body", "Body").into_string();
assert!(ok.contains(r#"aria-describedby="body-hint""#), "{ok}");
assert!(ok.contains(r#"aria-invalid="false""#), "{ok}");
let mut errors = HashMap::new();
errors.insert("body".to_string(), vec!["must not be blank".to_string()]);
let cs = Changeset::from_errors(
F {
body: String::new(),
},
errors,
);
let bad = rich_text_area(&cs, "body", "Body").into_string();
assert!(
bad.contains(r#"aria-describedby="body-hint body-error""#),
"{bad}"
);
assert!(bad.contains(r#"aria-invalid="true""#), "{bad}");
assert!(bad.contains(r#"role="alert""#), "{bad}");
assert!(bad.contains("must not be blank"), "{bad}");
}
#[cfg(feature = "maud")]
#[test]
fn rich_text_area_htmx_wires_a_live_preview_with_no_javascript() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F { body: "hi".into() });
let html = rich_text_area_htmx(&cs, "body", "Body", "/posts/preview/body").into_string();
assert!(html.contains(r#"hx-post="/posts/preview/body""#), "{html}");
assert!(html.contains(r##"hx-target="#body-preview""##), "{html}");
assert!(html.contains(r#"hx-swap="innerHTML""#), "{html}");
assert!(html.contains("hx-trigger="), "{html}");
assert!(html.contains(r#"hx-params="not _submit_token""#), "{html}");
assert!(html.contains(r#"id="body-preview""#), "{html}");
assert!(!html.contains("aria-live"), "{html}");
assert!(html.contains(r#"role="region""#), "{html}");
assert!(html.contains("aria-labelledby="), "{html}");
assert!(!html.contains("<script"), "{html}");
assert!(!html.contains("onkeyup"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn rich_text_area_htmx_with_token_field_filters_the_configured_field() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: String::new(),
});
let html = rich_text_area_htmx_with_token_field(
&cs,
"body",
"Body",
"/posts/preview/body",
"_one_time",
)
.into_string();
assert!(html.contains(r#"hx-params="not _one_time""#), "{html}");
assert!(!html.contains("_submit_token"), "{html}");
}
#[cfg(all(feature = "maud", feature = "markdown"))]
#[test]
fn rich_text_area_htmx_preview_pane_is_prerendered_and_sanitized() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let cs = Changeset::new(F {
body: "**bold** [x](javascript:alert(1))".into(),
});
let html = rich_text_area_htmx(&cs, "body", "Body", "/posts/preview/body").into_string();
assert!(html.contains("<strong>bold</strong>"), "{html}");
let pane = html
.split_once(r#"id="body-preview""#)
.expect("preview pane present")
.1;
assert!(!pane.to_ascii_lowercase().contains("javascript:"), "{pane}");
assert!(!pane.contains("href="), "{pane}");
assert!(
html.contains("[x](javascript:alert(1))</textarea>"),
"{html}"
);
}
#[cfg(feature = "maud")]
#[test]
fn changeset_form_exposes_the_rich_text_helpers() {
#[derive(serde::Serialize)]
struct F {
body: String,
}
let form = ChangesetForm {
changeset: Changeset::new(F { body: "hi".into() }),
csrf_token: None,
csrf_field: "_csrf".to_owned(),
files: None,
};
assert_eq!(
form.rich_text_area("body", "Body").into_string(),
rich_text_area(&form.changeset, "body", "Body").into_string()
);
assert_eq!(
form.rich_text_area_htmx("body", "Body", "/p").into_string(),
rich_text_area_htmx(&form.changeset, "body", "Body", "/p").into_string()
);
}
#[cfg(feature = "maud")]
#[test]
fn required_text_input_emits_aria_required() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let cs = Changeset::new(F {
name: "Alice".into(),
});
let html = required_text_input(&cs, "name", "Name").into_string();
assert!(html.contains(r#"aria-required="true""#), "{html}");
assert!(html.contains("required"), "{html}");
assert!(html.contains(r#"name="name""#), "{html}");
assert!(html.contains("Name"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn required_text_input_preserves_error_handling() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let mut errors = HashMap::new();
errors.insert("name".to_string(), vec!["required".to_string()]);
let cs = Changeset::from_errors(
F {
name: String::new(),
},
errors,
);
let html = required_text_input(&cs, "name", "Name").into_string();
assert!(html.contains(r#"aria-invalid="true""#), "{html}");
assert!(html.contains(r#"aria-required="true""#), "{html}");
assert!(html.contains(r#"role="alert""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn aria_live_region_renders_role_status() {
let html = aria_live_region("status-msg", "").into_string();
assert!(html.contains(r#"role="status""#), "{html}");
assert!(html.contains(r#"aria-live="polite""#), "{html}");
assert!(html.contains(r#"id="status-msg""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn aria_live_region_renders_message_content() {
let html = aria_live_region("status-msg", "Form submitted").into_string();
assert!(html.contains("Form submitted"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn skip_link_renders_anchor_with_href() {
let html = skip_link("#main-content", "Skip to main content").into_string();
assert!(html.contains(r##"href="#main-content""##), "{html}");
assert!(html.contains("Skip to main content"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn skip_link_has_visually_hidden_class_for_focus_reveal() {
let html = skip_link("#main", "Skip").into_string();
assert!(html.contains("skip-link"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn text_input_wrapper_div_has_stable_id() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let cs = Changeset::new(F {
name: "Alice".into(),
});
let html = text_input(&cs, "name", "Name").into_string();
assert!(html.contains(r#"id="name-field""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn password_input_wrapper_div_has_stable_id() {
#[derive(serde::Serialize)]
struct F {
password: String,
}
let cs = Changeset::new(F {
password: String::new(),
});
let html = password_input(&cs, "password", "Password").into_string();
assert!(html.contains(r#"id="password-field""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn textarea_input_wrapper_div_has_stable_id() {
#[derive(serde::Serialize)]
struct F {
bio: String,
}
let cs = Changeset::new(F {
bio: "Hello".into(),
});
let html = textarea_input(&cs, "bio", "Bio").into_string();
assert!(html.contains(r#"id="bio-field""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn required_text_input_wrapper_div_has_stable_id() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let cs = Changeset::new(F {
name: "Alice".into(),
});
let html = required_text_input(&cs, "name", "Name").into_string();
assert!(html.contains(r#"id="name-field""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn required_number_input_emits_aria_required() {
#[derive(serde::Serialize)]
struct F {
age: i32,
}
let cs = Changeset::new(F { age: 30 });
let html = required_number_input(&cs, "age", "Age", Some("1")).into_string();
assert!(html.contains(r#"aria-required="true""#), "{html}");
assert!(html.contains("required"), "{html}");
assert!(html.contains(r#"type="number""#), "{html}");
assert!(html.contains(r#"name="age""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn required_number_input_preserves_error_handling() {
#[derive(serde::Serialize)]
struct F {
age: i32,
}
let mut errors = HashMap::new();
errors.insert("age".to_string(), vec!["must be positive".to_string()]);
let cs = Changeset::from_errors(F { age: -1 }, errors);
let html = required_number_input(&cs, "age", "Age", Some("1")).into_string();
assert!(html.contains(r#"aria-invalid="true""#), "{html}");
assert!(html.contains(r#"role="alert""#), "{html}");
assert!(html.contains("must be positive"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn required_datetime_input_emits_aria_required() {
#[derive(serde::Serialize)]
struct F {
scheduled_at: String,
}
let cs = Changeset::new(F {
scheduled_at: "2026-01-01T12:00:00".into(),
});
let html = required_datetime_input(&cs, "scheduled_at", "Scheduled at").into_string();
assert!(html.contains(r#"aria-required="true""#), "{html}");
assert!(html.contains("required"), "{html}");
assert!(html.contains(r#"type="datetime-local""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn required_select_input_emits_aria_required() {
#[derive(serde::Serialize)]
struct F {
status: String,
}
let cs = Changeset::new(F {
status: "draft".into(),
});
let html = required_select_input(
&cs,
"status",
"Status",
&[("draft", "Draft"), ("published", "Published")],
)
.into_string();
assert!(html.contains(r#"aria-required="true""#), "{html}");
assert!(html.contains("required"), "{html}");
assert!(html.contains("<select"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn required_select_input_preserves_error_handling() {
#[derive(serde::Serialize)]
struct F {
status: String,
}
let mut errors = HashMap::new();
errors.insert("status".to_string(), vec!["is required".to_string()]);
let cs = Changeset::from_errors(
F {
status: String::new(),
},
errors,
);
let html =
required_select_input(&cs, "status", "Status", &[("draft", "Draft")]).into_string();
assert!(html.contains(r#"aria-invalid="true""#), "{html}");
assert!(html.contains(r#"role="alert""#), "{html}");
assert!(html.contains("is required"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn required_text_input_htmx_emits_aria_required() {
#[derive(serde::Serialize)]
struct F {
title: String,
}
let cs = Changeset::new(F {
title: "Hello".into(),
});
let html =
required_text_input_htmx(&cs, "title", "Title", "/posts/validate/title").into_string();
assert!(html.contains(r#"aria-required="true""#), "{html}");
assert!(html.contains("required"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn required_text_input_htmx_preserves_htmx_attributes() {
#[derive(serde::Serialize)]
struct F {
title: String,
}
let cs = Changeset::new(F {
title: String::new(),
});
let html =
required_text_input_htmx(&cs, "title", "Title", "/posts/validate/title").into_string();
assert!(
html.contains(r#"hx-post="/posts/validate/title""#),
"{html}"
);
assert!(html.contains(r#"hx-trigger="change""#), "{html}");
assert!(
html.contains(r#"hx-target="closest [data-autumn-field-wrapper]""#),
"{html}"
);
assert!(html.contains(r#"hx-swap="outerHTML""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn required_text_input_htmx_preserves_error_handling() {
#[derive(serde::Serialize)]
struct F {
title: String,
}
let mut errors = HashMap::new();
errors.insert("title".to_string(), vec!["is required".to_string()]);
let cs = Changeset::from_errors(
F {
title: String::new(),
},
errors,
);
let html =
required_text_input_htmx(&cs, "title", "Title", "/posts/validate/title").into_string();
assert!(html.contains(r#"aria-invalid="true""#), "{html}");
assert!(html.contains(r#"role="alert""#), "{html}");
assert!(html.contains("is required"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn text_input_htmx_wrapper_has_stable_id() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let cs = Changeset::new(F {
name: "Alice".into(),
});
let html = text_input_htmx(&cs, "name", "Name", "/validate/name").into_string();
assert!(html.contains(r#"id="name-field""#), "{html}");
assert!(
html.contains(r#"data-autumn-field-wrapper="name""#),
"{html}"
);
}
#[cfg(feature = "maud")]
#[test]
fn text_input_htmx_renders_hx_post() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let cs = Changeset::new(F {
name: "Alice".into(),
});
let html = text_input_htmx(&cs, "name", "Name", "/validate/name").into_string();
assert!(html.contains(r#"hx-post="/validate/name""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn text_input_htmx_renders_hx_trigger_change() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let cs = Changeset::new(F {
name: String::new(),
});
let html = text_input_htmx(&cs, "name", "Name", "/validate/name").into_string();
assert!(html.contains(r#"hx-trigger="change""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn text_input_htmx_renders_hx_target_and_swap() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let cs = Changeset::new(F {
name: String::new(),
});
let html = text_input_htmx(&cs, "name", "Name", "/validate/name").into_string();
assert!(
html.contains(r#"hx-target="closest [data-autumn-field-wrapper]""#),
"{html}"
);
assert!(html.contains(r#"hx-swap="outerHTML""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn text_input_htmx_target_is_safe_for_nested_field_names() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let cs = Changeset::new(F {
name: String::new(),
});
let html =
text_input_htmx(&cs, "address.street", "Street", "/validate/street").into_string();
assert!(html.contains(r#"id="address.street-field""#), "{html}");
assert!(
html.contains(r#"hx-target="closest [data-autumn-field-wrapper]""#),
"{html}"
);
assert!(
!html.contains("hx-target=\"#address.street-field\""),
"{html}"
);
}
#[cfg(feature = "maud")]
#[test]
fn text_input_htmx_includes_all_form_fields() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let cs = Changeset::new(F {
name: String::new(),
});
let html = text_input_htmx(&cs, "name", "Name", "/validate/name").into_string();
assert!(html.contains(r#"hx-include="closest form""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn text_input_htmx_drops_submit_token_param() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let cs = Changeset::new(F {
name: String::new(),
});
let plain = text_input_htmx(&cs, "name", "Name", "/validate/name").into_string();
assert!(
plain.contains(r#"hx-params="not _submit_token""#),
"{plain}"
);
let required =
required_text_input_htmx(&cs, "name", "Name", "/validate/name").into_string();
assert!(
required.contains(r#"hx-params="not _submit_token""#),
"{required}"
);
}
#[cfg(feature = "maud")]
#[test]
fn text_input_htmx_with_token_field_filters_custom_field() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let cs = Changeset::new(F {
name: String::new(),
});
let plain =
text_input_htmx_with_token_field(&cs, "name", "Name", "/validate/name", "csrf_tok")
.into_string();
assert!(plain.contains(r#"hx-params="not csrf_tok""#), "{plain}");
assert!(!plain.contains("_submit_token"), "{plain}");
let required = required_text_input_htmx_with_token_field(
&cs,
"name",
"Name",
"/validate/name",
"csrf_tok",
)
.into_string();
assert!(
required.contains(r#"hx-params="not csrf_tok""#),
"{required}"
);
assert!(!required.contains("_submit_token"), "{required}");
}
#[cfg(feature = "maud")]
#[test]
fn with_token_field_default_matches_legacy_helpers() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let cs = Changeset::new(F {
name: String::new(),
});
assert_eq!(
text_input_htmx_with_token_field(
&cs,
"name",
"Name",
"/validate/name",
"_submit_token"
)
.into_string(),
text_input_htmx(&cs, "name", "Name", "/validate/name").into_string(),
);
assert_eq!(
required_text_input_htmx_with_token_field(
&cs,
"name",
"Name",
"/validate/name",
"_submit_token",
)
.into_string(),
required_text_input_htmx(&cs, "name", "Name", "/validate/name").into_string(),
);
}
#[cfg(feature = "maud")]
#[test]
fn text_input_htmx_valid_state_no_error_markup() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let cs = Changeset::new(F {
name: "Alice".into(),
});
let html = text_input_htmx(&cs, "name", "Name", "/v").into_string();
assert!(!html.contains(r#"role="alert""#), "{html}");
assert!(html.contains(r#"aria-invalid="false""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn text_input_htmx_invalid_preserves_value_and_shows_errors() {
#[derive(serde::Serialize)]
struct F {
name: String,
}
let mut errors = HashMap::new();
errors.insert("name".to_string(), vec!["too short".to_string()]);
let cs = Changeset::from_errors(F { name: "ab".into() }, errors);
let html = text_input_htmx(&cs, "name", "Name", "/v").into_string();
assert!(html.contains(r#"value="ab""#), "{html}");
assert!(html.contains("too short"), "{html}");
assert!(html.contains(r#"aria-invalid="true""#), "{html}");
assert!(html.contains(r#"role="alert""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn text_input_htmx_invalid_has_describedby_link() {
#[derive(serde::Serialize)]
struct F {
email: String,
}
let mut errors = HashMap::new();
errors.insert("email".to_string(), vec!["invalid".to_string()]);
let cs = Changeset::from_errors(F { email: "x".into() }, errors);
let html = text_input_htmx(&cs, "email", "Email", "/v").into_string();
assert!(html.contains("email-error"), "{html}");
assert!(html.contains(r#"aria-describedby="email-error""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn checkbox_input_renders_type_checkbox() {
#[derive(serde::Serialize)]
struct F {
active: bool,
}
let cs = Changeset::new(F { active: false });
let html = checkbox_input(&cs, "active", "Active").into_string();
assert!(html.contains(r#"type="checkbox""#), "{html}");
assert!(html.contains(r#"name="active""#), "{html}");
assert!(html.contains("Active"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn checkbox_input_unchecked_when_value_false() {
#[derive(serde::Serialize)]
struct F {
active: bool,
}
let cs = Changeset::new(F { active: false });
let html = checkbox_input(&cs, "active", "Active").into_string();
assert!(!html.contains("checked"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn checkbox_input_checked_when_value_true() {
#[derive(serde::Serialize)]
struct F {
active: bool,
}
let cs = Changeset::new(F { active: true });
let html = checkbox_input(&cs, "active", "Active").into_string();
assert!(html.contains("checked"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn checkbox_input_never_emits_a_hidden_fallback() {
#[derive(serde::Serialize)]
struct F {
active: bool,
}
let cs = Changeset::new(F { active: false });
let html = checkbox_input(&cs, "active", "Active").into_string();
assert!(!html.contains(r#"type="hidden""#), "{html}");
assert_eq!(
html.matches(r#"name="active""#).count(),
1,
"checkbox_input must emit exactly one input named `active` \
(a second `name=\"active\"` sibling would duplicate the key \
on submission): {html}"
);
}
#[cfg(feature = "maud")]
#[test]
fn checkbox_input_round_trips_through_real_url_decode_when_checked() {
#[derive(serde::Serialize)]
struct F {
active: bool,
}
#[derive(serde::Deserialize)]
struct Decoded {
#[serde(default)]
active: bool,
}
let cs = Changeset::new(F { active: true });
let html = checkbox_input(&cs, "active", "Active").into_string();
assert!(html.contains("checked"), "{html}");
let decoded: Decoded = serde_urlencoded::from_str("active=true").unwrap();
assert!(decoded.active);
let decoded: Decoded = from_urlencoded(b"active=true").unwrap();
assert!(decoded.active);
}
#[cfg(feature = "maud")]
#[test]
fn checkbox_input_round_trips_through_real_url_decode_when_unchecked() {
#[derive(serde::Deserialize)]
struct Decoded {
#[serde(default)]
active: bool,
}
let decoded: Decoded = serde_urlencoded::from_str("").unwrap();
assert!(!decoded.active);
}
#[cfg(feature = "maud")]
#[test]
fn checkbox_input_wrapper_div_has_stable_id() {
#[derive(serde::Serialize)]
struct F {
active: bool,
}
let cs = Changeset::new(F { active: false });
let html = checkbox_input(&cs, "active", "Active").into_string();
assert!(html.contains(r#"id="active-field""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn checkbox_input_emits_aria_invalid_and_errors() {
#[derive(serde::Serialize)]
struct F {
active: bool,
}
let mut errors = HashMap::new();
errors.insert("active".to_string(), vec!["must be true".to_string()]);
let cs = Changeset::from_errors(F { active: false }, errors);
let html = checkbox_input(&cs, "active", "Active").into_string();
assert!(html.contains(r#"aria-invalid="true""#), "{html}");
assert!(html.contains(r#"role="alert""#), "{html}");
assert!(html.contains("must be true"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn number_input_renders_type_number() {
#[derive(serde::Serialize)]
struct F {
age: i32,
}
let cs = Changeset::new(F { age: 30 });
let html = number_input(&cs, "age", "Age", Some("1")).into_string();
assert!(html.contains(r#"type="number""#), "{html}");
assert!(html.contains(r#"name="age""#), "{html}");
assert!(html.contains(r#"value="30""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn number_input_renders_step_when_provided() {
#[derive(serde::Serialize)]
struct F {
price: f64,
}
let cs = Changeset::new(F { price: 9.99 });
let html = number_input(&cs, "price", "Price", Some("0.01")).into_string();
assert!(html.contains(r#"step="0.01""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn number_input_omits_step_when_none() {
#[derive(serde::Serialize)]
struct F {
age: i32,
}
let cs = Changeset::new(F { age: 30 });
let html = number_input(&cs, "age", "Age", None).into_string();
assert!(!html.contains("step="), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn number_input_emits_aria_invalid_and_errors() {
#[derive(serde::Serialize)]
struct F {
age: i32,
}
let mut errors = HashMap::new();
errors.insert("age".to_string(), vec!["must be positive".to_string()]);
let cs = Changeset::from_errors(F { age: -1 }, errors);
let html = number_input(&cs, "age", "Age", None).into_string();
assert!(html.contains(r#"aria-invalid="true""#), "{html}");
assert!(html.contains(r#"role="alert""#), "{html}");
assert!(html.contains("must be positive"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn number_input_wrapper_div_has_stable_id() {
#[derive(serde::Serialize)]
struct F {
age: i32,
}
let cs = Changeset::new(F { age: 30 });
let html = number_input(&cs, "age", "Age", None).into_string();
assert!(html.contains(r#"id="age-field""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn date_input_renders_type_date() {
#[derive(serde::Serialize)]
struct F {
born_on: String,
}
let cs = Changeset::new(F {
born_on: "2024-03-15".into(),
});
let html = date_input(&cs, "born_on", "Born on").into_string();
assert!(html.contains(r#"type="date""#), "{html}");
assert!(html.contains(r#"value="2024-03-15""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn date_input_normalizes_full_timestamp_to_date_only() {
#[derive(serde::Serialize)]
struct F {
born_on: String,
}
let cs = Changeset::new(F {
born_on: "2024-03-15T10:30:00Z".into(),
});
let html = date_input(&cs, "born_on", "Born on").into_string();
assert!(html.contains(r#"value="2024-03-15""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn date_input_normalizes_space_separated_datetime_to_date_only() {
#[derive(serde::Serialize)]
struct F {
born_on: String,
}
let cs = Changeset::new(F {
born_on: "2024-03-15 10:30:00".into(),
});
let html = date_input(&cs, "born_on", "Born on").into_string();
assert!(html.contains(r#"value="2024-03-15""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn date_input_emits_aria_invalid_and_errors() {
#[derive(serde::Serialize)]
struct F {
born_on: String,
}
let mut errors = HashMap::new();
errors.insert("born_on".to_string(), vec!["required".to_string()]);
let cs = Changeset::from_errors(
F {
born_on: String::new(),
},
errors,
);
let html = date_input(&cs, "born_on", "Born on").into_string();
assert!(html.contains(r#"aria-invalid="true""#), "{html}");
assert!(html.contains(r#"role="alert""#), "{html}");
assert!(html.contains("required"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn datetime_input_renders_type_datetime_local() {
#[derive(serde::Serialize)]
struct F {
starts_at: String,
}
let cs = Changeset::new(F {
starts_at: "2024-03-15T10:30:00".into(),
});
let html = datetime_input(&cs, "starts_at", "Starts at").into_string();
assert!(html.contains(r#"type="datetime-local""#), "{html}");
assert!(html.contains(r#"value="2024-03-15T10:30:00""#), "{html}");
assert!(html.contains(r#"step="any""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn datetime_input_normalizes_rfc3339_with_offset_to_local_shape() {
#[derive(serde::Serialize)]
struct F {
starts_at: String,
}
let cs = Changeset::new(F {
starts_at: "2024-03-15T10:30:00Z".into(),
});
let html = datetime_input(&cs, "starts_at", "Starts at").into_string();
assert!(html.contains(r#"value="2024-03-15T10:30:00""#), "{html}");
assert!(!html.contains(r#"value="2024-03-15T10:30:00Z""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn datetime_input_normalizes_space_separated_datetime() {
#[derive(serde::Serialize)]
struct F {
starts_at: String,
}
let cs = Changeset::new(F {
starts_at: "2024-03-15 10:30:00".into(),
});
let html = datetime_input(&cs, "starts_at", "Starts at").into_string();
assert!(html.contains(r#"value="2024-03-15T10:30:00""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn datetime_input_emits_aria_invalid_and_errors() {
#[derive(serde::Serialize)]
struct F {
starts_at: String,
}
let mut errors = HashMap::new();
errors.insert("starts_at".to_string(), vec!["required".to_string()]);
let cs = Changeset::from_errors(
F {
starts_at: String::new(),
},
errors,
);
let html = datetime_input(&cs, "starts_at", "Starts at").into_string();
assert!(html.contains(r#"aria-invalid="true""#), "{html}");
assert!(html.contains(r#"role="alert""#), "{html}");
assert!(html.contains("required"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn datetime_input_value_round_trips_through_chronos_default_deserialize() {
#[derive(serde::Serialize)]
struct F {
starts_at: chrono::NaiveDateTime,
}
#[derive(serde::Deserialize)]
struct Decoded {
starts_at: chrono::NaiveDateTime,
}
let stored = chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
.unwrap()
.and_hms_opt(10, 30, 56)
.unwrap();
let cs = Changeset::new(F { starts_at: stored });
let html = datetime_input(&cs, "starts_at", "Starts at").into_string();
let value = html
.split("value=\"")
.nth(1)
.and_then(|s| s.split('"').next())
.expect("value attribute present");
let body = format!("starts_at={value}");
let decoded: Decoded =
serde_urlencoded::from_str(&body).expect("pre-filled value must decode");
assert_eq!(decoded.starts_at, stored);
}
#[cfg(feature = "maud")]
#[test]
fn datetime_input_value_round_trips_for_utc_datetime_with_deserialize_helper() {
#[derive(serde::Serialize)]
struct F {
starts_at: chrono::DateTime<chrono::Utc>,
}
#[derive(serde::Deserialize)]
struct Decoded {
#[serde(deserialize_with = "deserialize_datetime_local_utc")]
starts_at: chrono::DateTime<chrono::Utc>,
}
let stored = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
.unwrap()
.and_hms_opt(10, 30, 56)
.unwrap(),
chrono::Utc,
);
let cs = Changeset::new(F { starts_at: stored });
let html = datetime_input(&cs, "starts_at", "Starts at").into_string();
let value = html
.split("value=\"")
.nth(1)
.and_then(|s| s.split('"').next())
.expect("value attribute present");
assert!(!value.contains('Z') && !value.contains('+'), "{value}");
let body = format!("starts_at={value}");
let decoded: Decoded =
serde_urlencoded::from_str(&body).expect("pre-filled value must decode");
assert_eq!(decoded.starts_at, stored);
}
#[cfg(feature = "maud")]
#[test]
fn deserialize_datetime_local_utc_pads_missing_seconds() {
#[derive(serde::Deserialize)]
struct Decoded {
#[serde(deserialize_with = "deserialize_datetime_local_utc")]
starts_at: chrono::DateTime<chrono::Utc>,
}
let decoded: Decoded = serde_urlencoded::from_str("starts_at=2024-03-15T10:30").unwrap();
assert_eq!(
decoded.starts_at,
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
.unwrap()
.and_hms_opt(10, 30, 0)
.unwrap(),
chrono::Utc,
)
);
}
#[test]
fn deserialize_datetime_local_utc_accepts_rfc3339_with_offset() {
#[derive(serde::Deserialize)]
struct Decoded {
#[serde(deserialize_with = "deserialize_datetime_local_utc")]
starts_at: chrono::DateTime<chrono::Utc>,
}
let expected = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
.unwrap()
.and_hms_opt(10, 30, 56)
.unwrap(),
chrono::Utc,
);
let decoded: Decoded =
serde_json::from_str(r#"{"starts_at":"2024-03-15T10:30:56Z"}"#).unwrap();
assert_eq!(decoded.starts_at, expected);
let decoded: Decoded =
serde_json::from_str(r#"{"starts_at":"2024-03-15T19:30:56+09:00"}"#).unwrap();
assert_eq!(decoded.starts_at, expected);
}
#[cfg(feature = "maud")]
#[test]
fn deserialize_datetime_local_utc_option_absent_key_is_none() {
#[derive(serde::Deserialize)]
struct Decoded {
#[serde(default, deserialize_with = "deserialize_datetime_local_utc_option")]
starts_at: Option<chrono::DateTime<chrono::Utc>>,
}
let decoded: Decoded = serde_urlencoded::from_str("").unwrap();
assert_eq!(decoded.starts_at, None);
}
#[cfg(feature = "maud")]
#[test]
fn deserialize_datetime_local_utc_option_present_key_is_some() {
#[derive(serde::Deserialize)]
struct Decoded {
#[serde(default, deserialize_with = "deserialize_datetime_local_utc_option")]
starts_at: Option<chrono::DateTime<chrono::Utc>>,
}
let decoded: Decoded = serde_urlencoded::from_str("starts_at=2024-03-15T10:30:56").unwrap();
assert_eq!(
decoded.starts_at,
Some(chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
.unwrap()
.and_hms_opt(10, 30, 56)
.unwrap(),
chrono::Utc,
))
);
}
#[test]
fn deserialize_datetime_local_local_interprets_offsetless_as_local_wall_clock() {
#[derive(serde::Deserialize)]
struct Decoded {
#[serde(deserialize_with = "deserialize_datetime_local_local")]
starts_at: chrono::DateTime<chrono::Local>,
}
let expected_wall_clock = chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
.unwrap()
.and_hms_opt(12, 30, 56)
.unwrap();
let decoded: Decoded =
serde_json::from_str(r#"{"starts_at":"2024-03-15T12:30:56"}"#).unwrap();
assert_eq!(decoded.starts_at.naive_local(), expected_wall_clock);
let decoded: Decoded = serde_json::from_str(r#"{"starts_at":"2024-03-15T12:30"}"#).unwrap();
assert_eq!(
decoded.starts_at.naive_local(),
chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
.unwrap()
.and_hms_opt(12, 30, 0)
.unwrap()
);
}
#[test]
fn deserialize_datetime_local_local_accepts_rfc3339_with_offset() {
#[derive(serde::Deserialize)]
struct Decoded {
#[serde(deserialize_with = "deserialize_datetime_local_local")]
starts_at: chrono::DateTime<chrono::Local>,
}
let expected = chrono::DateTime::parse_from_rfc3339("2024-03-15T10:30:56Z")
.unwrap()
.with_timezone(&chrono::Local);
let decoded: Decoded =
serde_json::from_str(r#"{"starts_at":"2024-03-15T10:30:56Z"}"#).unwrap();
assert_eq!(decoded.starts_at, expected);
let decoded: Decoded =
serde_json::from_str(r#"{"starts_at":"2024-03-15T19:30:56+09:00"}"#).unwrap();
assert_eq!(decoded.starts_at, expected);
}
#[cfg(feature = "maud")]
#[test]
fn deserialize_datetime_local_local_option_absent_and_empty_are_none() {
#[derive(serde::Deserialize)]
struct Decoded {
#[serde(default, deserialize_with = "deserialize_datetime_local_local_option")]
starts_at: Option<chrono::DateTime<chrono::Local>>,
}
let decoded: Decoded = serde_urlencoded::from_str("").unwrap();
assert_eq!(decoded.starts_at, None);
let decoded: Decoded = serde_urlencoded::from_str("starts_at=").unwrap();
assert_eq!(decoded.starts_at, None);
let decoded: Decoded = serde_urlencoded::from_str("starts_at=2024-03-15T12:30:56").unwrap();
assert_eq!(
decoded.starts_at.map(|dt| dt.naive_local()),
Some(
chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
.unwrap()
.and_hms_opt(12, 30, 56)
.unwrap()
)
);
}
#[cfg(feature = "maud")]
#[test]
fn deserialize_naive_datetime_local_pads_missing_seconds() {
#[derive(serde::Deserialize)]
struct Decoded {
#[serde(deserialize_with = "deserialize_naive_datetime_local")]
starts_at: chrono::NaiveDateTime,
}
let decoded: Decoded = serde_urlencoded::from_str("starts_at=2024-03-15T10:30").unwrap();
assert_eq!(
decoded.starts_at,
chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
.unwrap()
.and_hms_opt(10, 30, 0)
.unwrap()
);
}
#[cfg(feature = "maud")]
#[test]
fn deserialize_naive_datetime_local_preserves_seconds_when_present() {
#[derive(serde::Deserialize)]
struct Decoded {
#[serde(deserialize_with = "deserialize_naive_datetime_local")]
starts_at: chrono::NaiveDateTime,
}
let decoded: Decoded = serde_urlencoded::from_str("starts_at=2024-03-15T10:30:56").unwrap();
assert_eq!(
decoded.starts_at,
chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
.unwrap()
.and_hms_opt(10, 30, 56)
.unwrap()
);
}
#[cfg(feature = "maud")]
#[test]
fn deserialize_naive_datetime_local_option_absent_key_is_none() {
#[derive(serde::Deserialize)]
struct Decoded {
#[serde(default, deserialize_with = "deserialize_naive_datetime_local_option")]
starts_at: Option<chrono::NaiveDateTime>,
}
let decoded: Decoded = serde_urlencoded::from_str("").unwrap();
assert_eq!(decoded.starts_at, None);
}
#[cfg(feature = "maud")]
#[test]
fn deserialize_naive_datetime_local_option_present_key_is_some() {
#[derive(serde::Deserialize)]
struct Decoded {
#[serde(default, deserialize_with = "deserialize_naive_datetime_local_option")]
starts_at: Option<chrono::NaiveDateTime>,
}
let decoded: Decoded = serde_urlencoded::from_str("starts_at=2024-03-15T10:30").unwrap();
assert_eq!(
decoded.starts_at,
Some(
chrono::NaiveDate::from_ymd_opt(2024, 3, 15)
.unwrap()
.and_hms_opt(10, 30, 0)
.unwrap()
)
);
}
#[cfg(feature = "maud")]
#[test]
fn datetime_input_wrapper_div_has_stable_id() {
#[derive(serde::Serialize)]
struct F {
starts_at: String,
}
let cs = Changeset::new(F {
starts_at: "2024-03-15T10:30:00".into(),
});
let html = datetime_input(&cs, "starts_at", "Starts at").into_string();
assert!(html.contains(r#"id="starts_at-field""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn select_input_renders_select_element_with_options() {
#[derive(serde::Serialize)]
struct F {
status: String,
}
let cs = Changeset::new(F {
status: "draft".into(),
});
let options = [("draft", "Draft"), ("published", "Published")];
let html = select_input(&cs, "status", "Status", &options).into_string();
assert!(html.contains("<select"), "{html}");
assert!(html.contains(r#"name="status""#), "{html}");
assert!(html.contains(r#"value="draft""#), "{html}");
assert!(html.contains("Draft"), "{html}");
assert!(html.contains(r#"value="published""#), "{html}");
assert!(html.contains("Published"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn select_input_marks_current_value_selected() {
#[derive(serde::Serialize)]
struct F {
status: String,
}
let cs = Changeset::new(F {
status: "published".into(),
});
let options = [("draft", "Draft"), ("published", "Published")];
let html = select_input(&cs, "status", "Status", &options).into_string();
assert!(html.contains(r#"value="published" selected"#), "{html}");
assert!(!html.contains(r#"value="draft" selected"#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn select_input_emits_aria_invalid_and_errors() {
#[derive(serde::Serialize)]
struct F {
status: String,
}
let mut errors = HashMap::new();
errors.insert("status".to_string(), vec!["required".to_string()]);
let cs = Changeset::from_errors(
F {
status: String::new(),
},
errors,
);
let options = [("draft", "Draft"), ("published", "Published")];
let html = select_input(&cs, "status", "Status", &options).into_string();
assert!(html.contains(r#"aria-invalid="true""#), "{html}");
assert!(html.contains(r#"role="alert""#), "{html}");
assert!(html.contains("required"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn select_input_wrapper_div_has_stable_id() {
#[derive(serde::Serialize)]
struct F {
status: String,
}
let cs = Changeset::new(F {
status: "draft".into(),
});
let options = [("draft", "Draft"), ("published", "Published")];
let html = select_input(&cs, "status", "Status", &options).into_string();
assert!(html.contains(r#"id="status-field""#), "{html}");
}
#[cfg(feature = "maud")]
#[derive(serde::Serialize)]
struct FormForTestModel {
title: String,
views: i32,
published: bool,
}
#[cfg(feature = "maud")]
impl FormModel for FormForTestModel {
fn form_fields() -> Vec<FormField> {
vec![
FormField::new("title", "Title", FieldControl::Text, true),
FormField::new("views", "Views", FieldControl::Number { step: None }, false),
FormField::new("published", "Published", FieldControl::Checkbox, false),
]
}
}
#[cfg(feature = "maud")]
fn blank_form_for_model() -> FormForTestModel {
FormForTestModel {
title: String::new(),
views: 0,
published: false,
}
}
#[cfg(feature = "maud")]
#[test]
fn form_for_prefills_serde_renamed_field_via_value_name() {
#[derive(serde::Serialize)]
struct RenamedModel {
#[serde(rename = "headline")]
title: String,
}
impl FormModel for RenamedModel {
fn form_fields() -> Vec<FormField> {
vec![
FormField::new("title", "Title", FieldControl::Text, true)
.with_value_name("headline"),
]
}
}
let mut errors = HashMap::new();
errors.insert("title".to_string(), vec!["too short".to_string()]);
let cs = Changeset::from_errors(
RenamedModel {
title: "Hello".into(),
},
errors,
);
let html = form_for(&cs, "/posts", "post").render().into_string();
assert!(html.contains(r#"name="title""#), "{html}");
assert!(!html.contains(r#"name="headline""#), "{html}");
assert!(html.contains(r#"value="Hello""#), "{html}");
assert!(html.contains("too short"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_for_renders_form_tag_csrf_and_submit() {
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts", "post")
.csrf("tok123")
.render()
.into_string();
assert!(html.contains("<form"), "{html}");
assert!(html.contains(r#"name="_csrf""#), "{html}");
assert!(html.contains(r#"value="tok123""#), "{html}");
assert!(html.contains(r#"type="submit""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_for_method_override_emits_hidden_input() {
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts/1", "PUT").render().into_string();
assert!(html.contains(r#"name="_method""#), "{html}");
assert!(html.contains(r#"value="PUT""#), "{html}");
assert!(html.contains(r#"method="post""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_for_renders_one_control_per_field() {
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts", "post").render().into_string();
assert!(html.contains(r#"name="title""#), "{html}");
assert!(html.contains(r#"name="views""#), "{html}");
assert!(html.contains(r#"name="published""#), "{html}");
assert!(html.contains(r#"type="checkbox""#), "{html}");
assert!(html.contains(r#"type="number""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_for_prefills_values() {
let cs = Changeset::new(FormForTestModel {
title: "Hello".into(),
..blank_form_for_model()
});
let html = form_for(&cs, "/posts", "post").render().into_string();
assert!(html.contains(r#"value="Hello""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_for_renders_inline_error_adjacent() {
let mut errors = HashMap::new();
errors.insert("title".to_string(), vec!["can't be blank".to_string()]);
let cs = Changeset::from_errors(blank_form_for_model(), errors);
let html = form_for(&cs, "/posts", "post").render().into_string();
assert!(html.contains("can't be blank"), "{html}");
assert!(html.contains(r#"role="alert""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_for_exclude_drops_field() {
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts", "post")
.exclude("published")
.render()
.into_string();
assert!(!html.contains(r#"name="published""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_for_override_field_changes_control() {
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts", "post")
.override_field(
"title",
FieldControl::Select {
options: vec![("a".into(), "A".into())],
},
)
.render()
.into_string();
assert!(html.contains("<select"), "{html}");
assert!(html.contains(r#"name="title""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_for_override_label() {
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts", "post")
.override_label("title", "Headline")
.render()
.into_string();
assert!(html.contains("Headline"), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_for_append_inserts_before_submit() {
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts", "post")
.append(
maud::html! { input type="password" name="confirm" aria-label="Confirm password"; },
)
.render()
.into_string();
let append_idx = html
.find(r#"name="confirm""#)
.expect("append markup missing");
let submit_idx = html
.find(r#"type="submit""#)
.expect("submit button missing");
assert!(append_idx < submit_idx, "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_for_prepend_inserts_before_fields() {
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts", "post")
.csrf("tok")
.prepend(maud::html! { input type="hidden" name="_submit_token" value="st"; })
.render()
.into_string();
let csrf_idx = html.find(r#"name="_csrf""#).expect("csrf input missing");
let prepend_idx = html
.find(r#"name="_submit_token""#)
.expect("prepend markup missing");
let first_field_idx = html.find(r#"name="title""#).expect("derived field missing");
assert!(
csrf_idx < prepend_idx && prepend_idx < first_field_idx,
"prepended token must sit after csrf and before the first derived field: {html}"
);
}
#[cfg(feature = "maud")]
#[test]
fn form_for_required_date_field_renders_required_attribute() {
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts", "post")
.override_field("title", FieldControl::Date)
.render()
.into_string();
assert!(html.contains(r#"type="date""#), "{html}");
assert!(html.contains(r#"aria-required="true""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_for_file_field_sets_multipart() {
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts", "post")
.override_field("title", FieldControl::File)
.render()
.into_string();
assert!(html.contains(r#"enctype="multipart/form-data""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_for_multipart_put_emits_method_override_and_csrf_together() {
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts/1", "PUT")
.csrf("tok456")
.multipart()
.render()
.into_string();
assert!(html.contains(r#"enctype="multipart/form-data""#), "{html}");
assert!(html.contains(r#"method="post""#), "{html}");
assert!(html.contains(r#"name="_method""#), "{html}");
assert!(html.contains(r#"value="PUT""#), "{html}");
assert!(html.contains(r#"name="_csrf""#), "{html}");
assert!(html.contains(r#"value="tok456""#), "{html}");
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts/1", "PUT")
.csrf("tok456")
.render()
.into_string();
assert!(!html.contains("enctype"), "{html}");
assert!(html.contains(r#"name="_method""#), "{html}");
assert!(html.contains(r#"name="_csrf""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_for_file_field_renders_errors_aria_and_required() {
let mut errors = HashMap::new();
errors.insert("title".to_string(), vec!["must be a PDF".to_string()]);
let cs = Changeset::from_errors(blank_form_for_model(), errors);
let html = form_for(&cs, "/posts", "post")
.override_field("title", FieldControl::File)
.render()
.into_string();
assert!(html.contains(r#"type="file""#), "{html}");
assert!(html.contains(r#"id="title-field""#), "{html}");
assert!(html.contains(r#"aria-invalid="true""#), "{html}");
assert!(html.contains(r#"aria-describedby="title-error""#), "{html}");
assert!(html.contains(r#"role="alert""#), "{html}");
assert!(html.contains("must be a PDF"), "{html}");
assert!(html.contains(r#"aria-required="true""#), "{html}");
assert!(html.contains("required"), "{html}");
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts", "post")
.exclude("title")
.override_field("published", FieldControl::File)
.render()
.into_string();
assert!(html.contains(r#"id="published-field""#), "{html}");
assert!(html.contains(r#"aria-invalid="false""#), "{html}");
assert!(!html.contains(r#"aria-required="true""#), "{html}");
}
#[cfg(feature = "maud")]
#[test]
fn form_for_routes_controls_through_a11y_primitives() {
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts", "post")
.exclude("views")
.exclude("published")
.override_field("title", FieldControl::Textarea)
.render()
.into_string();
assert!(html.contains("<textarea"), "{html}");
assert!(html.contains(r#"aria-invalid="false""#), "{html}");
assert!(
!html.contains(r#"aria-describedby="""#),
"error-free textarea must not emit an empty aria-describedby: {html}"
);
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts", "post")
.exclude("views")
.exclude("title")
.render()
.into_string();
assert!(html.contains(r#"type="checkbox""#), "{html}");
assert!(html.contains(r#"<label for="published""#), "{html}");
let input_idx = html
.find(r#"type="checkbox""#)
.expect("checkbox input missing");
let label_idx = html
.find(r#"<label for="published""#)
.expect("checkbox label missing");
assert!(
input_idx < label_idx,
"checkbox input must precede its label (conventional layout): {html}"
);
assert!(
!html.contains(r#"aria-describedby="""#),
"error-free checkbox must not emit an empty aria-describedby: {html}"
);
}
#[cfg(feature = "maud")]
#[test]
fn form_for_duplicate_overrides_last_wins() {
let cs = Changeset::new(blank_form_for_model());
let html = form_for(&cs, "/posts", "post")
.override_field("title", FieldControl::Date)
.override_field("title", FieldControl::Textarea)
.override_label("title", "First")
.override_label("title", "Second")
.render()
.into_string();
assert!(html.contains("<textarea"), "{html}");
assert!(!html.contains(r#"type="date""#), "{html}");
assert!(html.contains("Second"), "{html}");
assert!(!html.contains("First"), "{html}");
}
mod extractor_tests {
use super::*;
use axum::{Router, body::Body, routing::post};
use tower::ServiceExt;
#[derive(serde::Deserialize, validator::Validate)]
struct TestForm {
#[validate(length(min = 3))]
name: String,
}
#[tokio::test]
async fn valid_form_body_produces_valid_changeset() {
async fn handler(form: ChangesetForm<TestForm>) -> String {
format!("valid={}", form.is_valid())
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=Alice"))
.await
.unwrap();
assert_body(resp, "valid=true").await;
}
#[tokio::test]
async fn invalid_form_body_produces_invalid_changeset() {
async fn handler(form: ChangesetForm<TestForm>) -> String {
format!("valid={}", form.is_valid())
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=ab"))
.await
.unwrap();
assert_body(resp, "valid=false").await;
}
#[tokio::test]
async fn invalid_form_exposes_field_errors() {
async fn handler(form: ChangesetForm<TestForm>) -> String {
form.errors_for("name").join("|")
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=ab"))
.await
.unwrap();
let body = body_text(resp).await;
assert!(!body.is_empty(), "expected errors, got empty string");
}
#[tokio::test]
async fn missing_required_field_returns_non_200() {
async fn handler(form: ChangesetForm<TestForm>) -> String {
format!("valid={}", form.is_valid())
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "other=value"))
.await
.unwrap();
assert_ne!(resp.status(), axum::http::StatusCode::OK);
}
#[derive(serde::Deserialize, validator::Validate)]
struct OptionalNumericForm {
#[validate(length(min = 3))]
name: String,
age: Option<i32>,
}
#[tokio::test]
async fn blank_optional_numeric_field_decodes_as_none_not_400() {
async fn handler(form: ChangesetForm<OptionalNumericForm>) -> String {
format!("valid={} age={:?}", form.is_valid(), form.data().age)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=Alice&age="))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
assert_body(resp, "valid=true age=None").await;
}
#[tokio::test]
async fn filled_optional_numeric_field_still_decodes() {
async fn handler(form: ChangesetForm<OptionalNumericForm>) -> String {
format!("valid={} age={:?}", form.is_valid(), form.data().age)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=Alice&age=30"))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
assert_body(resp, "valid=true age=Some(30)").await;
}
#[tokio::test]
async fn garbage_numeric_field_still_fails_decode() {
async fn handler(_form: ChangesetForm<OptionalNumericForm>) -> String {
"unreachable".to_string()
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=Alice&age=not-a-number"))
.await
.unwrap();
assert_ne!(resp.status(), axum::http::StatusCode::OK);
}
#[tokio::test]
async fn blank_required_string_survives_alongside_blank_optional_numeric() {
async fn handler(form: ChangesetForm<OptionalNumericForm>) -> String {
format!(
"valid={} name={:?} age={:?}",
form.is_valid(),
form.data().name,
form.data().age
)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=&age="))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
assert_body(resp, "valid=false name=\"\" age=None").await;
}
#[derive(serde::Deserialize, validator::Validate)]
struct CheckboxForm {
#[serde(default)]
published: bool,
}
#[tokio::test]
async fn blank_defaulted_checkbox_field_decodes_to_its_default() {
async fn handler(form: ChangesetForm<CheckboxForm>) -> String {
format!("published={}", form.data().published)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "published="))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
assert_body(resp, "published=false").await;
}
#[derive(serde::Deserialize, validator::Validate)]
struct RequiredBoolForm {
#[allow(
dead_code,
reason = "decode is expected to fail before this is ever read"
)]
accepted_terms: bool,
}
#[tokio::test]
async fn blank_required_field_without_default_still_fails_to_decode() {
async fn handler(_form: ChangesetForm<RequiredBoolForm>) -> String {
"unreachable".to_string()
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "accepted_terms="))
.await
.unwrap();
assert_ne!(resp.status(), axum::http::StatusCode::OK);
}
#[tokio::test]
async fn oversized_body_is_rejected_not_fully_buffered() {
async fn handler(_form: ChangesetForm<TestForm>) -> String {
"unreachable".to_string()
}
let oversized = format!("name={}", "a".repeat(3 * 1024 * 1024));
let req = axum::http::Request::builder()
.method("POST")
.uri("/test")
.header("Content-Type", "application/x-www-form-urlencoded")
.body(Body::from(oversized))
.unwrap();
let resp = Router::new()
.route("/test", post(handler))
.oneshot(req)
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn wrong_content_type_is_rejected_not_decoded_anyway() {
async fn handler(_form: ChangesetForm<TestForm>) -> String {
"unreachable".to_string()
}
let req = axum::http::Request::builder()
.method("POST")
.uri("/test")
.header("Content-Type", "text/plain")
.body(Body::from("name=Alice"))
.unwrap();
let resp = Router::new()
.route("/test", post(handler))
.oneshot(req)
.await
.unwrap();
assert_eq!(
resp.status(),
axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE
);
}
#[tokio::test]
async fn mixed_case_urlencoded_content_type_is_accepted() {
async fn handler(_form: ChangesetForm<TestForm>) -> &'static str {
"ok"
}
for ct in [
"Application/X-WWW-Form-Urlencoded",
"APPLICATION/X-WWW-FORM-URLENCODED; charset=UTF-8",
] {
let req = axum::http::Request::builder()
.method("POST")
.uri("/test")
.header("Content-Type", ct)
.body(Body::from("name=Alice"))
.unwrap();
let resp = Router::new()
.route("/test", post(handler))
.oneshot(req)
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK, "{ct}");
}
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn mixed_case_multipart_content_type_is_accepted() {
async fn handler(_form: ChangesetForm<TestForm>) -> &'static str {
"ok"
}
let mut req = multipart_req("/test", "name", "Alice");
let ct = req.headers()["content-type"]
.to_str()
.unwrap()
.replace("multipart/form-data", "Multipart/Form-Data");
req.headers_mut()
.insert("content-type", ct.parse().unwrap());
let resp = Router::new()
.route("/test", post(handler))
.oneshot(req)
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
}
#[tokio::test]
async fn missing_content_type_is_rejected_not_decoded_anyway() {
async fn handler(_form: ChangesetForm<TestForm>) -> String {
"unreachable".to_string()
}
let req = axum::http::Request::builder()
.method("POST")
.uri("/test")
.body(Body::from("name=Alice"))
.unwrap();
let resp = Router::new()
.route("/test", post(handler))
.oneshot(req)
.await
.unwrap();
assert_eq!(
resp.status(),
axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE
);
}
#[tokio::test]
async fn csrf_token_is_none_without_csrf_middleware() {
async fn handler(form: ChangesetForm<TestForm>) -> String {
form.csrf_token().unwrap_or("none").to_string()
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=Alice"))
.await
.unwrap();
assert_body(resp, "none").await;
}
#[tokio::test]
async fn csrf_token_captured_from_request_extensions() {
use crate::security::CsrfToken;
let mut req = axum::http::Request::builder()
.method("POST")
.uri("/test")
.header("Content-Type", "application/x-www-form-urlencoded")
.body(Body::from("name=Alice"))
.unwrap();
req.extensions_mut()
.insert(CsrfToken::new("secret-tok".to_string()));
let form = ChangesetForm::<TestForm>::from_request(req, &())
.await
.expect("extraction should succeed");
assert_eq!(form.csrf_token(), Some("secret-tok"));
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn multipart_form_decodes_text_fields() {
async fn handler(form: ChangesetForm<TestForm>) -> String {
format!("valid={} name={}", form.is_valid(), form.data().name)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(multipart_req("/test", "name", "Alice"))
.await
.unwrap();
assert_body(resp, "valid=true name=Alice").await;
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn multipart_blank_optional_numeric_field_decodes_as_none_not_400() {
async fn handler(form: ChangesetForm<OptionalNumericForm>) -> String {
format!("valid={} age={:?}", form.is_valid(), form.data().age)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(multipart_req_multi(
"/test",
&[("name", "Alice"), ("age", "")],
))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
assert_body(resp, "valid=true age=None").await;
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn multipart_form_validates_fields() {
async fn handler(form: ChangesetForm<TestForm>) -> String {
format!("valid={}", form.is_valid())
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(multipart_req("/test", "name", "ab"))
.await
.unwrap();
assert_body(resp, "valid=false").await;
}
#[derive(serde::Deserialize, validator::Validate, serde::Serialize)]
struct InlineTestForm {
#[validate(length(min = 3, message = "Name must be at least 3 characters"))]
name: String,
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn inline_valid_field_returns_field_partial_without_errors() {
async fn handler(form: ChangesetForm<InlineTestForm>) -> maud::Markup {
text_input_htmx(&form.changeset, "name", "Name", "/validate/name")
}
let resp = Router::new()
.route("/validate/name", post(handler))
.oneshot(urlencoded_req("/validate/name", "name=Alice"))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
let body = body_text(resp).await;
assert!(body.contains(r#"aria-invalid="false""#), "{body}");
assert!(!body.contains(r#"role="alert""#), "{body}");
assert!(body.contains(r#"value="Alice""#), "{body}");
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn inline_invalid_field_returns_field_partial_with_errors() {
async fn handler(form: ChangesetForm<InlineTestForm>) -> maud::Markup {
text_input_htmx(&form.changeset, "name", "Name", "/validate/name")
}
let resp = Router::new()
.route("/validate/name", post(handler))
.oneshot(urlencoded_req("/validate/name", "name=ab"))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
let body = body_text(resp).await;
assert!(body.contains(r#"aria-invalid="true""#), "{body}");
assert!(body.contains(r#"role="alert""#), "{body}");
assert!(
body.contains("Name must be at least 3 characters"),
"{body}"
);
assert!(body.contains(r#"value="ab""#), "{body}");
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn inline_invalid_field_partial_is_htmx_swappable() {
async fn handler(form: ChangesetForm<InlineTestForm>) -> maud::Markup {
text_input_htmx(&form.changeset, "name", "Name", "/validate/name")
}
let resp = Router::new()
.route("/validate/name", post(handler))
.oneshot(urlencoded_req("/validate/name", "name=ab"))
.await
.unwrap();
let body = body_text(resp).await;
assert!(body.contains(r#"id="name-field""#), "{body}");
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn full_form_submit_invalid_returns_422() {
async fn handler(form: ChangesetForm<InlineTestForm>) -> impl IntoResponse {
match form.into_valid() {
Ok(_) => axum::http::StatusCode::OK.into_response(),
Err(form) => (
axum::http::StatusCode::UNPROCESSABLE_ENTITY,
text_input_htmx(&form.changeset, "name", "Name", "/validate/name"),
)
.into_response(),
}
}
let resp = Router::new()
.route("/submit", post(handler))
.oneshot(urlencoded_req("/submit", "name=ab"))
.await
.unwrap();
assert_eq!(
resp.status(),
axum::http::StatusCode::UNPROCESSABLE_ENTITY,
"full-form invalid submit must return 422"
);
let body = body_text(resp).await;
assert!(
body.contains("Name must be at least 3 characters"),
"{body}"
);
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn full_form_submit_valid_returns_200() {
async fn handler(form: ChangesetForm<InlineTestForm>) -> impl IntoResponse {
match form.into_valid() {
Ok(_) => axum::http::StatusCode::OK.into_response(),
Err(form) => (
axum::http::StatusCode::UNPROCESSABLE_ENTITY,
text_input_htmx(&form.changeset, "name", "Name", "/validate/name"),
)
.into_response(),
}
}
let resp = Router::new()
.route("/submit", post(handler))
.oneshot(urlencoded_req("/submit", "name=Alice"))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::OK);
}
#[derive(serde::Deserialize, serde::Serialize, validator::Validate)]
struct NulTestForm {
#[validate(length(min = 3))]
name: String,
#[serde(default)]
body: String,
}
#[tokio::test]
async fn nul_byte_in_text_field_is_a_field_error() {
async fn handler(form: ChangesetForm<NulTestForm>) -> String {
format!(
"valid={} body_errors={}",
form.is_valid(),
form.errors_for("body").join("|")
)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=Alice&body=before%00after"))
.await
.unwrap();
assert_body(
resp,
&format!("valid=false body_errors={NUL_CHARACTER_FIELD_ERROR}"),
)
.await;
}
#[tokio::test]
async fn nul_byte_is_stripped_from_the_redisplayed_value() {
async fn handler(form: ChangesetForm<NulTestForm>) -> String {
form.into_changeset()
.field_value("body")
.unwrap_or_default()
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=Alice&body=before%00after"))
.await
.unwrap();
assert_body(resp, "beforeafter").await;
}
#[tokio::test]
async fn nul_byte_flags_only_the_offending_field() {
async fn handler(form: ChangesetForm<NulTestForm>) -> String {
format!(
"name={} body={}",
form.errors_for("name").len(),
form.errors_for("body").len()
)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=Alice&body=a%00b"))
.await
.unwrap();
assert_body(resp, "name=0 body=1").await;
}
#[tokio::test]
async fn form_without_nul_is_unaffected() {
async fn handler(form: ChangesetForm<NulTestForm>) -> String {
format!("valid={} body={}", form.is_valid(), form.data().body)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=Alice&body=plain"))
.await
.unwrap();
assert_body(resp, "valid=true body=plain").await;
}
#[tokio::test]
async fn nul_byte_in_the_method_override_is_not_a_form_error() {
async fn handler(form: ChangesetForm<NulTestForm>) -> String {
format!("valid={} errors={}", form.is_valid(), form.errors().len())
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req(
"/test",
"_method=PA%00TCH&name=Alice&body=ok",
))
.await
.unwrap();
assert_body(resp, "valid=true errors=0").await;
}
#[tokio::test]
async fn nul_byte_in_the_submit_token_is_not_a_form_error() {
async fn handler(form: ChangesetForm<NulTestForm>) -> String {
format!("valid={} errors={}", form.is_valid(), form.errors().len())
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req(
"/test",
"_submit_token=to%00ken&name=Alice&body=ok",
))
.await
.unwrap();
assert_body(resp, "valid=true errors=0").await;
}
#[tokio::test]
async fn nul_byte_in_a_renamed_csrf_field_is_not_a_form_error() {
let mut req = urlencoded_req("/test", "authenticity=to%00ken&name=Alice&body=ok");
req.extensions_mut()
.insert(crate::security::csrf::CsrfFormField(
"authenticity".to_owned(),
));
let form = ChangesetForm::<NulTestForm>::from_request(req, &())
.await
.expect("extraction should succeed");
assert!(form.is_valid(), "errors: {:?}", form.errors());
}
#[tokio::test]
async fn nul_byte_in_an_unknown_key_is_reported_under_that_key() {
async fn handler(form: ChangesetForm<NulTestForm>) -> String {
format!(
"valid={} unknown={}",
form.is_valid(),
form.errors_for("return_to").len()
)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req(
"/test",
"return_to=a%00b&name=Alice&body=ok",
))
.await
.unwrap();
assert_body(resp, "valid=false unknown=1").await;
}
#[tokio::test]
async fn a_repeated_key_reports_once() {
async fn handler(form: ChangesetForm<NulTestForm>) -> String {
format!("errors={}", form.errors_for("return_to").len())
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req(
"/test",
"name=Alice&body=ok&return_to=a%00a&return_to=b%00b&return_to=c%00c",
))
.await
.unwrap();
assert_body(resp, "errors=1").await;
}
#[tokio::test]
async fn a_nul_in_a_key_never_renames_it_onto_a_real_field() {
async fn handler(form: ChangesetForm<NulTestForm>) -> String {
format!("valid={} body={:?}", form.is_valid(), form.data().body)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=Alice&bod%00y=injected"))
.await
.unwrap();
assert_body(resp, r#"valid=true body="""#).await;
}
#[tokio::test]
async fn nul_byte_error_is_appended_to_existing_field_errors() {
async fn handler(form: ChangesetForm<NulTestForm>) -> String {
let errs = form.errors_for("name");
format!(
"count={} has_nul={}",
errs.len(),
errs.iter().any(|e| e == NUL_CHARACTER_FIELD_ERROR)
)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=a%00b&body="))
.await
.unwrap();
assert_body(resp, "count=2 has_nul=true").await;
}
#[tokio::test]
async fn nul_byte_in_the_csrf_field_is_not_a_form_error() {
async fn handler(form: ChangesetForm<NulTestForm>) -> String {
format!("valid={} errors={}", form.is_valid(), form.errors().len())
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "_csrf=to%00ken&name=Alice&body=ok"))
.await
.unwrap();
assert_body(resp, "valid=true errors=0").await;
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn multipart_nul_report_survives_the_blank_optional_retry() {
async fn handler(form: ChangesetForm<OptionalNumericForm>) -> String {
format!(
"age={:?} name_errors={}",
form.data().age,
form.errors_for("name").len()
)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(multipart_req_multi(
"/test",
&[("name", "Ali\u{0}ce"), ("age", "")],
))
.await
.unwrap();
assert_body(resp, "age=None name_errors=1").await;
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn multipart_file_part_carrying_a_nul_is_untouched() {
async fn handler(form: ChangesetForm<NulTestForm>) -> String {
format!("valid={} errors={}", form.is_valid(), form.errors().len())
}
let boundary = "----FormBoundary7MA4YWxkTrZu0gW";
let body = format!(
"--{boundary}\r\n\
Content-Disposition: form-data; name=\"name\"\r\n\r\n\
Alice\r\n\
--{boundary}\r\n\
Content-Disposition: form-data; name=\"avatar\"; filename=\"a.bin\"\r\n\
Content-Type: application/octet-stream\r\n\r\n\
bin\u{0}ary\r\n\
--{boundary}--\r\n"
);
let req = axum::http::Request::builder()
.method("POST")
.uri("/test")
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap();
let resp = Router::new()
.route("/test", post(handler))
.oneshot(req)
.await
.unwrap();
assert_body(resp, "valid=true errors=0").await;
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn multipart_nul_byte_in_text_field_is_a_field_error() {
async fn handler(form: ChangesetForm<NulTestForm>) -> String {
format!(
"valid={} body_errors={} body={}",
form.is_valid(),
form.errors_for("body").len(),
form.data().body
)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(multipart_req_multi(
"/test",
&[("name", "Alice"), ("body", "before\u{0}after")],
))
.await
.unwrap();
assert_body(resp, "valid=false body_errors=1 body=beforeafter").await;
}
#[derive(serde::Deserialize, validator::Validate)]
struct TagsForm {
#[validate(length(min = 1))]
name: String,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
ids: Vec<i64>,
}
async fn tags_handler(form: ChangesetForm<TagsForm>) -> String {
format!(
"name={} tags={:?} ids={:?}",
form.data().name,
form.data().tags,
form.data().ids
)
}
#[tokio::test]
async fn urlencoded_repeated_keys_reach_vec_fields() {
let resp = Router::new()
.route("/test", post(tags_handler))
.oneshot(urlencoded_req(
"/test",
"name=Ann&tags=a&tags=b%20c&ids=1&ids=2",
))
.await
.unwrap();
assert_body(resp, r#"name=Ann tags=["a", "b c"] ids=[1, 2]"#).await;
}
#[tokio::test]
async fn urlencoded_repeated_scalar_is_422_naming_the_field() {
let resp = Router::new()
.route("/test", post(tags_handler))
.oneshot(urlencoded_req("/test", "name=a&name=b"))
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::UNPROCESSABLE_ENTITY);
assert!(body_text(resp).await.contains("`name`"));
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn multipart_repeated_keys_reach_vec_fields() {
let resp = Router::new()
.route("/test", post(tags_handler))
.oneshot(multipart_req_multi(
"/test",
&[("name", "Ann"), ("tags", "a"), ("tags", "b"), ("ids", "9")],
))
.await
.unwrap();
assert_body(resp, r#"name=Ann tags=["a", "b"] ids=[9]"#).await;
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn multipart_file_field_reaches_the_form() {
async fn handler(form: ChangesetForm<TestForm>) -> String {
let file = form.file("avatar").expect("file part must reach the form");
format!(
"valid={} field={} name={} type={:?} bytes={:?}",
form.is_valid(),
file.field(),
file.file_name(),
file.content_type(),
std::str::from_utf8(file.bytes()).unwrap()
)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(multipart_parts_req(
"/test",
&[
Part::text("name", "Alice"),
Part::file("avatar", "me.txt", "text/plain", b"hello"),
],
))
.await
.unwrap();
assert_body(
resp,
r#"valid=true field=avatar name=me.txt type=Some("text/plain") bytes="hello""#,
)
.await;
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn multipart_files_under_one_name_all_reach_the_form() {
async fn handler(mut form: ChangesetForm<TestForm>) -> String {
let names: Vec<String> = form
.files("photos")
.map(|f| f.file_name().to_owned())
.collect();
let taken = form.take_files();
format!(
"{names:?} taken={} left={}",
taken.len(),
form.files("photos").count()
)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(multipart_parts_req(
"/test",
&[
Part::text("name", "Alice"),
Part::file("photos", "a.txt", "text/plain", b"a"),
Part::file("photos", "b.txt", "text/plain", b"b"),
],
))
.await
.unwrap();
assert_body(resp, r#"["a.txt", "b.txt"] taken=2 left=0"#).await;
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn multipart_empty_optional_file_input_is_skipped() {
async fn handler(form: ChangesetForm<TestForm>) -> String {
format!(
"valid={} file={}",
form.is_valid(),
form.file("avatar").is_some()
)
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(multipart_parts_req(
"/test",
&[
Part::text("name", "Alice"),
Part::file("avatar", "", "application/octet-stream", b""),
],
))
.await
.unwrap();
assert_body(resp, "valid=true file=false").await;
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn multipart_file_obeys_the_upload_mime_allow_list() {
async fn handler(_form: ChangesetForm<TestForm>) -> &'static str {
"accepted"
}
let mut req = multipart_parts_req(
"/test",
&[
Part::text("name", "Alice"),
Part::file("avatar", "x.html", "image/png", b"<script>1</script>"),
],
);
req.extensions_mut()
.insert(crate::security::config::UploadConfig {
allowed_mime_types: vec!["image/png".to_owned()],
..Default::default()
});
let resp = Router::new()
.route("/test", post(handler))
.oneshot(req)
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::BAD_REQUEST);
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn multipart_file_obeys_the_upload_size_cap() {
async fn handler(_form: ChangesetForm<TestForm>) -> &'static str {
"accepted"
}
let mut req = multipart_parts_req(
"/test",
&[
Part::text("name", "Alice"),
Part::file("avatar", "big.txt", "text/plain", &[b'x'; 64]),
],
);
req.extensions_mut()
.insert(crate::security::config::UploadConfig {
max_file_size_bytes: 16,
..Default::default()
});
let resp = Router::new()
.route("/test", post(handler))
.oneshot(req)
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::PAYLOAD_TOO_LARGE);
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn multipart_empty_optional_file_input_is_skipped_under_a_mime_policy() {
#[derive(serde::Deserialize, validator::Validate)]
#[serde(deny_unknown_fields)]
struct Strict {
name: String,
}
async fn handler(form: ChangesetForm<Strict>) -> String {
format!(
"{} file={}",
form.data().name,
form.file("avatar").is_some()
)
}
let mut req = multipart_parts_req(
"/test",
&[
Part::text("name", "Alice"),
Part::file("avatar", "", "application/octet-stream", b""),
],
);
req.extensions_mut()
.insert(crate::security::config::UploadConfig {
allowed_mime_types: vec!["image/png".to_owned()],
..Default::default()
});
let resp = Router::new()
.route("/test", post(handler))
.oneshot(req)
.await
.unwrap();
assert_body(resp, "Alice file=false").await;
}
#[cfg(feature = "multipart")]
#[tokio::test]
async fn multipart_keeps_a_tighter_route_body_limit() {
async fn handler(_form: ChangesetForm<TestForm>) -> &'static str {
"accepted"
}
let req = multipart_parts_req(
"/test",
&[
Part::text("name", "Alice"),
Part::file("avatar", "big.txt", "text/plain", &[b'x'; 4096]),
],
);
let resp = Router::new()
.route("/test", post(handler))
.layer(axum::extract::DefaultBodyLimit::max(1024))
.oneshot(req)
.await
.unwrap();
assert_eq!(resp.status(), axum::http::StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn urlencoded_form_has_no_files() {
async fn handler(form: ChangesetForm<TestForm>) -> String {
format!("{}", form.files("avatar").count())
}
let resp = Router::new()
.route("/test", post(handler))
.oneshot(urlencoded_req("/test", "name=Alice&avatar=x"))
.await
.unwrap();
assert_body(resp, "0").await;
}
#[cfg(feature = "multipart")]
struct Part<'a> {
name: &'a str,
file: Option<(&'a str, &'a str)>,
value: &'a [u8],
}
#[cfg(feature = "multipart")]
impl<'a> Part<'a> {
const fn text(name: &'a str, value: &'a str) -> Self {
Self {
name,
file: None,
value: value.as_bytes(),
}
}
const fn file(
name: &'a str,
file_name: &'a str,
content_type: &'a str,
value: &'a [u8],
) -> Self {
Self {
name,
file: Some((file_name, content_type)),
value,
}
}
}
#[cfg(feature = "multipart")]
fn multipart_parts_req(uri: &str, parts: &[Part<'_>]) -> axum::http::Request<Body> {
let boundary = "----FormBoundary7MA4YWxkTrZu0gW";
let mut body: Vec<u8> = Vec::new();
for part in parts {
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
match part.file {
Some((file_name, content_type)) => body.extend_from_slice(
format!(
"Content-Disposition: form-data; name=\"{}\"; filename=\"{file_name}\"\r\n\
Content-Type: {content_type}\r\n\r\n",
part.name
)
.as_bytes(),
),
None => body.extend_from_slice(
format!(
"Content-Disposition: form-data; name=\"{}\"\r\n\r\n",
part.name
)
.as_bytes(),
),
}
body.extend_from_slice(part.value);
body.extend_from_slice(b"\r\n");
}
body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
axum::http::Request::builder()
.method("POST")
.uri(uri)
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap()
}
fn urlencoded_req(uri: &str, body: &'static str) -> axum::http::Request<Body> {
axum::http::Request::builder()
.method("POST")
.uri(uri)
.header("Content-Type", "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap()
}
#[cfg(feature = "multipart")]
fn multipart_req(uri: &str, field: &str, value: &str) -> axum::http::Request<Body> {
multipart_req_multi(uri, &[(field, value)])
}
#[cfg(feature = "multipart")]
fn multipart_req_multi(uri: &str, fields: &[(&str, &str)]) -> axum::http::Request<Body> {
use std::fmt::Write as _;
let boundary = "----FormBoundary7MA4YWxkTrZu0gW";
let mut body = String::new();
for (field, value) in fields {
let _ = write!(
body,
"--{boundary}\r\n\
Content-Disposition: form-data; name=\"{field}\"\r\n\r\n\
{value}\r\n"
);
}
let _ = write!(body, "--{boundary}--\r\n");
axum::http::Request::builder()
.method("POST")
.uri(uri)
.header(
"Content-Type",
format!("multipart/form-data; boundary={boundary}"),
)
.body(Body::from(body))
.unwrap()
}
async fn body_text(resp: axum::response::Response) -> String {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
async fn assert_body(resp: axum::response::Response, expected: &str) {
assert_eq!(body_text(resp).await, expected);
}
}
}
#[cfg(all(test, feature = "maud"))]
mod custom_control_tests {
use super::*;
struct DateLike;
impl FieldRenderer for DateLike {
fn name(&self) -> &'static str {
"datelike"
}
fn render(&self, field: &FieldContext<'_>) -> maud::Markup {
field.control(
"input",
&[("type", "text"), ("class", "autumn-datelike-input")],
maud::html! {},
)
}
}
struct Hostile;
impl FieldRenderer for Hostile {
fn name(&self) -> &'static str {
"hostile"
}
fn render(&self, field: &FieldContext<'_>) -> maud::Markup {
field.control(
"div",
&[
("id", "evil"),
("name", "evil"),
("required", ""),
("aria-invalid", "false"),
("aria-describedby", "evil"),
("onclick", "alert(1)"),
("ONCLICK", "alert(1)"),
("hx-on:click", "alert(1)"),
("hx-on--click", "alert(1)"),
("data-hx-on:click", "alert(1)"),
("role", "first"),
("style", "color:red"),
("bad name", "x"),
("x\"y", "x"),
("role", "listbox"),
("aria-haspopup", "listbox"),
("data-autumn-roving", ""),
("value", "<b>\"quoted\"</b>"),
],
maud::html! { span { "body" } },
)
}
}
struct Radios;
impl FieldRenderer for Radios {
fn name(&self) -> &'static str {
"radios"
}
fn grouped(&self) -> bool {
true
}
fn render(&self, field: &FieldContext<'_>) -> maud::Markup {
maud::html! {
@for choice in ["a", "b"] {
label {
@let checked: &[(&str, &str)] = if field.value() == Some(choice) {
&[("checked", "")]
} else {
&[]
};
@let attrs: Vec<(&str, &str)> = [("type", "radio"), ("value", choice)]
.into_iter()
.chain(checked.iter().copied())
.collect();
(field.group_item("input", choice, &attrs, maud::html! {}))
(choice)
}
}
}
}
}
struct Upload;
impl FieldRenderer for Upload {
fn name(&self) -> &'static str {
"upload"
}
fn multipart(&self) -> bool {
true
}
fn render(&self, field: &FieldContext<'_>) -> maud::Markup {
field.control("input", &[("type", "file")], maud::html! {})
}
}
#[derive(Serialize)]
struct Task {
due: String,
title: String,
}
impl FormModel for Task {
fn form_fields() -> Vec<FormField> {
vec![
FormField::new("title", "Title", FieldControl::Text, false),
FormField::new("due", "Due", FieldControl::Text, true),
]
}
}
fn task() -> Task {
Task {
due: "2026-10-29".into(),
title: "Ship".into(),
}
}
fn invalid_task() -> Changeset<Task> {
let mut changeset = Changeset::new(task());
changeset.add_error("due", "is in the past");
changeset
}
#[test]
fn custom_input_writes_wrapper_label_hint_and_errors() {
let html = custom_input_with(
&invalid_task(),
"due",
"Due",
&DateLike,
&FieldOptions::new().hint("Pick a day."),
)
.into_string();
assert!(
html.starts_with(r#"<div id="due-field" class="autumn-field">"#),
"{html}"
);
assert!(
html.contains(
r#"<label id="due-label" for="due" class="autumn-field__label">Due</label>"#
),
"{html}"
);
assert!(
html.contains(r#"<p id="due-hint" class="autumn-field__hint">Pick a day.</p>"#),
"{html}"
);
assert!(
html.contains(
r#"<input type="text" id="due" name="due" value="2026-10-29" class="autumn-field__input autumn-field__input--invalid autumn-datelike-input" aria-invalid="true" aria-describedby="due-hint due-error">"#
),
"{html}"
);
assert!(
html.contains(r#"<div id="due-error" role="alert" class="autumn-field__errors"><p class="autumn-field__error">is in the past</p></div>"#),
"{html}"
);
assert!(html.ends_with("</div>"), "{html}");
}
#[test]
fn custom_input_renders_the_passthrough_on_the_control() {
let html = custom_input_with(
&Changeset::new(task()),
"due",
"Due",
&DateLike,
&FieldOptions::new()
.required()
.placeholder("YYYY-MM-DD")
.autocomplete("off")
.disabled()
.data("test-id", "due")
.hx("get", "/check"),
)
.into_string();
for want in [
r#" required aria-required="true""#,
r#" placeholder="YYYY-MM-DD""#,
r#" autocomplete="off""#,
" disabled",
r#" data-test-id="due""#,
r#" hx-get="/check""#,
r#"aria-invalid="false""#,
r#"<span class="autumn-field__required" aria-hidden="true">*</span>"#,
] {
assert!(html.contains(want), "missing `{want}`: {html}");
}
}
#[test]
fn control_drops_owned_and_unsafe_attributes() {
let html = custom_input_with(
&invalid_task(),
"due",
"Due",
&Hostile,
&FieldOptions::new(),
)
.into_string();
for banned in [
"evil",
"onclick",
"ONCLICK",
"alert(1)",
"hx-on",
r#"role="listbox""#,
"style=",
"bad name",
"x\"y",
r#"aria-invalid="false""#,
] {
assert!(!html.contains(banned), "`{banned}` leaked: {html}");
}
assert!(!html.contains(" required"), "required leaked: {html}");
assert!(!html.contains(" name="), "a div has no name: {html}");
for kept in [
r#"<div id="due" value="<b>"quoted"</b>" class="autumn-field__input autumn-field__input--invalid" aria-invalid="true" aria-describedby="due-error" aria-labelledby="due-label" role="first""#,
r#" aria-haspopup="listbox""#,
r#" data-autumn-roving="""#,
r#" value="<b>"quoted"</b>""#,
r#"aria-describedby="due-error""#,
"><span>body</span></div>",
] {
assert!(html.contains(kept), "missing `{kept}`: {html}");
}
}
#[test]
#[should_panic(expected = "tag")]
fn control_panics_on_a_bad_tag() {
struct BadTag;
impl FieldRenderer for BadTag {
fn name(&self) -> &'static str {
"bad"
}
fn render(&self, field: &FieldContext<'_>) -> maud::Markup {
field.control("div onclick=x", &[], maud::html! {})
}
}
let _ = custom_input_with(
&Changeset::new(task()),
"due",
"Due",
&BadTag,
&FieldOptions::new(),
);
}
#[test]
fn grouped_control_uses_fieldset_and_legend() {
let mut changeset = Changeset::new(Task {
due: "b".into(),
title: String::new(),
});
changeset.add_error("due", "pick one");
let html = custom_input_with(
&changeset,
"due",
"Due",
&Radios,
&FieldOptions::new().hint("One only.").required(),
)
.into_string();
assert!(
html.starts_with(
r#"<fieldset id="due-field" class="autumn-field autumn-field--group" aria-invalid="true" aria-describedby="due-hint due-error"><legend class="autumn-field__label">Due<span class="autumn-field__required" aria-hidden="true">*</span></legend>"#
),
"{html}"
);
assert!(!html.contains("<label id="), "{html}");
assert!(
html.contains(
r#"<input type="radio" id="due-b" name="due" required aria-invalid="true" value="b" checked="">"#
),
"{html}"
);
assert!(
html.contains(r#"<input type="radio" id="due-a" name="due" required aria-invalid="true" value="a">"#),
"{html}"
);
assert!(html.ends_with("</div></fieldset>"), "{html}");
}
#[test]
fn form_for_renders_a_custom_override_with_model_required() {
let changeset = Changeset::new(task());
let html = form_for(&changeset, "/tasks", "post")
.override_field("due", FieldControl::custom(DateLike))
.render()
.into_string();
assert!(
html.contains(
r#"<input type="text" id="due" name="due" value="2026-10-29" required aria-required="true" class="autumn-field__input autumn-datelike-input""#
),
"{html}"
);
assert!(!html.contains("multipart"), "{html}");
}
#[test]
fn form_for_is_multipart_for_a_multipart_custom_control() {
let changeset = Changeset::new(task());
let html = form_for(&changeset, "/tasks", "post")
.override_field("due", FieldControl::custom(Upload))
.render()
.into_string();
assert!(html.contains(r#"enctype="multipart/form-data""#), "{html}");
}
#[test]
fn form_for_prefills_a_custom_control_through_a_serde_rename() {
#[derive(Serialize)]
struct Renamed {
#[serde(rename = "dueOn")]
due: String,
}
impl FormModel for Renamed {
fn form_fields() -> Vec<FormField> {
vec![
FormField::new("due", "Due", FieldControl::custom(DateLike), false)
.with_value_name("dueOn"),
]
}
}
let changeset = Changeset::new(Renamed {
due: "2026-11-01".into(),
});
let html = form_for(&changeset, "/t", "post").render().into_string();
assert!(html.contains(r#"name="due" value="2026-11-01""#), "{html}");
}
#[test]
fn custom_control_equality_is_by_shared_renderer() {
let a = FieldControl::custom(DateLike);
assert_eq!(a, a.clone());
assert_ne!(a, FieldControl::custom(DateLike));
assert_ne!(a, FieldControl::Text);
assert_eq!(format!("{a:?}"), r#"Custom(CustomControl("datelike"))"#);
}
#[test]
fn field_context_reports_ids_and_state() {
let changeset = invalid_task();
let options = FieldOptions::new().hint("h").required();
let value = changeset.field_value("due");
let context = FieldContext {
field: "due",
label: "Due",
value: value.as_deref(),
values: &[],
errors: changeset.errors_for("due"),
options: &options,
grouped: false,
};
assert_eq!(context.id(), "due");
assert_eq!(context.name(), "due");
assert_eq!(context.label(), "Due");
assert_eq!(context.value(), Some("2026-10-29"));
assert!(context.is_invalid());
assert!(context.is_required());
assert_eq!(context.errors(), ["is in the past"]);
assert_eq!(
context.described_by().as_deref(),
Some("due-hint due-error")
);
assert!(context.options().is_required());
}
struct Secret;
impl FieldRenderer for Secret {
fn name(&self) -> &'static str {
"secret"
}
fn render(&self, field: &FieldContext<'_>) -> maud::Markup {
maud::html! {
(field.control("input", &[("type", "PASSWORD")], maud::html! {}))
(field.control("button", &[("aria-label", "Show")], maud::html! { "Show" }))
}
}
}
#[test]
fn control_does_not_prefill_a_password_and_gives_a_button_a_type() {
let html = custom_input_with(&invalid_task(), "due", "Due", &Secret, &FieldOptions::new())
.into_string();
assert!(!html.contains("2026-10-29"), "password echoed: {html}");
assert!(
html.contains(r#"<button type="button" id="due" name="due""#),
"{html}"
);
assert!(!html.contains("aria-labelledby"), "{html}");
}
struct Duplicates;
impl FieldRenderer for Duplicates {
fn name(&self) -> &'static str {
"duplicates"
}
fn render(&self, field: &FieldContext<'_>) -> maud::Markup {
field.control(
"div",
&[
("role", "listbox"),
("data-x", "renderer"),
("data-x", "second"),
("placeholder", "renderer"),
],
maud::html! {},
)
}
}
#[test]
fn control_writes_each_name_once_and_aria_required_with_a_role() {
let html = custom_input_with(
&Changeset::new(task()),
"due",
"Due",
&Duplicates,
&FieldOptions::new()
.required()
.placeholder("app")
.data("x", "app"),
)
.into_string();
assert_eq!(html.matches("data-x=").count(), 1, "{html}");
assert_eq!(html.matches("placeholder=").count(), 1, "{html}");
assert!(html.contains(r#"data-x="app""#), "{html}");
assert!(html.contains(r#"placeholder="app""#), "{html}");
assert!(html.contains(r#" aria-required="true""#), "{html}");
assert!(!html.contains(" required "), "{html}");
}
#[test]
fn described_by_is_escaped_once() {
#[derive(Serialize)]
struct Odd {
#[serde(rename = "a&b")]
value: String,
}
let mut changeset = Changeset::new(Odd {
value: String::new(),
});
changeset.add_error("a&b", "bad");
let html = custom_input_with(&changeset, "a&b", "A", &DateLike, &FieldOptions::new())
.into_string();
assert!(
html.contains(r#"aria-describedby="a&b-error""#),
"{html}"
);
assert!(html.contains(r#"<div id="a&b-error""#), "{html}");
}
#[test]
fn field_context_exposes_all_values_of_a_repeated_field() {
struct Tags;
impl FieldRenderer for Tags {
fn name(&self) -> &'static str {
"tags"
}
fn render(&self, field: &FieldContext<'_>) -> maud::Markup {
maud::html! { span { (field.values().join(",")) } }
}
}
#[derive(Serialize)]
struct Post {
tags: Vec<String>,
}
let changeset = Changeset::new(Post {
tags: vec!["rust".into(), "web".into()],
});
let html = custom_input_with(&changeset, "tags", "Tags", &Tags, &FieldOptions::new())
.into_string();
assert!(html.contains("<span>rust,web</span>"), "{html}");
}
#[test]
fn widget_element_writes_attrs_then_the_passthrough() {
let html = widget_element(
"div",
&[
("class", "autumn-x-bar"),
("role", "toolbar"),
("data-x", "widget"),
("onclick", "alert(1)"),
("hx-on:click", "alert(1)"),
("style", "color:red"),
],
&FieldOptions::new()
.data("x", "app")
.data("y", "app")
.hx("get", "/y"),
maud::html! { "body" },
)
.into_string();
assert_eq!(
html,
r#"<div class="autumn-x-bar" role="toolbar" data-x="widget" data-y="app" hx-get="/y">body</div>"#
);
}
#[test]
fn code_loading_tags_are_refused() {
for tag in ["script", "style", "iframe", "object", "base"] {
let result = std::panic::catch_unwind(|| {
widget_element(tag, &[], &FieldOptions::new(), maud::html! {})
});
assert!(result.is_err(), "`{tag}` must be refused");
}
}
#[test]
fn app_passthrough_matches_core_helpers_and_pack_attrs_are_filtered() {
let options = FieldOptions::new().hx("on:click", "app()").hx("get", "/ok");
let core = text_input_with(&Changeset::new(task()), "due", "Due", &options).into_string();
let control = custom_input_with(&Changeset::new(task()), "due", "Due", &DateLike, &options)
.into_string();
let element = widget_element(
"div",
&[("hx-on:click", "pack()"), ("onclick", "pack()")],
&options,
maud::html! {},
)
.into_string();
for html in [&core, &control, &element] {
assert!(html.contains(r#"hx-on:click="app()""#), "{html}");
assert!(html.contains(r#"hx-get="/ok""#), "{html}");
assert!(!html.contains("pack()"), "pack handler leaked: {html}");
}
}
#[test]
fn field_options_getters_report_the_state() {
let options = FieldOptions::new()
.hint("h")
.required()
.disabled()
.readonly();
assert!(options.is_required() && options.is_disabled() && options.is_readonly());
assert_eq!(options.hint_text(), Some("h"));
assert_eq!(FieldOptions::new().hint_text(), None);
}
}