Skip to main content

rustio_admin/admin/
filters.rs

1//! Field classification + filter inference (renamed from
2//! `intelligence` per Section 4 of the strategic reset plan).
3//!
4//! Pure helpers that turn *schema metadata* into *user-facing hints*:
5//! the form-field label beside an input, the masked display of a
6//! sensitive value on a list page, the filter dropdown inferred from
7//! a `status` column. Nothing in this module touches the filesystem,
8//! the database, or produces HTML — it returns structured data that
9//! the admin renderer consumes.
10//!
11//! ## Public API
12//!
13//! - [`classify_field`] — labels a field by role (`Id`, `Email`,
14//!   `Status`, …). Every downstream renderer branches on this enum.
15//! - [`field_ui_metadata`] — packages the label, placeholder, hint,
16//!   and sensitivity marker a form needs to render one input.
17//! - [`infer_filters`] — walks a model's fields and decides which
18//!   filters make sense on its list page.
19//! - [`format_relation_cell`] — render a foreign-key cell on the list
20//!   page as `Target #42` (or `42` when the target name is unknown).
21//! - [`mask_pii`] — deterministic string masker used to hide personal
22//!   data by default on list views.
23//!
24//! Slimmed for Tier 1: the legacy module's country/industry/GDPR
25//! `ContextConfig` plumbing was wired through `crate::ai` (Tier 2) so
26//! it's been removed; classification is now shape-based only.
27//! `classify_search` / `SearchIntent` are dropped per Section 3.
28
29use crate::admin::{AdminField, FieldType};
30
31// public:
32/// The role a field plays in the admin UI. One field maps to exactly
33/// one role; the ordering of branches in [`classify_field`] resolves
34/// overlaps (e.g. an `email` column is `FieldRole::Email`, not
35/// `FieldRole::PlainText`).
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum FieldRole {
38    /// Primary key. Rendered monospace, excluded from edit forms.
39    Id,
40    /// `DateTime<Utc>`-shaped columns.
41    Timestamp,
42    /// Booleans — rendered as a pill on the list page, a checkbox in forms.
43    Bool,
44    /// Numeric values that aren't identifiers.
45    NumericCount,
46    /// `<something>_id` integer column that points at another model.
47    ForeignKey,
48    /// A `status` / `*_status` column.
49    Status,
50    /// An email address. Masked by default on list views.
51    Email,
52    /// A phone number. Masked by default.
53    Phone,
54    /// Everything else.
55    PlainText,
56}
57
58impl FieldRole {
59    // public:
60    /// `true` when the role carries personal data and should be masked
61    /// by default on list views.
62    pub fn is_sensitive(self) -> bool {
63        matches!(self, FieldRole::Email | FieldRole::Phone)
64    }
65}
66
67// public:
68/// Everything a form / list renderer needs to present one field to a
69/// human. All strings are plain text (no HTML) — the caller escapes
70/// before emitting.
71#[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    /// `true` when the field should carry the lock marker and (for
78    /// list views) be masked by default.
79    pub sensitive: bool,
80    /// One-line explanation of *why* the field is sensitive.
81    pub sensitivity_note: Option<String>,
82    /// Set when the field is a FK to a known model. Carries the
83    /// *singular* display name of the target (e.g. `"Applicant"`).
84    pub relation_label: Option<String>,
85}
86
87// public:
88/// What shape of filter the admin list page should render for a given
89/// field.
90#[non_exhaustive]
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub enum FilterKind {
93    /// `<select>` over distinct string values.
94    DropdownText,
95    /// Yes / No dropdown over a boolean column.
96    BoolYesNo,
97    /// Two `date` / `datetime-local` inputs bounding a range.
98    DateRange,
99    /// Numeric exact-match input.
100    NumericExact,
101    /// Single-line input, compared exactly.
102    ExactMatch,
103    /// `<select>` populated by the admin runtime from rows of the
104    /// target model.
105    RelationSelect { target_model: String },
106}
107
108// public:
109/// One filter the list page should show for a model.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct FilterDef {
112    pub field: String,
113    pub label: String,
114    pub kind: FilterKind,
115}
116
117// ---------------------------------------------------------------------------
118// classify_field
119// ---------------------------------------------------------------------------
120
121// public:
122/// Assign a [`FieldRole`] to one field.
123///
124/// Order of precedence (highest first):
125/// 1. Shape: `id`, `email`, `phone`, `status`, bool, datetime, FK, numeric.
126/// 2. Fallback: `PlainText`.
127pub 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    // FK only when the column is integer-typed. A String column
151    // ending in `_id` is an opaque identifier, not a FK.
152    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
161// ---------------------------------------------------------------------------
162// field_ui_metadata
163// ---------------------------------------------------------------------------
164
165// public:
166/// Package a field's display metadata for the admin form / list
167/// renderers. All strings are plain text — escape before emitting.
168pub 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    // Name-based UI hints applied AFTER role classification.
197    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
218// public:
219/// Like [`field_ui_metadata`] but relation-aware. Pass the singular
220/// display name of the target model when the schema records a relation
221/// for this field; the returned [`FieldUI`] then carries
222/// `relation_label` and a hint of the form "Foreign key to Target".
223pub 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        // A known relation always renders as ForeignKey even if the
227        // column name wouldn't hit the `_id` heuristic.
228        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
235// public:
236/// Render "Target #42" for a foreign-key cell on a list view. Falls
237/// back to the raw id when the caller doesn't have a target name.
238pub 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
245// ---------------------------------------------------------------------------
246// infer_filters
247// ---------------------------------------------------------------------------
248
249// public:
250/// Infer the filter controls for a model's list page from its fields.
251/// Order follows the order of `fields`; every filter references a
252/// field that actually exists on the model.
253pub fn infer_filters(fields: &[AdminField]) -> Vec<FilterDef> {
254    infer_filters_with_relations(fields, |_| None)
255}
256
257// public:
258/// Like [`infer_filters`] but invokes `relation_target_of` for each
259/// field to detect relation columns. If the callback returns
260/// `Some(target)`, the filter is emitted as
261/// [`FilterKind::RelationSelect`] instead of the numeric-exact fallback.
262pub 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            // Plain text, email, phone — no stock filter.
287            _ => 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
298// ---------------------------------------------------------------------------
299// mask_pii
300// ---------------------------------------------------------------------------
301
302// public:
303/// Produce a masked display string for a sensitive value. Keeps the
304/// first few characters so a reviewer can tell which row they're
305/// looking at, replaces the rest with `•`. Length of the output
306/// matches the input. Deterministic, Unicode-safe.
307pub 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
325// ---------------------------------------------------------------------------
326// Internal helpers
327// ---------------------------------------------------------------------------
328
329fn 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        // String column ending _id is opaque, not FK.
393        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}