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)]
60pub struct FieldDef {
61 pub name: String,
62 pub data_type: DataType,
63 pub meaning: FieldMeaning,
64 #[serde(default = "default_true")]
65 pub required: bool,
66 #[serde(default)]
67 pub is_list: bool,
68 #[serde(default = "default_true")]
69 pub readable: bool,
70 #[serde(default = "default_true")]
71 pub writable: bool,
72}
73
74fn default_true() -> bool {
75 true
76}
77
78impl DataType {
79 pub fn from_column_type(type_str: &str) -> Self {
84 let inner = if let Some(stripped) = type_str
85 .strip_prefix("Option<")
86 .and_then(|s| s.strip_suffix('>'))
87 {
88 stripped
89 } else {
90 type_str
91 };
92
93 match inner {
94 "i32" | "i64" | "u32" | "u64" | "i8" | "i16" | "u8" | "u16" => Self::Integer,
95 "f32" | "f64" => Self::Float,
96 "bool" => Self::Boolean,
97 "Uuid" | "uuid::Uuid" => Self::Uuid,
98 s if s.contains("Decimal") => Self::Float,
99 s if s.starts_with("DateTime") || s.contains("chrono::") => Self::DateTime,
100 s if s.starts_with("NaiveDate") => Self::Date,
101 "Vec<u8>" => Self::Binary,
102 s if s.contains("Json") || s.contains("serde_json") => Self::Json,
103 _ => Self::String,
104 }
105 }
106}
107
108pub fn infer_meaning(field_name: &str) -> FieldMeaning {
117 match field_name {
119 "id" => return FieldMeaning::Identifier,
120 "email" => return FieldMeaning::Email,
121 "created_at" => return FieldMeaning::CreatedAt,
122 "updated_at" => return FieldMeaning::UpdatedAt,
123 _ => {}
124 }
125
126 if field_name.ends_with("_id") {
128 return FieldMeaning::ForeignKey;
129 }
130 if field_name.ends_with("_at") {
131 return FieldMeaning::DateTime;
132 }
133
134 if field_name.starts_with("is_") || field_name.starts_with("has_") {
136 return FieldMeaning::Boolean;
137 }
138
139 const SENSITIVE: &[&str] = &["password", "secret", "token", "api_key", "hashed_key"];
141 if SENSITIVE.iter().any(|s| field_name.contains(s)) {
142 return FieldMeaning::Sensitive;
143 }
144
145 FieldMeaning::Custom(field_name.to_string())
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 #[test]
153 fn from_column_type_mappings() {
154 assert_eq!(DataType::from_column_type("i32"), DataType::Integer);
155 assert_eq!(DataType::from_column_type("i64"), DataType::Integer);
156 assert_eq!(DataType::from_column_type("u32"), DataType::Integer);
157 assert_eq!(DataType::from_column_type("u64"), DataType::Integer);
158 assert_eq!(DataType::from_column_type("i8"), DataType::Integer);
159 assert_eq!(DataType::from_column_type("i16"), DataType::Integer);
160 assert_eq!(DataType::from_column_type("u8"), DataType::Integer);
161 assert_eq!(DataType::from_column_type("u16"), DataType::Integer);
162 assert_eq!(DataType::from_column_type("f32"), DataType::Float);
163 assert_eq!(DataType::from_column_type("f64"), DataType::Float);
164 assert_eq!(DataType::from_column_type("bool"), DataType::Boolean);
165 assert_eq!(DataType::from_column_type("String"), DataType::String);
166 assert_eq!(DataType::from_column_type("Uuid"), DataType::Uuid);
167 assert_eq!(DataType::from_column_type("uuid::Uuid"), DataType::Uuid);
168 assert_eq!(
169 DataType::from_column_type("DateTime<Utc>"),
170 DataType::DateTime
171 );
172 assert_eq!(
173 DataType::from_column_type("chrono::DateTime<chrono::Utc>"),
174 DataType::DateTime
175 );
176 assert_eq!(DataType::from_column_type("NaiveDate"), DataType::Date);
177 assert_eq!(DataType::from_column_type("Vec<u8>"), DataType::Binary);
178 assert_eq!(
179 DataType::from_column_type("serde_json::Value"),
180 DataType::Json
181 );
182 assert_eq!(DataType::from_column_type("Json"), DataType::Json);
183 assert_eq!(DataType::from_column_type("Decimal"), DataType::Float);
184 assert_eq!(
185 DataType::from_column_type("UnknownCustomType"),
186 DataType::String
187 );
188 }
189
190 #[test]
191 fn from_column_type_option_stripping() {
192 assert_eq!(
193 DataType::from_column_type("Option<String>"),
194 DataType::String
195 );
196 assert_eq!(DataType::from_column_type("Option<i32>"), DataType::Integer);
197 assert_eq!(
198 DataType::from_column_type("Option<DateTime<Utc>>"),
199 DataType::DateTime
200 );
201 }
202
203 #[test]
204 fn data_type_is_copy() {
205 let dt = DataType::Float;
206 let dt2 = dt;
207 assert_eq!(dt, dt2);
208 }
209
210 #[test]
211 fn data_type_serde_round_trip() {
212 for dt in [
213 DataType::String,
214 DataType::Integer,
215 DataType::Float,
216 DataType::Boolean,
217 DataType::DateTime,
218 DataType::Date,
219 DataType::Json,
220 DataType::Binary,
221 DataType::Uuid,
222 DataType::Enum,
223 ] {
224 let json = serde_json::to_string(&dt).unwrap();
225 let parsed: DataType = serde_json::from_str(&json).unwrap();
226 assert_eq!(dt, parsed);
227 }
228 }
229
230 #[test]
231 fn field_meaning_known_variants_serde_round_trip() {
232 let known = [
233 FieldMeaning::Identifier,
234 FieldMeaning::ForeignKey,
235 FieldMeaning::EntityName,
236 FieldMeaning::Email,
237 FieldMeaning::Phone,
238 FieldMeaning::Url,
239 FieldMeaning::ImageUrl,
240 FieldMeaning::Money,
241 FieldMeaning::Percentage,
242 FieldMeaning::Quantity,
243 FieldMeaning::Status,
244 FieldMeaning::Category,
245 FieldMeaning::Boolean,
246 FieldMeaning::FreeText,
247 FieldMeaning::CreatedAt,
248 FieldMeaning::UpdatedAt,
249 FieldMeaning::DateTime,
250 FieldMeaning::Sensitive,
251 ];
252 for meaning in known {
253 let json = serde_json::to_string(&meaning).unwrap();
254 let parsed: FieldMeaning = serde_json::from_str(&json).unwrap();
255 assert_eq!(meaning, parsed);
256 }
257 }
258
259 #[test]
260 fn field_meaning_custom_fallback() {
261 let parsed: FieldMeaning = serde_json::from_str(r#""tax_rate""#).unwrap();
262 assert_eq!(parsed, FieldMeaning::Custom("tax_rate".to_string()));
263 }
264
265 #[test]
266 fn field_meaning_custom_round_trip() {
267 let custom = FieldMeaning::Custom("my_thing".into());
268 let json = serde_json::to_string(&custom).unwrap();
269 let parsed: FieldMeaning = serde_json::from_str(&json).unwrap();
270 assert_eq!(parsed, FieldMeaning::Custom("my_thing".into()));
271 }
272
273 #[test]
274 fn field_meaning_known_not_custom() {
275 let parsed: FieldMeaning = serde_json::from_str(r#""money""#).unwrap();
277 assert_eq!(parsed, FieldMeaning::Money);
278 assert_ne!(parsed, FieldMeaning::Custom("money".into()));
279 }
280
281 #[test]
282 fn field_meaning_money_serializes_to_snake_case() {
283 let json = serde_json::to_string(&FieldMeaning::Money).unwrap();
284 assert_eq!(json, r#""money""#);
285 }
286
287 #[test]
288 fn field_meaning_foreign_key_serializes_to_snake_case() {
289 let json = serde_json::to_string(&FieldMeaning::ForeignKey).unwrap();
290 assert_eq!(json, r#""foreign_key""#);
291 }
292
293 #[test]
294 fn field_def_serde_round_trip() {
295 let field = FieldDef {
296 name: "total".to_string(),
297 data_type: DataType::Float,
298 meaning: FieldMeaning::Money,
299 required: true,
300 is_list: false,
301 readable: true,
302 writable: true,
303 };
304 let json = serde_json::to_string(&field).unwrap();
305 let parsed: FieldDef = serde_json::from_str(&json).unwrap();
306 assert_eq!(field, parsed);
307 }
308
309 #[test]
310 fn field_def_defaults() {
311 let json = r#"{"name":"total","data_type":"float","meaning":"money"}"#;
313 let parsed: FieldDef = serde_json::from_str(json).unwrap();
314 assert!(parsed.required);
315 assert!(!parsed.is_list);
316 assert!(parsed.readable);
317 assert!(parsed.writable);
318 }
319
320 #[test]
321 fn field_def_read_only() {
322 let json = r#"{"name":"id","data_type":"integer","meaning":"identifier","readable":true,"writable":false}"#;
323 let parsed: FieldDef = serde_json::from_str(json).unwrap();
324 assert!(parsed.readable);
325 assert!(!parsed.writable);
326 }
327
328 #[test]
329 fn field_def_write_only() {
330 let json = r#"{"name":"password","data_type":"string","meaning":"sensitive","readable":false,"writable":true}"#;
331 let parsed: FieldDef = serde_json::from_str(json).unwrap();
332 assert!(!parsed.readable);
333 assert!(parsed.writable);
334 }
335
336 #[test]
337 fn infer_meaning_exact_matches() {
338 assert_eq!(infer_meaning("id"), FieldMeaning::Identifier);
339 assert_eq!(infer_meaning("email"), FieldMeaning::Email);
340 assert_eq!(infer_meaning("created_at"), FieldMeaning::CreatedAt);
341 assert_eq!(infer_meaning("updated_at"), FieldMeaning::UpdatedAt);
342 }
343
344 #[test]
345 fn infer_meaning_suffix_patterns() {
346 assert_eq!(infer_meaning("user_id"), FieldMeaning::ForeignKey);
347 assert_eq!(infer_meaning("order_id"), FieldMeaning::ForeignKey);
348 assert_eq!(infer_meaning("deleted_at"), FieldMeaning::DateTime);
349 assert_eq!(infer_meaning("expires_at"), FieldMeaning::DateTime);
350 }
351
352 #[test]
353 fn infer_meaning_prefix_patterns() {
354 assert_eq!(infer_meaning("is_active"), FieldMeaning::Boolean);
355 assert_eq!(infer_meaning("has_premium"), FieldMeaning::Boolean);
356 }
357
358 #[test]
359 fn infer_meaning_sensitive_patterns() {
360 assert_eq!(infer_meaning("password"), FieldMeaning::Sensitive);
361 assert_eq!(infer_meaning("hashed_password"), FieldMeaning::Sensitive);
362 assert_eq!(infer_meaning("secret"), FieldMeaning::Sensitive);
363 assert_eq!(infer_meaning("api_key"), FieldMeaning::Sensitive);
364 assert_eq!(infer_meaning("hashed_key"), FieldMeaning::Sensitive);
365 assert_eq!(infer_meaning("remember_token"), FieldMeaning::Sensitive);
366 }
367
368 #[test]
369 fn infer_meaning_fallback_to_custom() {
370 assert_eq!(
371 infer_meaning("title"),
372 FieldMeaning::Custom("title".to_string())
373 );
374 assert_eq!(
375 infer_meaning("description"),
376 FieldMeaning::Custom("description".to_string())
377 );
378 }
379
380 #[test]
381 fn data_type_json_schema() {
382 let schema = schemars::schema_for!(DataType);
383 let value = schema.to_value();
384 let enum_values = value
385 .get("enum")
386 .expect("DataType schema must have enum key");
387 let arr = enum_values.as_array().unwrap();
388 let strings: Vec<&str> = arr.iter().map(|v| v.as_str().unwrap()).collect();
389 assert!(strings.contains(&"string"));
390 assert!(strings.contains(&"integer"));
391 assert!(strings.contains(&"float"));
392 assert!(strings.contains(&"boolean"));
393 assert!(strings.contains(&"date_time"));
394 assert!(strings.contains(&"uuid"));
395 }
396
397 #[test]
398 fn field_meaning_json_schema_has_description() {
399 let schema = schemars::schema_for!(FieldMeaning);
400 let value = schema.to_value();
401 let desc = value
402 .get("description")
403 .expect("FieldMeaning schema must have description");
404 let desc_str = desc.as_str().unwrap();
405 assert!(
406 desc_str.contains("Known variants"),
407 "description should document known variants, got: {desc_str}"
408 );
409 }
410
411 #[test]
412 fn field_def_json_schema() {
413 let schema = schemars::schema_for!(FieldDef);
414 let value = schema.to_value();
415 let props = value
416 .get("properties")
417 .expect("FieldDef schema must have properties");
418 let obj = props.as_object().unwrap();
419 assert!(obj.contains_key("name"), "missing 'name' property");
420 assert!(
421 obj.contains_key("data_type"),
422 "missing 'data_type' property"
423 );
424 assert!(obj.contains_key("meaning"), "missing 'meaning' property");
425 }
426
427 #[test]
430 fn field_def_readable_writable_defaults_from_json() {
431 let json = r#"{"name":"title","data_type":"string","meaning":"entity_name"}"#;
433 let parsed: FieldDef = serde_json::from_str(json).unwrap();
434 assert!(parsed.readable);
435 assert!(parsed.writable);
436 }
437
438 #[test]
439 fn field_def_readable_false_writable_true_round_trip() {
440 let field = FieldDef {
441 name: "password".to_string(),
442 data_type: DataType::String,
443 meaning: FieldMeaning::Sensitive,
444 required: true,
445 is_list: false,
446 readable: false,
447 writable: true,
448 };
449 let json = serde_json::to_string(&field).unwrap();
450 let parsed: FieldDef = serde_json::from_str(&json).unwrap();
451 assert_eq!(field, parsed);
452 assert!(!parsed.readable);
453 assert!(parsed.writable);
454 }
455
456 #[test]
457 fn field_def_readable_true_writable_false_round_trip() {
458 let field = FieldDef {
459 name: "id".to_string(),
460 data_type: DataType::Integer,
461 meaning: FieldMeaning::Identifier,
462 required: true,
463 is_list: false,
464 readable: true,
465 writable: false,
466 };
467 let json = serde_json::to_string(&field).unwrap();
468 let parsed: FieldDef = serde_json::from_str(&json).unwrap();
469 assert_eq!(field, parsed);
470 assert!(parsed.readable);
471 assert!(!parsed.writable);
472 }
473
474 #[test]
475 fn field_def_readable_false_writable_false_round_trip() {
476 let field = FieldDef {
477 name: "internal_hash".to_string(),
478 data_type: DataType::String,
479 meaning: FieldMeaning::Sensitive,
480 required: true,
481 is_list: false,
482 readable: false,
483 writable: false,
484 };
485 let json = serde_json::to_string(&field).unwrap();
486 let parsed: FieldDef = serde_json::from_str(&json).unwrap();
487 assert_eq!(field, parsed);
488 assert!(!parsed.readable);
489 assert!(!parsed.writable);
490 }
491}