1use crate::admin::{AdminField, FieldType};
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum FieldRole {
38 Id,
40 Timestamp,
42 Bool,
44 NumericCount,
46 ForeignKey,
48 Status,
50 Email,
52 Phone,
54 PlainText,
56}
57
58impl FieldRole {
59 pub fn is_sensitive(self) -> bool {
63 matches!(self, FieldRole::Email | FieldRole::Phone)
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct FieldUI {
73 pub role: FieldRole,
74 pub label: String,
75 pub placeholder: Option<String>,
76 pub hint: Option<String>,
77 pub sensitive: bool,
80 pub sensitivity_note: Option<String>,
82 pub relation_label: Option<String>,
85}
86
87#[non_exhaustive]
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub enum FilterKind {
93 DropdownText,
95 BoolYesNo,
97 DateRange,
99 NumericExact,
101 ExactMatch,
103 MultiSelect { values: &'static [&'static str] },
107 RelationSelect { target_model: String },
110 FkAutocomplete {
116 target_admin_name: String,
120 target_model: String,
123 },
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct FilterDef {
130 pub field: String,
131 pub label: String,
132 pub kind: FilterKind,
133}
134
135pub fn classify_field(f: &AdminField) -> FieldRole {
146 let name = f.name;
147 if name == "id" {
148 return FieldRole::Id;
149 }
150 if name == "email" {
151 return FieldRole::Email;
152 }
153 if name == "phone" {
154 return FieldRole::Phone;
155 }
156 if matches!(f.field_type, FieldType::Bool) {
157 return FieldRole::Bool;
158 }
159 if matches!(
160 f.field_type,
161 FieldType::DateTime | FieldType::OptionalDateTime
162 ) {
163 return FieldRole::Timestamp;
164 }
165 if name == "status" || name.ends_with("_status") {
166 return FieldRole::Status;
167 }
168 if name.ends_with("_id") && matches!(f.field_type, FieldType::I32 | FieldType::I64) {
171 return FieldRole::ForeignKey;
172 }
173 if matches!(f.field_type, FieldType::I32 | FieldType::I64) {
174 return FieldRole::NumericCount;
175 }
176 FieldRole::PlainText
177}
178
179pub fn field_ui_metadata(f: &AdminField) -> FieldUI {
187 let role = classify_field(f);
188 let label = humanise(f.name);
189 let mut placeholder: Option<String> = None;
190 let mut hint: Option<String> = None;
191 let mut sensitive = false;
192 let mut sensitivity_note: Option<String> = None;
193
194 match role {
195 FieldRole::Email => {
196 placeholder = Some("name@example.com".into());
197 }
198 FieldRole::Phone => {
199 placeholder = Some("+1 555 123 4567".into());
200 }
201 FieldRole::Timestamp => {
202 placeholder = Some("YYYY-MM-DDTHH:MM".into());
203 hint = Some("Interpreted as UTC.".into());
204 }
205 FieldRole::Status => {
206 hint = Some("Short status label (e.g. active, pending, resolved).".into());
207 }
208 FieldRole::ForeignKey => {
209 hint = Some("Foreign-key id — must reference an existing row.".into());
210 }
211 FieldRole::Id | FieldRole::Bool | FieldRole::NumericCount | FieldRole::PlainText => {}
212 }
213
214 if f.name == "slug" {
216 placeholder = Some("my-post-title".into());
217 hint = Some("URL-friendly identifier".into());
218 }
219
220 if role.is_sensitive() {
221 sensitive = true;
222 sensitivity_note = Some("Personal data.".into());
223 }
224
225 FieldUI {
226 role,
227 label,
228 placeholder,
229 hint,
230 sensitive,
231 sensitivity_note,
232 relation_label: None,
233 }
234}
235
236pub fn field_ui_metadata_with_relation(f: &AdminField, relation_target: Option<&str>) -> FieldUI {
242 let mut ui = field_ui_metadata(f);
243 if let Some(target) = relation_target.filter(|t| !t.is_empty()) {
244 ui.role = FieldRole::ForeignKey;
247 ui.relation_label = Some(target.to_string());
248 ui.hint = Some(format!("Foreign key to {target}."));
249 }
250 ui
251}
252
253pub fn format_relation_cell(id: i64, target: Option<&str>) -> String {
257 match target {
258 Some(t) if !t.is_empty() => format!("{t} #{id}"),
259 _ => id.to_string(),
260 }
261}
262
263pub fn infer_filters(fields: &[AdminField]) -> Vec<FilterDef> {
272 infer_filters_with_relations(fields, |_| None)
273}
274
275pub fn infer_filters_with_registry(
282 fields: &[AdminField],
283 source_model: &str,
284 registry: &super::relations::RelationRegistry,
285) -> Vec<FilterDef> {
286 let mut out: Vec<FilterDef> = Vec::new();
287 for f in fields {
288 if f.name == "id" {
289 continue;
290 }
291 if let Some(values) = f.choices {
292 if !values.is_empty() {
293 out.push(FilterDef {
294 field: f.name.to_string(),
295 label: humanise(f.name),
296 kind: FilterKind::MultiSelect { values },
297 });
298 continue;
299 }
300 }
301 let role = classify_field(f);
302 if matches!(role, FieldRole::ForeignKey) {
306 if let Some(rel) = registry.belongs_to(source_model, f.name) {
307 out.push(FilterDef {
308 field: f.name.to_string(),
309 label: humanise(f.name),
310 kind: FilterKind::FkAutocomplete {
311 target_admin_name: rel.target_admin_name.clone(),
312 target_model: rel.target_model.clone(),
313 },
314 });
315 continue;
316 }
317 }
318 let kind = match role {
319 FieldRole::Status => FilterKind::DropdownText,
320 FieldRole::Bool => FilterKind::BoolYesNo,
321 FieldRole::Timestamp => FilterKind::DateRange,
322 FieldRole::NumericCount => FilterKind::NumericExact,
323 FieldRole::ForeignKey => FilterKind::NumericExact,
324 _ => continue,
325 };
326 out.push(FilterDef {
327 field: f.name.to_string(),
328 label: humanise(f.name),
329 kind,
330 });
331 }
332 out
333}
334
335pub fn infer_filters_with_relations<F>(
341 fields: &[AdminField],
342 relation_target_of: F,
343) -> Vec<FilterDef>
344where
345 F: Fn(&AdminField) -> Option<String>,
346{
347 let mut out: Vec<FilterDef> = Vec::new();
348 for f in fields {
349 if f.name == "id" {
350 continue;
351 }
352 if let Some(values) = f.choices {
360 if !values.is_empty() {
361 out.push(FilterDef {
362 field: f.name.to_string(),
363 label: humanise(f.name),
364 kind: FilterKind::MultiSelect { values },
365 });
366 continue;
367 }
368 }
369 let role = classify_field(f);
370 let kind = match role {
371 FieldRole::Status => FilterKind::DropdownText,
372 FieldRole::Bool => FilterKind::BoolYesNo,
373 FieldRole::Timestamp => FilterKind::DateRange,
374 FieldRole::NumericCount => FilterKind::NumericExact,
375 FieldRole::ForeignKey => match relation_target_of(f) {
376 Some(target_model) if !target_model.is_empty() => {
377 FilterKind::RelationSelect { target_model }
378 }
379 _ => FilterKind::NumericExact,
380 },
381 _ => continue,
383 };
384 out.push(FilterDef {
385 field: f.name.to_string(),
386 label: humanise(f.name),
387 kind,
388 });
389 }
390 out
391}
392
393pub fn mask_pii(value: &str) -> String {
403 if value.is_empty() {
404 return String::new();
405 }
406 let chars: Vec<char> = value.chars().collect();
407 let n = chars.len();
408 let keep = (n / 3).clamp(2, 4).min(n);
409 let mut out = String::with_capacity(n);
410 for (i, c) in chars.iter().enumerate() {
411 if i < keep {
412 out.push(*c);
413 } else {
414 out.push('•');
415 }
416 }
417 out
418}
419
420fn humanise(s: &str) -> String {
425 let mut out = String::with_capacity(s.len());
426 let mut next_upper = true;
427 for ch in s.chars() {
428 if ch == '_' {
429 out.push(' ');
430 next_upper = true;
431 } else if next_upper {
432 out.push(ch.to_ascii_uppercase());
433 next_upper = false;
434 } else {
435 out.push(ch);
436 }
437 }
438 out
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444
445 fn field(name: &'static str, ty: FieldType) -> AdminField {
446 AdminField {
447 name,
448 label: name,
449 field_type: ty,
450 editable: true,
451 relation: None,
452 choices: None,
453 }
454 }
455
456 #[test]
457 fn classify_id_email_status_bool_timestamp() {
458 assert_eq!(classify_field(&field("id", FieldType::I64)), FieldRole::Id);
459 assert_eq!(
460 classify_field(&field("email", FieldType::String)),
461 FieldRole::Email
462 );
463 assert_eq!(
464 classify_field(&field("status", FieldType::String)),
465 FieldRole::Status
466 );
467 assert_eq!(
468 classify_field(&field("order_status", FieldType::String)),
469 FieldRole::Status
470 );
471 assert_eq!(
472 classify_field(&field("active", FieldType::Bool)),
473 FieldRole::Bool
474 );
475 assert_eq!(
476 classify_field(&field("created_at", FieldType::DateTime)),
477 FieldRole::Timestamp
478 );
479 }
480
481 #[test]
482 fn fk_only_for_integer_id_columns() {
483 assert_eq!(
484 classify_field(&field("user_id", FieldType::I64)),
485 FieldRole::ForeignKey
486 );
487 assert_eq!(
489 classify_field(&field("national_id", FieldType::String)),
490 FieldRole::PlainText
491 );
492 }
493
494 #[test]
495 fn infer_filters_skips_id_and_picks_kinds() {
496 let fields = vec![
497 field("id", FieldType::I64),
498 field("status", FieldType::String),
499 field("active", FieldType::Bool),
500 field("created_at", FieldType::DateTime),
501 field("title", FieldType::String),
502 ];
503 let filters = infer_filters(&fields);
504 assert_eq!(filters.len(), 3);
505 assert!(matches!(filters[0].kind, FilterKind::DropdownText));
506 assert!(matches!(filters[1].kind, FilterKind::BoolYesNo));
507 assert!(matches!(filters[2].kind, FilterKind::DateRange));
508 }
509
510 #[test]
511 fn declared_choices_promote_field_to_multi_select() {
512 const STATES: &[&str] = &["draft", "published", "archived"];
517 let mut f = field("state", FieldType::String);
518 f.choices = Some(STATES);
519 let filters = infer_filters(&[f]);
520 assert_eq!(filters.len(), 1);
521 match &filters[0].kind {
522 FilterKind::MultiSelect { values } => {
523 assert_eq!(*values, STATES);
524 }
525 other => panic!("expected MultiSelect, got {other:?}"),
526 }
527 }
528
529 #[test]
530 fn infer_with_registry_falls_back_to_numeric_when_no_relation_resolved() {
531 let fields = vec![
535 field("id", FieldType::I64),
536 field("author_id", FieldType::I64),
537 ];
538 let registry = super::super::relations::RelationRegistry::empty();
539 let filters = infer_filters_with_registry(&fields, "Post", ®istry);
540 assert_eq!(filters.len(), 1);
541 assert_eq!(filters[0].field, "author_id");
542 assert!(matches!(filters[0].kind, FilterKind::NumericExact));
543 }
544
545 #[test]
546 fn infer_with_registry_choices_still_win_over_fk_promotion() {
547 let mut f = field("workflow_id", FieldType::I64);
552 const STATES: &[&str] = &["draft", "ready", "shipped"];
553 f.choices = Some(STATES);
554 let registry = super::super::relations::RelationRegistry::empty();
555 let filters = infer_filters_with_registry(&[f], "Order", ®istry);
556 assert_eq!(filters.len(), 1);
557 assert!(matches!(filters[0].kind, FilterKind::MultiSelect { .. }));
558 }
559
560 #[test]
561 fn empty_choices_slice_falls_back_to_role_based_kind() {
562 let mut f = field("status", FieldType::String);
565 f.choices = Some(&[]);
566 let filters = infer_filters(&[f]);
567 assert_eq!(filters.len(), 1);
568 assert!(matches!(filters[0].kind, FilterKind::DropdownText));
569 }
570
571 #[test]
572 fn mask_pii_keeps_prefix_and_replaces_with_bullets() {
573 assert_eq!(mask_pii("alice@example.com"), "alic•••••••••••••");
574 assert_eq!(mask_pii(""), "");
575 }
576
577 #[test]
578 fn relation_label_overrides_role() {
579 let f = field("user_id", FieldType::I64);
580 let ui = field_ui_metadata_with_relation(&f, Some("User"));
581 assert_eq!(ui.role, FieldRole::ForeignKey);
582 assert_eq!(ui.relation_label.as_deref(), Some("User"));
583 assert!(ui.hint.unwrap().contains("Foreign key to User"));
584 }
585
586 #[test]
587 fn format_relation_cell_with_and_without_target() {
588 assert_eq!(format_relation_cell(42, Some("User")), "User #42");
589 assert_eq!(format_relation_cell(42, None), "42");
590 assert_eq!(format_relation_cell(42, Some("")), "42");
591 }
592}