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}