Skip to main content

rustio_admin/view_layer/
infer.rs

1//! Deterministic default inference: schema metadata in, a [`ViewSpec`] out.
2//!
3//! Inference is pure and offline — no database, no network, no AI. The same
4//! input always yields the same output, which is what makes it testable and
5//! safe to use as a *starting point* for the future view designer. The output
6//! is just a draft `ViewSpec`; once saved, the spec — not this function — is
7//! the source of truth at render time.
8
9use crate::admin::{AdminField, FieldType};
10
11use super::modes::ViewMode;
12use super::roles::FieldRole;
13use super::spec::{FieldViewSpec, ViewSpec, VIEW_SPEC_VERSION};
14
15// public:
16/// Minimal description of a column, as the admin already knows it from the
17/// schema. Inference works purely off this.
18///
19/// Build one from the framework's own field metadata with
20/// [`FieldMeta::from_admin_field`]; the raw constructor exists for tests and
21/// callers that already have name/kind/nullability in hand.
22#[derive(Debug, Clone)]
23pub struct FieldMeta {
24    /// Column name.
25    pub name: String,
26    /// Coarse type bucket used by the inference rules.
27    pub kind: FieldKind,
28    /// Whether the column is nullable.
29    pub nullable: bool,
30}
31
32impl FieldMeta {
33    // public:
34    /// Bridge the framework's [`AdminField`] into the inference vocabulary.
35    ///
36    /// The admin's `FieldType` has no `ForeignKey`/`Enum`/`Json` variants —
37    /// those live on `AdminField` as `relation` and `choices` — so they are
38    /// derived here. Anything textual (`String`/`Email`/`Phone`/`FilePath`/
39    /// their `Optional*` forms) collapses to [`FieldKind::Text`].
40    pub fn from_admin_field(field: &AdminField) -> Self {
41        let kind = if field.relation.is_some() {
42            FieldKind::ForeignKey
43        } else if field.choices.is_some() {
44            FieldKind::Enum
45        } else {
46            match field.field_type {
47                FieldType::Bool => FieldKind::Bool,
48                FieldType::DateTime
49                | FieldType::OptionalDateTime
50                | FieldType::Date
51                | FieldType::Time => FieldKind::DateTime,
52                FieldType::Uuid => FieldKind::Uuid,
53                FieldType::I32 | FieldType::I64 | FieldType::OptionalI64 => FieldKind::Integer,
54                FieldType::F64 | FieldType::Decimal => FieldKind::Float,
55                // String, Email, Phone, OptionalString, FilePath,
56                // OptionalFilePath, and any future variant -> Text.
57                _ => FieldKind::Text,
58            }
59        };
60        FieldMeta {
61            name: field.name.to_string(),
62            kind,
63            nullable: field.field_type.nullable(),
64        }
65    }
66}
67
68// public:
69/// Coarse type buckets. The admin maps real column types down to these.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum FieldKind {
72    /// Free text.
73    Text,
74    /// Whole number.
75    Integer,
76    /// Floating-point / decimal number.
77    Float,
78    /// Boolean.
79    Bool,
80    /// A closed set of choices (status, type, …).
81    Enum,
82    /// A date/time value.
83    DateTime,
84    /// A foreign-key reference to another model.
85    ForeignKey,
86    /// A UUID identifier.
87    Uuid,
88    /// A structured JSON blob.
89    Json,
90}
91
92/// Substrings that mark a field as sensitive. Matching is case-insensitive and
93/// substring-based so `user_password_hash` and `reset_token` both catch.
94const SENSITIVE_MARKERS: &[&str] = &[
95    "password",
96    "passwd",
97    "secret",
98    "token",
99    "api_key",
100    "apikey",
101    "private_key",
102    "session",
103    "hash",
104    "pin",
105];
106
107fn is_sensitive(name: &str) -> bool {
108    let lower = name.to_ascii_lowercase();
109    SENSITIVE_MARKERS.iter().any(|m| lower.contains(m))
110}
111
112fn is_timestamp_name(name: &str) -> bool {
113    let lower = name.to_ascii_lowercase();
114    lower == "created_at" || lower == "updated_at" || lower == "deleted_at"
115}
116
117// public:
118/// Produce a reasonable [`ViewSpec`] from a model name and its columns.
119///
120/// Deterministic: same input always yields the same output. The rules err on
121/// the side of hiding rather than exposing, and only the first human-readable
122/// text field is promoted to `Primary`.
123pub fn infer_view_spec(model: &str, columns: &[FieldMeta]) -> ViewSpec {
124    let mut fields = Vec::with_capacity(columns.len());
125    let mut primary_taken = false;
126    let mut default_filters = Vec::new();
127
128    for (index, col) in columns.iter().enumerate() {
129        let mut spec = FieldViewSpec::new(&col.name, FieldRole::Secondary);
130        // priority spreads fields out so the designer has room to reorder later
131        spec.priority = (index as i32) * 10;
132
133        if is_sensitive(&col.name) {
134            spec.role = FieldRole::Hidden;
135            fields.push(spec);
136            continue;
137        }
138
139        match col.kind {
140            FieldKind::Uuid => {
141                spec.role = FieldRole::Hidden;
142            }
143            FieldKind::DateTime => {
144                // explicit audit timestamps stay out of the main row;
145                // other datetimes are quiet but visible
146                spec.role = if is_timestamp_name(&col.name) {
147                    FieldRole::DetailOnly
148                } else {
149                    FieldRole::Timestamp
150                };
151                spec.sortable = true;
152            }
153            FieldKind::Enum | FieldKind::Bool => {
154                spec.role = FieldRole::Badge;
155                spec.filterable = true;
156                if default_filters.len() < 2 {
157                    spec.default_filter = true;
158                    default_filters.push(col.name.clone());
159                }
160            }
161            FieldKind::ForeignKey => {
162                // FKs shouldn't dominate; keep them secondary and filterable
163                spec.role = FieldRole::Secondary;
164                spec.filterable = true;
165            }
166            FieldKind::Json => {
167                spec.role = FieldRole::DetailOnly;
168            }
169            FieldKind::Text => {
170                let lower = col.name.to_ascii_lowercase();
171                if lower == "id" {
172                    spec.role = FieldRole::DetailOnly;
173                } else if !primary_taken {
174                    spec.role = FieldRole::Primary;
175                    spec.sortable = true;
176                    primary_taken = true;
177                } else if col.nullable {
178                    // optional secondary text is rarely worth a column
179                    spec.role = FieldRole::DetailOnly;
180                } else {
181                    spec.role = FieldRole::Secondary;
182                }
183            }
184            FieldKind::Integer | FieldKind::Float => {
185                let lower = col.name.to_ascii_lowercase();
186                if lower == "id" {
187                    spec.role = FieldRole::DetailOnly;
188                } else {
189                    spec.role = FieldRole::Secondary;
190                    spec.sortable = true;
191                }
192            }
193        }
194
195        fields.push(spec);
196    }
197
198    ViewSpec {
199        model: model.to_string(),
200        default_mode: ViewMode::Table,
201        allowed_modes: vec![
202            ViewMode::Table,
203            ViewMode::List,
204            ViewMode::Cards,
205            ViewMode::Compact,
206        ],
207        fields,
208        compositions: Vec::new(),
209        default_filters,
210        version: VIEW_SPEC_VERSION,
211    }
212}
213
214// public:
215/// Convenience wrapper: infer directly from the framework's own field
216/// metadata (`AdminModel::FIELDS` / `AdminEntry::fields`). Maps each
217/// [`AdminField`] through [`FieldMeta::from_admin_field`] then calls
218/// [`infer_view_spec`].
219pub fn infer_view_spec_from_fields(model: &str, fields: &[AdminField]) -> ViewSpec {
220    let columns: Vec<FieldMeta> = fields.iter().map(FieldMeta::from_admin_field).collect();
221    infer_view_spec(model, &columns)
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    fn col(name: &str, kind: FieldKind, nullable: bool) -> FieldMeta {
229        FieldMeta {
230            name: name.into(),
231            kind,
232            nullable,
233        }
234    }
235
236    fn role_of(spec: &ViewSpec, name: &str) -> FieldRole {
237        spec.fields
238            .iter()
239            .find(|f| f.field_name == name)
240            .unwrap()
241            .role
242    }
243
244    #[test]
245    fn id_is_detail_only() {
246        let spec = infer_view_spec("thing", &[col("id", FieldKind::Integer, false)]);
247        assert_eq!(role_of(&spec, "id"), FieldRole::DetailOnly);
248    }
249
250    #[test]
251    fn first_text_becomes_primary() {
252        let spec = infer_view_spec(
253            "customer",
254            &[
255                col("id", FieldKind::Integer, false),
256                col("full_name", FieldKind::Text, false),
257                col("company", FieldKind::Text, false),
258            ],
259        );
260        assert_eq!(role_of(&spec, "full_name"), FieldRole::Primary);
261        assert_eq!(role_of(&spec, "company"), FieldRole::Secondary);
262    }
263
264    #[test]
265    fn enums_become_filterable_badges() {
266        let spec = infer_view_spec("order", &[col("status", FieldKind::Enum, false)]);
267        let status = spec
268            .fields
269            .iter()
270            .find(|f| f.field_name == "status")
271            .unwrap();
272        assert_eq!(status.role, FieldRole::Badge);
273        assert!(status.filterable);
274        assert!(status.default_filter);
275    }
276
277    #[test]
278    fn audit_timestamps_are_detail_only() {
279        let spec = infer_view_spec(
280            "thing",
281            &[
282                col("created_at", FieldKind::DateTime, false),
283                col("scheduled_for", FieldKind::DateTime, true),
284            ],
285        );
286        assert_eq!(role_of(&spec, "created_at"), FieldRole::DetailOnly);
287        assert_eq!(role_of(&spec, "scheduled_for"), FieldRole::Timestamp);
288    }
289
290    #[test]
291    fn nullable_secondary_text_is_demoted() {
292        let spec = infer_view_spec(
293            "customer",
294            &[
295                col("name", FieldKind::Text, false),
296                col("notes", FieldKind::Text, true),
297            ],
298        );
299        assert_eq!(role_of(&spec, "notes"), FieldRole::DetailOnly);
300    }
301
302    #[test]
303    fn inference_is_deterministic() {
304        let cols = [
305            col("id", FieldKind::Integer, false),
306            col("email", FieldKind::Text, false),
307            col("status", FieldKind::Enum, false),
308        ];
309        let a = infer_view_spec("user", &cols);
310        let b = infer_view_spec("user", &cols);
311        assert_eq!(a, b);
312    }
313
314    // ---- adapter from the framework's own AdminField metadata ----
315
316    #[test]
317    fn from_admin_field_maps_relation_and_choices() {
318        use crate::admin::AdminRelation;
319
320        let fk = AdminField {
321            name: "customer_id",
322            label: "Customer",
323            field_type: FieldType::I64,
324            editable: true,
325            relation: Some(AdminRelation {
326                target_model: "customer",
327                display_field: None,
328                multi: false,
329            }),
330            choices: None,
331        };
332        assert_eq!(FieldMeta::from_admin_field(&fk).kind, FieldKind::ForeignKey);
333
334        let status = AdminField {
335            name: "status",
336            label: "Status",
337            field_type: FieldType::String,
338            editable: true,
339            relation: None,
340            choices: Some(&["open", "closed"]),
341        };
342        assert_eq!(FieldMeta::from_admin_field(&status).kind, FieldKind::Enum);
343
344        let created = AdminField {
345            name: "created_at",
346            label: "Created At",
347            field_type: FieldType::DateTime,
348            editable: false,
349            relation: None,
350            choices: None,
351        };
352        let meta = FieldMeta::from_admin_field(&created);
353        assert_eq!(meta.kind, FieldKind::DateTime);
354        assert!(!meta.nullable);
355
356        let bio = AdminField {
357            name: "bio",
358            label: "Bio",
359            field_type: FieldType::OptionalString,
360            editable: true,
361            relation: None,
362            choices: None,
363        };
364        let meta = FieldMeta::from_admin_field(&bio);
365        assert_eq!(meta.kind, FieldKind::Text);
366        assert!(meta.nullable);
367    }
368
369    // ---- worked example specs (the deliverable models) ----
370
371    #[test]
372    fn customers_example_spec() {
373        let spec = infer_view_spec(
374            "customer",
375            &[
376                col("id", FieldKind::Integer, false),
377                col("full_name", FieldKind::Text, false),
378                col("email", FieldKind::Text, false),
379                col("company", FieldKind::Text, true),
380                col("status", FieldKind::Enum, false),
381                col("region_id", FieldKind::ForeignKey, true),
382                col("password_hash", FieldKind::Text, false),
383                col("api_key", FieldKind::Text, true),
384                col("created_at", FieldKind::DateTime, false),
385                col("updated_at", FieldKind::DateTime, false),
386            ],
387        );
388        assert_eq!(role_of(&spec, "id"), FieldRole::DetailOnly);
389        assert_eq!(role_of(&spec, "full_name"), FieldRole::Primary);
390        assert_eq!(role_of(&spec, "email"), FieldRole::Secondary);
391        assert_eq!(role_of(&spec, "company"), FieldRole::DetailOnly);
392        assert_eq!(role_of(&spec, "status"), FieldRole::Badge);
393        assert_eq!(role_of(&spec, "region_id"), FieldRole::Secondary);
394        assert_eq!(role_of(&spec, "password_hash"), FieldRole::Hidden);
395        assert_eq!(role_of(&spec, "api_key"), FieldRole::Hidden);
396        assert_eq!(role_of(&spec, "created_at"), FieldRole::DetailOnly);
397        assert_eq!(spec.default_filters, vec!["status".to_string()]);
398    }
399
400    #[test]
401    fn bookings_example_spec() {
402        let spec = infer_view_spec(
403            "booking",
404            &[
405                col("id", FieldKind::Uuid, false),
406                col("reference", FieldKind::Text, false),
407                col("customer_id", FieldKind::ForeignKey, false),
408                col("service", FieldKind::Text, false),
409                col("state", FieldKind::Enum, false),
410                col("is_paid", FieldKind::Bool, false),
411                col("scheduled_for", FieldKind::DateTime, false),
412                col("notes", FieldKind::Text, true),
413                col("created_at", FieldKind::DateTime, false),
414            ],
415        );
416        assert_eq!(role_of(&spec, "id"), FieldRole::Hidden); // uuid
417        assert_eq!(role_of(&spec, "reference"), FieldRole::Primary);
418        assert_eq!(role_of(&spec, "customer_id"), FieldRole::Secondary);
419        assert_eq!(role_of(&spec, "service"), FieldRole::Secondary);
420        assert_eq!(role_of(&spec, "state"), FieldRole::Badge);
421        assert_eq!(role_of(&spec, "is_paid"), FieldRole::Badge);
422        assert_eq!(role_of(&spec, "scheduled_for"), FieldRole::Timestamp);
423        assert_eq!(role_of(&spec, "notes"), FieldRole::DetailOnly);
424        assert_eq!(
425            spec.default_filters,
426            vec!["state".to_string(), "is_paid".to_string()]
427        );
428    }
429}