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    /// Checkbox list joined with SQL `IN (...)`. The values come from
104    /// the field's `choices` slice; the URL carries one repeated
105    /// `?<col>=v1&<col>=v2` segment per checked option.
106    MultiSelect { values: &'static [&'static str] },
107    /// `<select>` populated by the admin runtime from rows of the
108    /// target model.
109    RelationSelect { target_model: String },
110    /// Type-ahead foreign-key picker. The list page renders a search
111    /// input that fetches candidates from
112    /// `/admin/_lookup/<target_admin_name>?q=…`; the chosen id is
113    /// submitted under the FK column name and applied as a plain
114    /// `WHERE col::text = $N` equality.
115    FkAutocomplete {
116        /// Admin slug of the FK target (`/admin/<slug>`). Drives both
117        /// the lookup endpoint URL and the click-through on the
118        /// active-filter pill.
119        target_admin_name: String,
120        /// Singular display name of the target — surfaces in the pill
121        /// fallback when no row label resolves.
122        target_model: String,
123    },
124}
125
126// public:
127/// One filter the list page should show for a model.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct FilterDef {
130    pub field: String,
131    pub label: String,
132    pub kind: FilterKind,
133}
134
135// ---------------------------------------------------------------------------
136// classify_field
137// ---------------------------------------------------------------------------
138
139// public:
140/// Assign a [`FieldRole`] to one field.
141///
142/// Order of precedence (highest first):
143/// 1. Shape: `id`, `email`, `phone`, `status`, bool, datetime, FK, numeric.
144/// 2. Fallback: `PlainText`.
145pub 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    // FK only when the column is integer-typed. A String column
169    // ending in `_id` is an opaque identifier, not a FK.
170    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
179// ---------------------------------------------------------------------------
180// field_ui_metadata
181// ---------------------------------------------------------------------------
182
183// public:
184/// Package a field's display metadata for the admin form / list
185/// renderers. All strings are plain text — escape before emitting.
186pub 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    // Name-based UI hints applied AFTER role classification.
215    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
236// public:
237/// Like [`field_ui_metadata`] but relation-aware. Pass the singular
238/// display name of the target model when the schema records a relation
239/// for this field; the returned [`FieldUI`] then carries
240/// `relation_label` and a hint of the form "Foreign key to Target".
241pub 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        // A known relation always renders as ForeignKey even if the
245        // column name wouldn't hit the `_id` heuristic.
246        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
253// public:
254/// Render "Target #42" for a foreign-key cell on a list view. Falls
255/// back to the raw id when the caller doesn't have a target name.
256pub 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
263// ---------------------------------------------------------------------------
264// infer_filters
265// ---------------------------------------------------------------------------
266
267// public:
268/// Infer the filter controls for a model's list page from its fields.
269/// Order follows the order of `fields`; every filter references a
270/// field that actually exists on the model.
271pub fn infer_filters(fields: &[AdminField]) -> Vec<FilterDef> {
272    infer_filters_with_relations(fields, |_| None)
273}
274
275// public:
276/// Like [`infer_filters_with_relations`] but consults the
277/// [`super::relations::RelationRegistry`] so FK fields can be
278/// promoted to [`FilterKind::FkAutocomplete`] — the registry carries
279/// the target's admin slug, which the autocomplete endpoint URL
280/// depends on.
281pub 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        // FK fields get the registry-aware promotion. Everything else
303        // routes through the simpler `infer_filters_with_relations`
304        // path with a `None` callback — same role-based mapping.
305        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
335// public:
336/// Like [`infer_filters`] but invokes `relation_target_of` for each
337/// field to detect relation columns. If the callback returns
338/// `Some(target)`, the filter is emitted as
339/// [`FilterKind::RelationSelect`] instead of the numeric-exact fallback.
340pub 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        // Any field that declares a closed `choices` slice gets a
353        // multi-select filter, regardless of role — the list of
354        // possible values is already known statically, so the
355        // dropdown can render checkboxes without an extra DB query.
356        // This catches status enums declared via `#[rustio(choices)]`
357        // (when the macro learns the attribute) and any field a
358        // project hand-sets `choices` on today.
359        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            // Plain text, email, phone — no stock filter.
382            _ => 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
393// ---------------------------------------------------------------------------
394// mask_pii
395// ---------------------------------------------------------------------------
396
397// public:
398/// Produce a masked display string for a sensitive value. Keeps the
399/// first few characters so a reviewer can tell which row they're
400/// looking at, replaces the rest with `•`. Length of the output
401/// matches the input. Deterministic, Unicode-safe.
402pub 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
420// ---------------------------------------------------------------------------
421// Internal helpers
422// ---------------------------------------------------------------------------
423
424fn 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        // String column ending _id is opaque, not FK.
488        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        // A field that carries a `choices` slice gets a multi-select
513        // filter regardless of its inferred role — `choices` is the
514        // strongest signal because it gives us a closed value set
515        // without an extra distinct-query.
516        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        // FK column with no registered target → the registry
532        // returns `None` for the lookup, and we degrade to the same
533        // NumericExact fallback the non-registry path uses.
534        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", &registry);
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        // A field that is *both* FK-shaped (`_id` suffix, integer)
548        // AND carries an explicit `choices` slice should pick
549        // multi-select — the operator is opting in to a closed
550        // value set, which trumps the FK heuristic.
551        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", &registry);
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        // A `choices` of `&[]` shouldn't render an empty multi-select
563        // dropdown — fall back to whatever the role would pick.
564        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}