use crate::admin::{AdminField, FieldType};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FieldRole {
Id,
Timestamp,
Bool,
NumericCount,
ForeignKey,
Status,
Email,
Phone,
PlainText,
}
impl FieldRole {
pub fn is_sensitive(self) -> bool {
matches!(self, FieldRole::Email | FieldRole::Phone)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FieldUI {
pub role: FieldRole,
pub label: String,
pub placeholder: Option<String>,
pub hint: Option<String>,
pub sensitive: bool,
pub sensitivity_note: Option<String>,
pub relation_label: Option<String>,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FilterKind {
DropdownText,
BoolYesNo,
DateRange,
NumericExact,
ExactMatch,
MultiSelect { values: &'static [&'static str] },
RelationSelect { target_model: String },
FkAutocomplete {
target_admin_name: String,
target_model: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FilterDef {
pub field: String,
pub label: String,
pub kind: FilterKind,
}
pub fn classify_field(f: &AdminField) -> FieldRole {
let name = f.name;
if name == "id" {
return FieldRole::Id;
}
if name == "email" {
return FieldRole::Email;
}
if name == "phone" {
return FieldRole::Phone;
}
if matches!(f.field_type, FieldType::Bool) {
return FieldRole::Bool;
}
if matches!(
f.field_type,
FieldType::DateTime | FieldType::OptionalDateTime
) {
return FieldRole::Timestamp;
}
if name == "status" || name.ends_with("_status") {
return FieldRole::Status;
}
if name.ends_with("_id") && matches!(f.field_type, FieldType::I32 | FieldType::I64) {
return FieldRole::ForeignKey;
}
if matches!(f.field_type, FieldType::I32 | FieldType::I64) {
return FieldRole::NumericCount;
}
FieldRole::PlainText
}
pub fn field_ui_metadata(f: &AdminField) -> FieldUI {
let role = classify_field(f);
let label = humanise(f.name);
let mut placeholder: Option<String> = None;
let mut hint: Option<String> = None;
let mut sensitive = false;
let mut sensitivity_note: Option<String> = None;
match role {
FieldRole::Email => {
placeholder = Some("name@example.com".into());
}
FieldRole::Phone => {
placeholder = Some("+1 555 123 4567".into());
}
FieldRole::Timestamp => {
placeholder = Some("YYYY-MM-DDTHH:MM".into());
hint = Some("Interpreted as UTC.".into());
}
FieldRole::Status => {
hint = Some("Short status label (e.g. active, pending, resolved).".into());
}
FieldRole::ForeignKey => {
hint = Some("Foreign-key id — must reference an existing row.".into());
}
FieldRole::Id | FieldRole::Bool | FieldRole::NumericCount | FieldRole::PlainText => {}
}
if f.name == "slug" {
placeholder = Some("my-post-title".into());
hint = Some("URL-friendly identifier".into());
}
if role.is_sensitive() {
sensitive = true;
sensitivity_note = Some("Personal data.".into());
}
FieldUI {
role,
label,
placeholder,
hint,
sensitive,
sensitivity_note,
relation_label: None,
}
}
pub fn field_ui_metadata_with_relation(f: &AdminField, relation_target: Option<&str>) -> FieldUI {
let mut ui = field_ui_metadata(f);
if let Some(target) = relation_target.filter(|t| !t.is_empty()) {
ui.role = FieldRole::ForeignKey;
ui.relation_label = Some(target.to_string());
ui.hint = Some(format!("Foreign key to {target}."));
}
ui
}
pub fn format_relation_cell(id: i64, target: Option<&str>) -> String {
match target {
Some(t) if !t.is_empty() => format!("{t} #{id}"),
_ => id.to_string(),
}
}
pub fn infer_filters(fields: &[AdminField]) -> Vec<FilterDef> {
infer_filters_with_relations(fields, |_| None)
}
pub fn infer_filters_with_registry(
fields: &[AdminField],
source_model: &str,
registry: &super::relations::RelationRegistry,
) -> Vec<FilterDef> {
let mut out: Vec<FilterDef> = Vec::new();
for f in fields {
if f.name == "id" {
continue;
}
if let Some(values) = f.choices {
if !values.is_empty() {
out.push(FilterDef {
field: f.name.to_string(),
label: humanise(f.name),
kind: FilterKind::MultiSelect { values },
});
continue;
}
}
let role = classify_field(f);
if matches!(role, FieldRole::ForeignKey) {
if let Some(rel) = registry.belongs_to(source_model, f.name) {
out.push(FilterDef {
field: f.name.to_string(),
label: humanise(f.name),
kind: FilterKind::FkAutocomplete {
target_admin_name: rel.target_admin_name.clone(),
target_model: rel.target_model.clone(),
},
});
continue;
}
}
let kind = match role {
FieldRole::Status => FilterKind::DropdownText,
FieldRole::Bool => FilterKind::BoolYesNo,
FieldRole::Timestamp => FilterKind::DateRange,
FieldRole::NumericCount => FilterKind::NumericExact,
FieldRole::ForeignKey => FilterKind::NumericExact,
_ => continue,
};
out.push(FilterDef {
field: f.name.to_string(),
label: humanise(f.name),
kind,
});
}
out
}
pub fn infer_filters_with_relations<F>(
fields: &[AdminField],
relation_target_of: F,
) -> Vec<FilterDef>
where
F: Fn(&AdminField) -> Option<String>,
{
let mut out: Vec<FilterDef> = Vec::new();
for f in fields {
if f.name == "id" {
continue;
}
if let Some(values) = f.choices {
if !values.is_empty() {
out.push(FilterDef {
field: f.name.to_string(),
label: humanise(f.name),
kind: FilterKind::MultiSelect { values },
});
continue;
}
}
let role = classify_field(f);
let kind = match role {
FieldRole::Status => FilterKind::DropdownText,
FieldRole::Bool => FilterKind::BoolYesNo,
FieldRole::Timestamp => FilterKind::DateRange,
FieldRole::NumericCount => FilterKind::NumericExact,
FieldRole::ForeignKey => match relation_target_of(f) {
Some(target_model) if !target_model.is_empty() => {
FilterKind::RelationSelect { target_model }
}
_ => FilterKind::NumericExact,
},
_ => continue,
};
out.push(FilterDef {
field: f.name.to_string(),
label: humanise(f.name),
kind,
});
}
out
}
pub fn mask_pii(value: &str) -> String {
if value.is_empty() {
return String::new();
}
let chars: Vec<char> = value.chars().collect();
let n = chars.len();
let keep = (n / 3).clamp(2, 4).min(n);
let mut out = String::with_capacity(n);
for (i, c) in chars.iter().enumerate() {
if i < keep {
out.push(*c);
} else {
out.push('•');
}
}
out
}
fn humanise(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut next_upper = true;
for ch in s.chars() {
if ch == '_' {
out.push(' ');
next_upper = true;
} else if next_upper {
out.push(ch.to_ascii_uppercase());
next_upper = false;
} else {
out.push(ch);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn field(name: &'static str, ty: FieldType) -> AdminField {
AdminField {
name,
label: name,
field_type: ty,
editable: true,
relation: None,
choices: None,
}
}
#[test]
fn classify_id_email_status_bool_timestamp() {
assert_eq!(classify_field(&field("id", FieldType::I64)), FieldRole::Id);
assert_eq!(
classify_field(&field("email", FieldType::String)),
FieldRole::Email
);
assert_eq!(
classify_field(&field("status", FieldType::String)),
FieldRole::Status
);
assert_eq!(
classify_field(&field("order_status", FieldType::String)),
FieldRole::Status
);
assert_eq!(
classify_field(&field("active", FieldType::Bool)),
FieldRole::Bool
);
assert_eq!(
classify_field(&field("created_at", FieldType::DateTime)),
FieldRole::Timestamp
);
}
#[test]
fn fk_only_for_integer_id_columns() {
assert_eq!(
classify_field(&field("user_id", FieldType::I64)),
FieldRole::ForeignKey
);
assert_eq!(
classify_field(&field("national_id", FieldType::String)),
FieldRole::PlainText
);
}
#[test]
fn infer_filters_skips_id_and_picks_kinds() {
let fields = vec![
field("id", FieldType::I64),
field("status", FieldType::String),
field("active", FieldType::Bool),
field("created_at", FieldType::DateTime),
field("title", FieldType::String),
];
let filters = infer_filters(&fields);
assert_eq!(filters.len(), 3);
assert!(matches!(filters[0].kind, FilterKind::DropdownText));
assert!(matches!(filters[1].kind, FilterKind::BoolYesNo));
assert!(matches!(filters[2].kind, FilterKind::DateRange));
}
#[test]
fn declared_choices_promote_field_to_multi_select() {
const STATES: &[&str] = &["draft", "published", "archived"];
let mut f = field("state", FieldType::String);
f.choices = Some(STATES);
let filters = infer_filters(&[f]);
assert_eq!(filters.len(), 1);
match &filters[0].kind {
FilterKind::MultiSelect { values } => {
assert_eq!(*values, STATES);
}
other => panic!("expected MultiSelect, got {other:?}"),
}
}
#[test]
fn infer_with_registry_falls_back_to_numeric_when_no_relation_resolved() {
let fields = vec![
field("id", FieldType::I64),
field("author_id", FieldType::I64),
];
let registry = super::super::relations::RelationRegistry::empty();
let filters = infer_filters_with_registry(&fields, "Post", ®istry);
assert_eq!(filters.len(), 1);
assert_eq!(filters[0].field, "author_id");
assert!(matches!(filters[0].kind, FilterKind::NumericExact));
}
#[test]
fn infer_with_registry_choices_still_win_over_fk_promotion() {
let mut f = field("workflow_id", FieldType::I64);
const STATES: &[&str] = &["draft", "ready", "shipped"];
f.choices = Some(STATES);
let registry = super::super::relations::RelationRegistry::empty();
let filters = infer_filters_with_registry(&[f], "Order", ®istry);
assert_eq!(filters.len(), 1);
assert!(matches!(filters[0].kind, FilterKind::MultiSelect { .. }));
}
#[test]
fn empty_choices_slice_falls_back_to_role_based_kind() {
let mut f = field("status", FieldType::String);
f.choices = Some(&[]);
let filters = infer_filters(&[f]);
assert_eq!(filters.len(), 1);
assert!(matches!(filters[0].kind, FilterKind::DropdownText));
}
#[test]
fn mask_pii_keeps_prefix_and_replaces_with_bullets() {
assert_eq!(mask_pii("alice@example.com"), "alic•••••••••••••");
assert_eq!(mask_pii(""), "");
}
#[test]
fn relation_label_overrides_role() {
let f = field("user_id", FieldType::I64);
let ui = field_ui_metadata_with_relation(&f, Some("User"));
assert_eq!(ui.role, FieldRole::ForeignKey);
assert_eq!(ui.relation_label.as_deref(), Some("User"));
assert!(ui.hint.unwrap().contains("Foreign key to User"));
}
#[test]
fn format_relation_cell_with_and_without_target() {
assert_eq!(format_relation_cell(42, Some("User")), "User #42");
assert_eq!(format_relation_cell(42, None), "42");
assert_eq!(format_relation_cell(42, Some("")), "42");
}
}