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.ty, FieldType::Bool) {
263        return FieldRole::Bool;
264    }
265    if matches!(f.ty, 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.ty, FieldType::I32 | FieldType::I64) {
277        return FieldRole::ForeignKey;
278    }
279    if matches!(f.ty, 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    FieldUI {
342        role,
343        label,
344        placeholder,
345        hint,
346        sensitive,
347        sensitivity_note,
348        relation_label: None,
349    }
350}
351
352/// 0.8.0 — like [`field_ui_metadata`] but relation-aware. Pass the
353/// singular display name of the target model (e.g. `"Applicant"`) when
354/// the schema records a relation for this field; the returned
355/// [`FieldUI`] then carries `relation_label` and a form hint of the
356/// form "Foreign key to Applicant". Passing `None` is equivalent to
357/// calling [`field_ui_metadata`].
358///
359/// The caller (admin renderer) looks the target up in
360/// [`Schema::relation_for`](crate::schema::Schema::relation_for); this
361/// helper intentionally doesn't take a `&Schema` so the intelligence
362/// module stays schema-free for callers that don't need it.
363pub fn field_ui_metadata_with_relation(
364    f: &AdminField,
365    context: Option<&ContextConfig>,
366    relation_target: Option<&str>,
367) -> FieldUI {
368    let mut ui = field_ui_metadata(f, context);
369    if let Some(target) = relation_target.filter(|t| !t.is_empty()) {
370        // Escalate the role — a known relation always renders as
371        // ForeignKey even if the column name wouldn't hit the `_id`
372        // heuristic.
373        ui.role = FieldRole::ForeignKey;
374        ui.relation_label = Some(target.to_string());
375        // Rewrite the generic ForeignKey hint to name the target.
376        ui.hint = Some(format!("Foreign key to {target}."));
377    }
378    ui
379}
380
381/// Render "Target #42" for a foreign-key cell on a list view. Falls
382/// back to the raw id when the caller doesn't have a target name.
383/// Kept as a free function so the admin list renderer doesn't have to
384/// reach into [`FieldUI`] directly for the common case.
385pub fn format_relation_cell(id: i64, target: Option<&str>) -> String {
386    match target {
387        Some(t) if !t.is_empty() => format!("{t} #{id}"),
388        _ => id.to_string(),
389    }
390}
391
392// ---------------------------------------------------------------------------
393// infer_filters
394// ---------------------------------------------------------------------------
395
396/// Infer the filter controls for a model's list page from its fields
397/// plus active context. Order follows the order of `fields`; every
398/// filter references a field that actually exists on the model.
399pub fn infer_filters(fields: &[AdminField], context: Option<&ContextConfig>) -> Vec<FilterDef> {
400    infer_filters_with_relations(fields, context, |_| None)
401}
402
403/// 0.8.0 — like [`infer_filters`] but invokes `relation_target_of` for
404/// each field to detect relation columns. If the callback returns
405/// `Some(target)`, the filter is emitted as
406/// [`FilterKind::RelationSelect`] instead of the numeric-exact fallback.
407///
408/// The callback shape (rather than a `&Schema`) keeps this module
409/// schema-agnostic; the admin renderer is free to wire it to
410/// [`Schema::relation_for`](crate::schema::Schema::relation_for).
411pub fn infer_filters_with_relations<F>(
412    fields: &[AdminField],
413    context: Option<&ContextConfig>,
414    relation_target_of: F,
415) -> Vec<FilterDef>
416where
417    F: Fn(&AdminField) -> Option<String>,
418{
419    let mut out: Vec<FilterDef> = Vec::new();
420    for f in fields {
421        if f.name == "id" {
422            continue;
423        }
424        let role = classify_field(f, context);
425        let kind = match role {
426            FieldRole::Status => FilterKind::DropdownText,
427            FieldRole::Bool => FilterKind::BoolYesNo,
428            FieldRole::Timestamp => FilterKind::DateRange,
429            FieldRole::NumericCount => FilterKind::NumericExact,
430            FieldRole::Personnummer => FilterKind::ExactMatch,
431            FieldRole::ForeignKey => match relation_target_of(f) {
432                Some(target_model) if !target_model.is_empty() => {
433                    FilterKind::RelationSelect { target_model }
434                }
435                _ => FilterKind::NumericExact,
436            },
437            // Plain text, email, phone, money, opaque-identifier —
438            // no stock filter. Email/phone would deserve their own
439            // filter UI, but live search already covers the common
440            // case; adding a dedicated control is a 0.7.1 candidate.
441            _ => continue,
442        };
443        out.push(FilterDef {
444            field: f.name.to_string(),
445            label: humanise(f.name),
446            kind,
447        });
448    }
449    out
450}
451
452// ---------------------------------------------------------------------------
453// classify_search
454// ---------------------------------------------------------------------------
455
456/// 0.8.0 — variant of [`classify_search`] that knows the field is a
457/// relation. When the query parses as a non-negative integer, emits
458/// [`SearchIntent::RelationId`] carrying the target model; otherwise
459/// falls through to [`classify_search`] for the usual shape-based
460/// routing. Called by the admin search handler when the user is
461/// searching a specific FK column.
462pub fn classify_search_for_field(query: &str, relation_target: Option<&str>) -> SearchIntent {
463    let t = query.trim();
464    if let Some(model) = relation_target.filter(|m| !m.is_empty()) {
465        if let Ok(id) = t.parse::<i64>() {
466            if id >= 0 {
467                return SearchIntent::RelationId {
468                    model: model.to_string(),
469                    id,
470                };
471            }
472        }
473    }
474    classify_search(query)
475}
476
477/// Guess what the user meant by the text in the list-page search box.
478/// Order of tries: numeric → email → personnummer → text.
479pub fn classify_search(query: &str) -> SearchIntent {
480    let t = query.trim();
481    if t.is_empty() {
482        return SearchIntent::Text(String::new());
483    }
484    // Personnummer first — a 12-digit string would otherwise look
485    // like a numeric ID, and `42` would still reach the Id branch
486    // below because only 12 digits match the shape.
487    if looks_like_personnummer(t) {
488        return SearchIntent::Personnummer(t.to_string());
489    }
490    if let Ok(n) = t.parse::<i64>() {
491        if n >= 0 {
492            return SearchIntent::NumericId(n);
493        }
494    }
495    if looks_like_email(t) {
496        return SearchIntent::Email(t.to_string());
497    }
498    SearchIntent::Text(t.to_string())
499}
500
501fn looks_like_email(s: &str) -> bool {
502    if s.len() > 254 || s.len() < 3 {
503        return false;
504    }
505    let at = match s.find('@') {
506        Some(i) => i,
507        None => return false,
508    };
509    if at == 0 || at == s.len() - 1 {
510        return false;
511    }
512    let domain = &s[at + 1..];
513    // Domain must contain a dot and neither start nor end with it.
514    if !domain.contains('.') || domain.starts_with('.') || domain.ends_with('.') {
515        return false;
516    }
517    // Local + domain must not contain whitespace.
518    !s.chars().any(|c| c.is_whitespace())
519}
520
521fn looks_like_personnummer(s: &str) -> bool {
522    // Accept 12 plain digits or 8-digit / 4-digit split by `-`.
523    let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
524    if digits.len() != 12 {
525        return false;
526    }
527    // The non-digit chars we allow are '-' only.
528    if s.chars().any(|c| !c.is_ascii_digit() && c != '-') {
529        return false;
530    }
531    match s.len() {
532        12 => true,
533        13 => s.as_bytes().get(8) == Some(&b'-'),
534        _ => false,
535    }
536}
537
538// ---------------------------------------------------------------------------
539// mask_pii
540// ---------------------------------------------------------------------------
541
542/// Produce a masked display string for a sensitive value. Keeps the
543/// first few characters so a reviewer can tell which row they're
544/// looking at, replaces the rest with `•`. Length of the output
545/// matches the input so the layout doesn't jump when a user toggles
546/// visibility.
547///
548/// Deterministic, Unicode-safe. Empty input → empty output.
549pub fn mask_pii(value: &str) -> String {
550    if value.is_empty() {
551        return String::new();
552    }
553    let chars: Vec<char> = value.chars().collect();
554    let n = chars.len();
555    // Keep ~⅓ of the string visible, clamped to [2, 4] so short
556    // values still show some identifying prefix without fully
557    // revealing the content.
558    let keep = (n / 3).clamp(2, 4).min(n);
559    let mut out = String::with_capacity(n);
560    for (i, c) in chars.iter().enumerate() {
561        if i < keep {
562            out.push(*c);
563        } else {
564            out.push('•');
565        }
566    }
567    out
568}
569
570// ---------------------------------------------------------------------------
571// Internal helpers
572// ---------------------------------------------------------------------------
573
574/// snake_case → Title Case. Mirrors `admin::humanise`; kept local so
575/// the intelligence module doesn't reach into private admin helpers.
576fn humanise(s: &str) -> String {
577    let mut out = String::with_capacity(s.len());
578    let mut next_upper = true;
579    for ch in s.chars() {
580        if ch == '_' {
581            out.push(' ');
582            next_upper = true;
583        } else if next_upper {
584            out.push(ch.to_ascii_uppercase());
585            next_upper = false;
586        } else {
587            out.push(ch);
588        }
589    }
590    out
591}