1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
9#[serde(rename_all = "snake_case")]
10pub enum DataType {
11 String,
12 Integer,
13 Float,
14 Boolean,
15 DateTime,
16 Date,
17 Json,
18 Binary,
19 Uuid,
20 Enum,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
31#[serde(rename_all = "snake_case")]
32#[schemars(
33 description = "Semantic field meaning. Known variants: identifier, foreign_key, entity_name, email, phone, url, image_url, money, percentage, quantity, status, category, boolean, free_text, created_at, updated_at, date_time, sensitive. Any other string is a custom domain-specific meaning."
34)]
35pub enum FieldMeaning {
36 Identifier,
37 ForeignKey,
38 EntityName,
39 Email,
40 Phone,
41 Url,
42 ImageUrl,
43 Money,
44 Percentage,
45 Quantity,
46 Status,
47 Category,
48 Boolean,
49 FreeText,
50 CreatedAt,
51 UpdatedAt,
52 DateTime,
53 Sensitive,
54 #[serde(untagged)]
55 Custom(String),
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
64#[serde(rename_all = "snake_case")]
65pub enum RenderHint {
66 AltText(String),
68 Skip,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
74pub struct FieldDef {
75 pub name: String,
76 pub data_type: DataType,
77 pub meaning: FieldMeaning,
78 #[serde(default = "default_true")]
79 pub required: bool,
80 #[serde(default)]
81 pub is_list: bool,
82 #[serde(default = "default_true")]
83 pub readable: bool,
84 #[serde(default = "default_true")]
85 pub writable: bool,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub render_hint: Option<RenderHint>,
90}
91
92impl FieldDef {
93 pub fn with_render_hint(mut self, hint: RenderHint) -> Self {
95 self.render_hint = Some(hint);
96 self
97 }
98}
99
100fn default_true() -> bool {
101 true
102}
103
104impl DataType {
105 pub fn from_column_type(type_str: &str) -> Self {
110 let inner = if let Some(stripped) = type_str
111 .strip_prefix("Option<")
112 .and_then(|s| s.strip_suffix('>'))
113 {
114 stripped
115 } else {
116 type_str
117 };
118
119 match inner {
120 "i32" | "i64" | "u32" | "u64" | "i8" | "i16" | "u8" | "u16" => Self::Integer,
121 "f32" | "f64" => Self::Float,
122 "bool" => Self::Boolean,
123 "Uuid" | "uuid::Uuid" => Self::Uuid,
124 s if s.contains("Decimal") => Self::Float,
125 s if s.starts_with("DateTime") || s.contains("chrono::") => Self::DateTime,
126 s if s.starts_with("NaiveDate") => Self::Date,
127 "Vec<u8>" => Self::Binary,
128 s if s.contains("Json") || s.contains("serde_json") => Self::Json,
129 _ => Self::String,
130 }
131 }
132}
133
134pub fn infer_meaning(field_name: &str) -> FieldMeaning {
143 match field_name {
145 "id" => return FieldMeaning::Identifier,
146 "email" => return FieldMeaning::Email,
147 "created_at" => return FieldMeaning::CreatedAt,
148 "updated_at" => return FieldMeaning::UpdatedAt,
149 _ => {}
150 }
151
152 if field_name.ends_with("_id") {
154 return FieldMeaning::ForeignKey;
155 }
156 if field_name.ends_with("_at") {
157 return FieldMeaning::DateTime;
158 }
159
160 if field_name.starts_with("is_") || field_name.starts_with("has_") {
162 return FieldMeaning::Boolean;
163 }
164
165 const SENSITIVE: &[&str] = &["password", "secret", "token", "api_key", "hashed_key"];
167 if SENSITIVE.iter().any(|s| field_name.contains(s)) {
168 return FieldMeaning::Sensitive;
169 }
170
171 FieldMeaning::Custom(field_name.to_string())
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn from_column_type_mappings() {
180 assert_eq!(DataType::from_column_type("i32"), DataType::Integer);
181 assert_eq!(DataType::from_column_type("i64"), DataType::Integer);
182 assert_eq!(DataType::from_column_type("u32"), DataType::Integer);
183 assert_eq!(DataType::from_column_type("u64"), DataType::Integer);
184 assert_eq!(DataType::from_column_type("i8"), DataType::Integer);
185 assert_eq!(DataType::from_column_type("i16"), DataType::Integer);
186 assert_eq!(DataType::from_column_type("u8"), DataType::Integer);
187 assert_eq!(DataType::from_column_type("u16"), DataType::Integer);
188 assert_eq!(DataType::from_column_type("f32"), DataType::Float);
189 assert_eq!(DataType::from_column_type("f64"), DataType::Float);
190 assert_eq!(DataType::from_column_type("bool"), DataType::Boolean);
191 assert_eq!(DataType::from_column_type("String"), DataType::String);
192 assert_eq!(DataType::from_column_type("Uuid"), DataType::Uuid);
193 assert_eq!(DataType::from_column_type("uuid::Uuid"), DataType::Uuid);
194 assert_eq!(
195 DataType::from_column_type("DateTime<Utc>"),
196 DataType::DateTime
197 );
198 assert_eq!(
199 DataType::from_column_type("chrono::DateTime<chrono::Utc>"),
200 DataType::DateTime
201 );
202 assert_eq!(DataType::from_column_type("NaiveDate"), DataType::Date);
203 assert_eq!(DataType::from_column_type("Vec<u8>"), DataType::Binary);
204 assert_eq!(
205 DataType::from_column_type("serde_json::Value"),
206 DataType::Json
207 );
208 assert_eq!(DataType::from_column_type("Json"), DataType::Json);
209 assert_eq!(DataType::from_column_type("Decimal"), DataType::Float);
210 assert_eq!(
211 DataType::from_column_type("UnknownCustomType"),
212 DataType::String
213 );
214 }
215
216 #[test]
217 fn from_column_type_option_stripping() {
218 assert_eq!(
219 DataType::from_column_type("Option<String>"),
220 DataType::String
221 );
222 assert_eq!(DataType::from_column_type("Option<i32>"), DataType::Integer);
223 assert_eq!(
224 DataType::from_column_type("Option<DateTime<Utc>>"),
225 DataType::DateTime
226 );
227 }
228
229 #[test]
230 fn data_type_is_copy() {
231 let dt = DataType::Float;
232 let dt2 = dt;
233 assert_eq!(dt, dt2);
234 }
235
236 #[test]
237 fn data_type_serde_round_trip() {
238 for dt in [
239 DataType::String,
240 DataType::Integer,
241 DataType::Float,
242 DataType::Boolean,
243 DataType::DateTime,
244 DataType::Date,
245 DataType::Json,
246 DataType::Binary,
247 DataType::Uuid,
248 DataType::Enum,
249 ] {
250 let json = serde_json::to_string(&dt).unwrap();
251 let parsed: DataType = serde_json::from_str(&json).unwrap();
252 assert_eq!(dt, parsed);
253 }
254 }
255
256 #[test]
257 fn field_meaning_known_variants_serde_round_trip() {
258 let known = [
259 FieldMeaning::Identifier,
260 FieldMeaning::ForeignKey,
261 FieldMeaning::EntityName,
262 FieldMeaning::Email,
263 FieldMeaning::Phone,
264 FieldMeaning::Url,
265 FieldMeaning::ImageUrl,
266 FieldMeaning::Money,
267 FieldMeaning::Percentage,
268 FieldMeaning::Quantity,
269 FieldMeaning::Status,
270 FieldMeaning::Category,
271 FieldMeaning::Boolean,
272 FieldMeaning::FreeText,
273 FieldMeaning::CreatedAt,
274 FieldMeaning::UpdatedAt,
275 FieldMeaning::DateTime,
276 FieldMeaning::Sensitive,
277 ];
278 for meaning in known {
279 let json = serde_json::to_string(&meaning).unwrap();
280 let parsed: FieldMeaning = serde_json::from_str(&json).unwrap();
281 assert_eq!(meaning, parsed);
282 }
283 }
284
285 #[test]
286 fn field_meaning_custom_fallback() {
287 let parsed: FieldMeaning = serde_json::from_str(r#""tax_rate""#).unwrap();
288 assert_eq!(parsed, FieldMeaning::Custom("tax_rate".to_string()));
289 }
290
291 #[test]
292 fn field_meaning_custom_round_trip() {
293 let custom = FieldMeaning::Custom("my_thing".into());
294 let json = serde_json::to_string(&custom).unwrap();
295 let parsed: FieldMeaning = serde_json::from_str(&json).unwrap();
296 assert_eq!(parsed, FieldMeaning::Custom("my_thing".into()));
297 }
298
299 #[test]
300 fn field_meaning_known_not_custom() {
301 let parsed: FieldMeaning = serde_json::from_str(r#""money""#).unwrap();
303 assert_eq!(parsed, FieldMeaning::Money);
304 assert_ne!(parsed, FieldMeaning::Custom("money".into()));
305 }
306
307 #[test]
308 fn field_meaning_money_serializes_to_snake_case() {
309 let json = serde_json::to_string(&FieldMeaning::Money).unwrap();
310 assert_eq!(json, r#""money""#);
311 }
312
313 #[test]
314 fn field_meaning_foreign_key_serializes_to_snake_case() {
315 let json = serde_json::to_string(&FieldMeaning::ForeignKey).unwrap();
316 assert_eq!(json, r#""foreign_key""#);
317 }
318
319 #[test]
320 fn field_def_serde_round_trip() {
321 let field = FieldDef {
322 name: "total".to_string(),
323 data_type: DataType::Float,
324 meaning: FieldMeaning::Money,
325 required: true,
326 is_list: false,
327 readable: true,
328 writable: true,
329 render_hint: None,
330 };
331 let json = serde_json::to_string(&field).unwrap();
332 let parsed: FieldDef = serde_json::from_str(&json).unwrap();
333 assert_eq!(field, parsed);
334 }
335
336 #[test]
337 fn field_def_defaults() {
338 let json = r#"{"name":"total","data_type":"float","meaning":"money"}"#;
340 let parsed: FieldDef = serde_json::from_str(json).unwrap();
341 assert!(parsed.required);
342 assert!(!parsed.is_list);
343 assert!(parsed.readable);
344 assert!(parsed.writable);
345 }
346
347 #[test]
348 fn field_def_read_only() {
349 let json = r#"{"name":"id","data_type":"integer","meaning":"identifier","readable":true,"writable":false}"#;
350 let parsed: FieldDef = serde_json::from_str(json).unwrap();
351 assert!(parsed.readable);
352 assert!(!parsed.writable);
353 }
354
355 #[test]
356 fn field_def_write_only() {
357 let json = r#"{"name":"password","data_type":"string","meaning":"sensitive","readable":false,"writable":true}"#;
358 let parsed: FieldDef = serde_json::from_str(json).unwrap();
359 assert!(!parsed.readable);
360 assert!(parsed.writable);
361 }
362
363 #[test]
364 fn infer_meaning_exact_matches() {
365 assert_eq!(infer_meaning("id"), FieldMeaning::Identifier);
366 assert_eq!(infer_meaning("email"), FieldMeaning::Email);
367 assert_eq!(infer_meaning("created_at"), FieldMeaning::CreatedAt);
368 assert_eq!(infer_meaning("updated_at"), FieldMeaning::UpdatedAt);
369 }
370
371 #[test]
372 fn infer_meaning_suffix_patterns() {
373 assert_eq!(infer_meaning("user_id"), FieldMeaning::ForeignKey);
374 assert_eq!(infer_meaning("order_id"), FieldMeaning::ForeignKey);
375 assert_eq!(infer_meaning("deleted_at"), FieldMeaning::DateTime);
376 assert_eq!(infer_meaning("expires_at"), FieldMeaning::DateTime);
377 }
378
379 #[test]
380 fn infer_meaning_prefix_patterns() {
381 assert_eq!(infer_meaning("is_active"), FieldMeaning::Boolean);
382 assert_eq!(infer_meaning("has_premium"), FieldMeaning::Boolean);
383 }
384
385 #[test]
386 fn infer_meaning_sensitive_patterns() {
387 assert_eq!(infer_meaning("password"), FieldMeaning::Sensitive);
388 assert_eq!(infer_meaning("hashed_password"), FieldMeaning::Sensitive);
389 assert_eq!(infer_meaning("secret"), FieldMeaning::Sensitive);
390 assert_eq!(infer_meaning("api_key"), FieldMeaning::Sensitive);
391 assert_eq!(infer_meaning("hashed_key"), FieldMeaning::Sensitive);
392 assert_eq!(infer_meaning("remember_token"), FieldMeaning::Sensitive);
393 }
394
395 #[test]
396 fn infer_meaning_fallback_to_custom() {
397 assert_eq!(
398 infer_meaning("title"),
399 FieldMeaning::Custom("title".to_string())
400 );
401 assert_eq!(
402 infer_meaning("description"),
403 FieldMeaning::Custom("description".to_string())
404 );
405 }
406
407 #[test]
408 fn data_type_json_schema() {
409 let schema = schemars::schema_for!(DataType);
410 let value = schema.to_value();
411 let enum_values = value
412 .get("enum")
413 .expect("DataType schema must have enum key");
414 let arr = enum_values.as_array().unwrap();
415 let strings: Vec<&str> = arr.iter().map(|v| v.as_str().unwrap()).collect();
416 assert!(strings.contains(&"string"));
417 assert!(strings.contains(&"integer"));
418 assert!(strings.contains(&"float"));
419 assert!(strings.contains(&"boolean"));
420 assert!(strings.contains(&"date_time"));
421 assert!(strings.contains(&"uuid"));
422 }
423
424 #[test]
425 fn field_meaning_json_schema_has_description() {
426 let schema = schemars::schema_for!(FieldMeaning);
427 let value = schema.to_value();
428 let desc = value
429 .get("description")
430 .expect("FieldMeaning schema must have description");
431 let desc_str = desc.as_str().unwrap();
432 assert!(
433 desc_str.contains("Known variants"),
434 "description should document known variants, got: {desc_str}"
435 );
436 }
437
438 #[test]
439 fn field_def_json_schema() {
440 let schema = schemars::schema_for!(FieldDef);
441 let value = schema.to_value();
442 let props = value
443 .get("properties")
444 .expect("FieldDef schema must have properties");
445 let obj = props.as_object().unwrap();
446 assert!(obj.contains_key("name"), "missing 'name' property");
447 assert!(
448 obj.contains_key("data_type"),
449 "missing 'data_type' property"
450 );
451 assert!(obj.contains_key("meaning"), "missing 'meaning' property");
452 }
453
454 #[test]
457 fn field_def_readable_writable_defaults_from_json() {
458 let json = r#"{"name":"title","data_type":"string","meaning":"entity_name"}"#;
460 let parsed: FieldDef = serde_json::from_str(json).unwrap();
461 assert!(parsed.readable);
462 assert!(parsed.writable);
463 }
464
465 #[test]
466 fn field_def_readable_false_writable_true_round_trip() {
467 let field = FieldDef {
468 name: "password".to_string(),
469 data_type: DataType::String,
470 meaning: FieldMeaning::Sensitive,
471 required: true,
472 is_list: false,
473 readable: false,
474 writable: true,
475 render_hint: None,
476 };
477 let json = serde_json::to_string(&field).unwrap();
478 let parsed: FieldDef = serde_json::from_str(&json).unwrap();
479 assert_eq!(field, parsed);
480 assert!(!parsed.readable);
481 assert!(parsed.writable);
482 }
483
484 #[test]
485 fn field_def_readable_true_writable_false_round_trip() {
486 let field = FieldDef {
487 name: "id".to_string(),
488 data_type: DataType::Integer,
489 meaning: FieldMeaning::Identifier,
490 required: true,
491 is_list: false,
492 readable: true,
493 writable: false,
494 render_hint: None,
495 };
496 let json = serde_json::to_string(&field).unwrap();
497 let parsed: FieldDef = serde_json::from_str(&json).unwrap();
498 assert_eq!(field, parsed);
499 assert!(parsed.readable);
500 assert!(!parsed.writable);
501 }
502
503 #[test]
504 fn field_def_readable_false_writable_false_round_trip() {
505 let field = FieldDef {
506 name: "internal_hash".to_string(),
507 data_type: DataType::String,
508 meaning: FieldMeaning::Sensitive,
509 required: true,
510 is_list: false,
511 readable: false,
512 writable: false,
513 render_hint: None,
514 };
515 let json = serde_json::to_string(&field).unwrap();
516 let parsed: FieldDef = serde_json::from_str(&json).unwrap();
517 assert_eq!(field, parsed);
518 assert!(!parsed.readable);
519 assert!(!parsed.writable);
520 }
521
522 #[test]
525 fn render_hint_builder_sets_field() {
526 let field = FieldDef {
527 name: "photo".to_string(),
528 data_type: DataType::String,
529 meaning: FieldMeaning::ImageUrl,
530 required: false,
531 is_list: false,
532 readable: true,
533 writable: true,
534 render_hint: None,
535 }
536 .with_render_hint(RenderHint::AltText("Photo".into()));
537 assert_eq!(field.render_hint, Some(RenderHint::AltText("Photo".into())));
538 }
539
540 #[test]
541 fn render_hint_none_omitted_from_json_and_restores_on_deser() {
542 let field = FieldDef {
543 name: "total".to_string(),
544 data_type: DataType::Float,
545 meaning: FieldMeaning::Money,
546 required: true,
547 is_list: false,
548 readable: true,
549 writable: true,
550 render_hint: None,
551 };
552 let json = serde_json::to_string(&field).unwrap();
553 assert!(
555 !json.contains("render_hint"),
556 "render_hint key must be absent when None, got: {json}"
557 );
558 let parsed: FieldDef = serde_json::from_str(&json).unwrap();
560 assert_eq!(parsed.render_hint, None);
561 }
562
563 #[test]
564 fn render_hint_variants_serde_round_trip() {
565 let alt = RenderHint::AltText("x".into());
566 let skip = RenderHint::Skip;
567 for hint in [alt, skip] {
568 let json = serde_json::to_string(&hint).unwrap();
569 let parsed: RenderHint = serde_json::from_str(&json).unwrap();
570 assert_eq!(hint, parsed);
571 }
572 }
573}