Skip to main content

rustio_core/admin/
intelligence.rs

1//! Admin Intelligence Layer — 0.7.0.
2//!
3//! Pure helpers that turn *schema + context* into *user-facing hints*:
4//! the form-field label beside a `personnummer` input, the masked
5//! display of a sensitive value on a list page, the filter dropdown
6//! inferred from a `status` column, the "Interpreted as ID" badge on a
7//! numeric search. Nothing in this module touches the filesystem, the
8//! database, or produces HTML — it returns structured data that the
9//! admin renderer consumes.
10//!
11//! ## Principles
12//!
13//! - **Inference, not configuration.** Rules are derived from
14//!   `(field name, field type, nullability) + ContextConfig`. No
15//!   per-project hooks.
16//! - **Conservative sensitivity.** Under GDPR / country rules, the
17//!   layer marks a field as sensitive *up*, never down. A project
18//!   without context gets 0.6.x behaviour.
19//! - **Deterministic.** Same inputs → same outputs. No ordering
20//!   surprises, no random masking length.
21//!
22//! ## Public API
23//!
24//! - [`classify_field`] — labels a field by role (`Id`, `Email`,
25//!   `Personnummer`, …). Every downstream renderer branches on
26//!   this enum.
27//! - [`field_ui_metadata`] — packages the label, placeholder, hint,
28//!   and sensitivity marker a form needs to render one input.
29//! - [`infer_filters`] — walks a model's fields and decides which
30//!   filters make sense on its list page.
31//! - [`classify_search`] — inspects a search query and tells the
32//!   list handler what the user probably meant (`NumericId`, `Email`,
33//!   `Personnummer`, `Text`).
34//! - [`mask_pii`] — deterministic string masker used to hide
35//!   personal data by default on list views.
36
37use std::sync::OnceLock;
38
39use crate::admin::{AdminField, FieldType};
40use crate::ai::ContextConfig;
41
42/// Process-global cache for the project's `rustio.context.json`.
43///
44/// Loaded lazily on first access and held for the life of the
45/// process — the admin runs as a long-lived server and the context
46/// file is static between restarts. `None` means either the file
47/// isn't present or it couldn't be parsed.
48///
49/// Pattern mirrors [`crate::admin::design::Design::global`]; the two
50/// artefacts are read once and shared across every render.
51pub fn context_global() -> Option<&'static ContextConfig> {
52    static INSTANCE: OnceLock<Option<ContextConfig>> = OnceLock::new();
53    INSTANCE
54        .get_or_init(|| {
55            let raw = std::fs::read_to_string("rustio.context.json").ok()?;
56            ContextConfig::parse(&raw).ok()
57        })
58        .as_ref()
59}
60
61/// The role a field plays in the admin UI. One field maps to exactly
62/// one role; the ordering of branches in [`classify_field`] resolves
63/// overlaps (e.g. an `email` column is `FieldRole::Email`, not
64/// `FieldRole::PlainText`).
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum FieldRole {
67    /// Primary key. Rendered monospace, excluded from edit forms.
68    Id,
69    /// `DateTime<Utc>`-shaped columns.
70    Timestamp,
71    /// Booleans — rendered as a pill on the list page, a checkbox in
72    /// forms.
73    Bool,
74    /// Numeric values that aren't identifiers — priorities, scores,
75    /// counts. Rendered with tabular numerics on the list page.
76    NumericCount,
77    /// `<something>_id` column that points at another model. Rendered
78    /// monospace, filter is a relation dropdown (deferred).
79    ForeignKey,
80    /// A `status` / `*_status` column. Renders as a coloured pill and
81    /// becomes a dropdown filter.
82    Status,
83    /// A Swedish personal identity number under `country=SE`.
84    Personnummer,
85    /// An email address under GDPR. Masked by default on list views.
86    Email,
87    /// A phone number under GDPR. Masked by default.
88    Phone,
89    /// An opaque healthcare identifier (`patient_id`, `mrn`, ...)
90    /// under `industry=healthcare`.
91    OpaqueIdentifier,
92    /// A monetary amount under `industry=banking`. Stored as integer
93    /// minor units.
94    Money,
95    /// Everything else. Default role; triggers the plain-text input.
96    PlainText,
97}
98
99impl FieldRole {
100    /// `true` when the role carries personal / sensitive data and
101    /// should be masked by default on list views.
102    pub fn is_sensitive(self) -> bool {
103        matches!(
104            self,
105            FieldRole::Personnummer
106                | FieldRole::Email
107                | FieldRole::Phone
108                | FieldRole::OpaqueIdentifier
109        )
110    }
111}
112
113/// Everything a form / list renderer needs to present one field to a
114/// human. All strings are plain text (no HTML) — the caller escapes
115/// before emitting.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct FieldUI {
118    pub role: FieldRole,
119    pub label: String,
120    pub placeholder: Option<String>,
121    pub hint: Option<String>,
122    /// `true` when the field should carry the lock marker and (for
123    /// list views) be masked by default.
124    pub sensitive: bool,
125    /// One-line explanation of *why* the field is sensitive — shown
126    /// next to the lock marker or in a tooltip.
127    pub sensitivity_note: Option<String>,
128    /// 0.8.0 — set when the field is a FK to a known model. Carries
129    /// the *singular* display name of the target (e.g. `"Applicant"`)
130    /// so list views can render "Applicant #42" and forms can hint
131    /// "Foreign key to Applicant". `None` for every field that isn't
132    /// a modelled relation — callers must not invent a label from the
133    /// column name alone.
134    pub relation_label: Option<String>,
135}
136
137/// What shape of filter the admin list page should render for a given
138/// field. Each variant maps to a concrete HTML control.
139#[non_exhaustive]
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum FilterKind {
142    /// `<select>` over distinct string values, filled at render time.
143    DropdownText,
144    /// Yes / No dropdown over a boolean column.
145    BoolYesNo,
146    /// Two `date` / `datetime-local` inputs bounding a range.
147    DateRange,
148    /// Numeric exact-match input (integer).
149    NumericExact,
150    /// Single-line input, compared exactly. Used for identity numbers
151    /// where substring is the wrong semantics.
152    ExactMatch,
153    /// 0.8.0 — `<select>` populated by the admin runtime from rows of
154    /// the target model. Rendered as "Applicant (42)" / "Applicant
155    /// (43)" etc. The `target_model` carries the *singular* display
156    /// name so the handler knows which table to read.
157    RelationSelect { target_model: String },
158}
159
160/// One filter the list page should show for a model. Produced by
161/// [`infer_filters`].
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct FilterDef {
164    pub field: String,
165    pub label: String,
166    pub kind: FilterKind,
167}
168
169/// What the user *probably* typed into the list-page search box.
170/// Letting the handler branch on this gives cleaner narrow-match
171/// behaviour than "grep every String field".
172#[non_exhaustive]
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub enum SearchIntent {
175    /// Parsed as a non-negative integer — likely an ID lookup.
176    NumericId(i64),
177    /// Contains `@` and `.` in plausible positions — email search.
178    Email(String),
179    /// Matches the 12/13-character Swedish personnummer shape.
180    Personnummer(String),
181    /// 0.8.0 — an FK field is being searched by target id. Emitted
182    /// only by [`classify_search_for_field`] when the caller supplies
183    /// a relation target; plain `classify_search` never produces it.
184    RelationId { model: String, id: i64 },
185    /// Everything else, including empty string.
186    Text(String),
187}
188
189impl SearchIntent {
190    /// Stable short label for the CLI / UI badge.
191    pub fn label(&self) -> &'static str {
192        match self {
193            SearchIntent::NumericId(_) => "ID",
194            SearchIntent::Email(_) => "email",
195            SearchIntent::Personnummer(_) => "personnummer",
196            SearchIntent::RelationId { .. } => "relation",
197            SearchIntent::Text(_) => "text",
198        }
199    }
200}
201
202// ---------------------------------------------------------------------------
203// classify_field
204// ---------------------------------------------------------------------------
205
206/// Assign a [`FieldRole`] to one field, taking context into account.
207///
208/// Order of precedence (highest first):
209///
210/// 1. Country-scoped PII names (`personnummer` under `SE`).
211/// 2. Industry-scoped opaque identifiers (`patient_id` under
212///    `healthcare`, `balance` under `banking`).
213/// 3. GDPR-scoped generics (`email`, `phone`).
214/// 4. Shape: `id`, `*_id`, `status`, bool, datetime, numeric.
215/// 5. Fallback: `PlainText`.
216pub fn classify_field(f: &AdminField, context: Option<&ContextConfig>) -> FieldRole {
217    let name = f.name;
218    if let Some(ctx) = context {
219        if matches!(ctx.country.as_deref(), Some(cc) if cc.eq_ignore_ascii_case("SE"))
220            && matches!(
221                name,
222                "personnummer" | "personal_id" | "personal_number" | "pnr"
223            )
224        {
225            return FieldRole::Personnummer;
226        }
227        if matches!(ctx.country.as_deref(), Some(cc) if cc.eq_ignore_ascii_case("NO"))
228            && matches!(name, "fodselsnummer" | "personal_number")
229        {
230            return FieldRole::Personnummer;
231        }
232        if matches!(ctx.industry.as_deref(), Some(i) if i.eq_ignore_ascii_case("healthcare"))
233            && matches!(name, "patient_id" | "mrn" | "medical_record_number")
234        {
235            return FieldRole::OpaqueIdentifier;
236        }
237        if matches!(ctx.industry.as_deref(), Some(i) if i.eq_ignore_ascii_case("banking"))
238            && (name == "balance" || name == "amount" || name.ends_with("_amount"))
239        {
240            return FieldRole::Money;
241        }
242        if ctx.requires_gdpr() {
243            if name == "email" {
244                return FieldRole::Email;
245            }
246            if name == "phone" {
247                return FieldRole::Phone;
248            }
249        }
250    }
251
252    // Shape-only fallbacks (no context needed).
253    if name == "id" {
254        return FieldRole::Id;
255    }
256    if name == "email" {
257        return FieldRole::Email;
258    }
259    if name == "phone" {
260        return FieldRole::Phone;
261    }
262    if matches!(f.field_type, FieldType::Bool) {
263        return FieldRole::Bool;
264    }
265    if matches!(f.field_type, FieldType::DateTime) {
266        return FieldRole::Timestamp;
267    }
268    if name == "status" || name.ends_with("_status") {
269        return FieldRole::Status;
270    }
271    // Only classify as ForeignKey when the column is integer-typed.
272    // A String column ending in `_id` (e.g. `national_id`, `mrn`,
273    // `license_no`) is an opaque identifier, not a FK — and the
274    // "Foreign-key id — must reference an existing row" hint is
275    // actively wrong for it.
276    if name.ends_with("_id") && matches!(f.field_type, FieldType::I32 | FieldType::I64) {
277        return FieldRole::ForeignKey;
278    }
279    if matches!(f.field_type, FieldType::I32 | FieldType::I64) {
280        return FieldRole::NumericCount;
281    }
282    FieldRole::PlainText
283}
284
285// ---------------------------------------------------------------------------
286// field_ui_metadata
287// ---------------------------------------------------------------------------
288
289/// Package a field's display metadata for the admin form / list
290/// renderers. All strings are plain text — escape before emitting.
291pub fn field_ui_metadata(f: &AdminField, context: Option<&ContextConfig>) -> FieldUI {
292    let role = classify_field(f, context);
293    let label = humanise(f.name);
294    let mut placeholder: Option<String> = None;
295    let mut hint: Option<String> = None;
296    let mut sensitive = false;
297    let mut sensitivity_note: Option<String> = None;
298
299    match role {
300        FieldRole::Personnummer => {
301            placeholder = Some("YYYYMMDD-XXXX".into());
302            hint = Some("Swedish personal identity number.".into());
303            sensitive = true;
304            sensitivity_note = Some("Sensitive personal data (GDPR).".into());
305        }
306        FieldRole::Email => {
307            placeholder = Some("name@example.com".into());
308            if context.is_some_and(|c| c.requires_gdpr()) {
309                sensitive = true;
310                sensitivity_note = Some("Personal data (GDPR).".into());
311            }
312        }
313        FieldRole::Phone => {
314            placeholder = Some("+46 70 123 45 67".into());
315            if context.is_some_and(|c| c.requires_gdpr()) {
316                sensitive = true;
317                sensitivity_note = Some("Personal data (GDPR).".into());
318            }
319        }
320        FieldRole::OpaqueIdentifier => {
321            hint = Some("Opaque identifier — do not expose publicly.".into());
322            sensitive = true;
323            sensitivity_note = Some("Clinical identifier.".into());
324        }
325        FieldRole::Money => {
326            hint = Some("Integer minor units (öre, cents). Never use floats.".into());
327        }
328        FieldRole::Timestamp => {
329            placeholder = Some("YYYY-MM-DDTHH:MM".into());
330            hint = Some("Interpreted as UTC.".into());
331        }
332        FieldRole::Status => {
333            hint = Some("Short status label (e.g. active, pending, resolved).".into());
334        }
335        FieldRole::ForeignKey => {
336            hint = Some("Foreign-key id — must reference an existing row.".into());
337        }
338        FieldRole::Id | FieldRole::Bool | FieldRole::NumericCount | FieldRole::PlainText => {}
339    }
340
341    // Phase 10 — name-based UI hints applied AFTER role classification.
342    // These match the spec's literal field-name rules (`slug`,
343    // `status`); they override role-derived hints when the field name
344    // is unambiguous.
345    if f.name == "slug" {
346        placeholder = Some("my-post-title".into());
347        hint = Some("URL-friendly identifier".into());
348    }
349
350    FieldUI {
351        role,
352        label,
353        placeholder,
354        hint,
355        sensitive,
356        sensitivity_note,
357        relation_label: None,
358    }
359}
360
361/// 0.8.0 — like [`field_ui_metadata`] but relation-aware. Pass the
362/// singular display name of the target model (e.g. `"Applicant"`) when
363/// the schema records a relation for this field; the returned
364/// [`FieldUI`] then carries `relation_label` and a form hint of the
365/// form "Foreign key to Applicant". Passing `None` is equivalent to
366/// calling [`field_ui_metadata`].
367///
368/// The caller (admin renderer) looks the target up in
369/// [`Schema::relation_for`](crate::schema::Schema::relation_for); this
370/// helper intentionally doesn't take a `&Schema` so the intelligence
371/// module stays schema-free for callers that don't need it.
372pub fn field_ui_metadata_with_relation(
373    f: &AdminField,
374    context: Option<&ContextConfig>,
375    relation_target: Option<&str>,
376) -> FieldUI {
377    let mut ui = field_ui_metadata(f, context);
378    if let Some(target) = relation_target.filter(|t| !t.is_empty()) {
379        // Escalate the role — a known relation always renders as
380        // ForeignKey even if the column name wouldn't hit the `_id`
381        // heuristic.
382        ui.role = FieldRole::ForeignKey;
383        ui.relation_label = Some(target.to_string());
384        // Rewrite the generic ForeignKey hint to name the target.
385        ui.hint = Some(format!("Foreign key to {target}."));
386    }
387    ui
388}
389
390/// Render "Target #42" for a foreign-key cell on a list view. Falls
391/// back to the raw id when the caller doesn't have a target name.
392/// Kept as a free function so the admin list renderer doesn't have to
393/// reach into [`FieldUI`] directly for the common case.
394pub fn format_relation_cell(id: i64, target: Option<&str>) -> String {
395    match target {
396        Some(t) if !t.is_empty() => format!("{t} #{id}"),
397        _ => id.to_string(),
398    }
399}
400
401// ---------------------------------------------------------------------------
402// infer_filters
403// ---------------------------------------------------------------------------
404
405/// Infer the filter controls for a model's list page from its fields
406/// plus active context. Order follows the order of `fields`; every
407/// filter references a field that actually exists on the model.
408pub fn infer_filters(fields: &[AdminField], context: Option<&ContextConfig>) -> Vec<FilterDef> {
409    infer_filters_with_relations(fields, context, |_| None)
410}
411
412/// 0.8.0 — like [`infer_filters`] but invokes `relation_target_of` for
413/// each field to detect relation columns. If the callback returns
414/// `Some(target)`, the filter is emitted as
415/// [`FilterKind::RelationSelect`] instead of the numeric-exact fallback.
416///
417/// The callback shape (rather than a `&Schema`) keeps this module
418/// schema-agnostic; the admin renderer is free to wire it to
419/// [`Schema::relation_for`](crate::schema::Schema::relation_for).
420pub fn infer_filters_with_relations<F>(
421    fields: &[AdminField],
422    context: Option<&ContextConfig>,
423    relation_target_of: F,
424) -> Vec<FilterDef>
425where
426    F: Fn(&AdminField) -> Option<String>,
427{
428    let mut out: Vec<FilterDef> = Vec::new();
429    for f in fields {
430        if f.name == "id" {
431            continue;
432        }
433        let role = classify_field(f, context);
434        let kind = match role {
435            FieldRole::Status => FilterKind::DropdownText,
436            FieldRole::Bool => FilterKind::BoolYesNo,
437            FieldRole::Timestamp => FilterKind::DateRange,
438            FieldRole::NumericCount => FilterKind::NumericExact,
439            FieldRole::Personnummer => FilterKind::ExactMatch,
440            FieldRole::ForeignKey => match relation_target_of(f) {
441                Some(target_model) if !target_model.is_empty() => {
442                    FilterKind::RelationSelect { target_model }
443                }
444                _ => FilterKind::NumericExact,
445            },
446            // Plain text, email, phone, money, opaque-identifier —
447            // no stock filter. Email/phone would deserve their own
448            // filter UI, but live search already covers the common
449            // case; adding a dedicated control is a 0.7.1 candidate.
450            _ => continue,
451        };
452        out.push(FilterDef {
453            field: f.name.to_string(),
454            label: humanise(f.name),
455            kind,
456        });
457    }
458    out
459}
460
461// ---------------------------------------------------------------------------
462// classify_search
463// ---------------------------------------------------------------------------
464
465/// 0.8.0 — variant of [`classify_search`] that knows the field is a
466/// relation. When the query parses as a non-negative integer, emits
467/// [`SearchIntent::RelationId`] carrying the target model; otherwise
468/// falls through to [`classify_search`] for the usual shape-based
469/// routing. Called by the admin search handler when the user is
470/// searching a specific FK column.
471pub fn classify_search_for_field(query: &str, relation_target: Option<&str>) -> SearchIntent {
472    let t = query.trim();
473    if let Some(model) = relation_target.filter(|m| !m.is_empty()) {
474        if let Ok(id) = t.parse::<i64>() {
475            if id >= 0 {
476                return SearchIntent::RelationId {
477                    model: model.to_string(),
478                    id,
479                };
480            }
481        }
482    }
483    classify_search(query)
484}
485
486/// Guess what the user meant by the text in the list-page search box.
487/// Order of tries: numeric → email → personnummer → text.
488pub fn classify_search(query: &str) -> SearchIntent {
489    let t = query.trim();
490    if t.is_empty() {
491        return SearchIntent::Text(String::new());
492    }
493    // Personnummer first — a 12-digit string would otherwise look
494    // like a numeric ID, and `42` would still reach the Id branch
495    // below because only 12 digits match the shape.
496    if looks_like_personnummer(t) {
497        return SearchIntent::Personnummer(t.to_string());
498    }
499    if let Ok(n) = t.parse::<i64>() {
500        if n >= 0 {
501            return SearchIntent::NumericId(n);
502        }
503    }
504    if looks_like_email(t) {
505        return SearchIntent::Email(t.to_string());
506    }
507    SearchIntent::Text(t.to_string())
508}
509
510fn looks_like_email(s: &str) -> bool {
511    if s.len() > 254 || s.len() < 3 {
512        return false;
513    }
514    let at = match s.find('@') {
515        Some(i) => i,
516        None => return false,
517    };
518    if at == 0 || at == s.len() - 1 {
519        return false;
520    }
521    let domain = &s[at + 1..];
522    // Domain must contain a dot and neither start nor end with it.
523    if !domain.contains('.') || domain.starts_with('.') || domain.ends_with('.') {
524        return false;
525    }
526    // Local + domain must not contain whitespace.
527    !s.chars().any(|c| c.is_whitespace())
528}
529
530fn looks_like_personnummer(s: &str) -> bool {
531    // Accept 12 plain digits or 8-digit / 4-digit split by `-`.
532    let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
533    if digits.len() != 12 {
534        return false;
535    }
536    // The non-digit chars we allow are '-' only.
537    if s.chars().any(|c| !c.is_ascii_digit() && c != '-') {
538        return false;
539    }
540    match s.len() {
541        12 => true,
542        13 => s.as_bytes().get(8) == Some(&b'-'),
543        _ => false,
544    }
545}
546
547// ---------------------------------------------------------------------------
548// mask_pii
549// ---------------------------------------------------------------------------
550
551/// Produce a masked display string for a sensitive value. Keeps the
552/// first few characters so a reviewer can tell which row they're
553/// looking at, replaces the rest with `•`. Length of the output
554/// matches the input so the layout doesn't jump when a user toggles
555/// visibility.
556///
557/// Deterministic, Unicode-safe. Empty input → empty output.
558pub fn mask_pii(value: &str) -> String {
559    if value.is_empty() {
560        return String::new();
561    }
562    let chars: Vec<char> = value.chars().collect();
563    let n = chars.len();
564    // Keep ~⅓ of the string visible, clamped to [2, 4] so short
565    // values still show some identifying prefix without fully
566    // revealing the content.
567    let keep = (n / 3).clamp(2, 4).min(n);
568    let mut out = String::with_capacity(n);
569    for (i, c) in chars.iter().enumerate() {
570        if i < keep {
571            out.push(*c);
572        } else {
573            out.push('•');
574        }
575    }
576    out
577}
578
579// ---------------------------------------------------------------------------
580// Internal helpers
581// ---------------------------------------------------------------------------
582
583/// snake_case → Title Case. Mirrors `admin::humanise`; kept local so
584/// the intelligence module doesn't reach into private admin helpers.
585fn humanise(s: &str) -> String {
586    let mut out = String::with_capacity(s.len());
587    let mut next_upper = true;
588    for ch in s.chars() {
589        if ch == '_' {
590            out.push(' ');
591            next_upper = true;
592        } else if next_upper {
593            out.push(ch.to_ascii_uppercase());
594            next_upper = false;
595        } else {
596            out.push(ch);
597        }
598    }
599    out
600}