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 RelationSelect { target_model: String },
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct FilterDef {
112 pub field: String,
113 pub label: String,
114 pub kind: FilterKind,
115}
116
117pub fn classify_field(f: &AdminField) -> FieldRole {
128 let name = f.name;
129 if name == "id" {
130 return FieldRole::Id;
131 }
132 if name == "email" {
133 return FieldRole::Email;
134 }
135 if name == "phone" {
136 return FieldRole::Phone;
137 }
138 if matches!(f.field_type, FieldType::Bool) {
139 return FieldRole::Bool;
140 }
141 if matches!(
142 f.field_type,
143 FieldType::DateTime | FieldType::OptionalDateTime
144 ) {
145 return FieldRole::Timestamp;
146 }
147 if name == "status" || name.ends_with("_status") {
148 return FieldRole::Status;
149 }
150 if name.ends_with("_id") && matches!(f.field_type, FieldType::I32 | FieldType::I64) {
153 return FieldRole::ForeignKey;
154 }
155 if matches!(f.field_type, FieldType::I32 | FieldType::I64) {
156 return FieldRole::NumericCount;
157 }
158 FieldRole::PlainText
159}
160
161pub fn field_ui_metadata(f: &AdminField) -> FieldUI {
169 let role = classify_field(f);
170 let label = humanise(f.name);
171 let mut placeholder: Option<String> = None;
172 let mut hint: Option<String> = None;
173 let mut sensitive = false;
174 let mut sensitivity_note: Option<String> = None;
175
176 match role {
177 FieldRole::Email => {
178 placeholder = Some("name@example.com".into());
179 }
180 FieldRole::Phone => {
181 placeholder = Some("+1 555 123 4567".into());
182 }
183 FieldRole::Timestamp => {
184 placeholder = Some("YYYY-MM-DDTHH:MM".into());
185 hint = Some("Interpreted as UTC.".into());
186 }
187 FieldRole::Status => {
188 hint = Some("Short status label (e.g. active, pending, resolved).".into());
189 }
190 FieldRole::ForeignKey => {
191 hint = Some("Foreign-key id — must reference an existing row.".into());
192 }
193 FieldRole::Id | FieldRole::Bool | FieldRole::NumericCount | FieldRole::PlainText => {}
194 }
195
196 if f.name == "slug" {
198 placeholder = Some("my-post-title".into());
199 hint = Some("URL-friendly identifier".into());
200 }
201
202 if role.is_sensitive() {
203 sensitive = true;
204 sensitivity_note = Some("Personal data.".into());
205 }
206
207 FieldUI {
208 role,
209 label,
210 placeholder,
211 hint,
212 sensitive,
213 sensitivity_note,
214 relation_label: None,
215 }
216}
217
218pub fn field_ui_metadata_with_relation(f: &AdminField, relation_target: Option<&str>) -> FieldUI {
224 let mut ui = field_ui_metadata(f);
225 if let Some(target) = relation_target.filter(|t| !t.is_empty()) {
226 ui.role = FieldRole::ForeignKey;
229 ui.relation_label = Some(target.to_string());
230 ui.hint = Some(format!("Foreign key to {target}."));
231 }
232 ui
233}
234
235pub fn format_relation_cell(id: i64, target: Option<&str>) -> String {
239 match target {
240 Some(t) if !t.is_empty() => format!("{t} #{id}"),
241 _ => id.to_string(),
242 }
243}
244
245pub fn infer_filters(fields: &[AdminField]) -> Vec<FilterDef> {
254 infer_filters_with_relations(fields, |_| None)
255}
256
257pub fn infer_filters_with_relations<F>(
263 fields: &[AdminField],
264 relation_target_of: F,
265) -> Vec<FilterDef>
266where
267 F: Fn(&AdminField) -> Option<String>,
268{
269 let mut out: Vec<FilterDef> = Vec::new();
270 for f in fields {
271 if f.name == "id" {
272 continue;
273 }
274 let role = classify_field(f);
275 let kind = match role {
276 FieldRole::Status => FilterKind::DropdownText,
277 FieldRole::Bool => FilterKind::BoolYesNo,
278 FieldRole::Timestamp => FilterKind::DateRange,
279 FieldRole::NumericCount => FilterKind::NumericExact,
280 FieldRole::ForeignKey => match relation_target_of(f) {
281 Some(target_model) if !target_model.is_empty() => {
282 FilterKind::RelationSelect { target_model }
283 }
284 _ => FilterKind::NumericExact,
285 },
286 _ => continue,
288 };
289 out.push(FilterDef {
290 field: f.name.to_string(),
291 label: humanise(f.name),
292 kind,
293 });
294 }
295 out
296}
297
298pub fn mask_pii(value: &str) -> String {
308 if value.is_empty() {
309 return String::new();
310 }
311 let chars: Vec<char> = value.chars().collect();
312 let n = chars.len();
313 let keep = (n / 3).clamp(2, 4).min(n);
314 let mut out = String::with_capacity(n);
315 for (i, c) in chars.iter().enumerate() {
316 if i < keep {
317 out.push(*c);
318 } else {
319 out.push('•');
320 }
321 }
322 out
323}
324
325fn humanise(s: &str) -> String {
330 let mut out = String::with_capacity(s.len());
331 let mut next_upper = true;
332 for ch in s.chars() {
333 if ch == '_' {
334 out.push(' ');
335 next_upper = true;
336 } else if next_upper {
337 out.push(ch.to_ascii_uppercase());
338 next_upper = false;
339 } else {
340 out.push(ch);
341 }
342 }
343 out
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349
350 fn field(name: &'static str, ty: FieldType) -> AdminField {
351 AdminField {
352 name,
353 label: name,
354 field_type: ty,
355 editable: true,
356 relation: None,
357 choices: None,
358 }
359 }
360
361 #[test]
362 fn classify_id_email_status_bool_timestamp() {
363 assert_eq!(classify_field(&field("id", FieldType::I64)), FieldRole::Id);
364 assert_eq!(
365 classify_field(&field("email", FieldType::String)),
366 FieldRole::Email
367 );
368 assert_eq!(
369 classify_field(&field("status", FieldType::String)),
370 FieldRole::Status
371 );
372 assert_eq!(
373 classify_field(&field("order_status", FieldType::String)),
374 FieldRole::Status
375 );
376 assert_eq!(
377 classify_field(&field("active", FieldType::Bool)),
378 FieldRole::Bool
379 );
380 assert_eq!(
381 classify_field(&field("created_at", FieldType::DateTime)),
382 FieldRole::Timestamp
383 );
384 }
385
386 #[test]
387 fn fk_only_for_integer_id_columns() {
388 assert_eq!(
389 classify_field(&field("user_id", FieldType::I64)),
390 FieldRole::ForeignKey
391 );
392 assert_eq!(
394 classify_field(&field("national_id", FieldType::String)),
395 FieldRole::PlainText
396 );
397 }
398
399 #[test]
400 fn infer_filters_skips_id_and_picks_kinds() {
401 let fields = vec![
402 field("id", FieldType::I64),
403 field("status", FieldType::String),
404 field("active", FieldType::Bool),
405 field("created_at", FieldType::DateTime),
406 field("title", FieldType::String),
407 ];
408 let filters = infer_filters(&fields);
409 assert_eq!(filters.len(), 3);
410 assert!(matches!(filters[0].kind, FilterKind::DropdownText));
411 assert!(matches!(filters[1].kind, FilterKind::BoolYesNo));
412 assert!(matches!(filters[2].kind, FilterKind::DateRange));
413 }
414
415 #[test]
416 fn mask_pii_keeps_prefix_and_replaces_with_bullets() {
417 assert_eq!(mask_pii("alice@example.com"), "alic•••••••••••••");
418 assert_eq!(mask_pii(""), "");
419 }
420
421 #[test]
422 fn relation_label_overrides_role() {
423 let f = field("user_id", FieldType::I64);
424 let ui = field_ui_metadata_with_relation(&f, Some("User"));
425 assert_eq!(ui.role, FieldRole::ForeignKey);
426 assert_eq!(ui.relation_label.as_deref(), Some("User"));
427 assert!(ui.hint.unwrap().contains("Foreign key to User"));
428 }
429
430 #[test]
431 fn format_relation_cell_with_and_without_target() {
432 assert_eq!(format_relation_cell(42, Some("User")), "User #42");
433 assert_eq!(format_relation_cell(42, None), "42");
434 assert_eq!(format_relation_cell(42, Some("")), "42");
435 }
436}