use std::fmt::Write as _;
use crate::core::{FieldSchema, FieldType};
#[cfg(feature = "postgres")]
use crate::sql::sqlx::{postgres::PgRow, Row};
pub(crate) fn escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
other => out.push(other),
}
}
out
}
pub(crate) fn coerce_form_to_json(field: &FieldSchema, raw: &str) -> serde_json::Value {
use serde_json::Value;
if raw.is_empty() && field.nullable {
return Value::Null;
}
match field.ty {
FieldType::I16 => raw
.parse::<i16>()
.map(Value::from)
.unwrap_or_else(|_| Value::String(raw.to_owned())),
FieldType::I32 => raw
.parse::<i32>()
.map(Value::from)
.unwrap_or_else(|_| Value::String(raw.to_owned())),
FieldType::I64 => raw
.parse::<i64>()
.map(Value::from)
.unwrap_or_else(|_| Value::String(raw.to_owned())),
FieldType::F32 => raw
.parse::<f32>()
.map(Value::from)
.unwrap_or_else(|_| Value::String(raw.to_owned())),
FieldType::F64 => raw
.parse::<f64>()
.map(Value::from)
.unwrap_or_else(|_| Value::String(raw.to_owned())),
FieldType::Bool => match raw.to_ascii_lowercase().as_str() {
"true" | "on" | "1" | "yes" => Value::Bool(true),
"false" | "off" | "0" | "no" | "" => Value::Bool(false),
_ => Value::String(raw.to_owned()),
},
_ => Value::String(raw.to_owned()),
}
}
pub(crate) fn render_value_for_input_json(row: &serde_json::Value, field: &FieldSchema) -> String {
let v = row.get(field.column).or_else(|| row.get(field.name));
let Some(v) = v else { return String::new() };
if v.is_null() {
return String::new();
}
match field.ty {
FieldType::I16 | FieldType::I32 | FieldType::I64 => v
.as_i64()
.map(|n| n.to_string())
.unwrap_or_else(|| v.as_str().unwrap_or("").to_owned()),
FieldType::F32 | FieldType::F64 => v
.as_f64()
.map(|n| n.to_string())
.unwrap_or_else(|| v.as_str().unwrap_or("").to_owned()),
FieldType::Bool => v
.as_bool()
.map(|b| b.to_string())
.unwrap_or_else(|| v.as_str().unwrap_or("").to_owned()),
FieldType::String | FieldType::Uuid => v.as_str().unwrap_or("").to_owned(),
FieldType::Date => v.as_str().unwrap_or("").to_owned(),
FieldType::Time => v.as_str().unwrap_or("").to_owned(),
FieldType::DateTime => {
let s = v.as_str().unwrap_or("");
if s.len() >= 19 {
s[..19].to_owned()
} else {
s.to_owned()
}
}
FieldType::Json => {
if v == &serde_json::Value::Object(serde_json::Map::new()) {
String::new()
} else {
serde_json::to_string_pretty(v).unwrap_or_default()
}
}
FieldType::Decimal | FieldType::Binary => v.as_str().unwrap_or("").to_owned(),
}
}
pub(crate) fn render_gfk_select(
field: &FieldSchema,
value: &str,
readonly: bool,
cts: &[crate::contenttypes::ContentType],
) -> String {
let name = escape(field.name);
let disabled = if readonly { " disabled" } else { "" };
let required = if field.nullable || field.primary_key {
""
} else {
" required"
};
let mut out = format!(r#"<select name="{name}" id="{name}"{required}{disabled}>"#,);
out.push_str(r#"<option value="">— choose target —</option>"#);
for ct in cts {
let Some(id) = ct.id.get().copied() else {
continue;
};
let selected = if value == id.to_string() {
" selected"
} else {
""
};
let label = escape(&format!("{}.{}", ct.app_label, ct.model_name));
let _ = write!(out, r#"<option value="{id}"{selected}>{label}</option>"#);
}
out.push_str("</select>");
out
}
pub(crate) fn render_input(field: &FieldSchema, value: &str, pk_locked: bool) -> String {
render_input_with_widget(field, value, pk_locked, None)
}
pub(crate) fn render_input_with_widget(
field: &FieldSchema,
value: &str,
pk_locked: bool,
widget: Option<&str>,
) -> String {
if let Some(name) = widget {
if let Some(html) = render_named_widget(field, value, pk_locked, name) {
return html;
}
tracing::warn!(
target: "rustango::admin",
field = %field.name,
widget = %name,
"unknown or incompatible formfield_overrides widget — falling back to FieldType default"
);
}
render_input_default(field, value, pk_locked)
}
fn render_input_default(field: &FieldSchema, value: &str, pk_locked: bool) -> String {
let name = escape(field.name);
let val = escape(value);
let required = if field.nullable
|| field.ty == FieldType::Bool
|| field.auto
|| field.primary_key
|| field.blank
{
""
} else {
" required"
};
let readonly = if pk_locked { " readonly" } else { "" };
if let Some(choices) = field.choices {
let disabled = if pk_locked { " disabled" } else { "" };
let mut out = format!(r#"<select name="{name}" id="{name}"{required}{disabled}>"#);
if field.nullable {
out.push_str(r#"<option value=""></option>"#);
}
for (v, label) in choices {
let v_esc = escape(v);
let label_esc = escape(label);
let selected = if value == *v { " selected" } else { "" };
let _ = write!(
out,
r#"<option value="{v_esc}"{selected}>{label_esc}</option>"#
);
}
out.push_str("</select>");
return out;
}
match field.ty {
FieldType::Bool => {
let checked = if value == "true" { " checked" } else { "" };
format!(
r#"<input type="checkbox" name="{name}" id="{name}" value="true"{checked}{readonly}>"#
)
}
FieldType::I16 | FieldType::I32 | FieldType::I64 => {
let mut attrs = String::new();
if let Some(min) = field.min {
let _ = write!(attrs, r#" min="{min}""#);
}
if let Some(max) = field.max {
let _ = write!(attrs, r#" max="{max}""#);
}
format!(
r#"<input type="number" step="1" name="{name}" id="{name}" value="{val}"{attrs}{required}{readonly}>"#
)
}
FieldType::F32 | FieldType::F64 => {
format!(
r#"<input type="number" step="any" name="{name}" id="{name}" value="{val}"{required}{readonly}>"#
)
}
FieldType::String => match field.max_length {
Some(n) if n <= 80 => format!(
r#"<input type="text" name="{name}" id="{name}" value="{val}" maxlength="{n}"{required}{readonly}>"#
),
Some(n) => format!(
r#"<textarea name="{name}" id="{name}" maxlength="{n}"{required}{readonly}>{val}</textarea>"#
),
None => format!(
r#"<textarea name="{name}" id="{name}"{required}{readonly}>{val}</textarea>"#
),
},
FieldType::Date => format!(
r#"<input type="date" name="{name}" id="{name}" value="{val}"{required}{readonly}>"#
),
FieldType::DateTime => format!(
r#"<input type="datetime-local" name="{name}" id="{name}" value="{val}"{required}{readonly}>"#
),
FieldType::Time => format!(
r#"<input type="time" name="{name}" id="{name}" value="{val}" step="1"{required}{readonly}>"#
),
FieldType::Uuid => format!(
r#"<input type="text" name="{name}" id="{name}" value="{val}" pattern="[0-9a-fA-F\-]+"{required}{readonly}>"#
),
FieldType::Json => format!(
r#"<textarea name="{name}" id="{name}"{readonly} style="font-family:monospace">{val}</textarea>"#
),
FieldType::Decimal => format!(
r#"<input type="number" step="any" inputmode="decimal" name="{name}" id="{name}" value="{val}"{required}{readonly}>"#
),
FieldType::Binary => format!(
r#"<input type="text" name="{name}" id="{name}" value="{val}" pattern="[0-9a-f]*" inputmode="latin"{readonly} style="font-family:monospace">"#
),
}
}
fn render_named_widget(
field: &FieldSchema,
value: &str,
pk_locked: bool,
widget: &str,
) -> Option<String> {
let name = escape(field.name);
let val = escape(value);
let required = if field.nullable
|| field.ty == FieldType::Bool
|| field.auto
|| field.primary_key
|| field.blank
{
""
} else {
" required"
};
let readonly = if pk_locked { " readonly" } else { "" };
match widget {
"hidden" => Some(format!(
r#"<input type="hidden" name="{name}" id="{name}" value="{val}">"#
)),
"password" if matches!(field.ty, FieldType::String) => Some(format!(
r#"<input type="password" name="{name}" id="{name}" value="{val}"{required}{readonly}>"#
)),
"textarea" if matches!(field.ty, FieldType::String) => {
let maxlen = field
.max_length
.map(|n| format!(r#" maxlength="{n}""#))
.unwrap_or_default();
Some(format!(
r#"<textarea name="{name}" id="{name}"{maxlen}{required}{readonly}>{val}</textarea>"#
))
}
"color" if matches!(field.ty, FieldType::String) => Some(format!(
r#"<input type="color" name="{name}" id="{name}" value="{val}"{required}{readonly}>"#
)),
"email" if matches!(field.ty, FieldType::String) => Some(format!(
r#"<input type="email" name="{name}" id="{name}" value="{val}"{required}{readonly}>"#
)),
"url" if matches!(field.ty, FieldType::String) => Some(format!(
r#"<input type="url" name="{name}" id="{name}" value="{val}"{required}{readonly}>"#
)),
"tel" if matches!(field.ty, FieldType::String) => Some(format!(
r#"<input type="tel" name="{name}" id="{name}" value="{val}"{required}{readonly}>"#
)),
"search" if matches!(field.ty, FieldType::String) => Some(format!(
r#"<input type="search" name="{name}" id="{name}" value="{val}"{required}{readonly}>"#
)),
"range" if matches!(field.ty, FieldType::I16 | FieldType::I32 | FieldType::I64) => {
let mut attrs = String::new();
if let Some(min) = field.min {
attrs.push_str(&format!(r#" min="{min}""#));
}
if let Some(max) = field.max {
attrs.push_str(&format!(r#" max="{max}""#));
}
Some(format!(
r#"<input type="range" step="1" name="{name}" id="{name}" value="{val}"{attrs}{readonly}>"#
))
}
_ => None,
}
}
#[cfg(feature = "postgres")]
pub(crate) fn read_value_as_string(row: &PgRow, field: &FieldSchema) -> Option<String> {
read_value_as_string_at(row, field, field.column)
}
pub(crate) fn render_value_json(row: &serde_json::Value, field: &FieldSchema) -> String {
let v = row.get(field.name);
if matches!(v, None | Some(serde_json::Value::Null)) {
return if field.ty == FieldType::Bool {
r#"<span class="rcms-bool no" aria-label="false">☐</span>"#.to_owned()
} else {
"<em>NULL</em>".to_owned()
};
}
let v = v.unwrap();
match field.ty {
FieldType::Bool => match v.as_bool() {
Some(true) => r#"<span class="rcms-bool yes" aria-label="true">☑</span>"#.to_owned(),
_ => r#"<span class="rcms-bool no" aria-label="false">☐</span>"#.to_owned(),
},
FieldType::I16 | FieldType::I32 | FieldType::I64 => v
.as_i64()
.map(|n| escape(&n.to_string()))
.unwrap_or_else(|| escape(&v.to_string())),
FieldType::F32 | FieldType::F64 => v
.as_f64()
.map(|n| escape(&n.to_string()))
.unwrap_or_else(|| escape(&v.to_string())),
FieldType::String
| FieldType::Uuid
| FieldType::Date
| FieldType::Time
| FieldType::DateTime
| FieldType::Decimal => escape(v.as_str().unwrap_or("")),
FieldType::Binary => {
let s = v.as_str().unwrap_or("");
if s.len() > 16 {
escape(&format!("{}… ({} hex chars)", &s[..16], s.len()))
} else {
escape(s)
}
}
FieldType::Json => {
serde_json::to_string(v)
.map(|s| escape(&s))
.unwrap_or_default()
}
}
}
pub(crate) fn read_value_as_string_json(
row: &serde_json::Value,
field: &FieldSchema,
) -> Option<String> {
read_value_as_string_at_json(row, field, field.name)
}
pub(crate) fn read_value_as_string_at_json(
row: &serde_json::Value,
field: &FieldSchema,
key: &str,
) -> Option<String> {
let v = row.get(key)?;
if v.is_null() {
return None;
}
match field.ty {
FieldType::I16 | FieldType::I32 | FieldType::I64 => v.as_i64().map(|n| n.to_string()),
FieldType::String | FieldType::Uuid => v.as_str().map(str::to_owned),
_ => None,
}
}
pub(crate) fn read_joined_value_as_html_json(
row: &serde_json::Value,
alias: &str,
field: &FieldSchema,
) -> Option<String> {
let key = format!("{}__{}", alias, field.column);
let v = row.get(&key)?;
if v.is_null() {
return None;
}
let text: Option<String> = match field.ty {
FieldType::I16 | FieldType::I32 | FieldType::I64 => v.as_i64().map(|n| n.to_string()),
FieldType::F32 | FieldType::F64 => v.as_f64().map(|n| n.to_string()),
FieldType::Bool => v.as_bool().map(|b| b.to_string()),
FieldType::String
| FieldType::Uuid
| FieldType::Date
| FieldType::Time
| FieldType::DateTime
| FieldType::Decimal => v.as_str().map(str::to_owned),
FieldType::Binary => v.as_str().map(|s| {
if s.len() > 16 {
format!("{}… ({} hex chars)", &s[..16], s.len())
} else {
s.to_owned()
}
}),
FieldType::Json => None,
};
text.map(|s| escape(&s))
}
pub(crate) fn read_value_as_json_from_json(
row: &serde_json::Value,
field: &FieldSchema,
) -> serde_json::Value {
use serde_json::Value;
let v = row.get(field.name).cloned().unwrap_or(Value::Null);
if v.is_null() {
return Value::Null;
}
match field.ty {
FieldType::I16 | FieldType::I32 | FieldType::I64 => {
v.as_i64().map(Value::from).unwrap_or(v)
}
FieldType::F32 | FieldType::F64 => v.as_f64().map(Value::from).unwrap_or(v),
FieldType::Bool => v.as_bool().map(Value::from).unwrap_or(v),
_ => v,
}
}
#[cfg(feature = "postgres")]
pub(crate) fn read_value_as_string_at(
row: &PgRow,
field: &FieldSchema,
column_alias: &str,
) -> Option<String> {
match field.ty {
FieldType::I16 => row
.try_get::<Option<i16>, _>(column_alias)
.ok()
.flatten()
.map(|v| v.to_string()),
FieldType::I32 => row
.try_get::<Option<i32>, _>(column_alias)
.ok()
.flatten()
.map(|v| v.to_string()),
FieldType::I64 => row
.try_get::<Option<i64>, _>(column_alias)
.ok()
.flatten()
.map(|v| v.to_string()),
FieldType::String => row
.try_get::<Option<String>, _>(column_alias)
.ok()
.flatten(),
FieldType::Uuid => row
.try_get::<Option<uuid::Uuid>, _>(column_alias)
.ok()
.flatten()
.map(|v| v.to_string()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::{FieldSchema, FieldType};
use serde_json::json;
fn field(name: &'static str, column: &'static str, ty: FieldType) -> FieldSchema {
FieldSchema {
name,
column,
ty,
nullable: true,
primary_key: false,
auto: false,
unique: false,
max_length: None,
min: None,
max: None,
default: None,
relation: None,
generated_as: None,
help_text: None,
choices: None,
db_comment: None,
verbose_name: None,
editable: true,
blank: false,
validators: &[],
}
}
#[test]
fn render_value_json_bool_uses_checkbox_glyph() {
let f = field("active", "active", FieldType::Bool);
let row = json!({ "active": true });
let html = render_value_json(&row, &f);
assert!(html.contains("rcms-bool yes"));
assert!(html.contains("☑"));
let row = json!({ "active": false });
let html = render_value_json(&row, &f);
assert!(html.contains("rcms-bool no"));
assert!(html.contains("☐"));
}
#[test]
fn render_value_json_null_renders_em_null_except_for_bool() {
let f_str = field("name", "name", FieldType::String);
let row = json!({ "name": null });
assert_eq!(render_value_json(&row, &f_str), "<em>NULL</em>");
let f_bool = field("active", "active", FieldType::Bool);
let row = json!({ "active": null });
let html = render_value_json(&row, &f_bool);
assert!(html.contains("☐"));
}
#[test]
fn render_value_json_integers_render_unadorned() {
let f = field("count", "count", FieldType::I64);
let row = json!({ "count": 42 });
assert_eq!(render_value_json(&row, &f), "42");
}
#[test]
fn render_value_json_strings_are_html_escaped() {
let f = field("title", "title", FieldType::String);
let row = json!({ "title": "<script>alert('xss')</script>" });
let html = render_value_json(&row, &f);
assert!(html.contains("<script"));
assert!(!html.contains("<script"));
}
#[test]
fn read_value_as_string_json_handles_supported_types() {
let row = json!({ "id": 42, "name": "alice" });
assert_eq!(
read_value_as_string_json(&row, &field("id", "id", FieldType::I64)),
Some("42".into())
);
assert_eq!(
read_value_as_string_json(&row, &field("name", "name", FieldType::String)),
Some("alice".into())
);
let row = json!({ "active": true });
assert_eq!(
read_value_as_string_json(&row, &field("active", "active", FieldType::Bool)),
None
);
}
#[test]
fn read_value_as_string_at_json_uses_custom_key() {
let row = json!({ "facet_value": 7 });
let f = field("id", "id", FieldType::I64);
assert_eq!(
read_value_as_string_at_json(&row, &f, "facet_value"),
Some("7".into())
);
}
#[test]
fn read_joined_value_as_html_json_reads_prefixed_key() {
let row = json!({ "author__name": "Ada Lovelace" });
let f = field("name", "name", FieldType::String);
assert_eq!(
read_joined_value_as_html_json(&row, "author", &f),
Some("Ada Lovelace".into())
);
}
#[test]
fn read_joined_value_as_html_json_returns_none_for_left_join_miss() {
let row = json!({ "author__name": null });
let f = field("name", "name", FieldType::String);
assert_eq!(read_joined_value_as_html_json(&row, "author", &f), None);
}
#[test]
fn read_value_as_json_from_json_passes_through_numbers() {
let row = json!({ "count": 7 });
let f = field("count", "count", FieldType::I64);
let v = read_value_as_json_from_json(&row, &f);
assert_eq!(v, json!(7));
}
#[test]
fn render_input_emits_select_when_choices_present() {
let mut f = field("status", "status", FieldType::String);
f.choices = Some(&[("draft", "Draft"), ("published", "Published")]);
f.nullable = false;
let html = render_input(&f, "published", false);
assert!(
html.starts_with("<select "),
"expected <select>, got: {html}"
);
assert!(html.contains(r#"<option value="draft">Draft</option>"#));
assert!(html.contains(r#"<option value="published" selected>Published</option>"#));
assert!(
!html.contains(r#"<option value="">"#),
"NOT NULL field should not have empty option, got: {html}"
);
assert!(html.contains(" required"));
}
#[test]
fn render_input_choices_include_blank_option_when_nullable() {
let mut f = field("status", "status", FieldType::String);
f.choices = Some(&[("a", "Alpha"), ("b", "Beta")]);
f.nullable = true;
let html = render_input(&f, "", false);
assert!(html.contains(r#"<option value=""></option>"#));
assert!(!html.contains("selected"));
assert!(!html.contains(" required"));
}
#[test]
fn render_input_choices_escape_html() {
let mut f = field("status", "status", FieldType::String);
f.choices = Some(&[(r#"<a>"#, r#"<b>"#)]);
f.nullable = false;
let html = render_input(&f, "<a>", false);
assert!(html.contains("<a>"));
assert!(html.contains("<b>"));
assert!(!html.contains("<a>"));
assert!(!html.contains("<b>"));
}
#[test]
fn render_input_blank_drops_required_on_not_null_column() {
let mut f = field("subtitle", "subtitle", FieldType::String);
f.nullable = false; f.blank = true; f.max_length = Some(50);
let html = render_input(&f, "", false);
assert!(
!html.contains(" required"),
"blank=true should drop `required`, got: {html}"
);
}
#[test]
fn render_input_keeps_required_on_not_null_when_blank_false() {
let mut f = field("title", "title", FieldType::String);
f.nullable = false;
f.blank = false;
f.max_length = Some(50);
let html = render_input(&f, "", false);
assert!(
html.contains(" required"),
"NOT NULL non-blank field should be required, got: {html}"
);
}
}