1use crate::admin::{AdminField, FieldType};
10
11use super::modes::ViewMode;
12use super::roles::FieldRole;
13use super::spec::{FieldViewSpec, ViewSpec, VIEW_SPEC_VERSION};
14
15#[derive(Debug, Clone)]
23pub struct FieldMeta {
24 pub name: String,
26 pub kind: FieldKind,
28 pub nullable: bool,
30}
31
32impl FieldMeta {
33 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 _ => 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum FieldKind {
72 Text,
74 Integer,
76 Float,
78 Bool,
80 Enum,
82 DateTime,
84 ForeignKey,
86 Uuid,
88 Json,
90}
91
92const 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
117pub 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 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 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 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 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
214pub 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 #[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 #[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); 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}