use std::sync::OnceLock;
use crate::admin::{AdminField, FieldType};
use crate::ai::ContextConfig;
pub fn context_global() -> Option<&'static ContextConfig> {
static INSTANCE: OnceLock<Option<ContextConfig>> = OnceLock::new();
INSTANCE
.get_or_init(|| {
let raw = std::fs::read_to_string("rustio.context.json").ok()?;
ContextConfig::parse(&raw).ok()
})
.as_ref()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FieldRole {
Id,
Timestamp,
Bool,
NumericCount,
ForeignKey,
Status,
Personnummer,
Email,
Phone,
OpaqueIdentifier,
Money,
PlainText,
}
impl FieldRole {
pub fn is_sensitive(self) -> bool {
matches!(
self,
FieldRole::Personnummer
| FieldRole::Email
| FieldRole::Phone
| FieldRole::OpaqueIdentifier
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FieldUI {
pub role: FieldRole,
pub label: String,
pub placeholder: Option<String>,
pub hint: Option<String>,
pub sensitive: bool,
pub sensitivity_note: Option<String>,
pub relation_label: Option<String>,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FilterKind {
DropdownText,
BoolYesNo,
DateRange,
NumericExact,
ExactMatch,
RelationSelect { target_model: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FilterDef {
pub field: String,
pub label: String,
pub kind: FilterKind,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SearchIntent {
NumericId(i64),
Email(String),
Personnummer(String),
RelationId { model: String, id: i64 },
Text(String),
}
impl SearchIntent {
pub fn label(&self) -> &'static str {
match self {
SearchIntent::NumericId(_) => "ID",
SearchIntent::Email(_) => "email",
SearchIntent::Personnummer(_) => "personnummer",
SearchIntent::RelationId { .. } => "relation",
SearchIntent::Text(_) => "text",
}
}
}
pub fn classify_field(f: &AdminField, context: Option<&ContextConfig>) -> FieldRole {
let name = f.name;
if let Some(ctx) = context {
if matches!(ctx.country.as_deref(), Some(cc) if cc.eq_ignore_ascii_case("SE"))
&& matches!(
name,
"personnummer" | "personal_id" | "personal_number" | "pnr"
)
{
return FieldRole::Personnummer;
}
if matches!(ctx.country.as_deref(), Some(cc) if cc.eq_ignore_ascii_case("NO"))
&& matches!(name, "fodselsnummer" | "personal_number")
{
return FieldRole::Personnummer;
}
if matches!(ctx.industry.as_deref(), Some(i) if i.eq_ignore_ascii_case("healthcare"))
&& matches!(name, "patient_id" | "mrn" | "medical_record_number")
{
return FieldRole::OpaqueIdentifier;
}
if matches!(ctx.industry.as_deref(), Some(i) if i.eq_ignore_ascii_case("banking"))
&& (name == "balance" || name == "amount" || name.ends_with("_amount"))
{
return FieldRole::Money;
}
if ctx.requires_gdpr() {
if name == "email" {
return FieldRole::Email;
}
if name == "phone" {
return FieldRole::Phone;
}
}
}
if name == "id" {
return FieldRole::Id;
}
if name == "email" {
return FieldRole::Email;
}
if name == "phone" {
return FieldRole::Phone;
}
if matches!(f.ty, FieldType::Bool) {
return FieldRole::Bool;
}
if matches!(f.ty, FieldType::DateTime) {
return FieldRole::Timestamp;
}
if name == "status" || name.ends_with("_status") {
return FieldRole::Status;
}
if name.ends_with("_id") && matches!(f.ty, FieldType::I32 | FieldType::I64) {
return FieldRole::ForeignKey;
}
if matches!(f.ty, FieldType::I32 | FieldType::I64) {
return FieldRole::NumericCount;
}
FieldRole::PlainText
}
pub fn field_ui_metadata(f: &AdminField, context: Option<&ContextConfig>) -> FieldUI {
let role = classify_field(f, context);
let label = humanise(f.name);
let mut placeholder: Option<String> = None;
let mut hint: Option<String> = None;
let mut sensitive = false;
let mut sensitivity_note: Option<String> = None;
match role {
FieldRole::Personnummer => {
placeholder = Some("YYYYMMDD-XXXX".into());
hint = Some("Swedish personal identity number.".into());
sensitive = true;
sensitivity_note = Some("Sensitive personal data (GDPR).".into());
}
FieldRole::Email => {
placeholder = Some("name@example.com".into());
if context.is_some_and(|c| c.requires_gdpr()) {
sensitive = true;
sensitivity_note = Some("Personal data (GDPR).".into());
}
}
FieldRole::Phone => {
placeholder = Some("+46 70 123 45 67".into());
if context.is_some_and(|c| c.requires_gdpr()) {
sensitive = true;
sensitivity_note = Some("Personal data (GDPR).".into());
}
}
FieldRole::OpaqueIdentifier => {
hint = Some("Opaque identifier — do not expose publicly.".into());
sensitive = true;
sensitivity_note = Some("Clinical identifier.".into());
}
FieldRole::Money => {
hint = Some("Integer minor units (öre, cents). Never use floats.".into());
}
FieldRole::Timestamp => {
placeholder = Some("YYYY-MM-DDTHH:MM".into());
hint = Some("Interpreted as UTC.".into());
}
FieldRole::Status => {
hint = Some("Short status label (e.g. active, pending, resolved).".into());
}
FieldRole::ForeignKey => {
hint = Some("Foreign-key id — must reference an existing row.".into());
}
FieldRole::Id | FieldRole::Bool | FieldRole::NumericCount | FieldRole::PlainText => {}
}
FieldUI {
role,
label,
placeholder,
hint,
sensitive,
sensitivity_note,
relation_label: None,
}
}
pub fn field_ui_metadata_with_relation(
f: &AdminField,
context: Option<&ContextConfig>,
relation_target: Option<&str>,
) -> FieldUI {
let mut ui = field_ui_metadata(f, context);
if let Some(target) = relation_target.filter(|t| !t.is_empty()) {
ui.role = FieldRole::ForeignKey;
ui.relation_label = Some(target.to_string());
ui.hint = Some(format!("Foreign key to {target}."));
}
ui
}
pub fn format_relation_cell(id: i64, target: Option<&str>) -> String {
match target {
Some(t) if !t.is_empty() => format!("{t} #{id}"),
_ => id.to_string(),
}
}
pub fn infer_filters(fields: &[AdminField], context: Option<&ContextConfig>) -> Vec<FilterDef> {
infer_filters_with_relations(fields, context, |_| None)
}
pub fn infer_filters_with_relations<F>(
fields: &[AdminField],
context: Option<&ContextConfig>,
relation_target_of: F,
) -> Vec<FilterDef>
where
F: Fn(&AdminField) -> Option<String>,
{
let mut out: Vec<FilterDef> = Vec::new();
for f in fields {
if f.name == "id" {
continue;
}
let role = classify_field(f, context);
let kind = match role {
FieldRole::Status => FilterKind::DropdownText,
FieldRole::Bool => FilterKind::BoolYesNo,
FieldRole::Timestamp => FilterKind::DateRange,
FieldRole::NumericCount => FilterKind::NumericExact,
FieldRole::Personnummer => FilterKind::ExactMatch,
FieldRole::ForeignKey => match relation_target_of(f) {
Some(target_model) if !target_model.is_empty() => {
FilterKind::RelationSelect { target_model }
}
_ => FilterKind::NumericExact,
},
_ => continue,
};
out.push(FilterDef {
field: f.name.to_string(),
label: humanise(f.name),
kind,
});
}
out
}
pub fn classify_search_for_field(query: &str, relation_target: Option<&str>) -> SearchIntent {
let t = query.trim();
if let Some(model) = relation_target.filter(|m| !m.is_empty()) {
if let Ok(id) = t.parse::<i64>() {
if id >= 0 {
return SearchIntent::RelationId {
model: model.to_string(),
id,
};
}
}
}
classify_search(query)
}
pub fn classify_search(query: &str) -> SearchIntent {
let t = query.trim();
if t.is_empty() {
return SearchIntent::Text(String::new());
}
if looks_like_personnummer(t) {
return SearchIntent::Personnummer(t.to_string());
}
if let Ok(n) = t.parse::<i64>() {
if n >= 0 {
return SearchIntent::NumericId(n);
}
}
if looks_like_email(t) {
return SearchIntent::Email(t.to_string());
}
SearchIntent::Text(t.to_string())
}
fn looks_like_email(s: &str) -> bool {
if s.len() > 254 || s.len() < 3 {
return false;
}
let at = match s.find('@') {
Some(i) => i,
None => return false,
};
if at == 0 || at == s.len() - 1 {
return false;
}
let domain = &s[at + 1..];
if !domain.contains('.') || domain.starts_with('.') || domain.ends_with('.') {
return false;
}
!s.chars().any(|c| c.is_whitespace())
}
fn looks_like_personnummer(s: &str) -> bool {
let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
if digits.len() != 12 {
return false;
}
if s.chars().any(|c| !c.is_ascii_digit() && c != '-') {
return false;
}
match s.len() {
12 => true,
13 => s.as_bytes().get(8) == Some(&b'-'),
_ => false,
}
}
pub fn mask_pii(value: &str) -> String {
if value.is_empty() {
return String::new();
}
let chars: Vec<char> = value.chars().collect();
let n = chars.len();
let keep = (n / 3).clamp(2, 4).min(n);
let mut out = String::with_capacity(n);
for (i, c) in chars.iter().enumerate() {
if i < keep {
out.push(*c);
} else {
out.push('•');
}
}
out
}
fn humanise(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut next_upper = true;
for ch in s.chars() {
if ch == '_' {
out.push(' ');
next_upper = true;
} else if next_upper {
out.push(ch.to_ascii_uppercase());
next_upper = false;
} else {
out.push(ch);
}
}
out
}