Skip to main content

ferro_projections/
field.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4/// Abstract data type categories for service fields.
5///
6/// Represents structural types independent of database storage details.
7/// Maps from database column types at introspection time.
8#[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/// Semantic meaning of a field, driving rendering and behavior decisions.
24///
25/// Known variants map to specific UI treatments (e.g., `Money` formats as currency,
26/// `Status` renders as a badge). The `Custom` fallback captures domain-specific
27/// meanings not covered by built-in variants.
28///
29/// `Custom(String)` must remain the last variant for correct serde deserialization.
30#[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/// Non-visual rendering hint for URL/ImageUrl fields.
59///
60/// Applied by non-visual renderers (e.g., `TextRenderer` in `ferro-text`) to
61/// handle fields that have no meaningful text representation without context.
62/// Absent hint (`None` on `FieldDef::render_hint`) preserves current behavior.
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
64#[serde(rename_all = "snake_case")]
65pub enum RenderHint {
66    /// Substitute this string in place of the raw URL/ImageUrl value.
67    AltText(String),
68    /// Omit this field entirely from non-visual output.
69    Skip,
70}
71
72/// A field definition within a service projection.
73#[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    /// Non-visual rendering hint. `None` preserves current behavior
87    /// (renderers emit a `"(link)"`/`"(image)"` label for `Url`/`ImageUrl` fields).
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub render_hint: Option<RenderHint>,
90}
91
92impl FieldDef {
93    /// Set the non-visual rendering hint (consuming builder).
94    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    /// Infers a DataType from a Rust/SeaORM column type string.
106    ///
107    /// Strips `Option<>` wrappers before matching. Falls back to `String`
108    /// for unrecognized types.
109    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
134/// Infers a [`FieldMeaning`] from a field name using common naming conventions.
135///
136/// Applies seven inference rules based on patterns found across the codebase:
137/// - Exact matches: `id`, `email`, `created_at`, `updated_at`
138/// - Suffix: `_id` (foreign key), `_at` (datetime)
139/// - Prefix: `is_`, `has_` (boolean)
140/// - Contains: `password`, `secret`, `token`, `api_key`, `hashed_key` (sensitive)
141/// - Fallback: `Custom(field_name)`
142pub fn infer_meaning(field_name: &str) -> FieldMeaning {
143    // Exact matches first
144    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    // Suffix patterns
153    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    // Prefix patterns
161    if field_name.starts_with("is_") || field_name.starts_with("has_") {
162        return FieldMeaning::Boolean;
163    }
164
165    // Sensitive field patterns
166    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        // Deserializing "money" must match Money variant, not Custom("money")
302        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        // Verify that omitting required/is_list/readable/writable uses correct defaults
339        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    // -- Additional Phase 86-02 tests: readable/writable defaults --
455
456    #[test]
457    fn field_def_readable_writable_defaults_from_json() {
458        // Both readable and writable default to true when omitted — backward compatibility
459        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    // -- render_hint tests (Phase 216-01) --
523
524    #[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        // `render_hint` key must be absent when None (skip_serializing_if)
554        assert!(
555            !json.contains("render_hint"),
556            "render_hint key must be absent when None, got: {json}"
557        );
558        // Deserializing JSON without the key must yield render_hint: None
559        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}