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/// The role a field plays in the admin UI. One field maps to exactly
32/// one role; the ordering of branches in [`classify_field`] resolves
33/// overlaps (e.g. an `email` column is `FieldRole::Email`, not
34/// `FieldRole::PlainText`).
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum FieldRole {
37    /// Primary key. Rendered monospace, excluded from edit forms.
38    Id,
39    /// `DateTime<Utc>`-shaped columns.
40    Timestamp,
41    /// Booleans — rendered as a pill on the list page, a checkbox in forms.
42    Bool,
43    /// Numeric values that aren't identifiers.
44    NumericCount,
45    /// `<something>_id` integer column that points at another model.
46    ForeignKey,
47    /// A `status` / `*_status` column.
48    Status,
49    /// An email address. Masked by default on list views.
50    Email,
51    /// A phone number. Masked by default.
52    Phone,
53    /// Everything else.
54    PlainText,
55}
56
57impl FieldRole {
58    /// `true` when the role carries personal data and should be masked
59    /// by default on list views.
60    pub fn is_sensitive(self) -> bool {
61        matches!(self, FieldRole::Email | FieldRole::Phone)
62    }
63}
64
65/// Everything a form / list renderer needs to present one field to a
66/// human. All strings are plain text (no HTML) — the caller escapes
67/// before emitting.
68#[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    /// `true` when the field should carry the lock marker and (for
75    /// list views) be masked by default.
76    pub sensitive: bool,
77    /// One-line explanation of *why* the field is sensitive.
78    pub sensitivity_note: Option<String>,
79    /// Set when the field is a FK to a known model. Carries the
80    /// *singular* display name of the target (e.g. `"Applicant"`).
81    pub relation_label: Option<String>,
82}
83
84/// What shape of filter the admin list page should render for a given
85/// field.
86#[non_exhaustive]
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum FilterKind {
89    /// `<select>` over distinct string values.
90    DropdownText,
91    /// Yes / No dropdown over a boolean column.
92    BoolYesNo,
93    /// Two `date` / `datetime-local` inputs bounding a range.
94    DateRange,
95    /// Numeric exact-match input.
96    NumericExact,
97    /// Single-line input, compared exactly.
98    ExactMatch,
99    /// `<select>` populated by the admin runtime from rows of the
100    /// target model.
101    RelationSelect { target_model: String },
102}
103
104/// One filter the list page should show for a model.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct FilterDef {
107    pub field: String,
108    pub label: String,
109    pub kind: FilterKind,
110}
111
112// ---------------------------------------------------------------------------
113// classify_field
114// ---------------------------------------------------------------------------
115
116/// Assign a [`FieldRole`] to one field.
117///
118/// Order of precedence (highest first):
119/// 1. Shape: `id`, `email`, `phone`, `status`, bool, datetime, FK, numeric.
120/// 2. Fallback: `PlainText`.
121pub 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    // FK only when the column is integer-typed. A String column
145    // ending in `_id` is an opaque identifier, not a FK.
146    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
155// ---------------------------------------------------------------------------
156// field_ui_metadata
157// ---------------------------------------------------------------------------
158
159/// Package a field's display metadata for the admin form / list
160/// renderers. All strings are plain text — escape before emitting.
161pub 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    // Name-based UI hints applied AFTER role classification.
190    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
211/// Like [`field_ui_metadata`] but relation-aware. Pass the singular
212/// display name of the target model when the schema records a relation
213/// for this field; the returned [`FieldUI`] then carries
214/// `relation_label` and a hint of the form "Foreign key to Target".
215pub 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        // A known relation always renders as ForeignKey even if the
219        // column name wouldn't hit the `_id` heuristic.
220        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
227/// Render "Target #42" for a foreign-key cell on a list view. Falls
228/// back to the raw id when the caller doesn't have a target name.
229pub 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
236// ---------------------------------------------------------------------------
237// infer_filters
238// ---------------------------------------------------------------------------
239
240/// Infer the filter controls for a model's list page from its fields.
241/// Order follows the order of `fields`; every filter references a
242/// field that actually exists on the model.
243pub fn infer_filters(fields: &[AdminField]) -> Vec<FilterDef> {
244    infer_filters_with_relations(fields, |_| None)
245}
246
247/// Like [`infer_filters`] but invokes `relation_target_of` for each
248/// field to detect relation columns. If the callback returns
249/// `Some(target)`, the filter is emitted as
250/// [`FilterKind::RelationSelect`] instead of the numeric-exact fallback.
251pub 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            // Plain text, email, phone — no stock filter.
276            _ => 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
287// ---------------------------------------------------------------------------
288// mask_pii
289// ---------------------------------------------------------------------------
290
291/// Produce a masked display string for a sensitive value. Keeps the
292/// first few characters so a reviewer can tell which row they're
293/// looking at, replaces the rest with `•`. Length of the output
294/// matches the input. Deterministic, Unicode-safe.
295pub 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
313// ---------------------------------------------------------------------------
314// Internal helpers
315// ---------------------------------------------------------------------------
316
317fn 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        // String column ending _id is opaque, not FK.
381        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}