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}