1use crate::admin::{AdminField, FieldType};
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum FieldRole {
37 Id,
39 Timestamp,
41 Bool,
43 NumericCount,
45 ForeignKey,
47 Status,
49 Email,
51 Phone,
53 PlainText,
55}
56
57impl FieldRole {
58 pub fn is_sensitive(self) -> bool {
61 matches!(self, FieldRole::Email | FieldRole::Phone)
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct FieldUI {
70 pub role: FieldRole,
71 pub label: String,
72 pub placeholder: Option<String>,
73 pub hint: Option<String>,
74 pub sensitive: bool,
77 pub sensitivity_note: Option<String>,
79 pub relation_label: Option<String>,
82}
83
84#[non_exhaustive]
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum FilterKind {
89 DropdownText,
91 BoolYesNo,
93 DateRange,
95 NumericExact,
97 ExactMatch,
99 RelationSelect { target_model: String },
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct FilterDef {
107 pub field: String,
108 pub label: String,
109 pub kind: FilterKind,
110}
111
112pub fn classify_field(f: &AdminField) -> FieldRole {
122 let name = f.name;
123 if name == "id" {
124 return FieldRole::Id;
125 }
126 if name == "email" {
127 return FieldRole::Email;
128 }
129 if name == "phone" {
130 return FieldRole::Phone;
131 }
132 if matches!(f.field_type, FieldType::Bool) {
133 return FieldRole::Bool;
134 }
135 if matches!(
136 f.field_type,
137 FieldType::DateTime | FieldType::OptionalDateTime
138 ) {
139 return FieldRole::Timestamp;
140 }
141 if name == "status" || name.ends_with("_status") {
142 return FieldRole::Status;
143 }
144 if name.ends_with("_id") && matches!(f.field_type, FieldType::I32 | FieldType::I64) {
147 return FieldRole::ForeignKey;
148 }
149 if matches!(f.field_type, FieldType::I32 | FieldType::I64) {
150 return FieldRole::NumericCount;
151 }
152 FieldRole::PlainText
153}
154
155pub fn field_ui_metadata(f: &AdminField) -> FieldUI {
162 let role = classify_field(f);
163 let label = humanise(f.name);
164 let mut placeholder: Option<String> = None;
165 let mut hint: Option<String> = None;
166 let mut sensitive = false;
167 let mut sensitivity_note: Option<String> = None;
168
169 match role {
170 FieldRole::Email => {
171 placeholder = Some("name@example.com".into());
172 }
173 FieldRole::Phone => {
174 placeholder = Some("+1 555 123 4567".into());
175 }
176 FieldRole::Timestamp => {
177 placeholder = Some("YYYY-MM-DDTHH:MM".into());
178 hint = Some("Interpreted as UTC.".into());
179 }
180 FieldRole::Status => {
181 hint = Some("Short status label (e.g. active, pending, resolved).".into());
182 }
183 FieldRole::ForeignKey => {
184 hint = Some("Foreign-key id — must reference an existing row.".into());
185 }
186 FieldRole::Id | FieldRole::Bool | FieldRole::NumericCount | FieldRole::PlainText => {}
187 }
188
189 if f.name == "slug" {
191 placeholder = Some("my-post-title".into());
192 hint = Some("URL-friendly identifier".into());
193 }
194
195 if role.is_sensitive() {
196 sensitive = true;
197 sensitivity_note = Some("Personal data.".into());
198 }
199
200 FieldUI {
201 role,
202 label,
203 placeholder,
204 hint,
205 sensitive,
206 sensitivity_note,
207 relation_label: None,
208 }
209}
210
211pub fn field_ui_metadata_with_relation(f: &AdminField, relation_target: Option<&str>) -> FieldUI {
216 let mut ui = field_ui_metadata(f);
217 if let Some(target) = relation_target.filter(|t| !t.is_empty()) {
218 ui.role = FieldRole::ForeignKey;
221 ui.relation_label = Some(target.to_string());
222 ui.hint = Some(format!("Foreign key to {target}."));
223 }
224 ui
225}
226
227pub fn format_relation_cell(id: i64, target: Option<&str>) -> String {
230 match target {
231 Some(t) if !t.is_empty() => format!("{t} #{id}"),
232 _ => id.to_string(),
233 }
234}
235
236pub fn infer_filters(fields: &[AdminField]) -> Vec<FilterDef> {
244 infer_filters_with_relations(fields, |_| None)
245}
246
247pub fn infer_filters_with_relations<F>(
252 fields: &[AdminField],
253 relation_target_of: F,
254) -> Vec<FilterDef>
255where
256 F: Fn(&AdminField) -> Option<String>,
257{
258 let mut out: Vec<FilterDef> = Vec::new();
259 for f in fields {
260 if f.name == "id" {
261 continue;
262 }
263 let role = classify_field(f);
264 let kind = match role {
265 FieldRole::Status => FilterKind::DropdownText,
266 FieldRole::Bool => FilterKind::BoolYesNo,
267 FieldRole::Timestamp => FilterKind::DateRange,
268 FieldRole::NumericCount => FilterKind::NumericExact,
269 FieldRole::ForeignKey => match relation_target_of(f) {
270 Some(target_model) if !target_model.is_empty() => {
271 FilterKind::RelationSelect { target_model }
272 }
273 _ => FilterKind::NumericExact,
274 },
275 _ => continue,
277 };
278 out.push(FilterDef {
279 field: f.name.to_string(),
280 label: humanise(f.name),
281 kind,
282 });
283 }
284 out
285}
286
287pub fn mask_pii(value: &str) -> String {
296 if value.is_empty() {
297 return String::new();
298 }
299 let chars: Vec<char> = value.chars().collect();
300 let n = chars.len();
301 let keep = (n / 3).clamp(2, 4).min(n);
302 let mut out = String::with_capacity(n);
303 for (i, c) in chars.iter().enumerate() {
304 if i < keep {
305 out.push(*c);
306 } else {
307 out.push('•');
308 }
309 }
310 out
311}
312
313fn humanise(s: &str) -> String {
318 let mut out = String::with_capacity(s.len());
319 let mut next_upper = true;
320 for ch in s.chars() {
321 if ch == '_' {
322 out.push(' ');
323 next_upper = true;
324 } else if next_upper {
325 out.push(ch.to_ascii_uppercase());
326 next_upper = false;
327 } else {
328 out.push(ch);
329 }
330 }
331 out
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 fn field(name: &'static str, ty: FieldType) -> AdminField {
339 AdminField {
340 name,
341 label: name,
342 field_type: ty,
343 editable: true,
344 relation: None,
345 choices: None,
346 }
347 }
348
349 #[test]
350 fn classify_id_email_status_bool_timestamp() {
351 assert_eq!(classify_field(&field("id", FieldType::I64)), FieldRole::Id);
352 assert_eq!(
353 classify_field(&field("email", FieldType::String)),
354 FieldRole::Email
355 );
356 assert_eq!(
357 classify_field(&field("status", FieldType::String)),
358 FieldRole::Status
359 );
360 assert_eq!(
361 classify_field(&field("order_status", FieldType::String)),
362 FieldRole::Status
363 );
364 assert_eq!(
365 classify_field(&field("active", FieldType::Bool)),
366 FieldRole::Bool
367 );
368 assert_eq!(
369 classify_field(&field("created_at", FieldType::DateTime)),
370 FieldRole::Timestamp
371 );
372 }
373
374 #[test]
375 fn fk_only_for_integer_id_columns() {
376 assert_eq!(
377 classify_field(&field("user_id", FieldType::I64)),
378 FieldRole::ForeignKey
379 );
380 assert_eq!(
382 classify_field(&field("national_id", FieldType::String)),
383 FieldRole::PlainText
384 );
385 }
386
387 #[test]
388 fn infer_filters_skips_id_and_picks_kinds() {
389 let fields = vec![
390 field("id", FieldType::I64),
391 field("status", FieldType::String),
392 field("active", FieldType::Bool),
393 field("created_at", FieldType::DateTime),
394 field("title", FieldType::String),
395 ];
396 let filters = infer_filters(&fields);
397 assert_eq!(filters.len(), 3);
398 assert!(matches!(filters[0].kind, FilterKind::DropdownText));
399 assert!(matches!(filters[1].kind, FilterKind::BoolYesNo));
400 assert!(matches!(filters[2].kind, FilterKind::DateRange));
401 }
402
403 #[test]
404 fn mask_pii_keeps_prefix_and_replaces_with_bullets() {
405 assert_eq!(mask_pii("alice@example.com"), "alic•••••••••••••");
406 assert_eq!(mask_pii(""), "");
407 }
408
409 #[test]
410 fn relation_label_overrides_role() {
411 let f = field("user_id", FieldType::I64);
412 let ui = field_ui_metadata_with_relation(&f, Some("User"));
413 assert_eq!(ui.role, FieldRole::ForeignKey);
414 assert_eq!(ui.relation_label.as_deref(), Some("User"));
415 assert!(ui.hint.unwrap().contains("Foreign key to User"));
416 }
417
418 #[test]
419 fn format_relation_cell_with_and_without_target() {
420 assert_eq!(format_relation_cell(42, Some("User")), "User #42");
421 assert_eq!(format_relation_cell(42, None), "42");
422 assert_eq!(format_relation_cell(42, Some("")), "42");
423 }
424}