Skip to main content

romance_core/
entity.rs

1use anyhow::{bail, Result};
2use dialoguer::{Confirm, Input, Select};
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
6pub enum RelationType {
7    BelongsTo,
8    HasMany,
9    ManyToMany,
10}
11
12#[derive(Debug, Clone, Serialize)]
13pub struct RelationDefinition {
14    pub name: String,
15    pub relation_type: RelationType,
16    pub target_entity: String,
17    pub fk_column: Option<String>,
18    pub optional: bool,
19}
20
21#[derive(Debug, Clone, Serialize)]
22pub struct EntityDefinition {
23    pub name: String,
24    pub fields: Vec<FieldDefinition>,
25    pub relations: Vec<RelationDefinition>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
29pub enum ValidationRule {
30    Min(u64),
31    Max(u64),
32    Email,
33    Url,
34    Regex(String),
35    Required,
36    Unique,
37}
38
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40pub enum FieldVisibility {
41    Public,             // Everyone can see
42    Authenticated,      // Only logged-in users
43    AdminOnly,          // Only admin role
44    Roles(Vec<String>), // Specific roles
45}
46
47impl Default for FieldVisibility {
48    fn default() -> Self {
49        FieldVisibility::Public
50    }
51}
52
53#[derive(Debug, Clone, Serialize)]
54pub struct FieldDefinition {
55    pub name: String,
56    pub field_type: FieldType,
57    pub optional: bool,
58    pub default: Option<String>,
59    pub relation: Option<String>,
60    #[serde(default)]
61    pub validations: Vec<ValidationRule>,
62    #[serde(default)]
63    pub searchable: bool,
64    #[serde(default)]
65    pub visibility: FieldVisibility,
66}
67
68#[derive(Debug, Clone, Serialize)]
69pub enum FieldType {
70    String,
71    Text,
72    Bool,
73    Int32,
74    Int64,
75    Float64,
76    Decimal,
77    Uuid,
78    DateTime,
79    Date,
80    Json,
81    Enum(Vec<String>),
82    File,
83    Image,
84}
85
86impl FieldType {
87    pub fn to_rust(&self) -> &str {
88        match self {
89            FieldType::String => "String",
90            FieldType::Text => "String",
91            FieldType::Bool => "bool",
92            FieldType::Int32 => "i32",
93            FieldType::Int64 => "i64",
94            FieldType::Float64 => "f64",
95            FieldType::Decimal => "Decimal",
96            FieldType::Uuid => "Uuid",
97            FieldType::DateTime => "DateTimeWithTimeZone",
98            FieldType::Date => "Date",
99            FieldType::Json => "Json",
100            FieldType::Enum(_) => "String",
101            FieldType::File => "String",
102            FieldType::Image => "String",
103        }
104    }
105
106    pub fn to_typescript(&self) -> &str {
107        match self {
108            FieldType::String
109            | FieldType::Text
110            | FieldType::Uuid
111            | FieldType::Enum(_)
112            | FieldType::File
113            | FieldType::Image => "string",
114            FieldType::Bool => "boolean",
115            FieldType::Int32 | FieldType::Int64 | FieldType::Float64 | FieldType::Decimal => {
116                "number"
117            }
118            FieldType::DateTime | FieldType::Date => "string",
119            FieldType::Json => "unknown",
120        }
121    }
122
123    pub fn to_postgres(&self) -> &str {
124        match self {
125            FieldType::String => "VARCHAR(255)",
126            FieldType::Text => "TEXT",
127            FieldType::Bool => "BOOLEAN",
128            FieldType::Int32 => "INTEGER",
129            FieldType::Int64 => "BIGINT",
130            FieldType::Float64 => "DOUBLE PRECISION",
131            FieldType::Decimal => "DECIMAL",
132            FieldType::Uuid => "UUID",
133            FieldType::DateTime => "TIMESTAMPTZ",
134            FieldType::Date => "DATE",
135            FieldType::Json => "JSONB",
136            FieldType::Enum(_) => "VARCHAR(255)",
137            FieldType::File => "VARCHAR(512)",
138            FieldType::Image => "VARCHAR(512)",
139        }
140    }
141
142    pub fn to_sea_orm_column(&self) -> &str {
143        match self {
144            FieldType::String => "ColumnType::String(StringLen::N(255))",
145            FieldType::Text => "ColumnType::Text",
146            FieldType::Bool => "ColumnType::Boolean",
147            FieldType::Int32 => "ColumnType::Integer",
148            FieldType::Int64 => "ColumnType::BigInteger",
149            FieldType::Float64 => "ColumnType::Double",
150            FieldType::Decimal => "ColumnType::Decimal(None)",
151            FieldType::Uuid => "ColumnType::Uuid",
152            FieldType::DateTime => "ColumnType::TimestampWithTimeZone",
153            FieldType::Date => "ColumnType::Date",
154            FieldType::Json => "ColumnType::JsonBinary",
155            FieldType::Enum(_) => "ColumnType::String(StringLen::N(255))",
156            FieldType::File => "ColumnType::String(StringLen::N(512))",
157            FieldType::Image => "ColumnType::String(StringLen::N(512))",
158        }
159    }
160
161    /// Returns the SeaORM migration builder method chain for this type.
162    pub fn to_sea_orm_migration(&self) -> &str {
163        match self {
164            FieldType::String => "string_len(255)",
165            FieldType::Text => "text()",
166            FieldType::Bool => "boolean()",
167            FieldType::Int32 => "integer()",
168            FieldType::Int64 => "big_integer()",
169            FieldType::Float64 => "double()",
170            FieldType::Decimal => "decimal()",
171            FieldType::Uuid => "uuid()",
172            FieldType::DateTime => "timestamp_with_time_zone()",
173            FieldType::Date => "date()",
174            FieldType::Json => "json_binary()",
175            FieldType::Enum(_) => "string_len(255)",
176            FieldType::File => "string_len(512)",
177            FieldType::Image => "string_len(512)",
178        }
179    }
180
181    pub fn to_shadcn(&self) -> &str {
182        match self {
183            FieldType::String | FieldType::Uuid => "Input",
184            FieldType::Text => "Textarea",
185            FieldType::Bool => "Switch",
186            FieldType::Int32 | FieldType::Int64 | FieldType::Float64 | FieldType::Decimal => {
187                "Input"
188            }
189            FieldType::DateTime | FieldType::Date => "Input",
190            FieldType::Json => "Textarea",
191            FieldType::Enum(_) => "Select",
192            FieldType::File => "FileInput",
193            FieldType::Image => "ImageInput",
194        }
195    }
196
197    pub fn input_type(&self) -> &str {
198        match self {
199            FieldType::Int32 | FieldType::Int64 | FieldType::Float64 | FieldType::Decimal => {
200                "number"
201            }
202            FieldType::Date => "date",
203            FieldType::DateTime => "datetime-local",
204            FieldType::File | FieldType::Image => "file",
205            _ => "text",
206        }
207    }
208}
209
210fn parse_field_type(s: &str) -> Result<FieldType> {
211    let lower = s.to_lowercase();
212    if lower.starts_with("enum(") && lower.ends_with(')') {
213        let inner = &s[5..s.len() - 1];
214        let variants: Vec<String> = inner
215            .split(',')
216            .map(|v| v.trim().to_string())
217            .filter(|v| !v.is_empty())
218            .collect();
219        if variants.is_empty() {
220            bail!("Enum type requires at least one variant: enum(a,b,c)");
221        }
222        return Ok(FieldType::Enum(variants));
223    }
224    match lower.as_str() {
225        "string" | "str" => Ok(FieldType::String),
226        "text" => Ok(FieldType::Text),
227        "bool" | "boolean" => Ok(FieldType::Bool),
228        "i32" | "int" | "int32" | "integer" => Ok(FieldType::Int32),
229        "i64" | "int64" | "bigint" => Ok(FieldType::Int64),
230        "f64" | "float" | "float64" | "double" => Ok(FieldType::Float64),
231        "decimal" | "money" => Ok(FieldType::Decimal),
232        "uuid" => Ok(FieldType::Uuid),
233        "datetime" | "timestamp" => Ok(FieldType::DateTime),
234        "date" => Ok(FieldType::Date),
235        "json" | "jsonb" => Ok(FieldType::Json),
236        "file" => Ok(FieldType::File),
237        "image" => Ok(FieldType::Image),
238        _ => bail!("Unknown field type: '{}'", s),
239    }
240}
241
242/// Parse validation rules from a bracket-enclosed string like `[min=3,max=100,email]`.
243/// Visibility annotations (`admin_only`, `authenticated`, `roles=hr,admin`) are
244/// skipped here and handled by `parse_visibility()` instead.
245///
246/// Returns an error if a regex pattern is invalid or min/max values are not valid integers.
247fn parse_validations(s: &str) -> Result<Vec<ValidationRule>> {
248    let mut rules = Vec::new();
249
250    for part in s.split(',') {
251        let part = part.trim();
252        if part.is_empty() {
253            continue;
254        }
255
256        if let Some((key, value)) = part.split_once('=') {
257            match key.trim() {
258                "min" => {
259                    let n = value.trim().parse::<u64>()
260                        .map_err(|_| anyhow::anyhow!("Invalid min value '{}': expected a positive integer", value.trim()))?;
261                    rules.push(ValidationRule::Min(n));
262                }
263                "max" => {
264                    let n = value.trim().parse::<u64>()
265                        .map_err(|_| anyhow::anyhow!("Invalid max value '{}': expected a positive integer", value.trim()))?;
266                    rules.push(ValidationRule::Max(n));
267                }
268                "regex" => {
269                    let pattern = value.trim().to_string();
270                    regex::Regex::new(&pattern)
271                        .map_err(|e| anyhow::anyhow!("Invalid regex pattern '{}': {}", pattern, e))?;
272                    rules.push(ValidationRule::Regex(pattern));
273                }
274                "roles" => {} // Handled by parse_visibility
275                _ => {}
276            }
277        } else {
278            match part {
279                "email" => rules.push(ValidationRule::Email),
280                "url" => rules.push(ValidationRule::Url),
281                "required" => rules.push(ValidationRule::Required),
282                "unique" => rules.push(ValidationRule::Unique),
283                "searchable" => {}       // Handled separately
284                "admin_only" => {}       // Handled by parse_visibility
285                "authenticated" => {}    // Handled by parse_visibility
286                _ => {}
287            }
288        }
289    }
290
291    Ok(rules)
292}
293
294/// Parse field visibility from bracket annotations.
295///
296/// Recognized annotations:
297/// - `admin_only` → `FieldVisibility::AdminOnly`
298/// - `authenticated` → `FieldVisibility::Authenticated`
299/// - `roles=hr,admin` → `FieldVisibility::Roles(vec!["hr", "admin"])`
300/// - (none of the above) → `FieldVisibility::Public`
301fn parse_visibility(s: &str) -> FieldVisibility {
302    for part in s.split(',') {
303        let part = part.trim();
304        if part.is_empty() {
305            continue;
306        }
307
308        if part == "admin_only" {
309            return FieldVisibility::AdminOnly;
310        }
311        if part == "authenticated" {
312            return FieldVisibility::Authenticated;
313        }
314
315        if let Some((key, value)) = part.split_once('=') {
316            if key.trim() == "roles" {
317                let roles: Vec<String> = value
318                    .split(';')
319                    .map(|r| r.trim().to_string())
320                    .filter(|r| !r.is_empty())
321                    .collect();
322                if !roles.is_empty() {
323                    return FieldVisibility::Roles(roles);
324                }
325            }
326        }
327    }
328
329    FieldVisibility::Public
330}
331
332/// Parse entity from CLI args.
333/// Format: name:type or name:type->Relation or name:type?  (? = optional)
334/// Validation: name:type[min=3,max=100]
335/// Searchable: name:type[searchable]
336/// Relation formats: name:has_many->Entity, name:m2m->Entity
337pub fn parse_entity(name: &str, field_strs: &[String]) -> Result<EntityDefinition> {
338    let mut fields = Vec::new();
339    let mut relations = Vec::new();
340
341    for field_str in field_strs {
342        let (field_str, optional) = if field_str.ends_with('?') {
343            (&field_str[..field_str.len() - 1], true)
344        } else {
345            (field_str.as_str(), false)
346        };
347
348        let parts: Vec<&str> = field_str.splitn(2, ':').collect();
349        if parts.len() != 2 {
350            bail!(
351                "Invalid field format '{}'. Expected name:type",
352                field_str
353            );
354        }
355
356        let field_name = parts[0].to_string();
357
358        // Extract annotations from brackets: type[annotations]
359        let (type_and_relation_str, annotations) = if let Some(bracket_start) = parts[1].find('[') {
360            if let Some(bracket_end) = parts[1].find(']') {
361                let annotations_str = &parts[1][bracket_start + 1..bracket_end];
362                let type_str = &parts[1][..bracket_start];
363                // Append anything after ] (like ->Entity)
364                let after_bracket = &parts[1][bracket_end + 1..];
365                let full_type = format!("{}{}", type_str, after_bracket);
366                (full_type, annotations_str.to_string())
367            } else {
368                (parts[1].to_string(), String::new())
369            }
370        } else {
371            (parts[1].to_string(), String::new())
372        };
373
374        let type_and_relation: Vec<&str> = type_and_relation_str.splitn(2, "->").collect();
375        // Support `uuid?->Entity` syntax: strip `?` from end of type part
376        let raw_type_str = type_and_relation[0];
377        let (type_str, type_optional) = if raw_type_str.ends_with('?') {
378            (raw_type_str[..raw_type_str.len() - 1].to_lowercase(), true)
379        } else {
380            (raw_type_str.to_lowercase(), false)
381        };
382        let optional = optional || type_optional;
383
384        // Check for relation-only types (no column generated)
385        match type_str.as_str() {
386            "has_many" => {
387                let target = type_and_relation
388                    .get(1)
389                    .ok_or_else(|| anyhow::anyhow!("has_many requires target entity: {}:has_many->Entity", field_name))?;
390                relations.push(RelationDefinition {
391                    name: field_name,
392                    relation_type: RelationType::HasMany,
393                    target_entity: target.to_string(),
394                    fk_column: None,
395                    optional: false,
396                });
397                continue;
398            }
399            "m2m" => {
400                let target = type_and_relation
401                    .get(1)
402                    .ok_or_else(|| anyhow::anyhow!("m2m requires target entity: {}:m2m->Entity", field_name))?;
403                relations.push(RelationDefinition {
404                    name: field_name,
405                    relation_type: RelationType::ManyToMany,
406                    target_entity: target.to_string(),
407                    fk_column: None,
408                    optional: false,
409                });
410                continue;
411            }
412            _ => {}
413        }
414
415        let field_type = parse_field_type(&type_str)?;
416        let relation = type_and_relation.get(1).map(|s| s.to_string());
417
418        // Parse validations, searchable, and visibility from annotations
419        let validations = parse_validations(&annotations)?;
420        let searchable = annotations.contains("searchable");
421        let visibility = parse_visibility(&annotations);
422
423        // If field has a belongs_to relation, also add it to relations vec
424        if let Some(ref target) = relation {
425            relations.push(RelationDefinition {
426                name: field_name.clone(),
427                relation_type: RelationType::BelongsTo,
428                target_entity: target.clone(),
429                fk_column: Some(field_name.clone()),
430                optional,
431            });
432        }
433
434        fields.push(FieldDefinition {
435            name: field_name,
436            field_type,
437            optional,
438            default: None,
439            relation,
440            validations,
441            searchable,
442            visibility,
443        });
444    }
445
446    Ok(EntityDefinition {
447        name: name.to_string(),
448        fields,
449        relations,
450    })
451}
452
453const FIELD_TYPE_OPTIONS: &[(&str, &str)] = &[
454    ("string", "String (VARCHAR 255)"),
455    ("text", "Text (unlimited)"),
456    ("bool", "Boolean"),
457    ("int", "Integer (i32)"),
458    ("bigint", "Big Integer (i64)"),
459    ("float", "Float (f64)"),
460    ("decimal", "Decimal"),
461    ("uuid", "UUID"),
462    ("datetime", "DateTime (with timezone)"),
463    ("date", "Date"),
464    ("json", "JSON"),
465    ("file", "File (upload)"),
466    ("image", "Image (upload with validation)"),
467    ("enum", "Enum (custom variants)"),
468];
469
470const RELATION_TYPE_OPTIONS: &[(&str, &str)] = &[
471    ("belongs_to", "Belongs To (FK on this entity)"),
472    ("has_many", "Has Many (reverse side)"),
473    ("m2m", "Many to Many (junction table)"),
474];
475
476/// Interactively prompt the user to define entity fields and relations.
477pub fn prompt_entity_fields(entity_name: &str) -> Result<(Vec<FieldDefinition>, Vec<RelationDefinition>)> {
478    println!(
479        "Define fields for '{}' (press Enter with empty name to finish):",
480        entity_name
481    );
482
483    let type_labels: Vec<&str> = FIELD_TYPE_OPTIONS.iter().map(|(_, label)| *label).collect();
484    let mut fields = Vec::new();
485    let mut relations = Vec::new();
486
487    loop {
488        let field_name: String = Input::new()
489            .with_prompt("Field name")
490            .allow_empty(true)
491            .interact_text()?;
492
493        if field_name.is_empty() {
494            break;
495        }
496
497        let type_idx = Select::new()
498            .with_prompt("Field type")
499            .items(&type_labels)
500            .default(0)
501            .interact()?;
502
503        let optional = Confirm::new()
504            .with_prompt("Optional (nullable)?")
505            .default(false)
506            .interact()?;
507
508        let relation: String = Input::new()
509            .with_prompt("Foreign key (entity name, or empty to skip)")
510            .allow_empty(true)
511            .interact_text()?;
512
513        let (type_key, _) = FIELD_TYPE_OPTIONS[type_idx];
514        let field_type = if type_key == "enum" {
515            let variants_input: String = Input::new()
516                .with_prompt("Enter enum variants (comma-separated)")
517                .interact_text()?;
518            let variants: Vec<String> = variants_input
519                .split(',')
520                .map(|v| v.trim().to_string())
521                .filter(|v| !v.is_empty())
522                .collect();
523            if variants.is_empty() {
524                println!("No variants provided, defaulting to String type.");
525                FieldType::String
526            } else {
527                FieldType::Enum(variants)
528            }
529        } else {
530            parse_field_type(type_key)?
531        };
532        let relation = if relation.is_empty() {
533            None
534        } else {
535            Some(relation)
536        };
537
538        if let Some(ref target) = relation {
539            relations.push(RelationDefinition {
540                name: field_name.clone(),
541                relation_type: RelationType::BelongsTo,
542                target_entity: target.clone(),
543                fk_column: Some(field_name.clone()),
544                optional,
545            });
546        }
547
548        fields.push(FieldDefinition {
549            name: field_name,
550            field_type,
551            optional,
552            default: None,
553            relation,
554            validations: Vec::new(),
555            searchable: false,
556            visibility: FieldVisibility::default(),
557        });
558
559        println!();
560    }
561
562    // Prompt for additional relations (has_many, m2m)
563    let add_relations = Confirm::new()
564        .with_prompt("Add relations (has_many, m2m)?")
565        .default(false)
566        .interact()?;
567
568    if add_relations {
569        let rel_labels: Vec<&str> = RELATION_TYPE_OPTIONS.iter().map(|(_, label)| *label).collect();
570
571        loop {
572            let rel_name: String = Input::new()
573                .with_prompt("Relation name (empty to finish)")
574                .allow_empty(true)
575                .interact_text()?;
576
577            if rel_name.is_empty() {
578                break;
579            }
580
581            let rel_idx = Select::new()
582                .with_prompt("Relation type")
583                .items(&rel_labels)
584                .default(0)
585                .interact()?;
586
587            let target: String = Input::new()
588                .with_prompt("Target entity (PascalCase)")
589                .interact_text()?;
590
591            let (rel_key, _) = RELATION_TYPE_OPTIONS[rel_idx];
592            let relation_type = match rel_key {
593                "belongs_to" => RelationType::BelongsTo,
594                "has_many" => RelationType::HasMany,
595                "m2m" => RelationType::ManyToMany,
596                _ => unreachable!(),
597            };
598
599            relations.push(RelationDefinition {
600                name: rel_name,
601                relation_type,
602                target_entity: target,
603                fk_column: None,
604                optional: false,
605            });
606
607            println!();
608        }
609    }
610
611    Ok((fields, relations))
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617
618    // ── parse_field_type ──────────────────────────────────────────────
619
620    #[test]
621    fn parse_string_aliases() {
622        assert!(matches!(parse_field_type("string").unwrap(), FieldType::String));
623        assert!(matches!(parse_field_type("str").unwrap(), FieldType::String));
624        assert!(matches!(parse_field_type("STRING").unwrap(), FieldType::String));
625    }
626
627    #[test]
628    fn parse_text() {
629        assert!(matches!(parse_field_type("text").unwrap(), FieldType::Text));
630    }
631
632    #[test]
633    fn parse_bool_aliases() {
634        assert!(matches!(parse_field_type("bool").unwrap(), FieldType::Bool));
635        assert!(matches!(parse_field_type("boolean").unwrap(), FieldType::Bool));
636    }
637
638    #[test]
639    fn parse_int32_aliases() {
640        assert!(matches!(parse_field_type("i32").unwrap(), FieldType::Int32));
641        assert!(matches!(parse_field_type("int").unwrap(), FieldType::Int32));
642        assert!(matches!(parse_field_type("int32").unwrap(), FieldType::Int32));
643        assert!(matches!(parse_field_type("integer").unwrap(), FieldType::Int32));
644    }
645
646    #[test]
647    fn parse_int64_aliases() {
648        assert!(matches!(parse_field_type("i64").unwrap(), FieldType::Int64));
649        assert!(matches!(parse_field_type("int64").unwrap(), FieldType::Int64));
650        assert!(matches!(parse_field_type("bigint").unwrap(), FieldType::Int64));
651    }
652
653    #[test]
654    fn parse_float64_aliases() {
655        assert!(matches!(parse_field_type("f64").unwrap(), FieldType::Float64));
656        assert!(matches!(parse_field_type("float").unwrap(), FieldType::Float64));
657        assert!(matches!(parse_field_type("float64").unwrap(), FieldType::Float64));
658        assert!(matches!(parse_field_type("double").unwrap(), FieldType::Float64));
659    }
660
661    #[test]
662    fn parse_decimal_aliases() {
663        assert!(matches!(parse_field_type("decimal").unwrap(), FieldType::Decimal));
664        assert!(matches!(parse_field_type("money").unwrap(), FieldType::Decimal));
665    }
666
667    #[test]
668    fn parse_uuid() {
669        assert!(matches!(parse_field_type("uuid").unwrap(), FieldType::Uuid));
670    }
671
672    #[test]
673    fn parse_datetime_aliases() {
674        assert!(matches!(parse_field_type("datetime").unwrap(), FieldType::DateTime));
675        assert!(matches!(parse_field_type("timestamp").unwrap(), FieldType::DateTime));
676    }
677
678    #[test]
679    fn parse_date() {
680        assert!(matches!(parse_field_type("date").unwrap(), FieldType::Date));
681    }
682
683    #[test]
684    fn parse_json_aliases() {
685        assert!(matches!(parse_field_type("json").unwrap(), FieldType::Json));
686        assert!(matches!(parse_field_type("jsonb").unwrap(), FieldType::Json));
687    }
688
689    #[test]
690    fn parse_file() {
691        assert!(matches!(parse_field_type("file").unwrap(), FieldType::File));
692    }
693
694    #[test]
695    fn parse_image() {
696        assert!(matches!(parse_field_type("image").unwrap(), FieldType::Image));
697    }
698
699    #[test]
700    fn parse_enum_type() {
701        let ft = parse_field_type("enum(draft,published,archived)").unwrap();
702        assert!(matches!(ft, FieldType::Enum(ref v) if v.len() == 3));
703    }
704
705    #[test]
706    fn parse_enum_type_with_spaces() {
707        let ft = parse_field_type("enum(a, b, c)").unwrap();
708        assert!(matches!(ft, FieldType::Enum(ref v) if v.len() == 3 && v[0] == "a"));
709    }
710
711    #[test]
712    fn parse_enum_empty_errors() {
713        assert!(parse_field_type("enum()").is_err());
714    }
715
716    #[test]
717    fn parse_enum_case_insensitive() {
718        let ft = parse_field_type("Enum(Draft,Published)").unwrap();
719        assert!(matches!(ft, FieldType::Enum(ref v) if v.len() == 2 && v[0] == "Draft"));
720    }
721
722    #[test]
723    fn parse_unknown_type_errors() {
724        assert!(parse_field_type("foobar").is_err());
725    }
726
727    // ── FieldType::to_rust ────────────────────────────────────────────
728
729    #[test]
730    fn to_rust_mappings() {
731        assert_eq!(FieldType::String.to_rust(), "String");
732        assert_eq!(FieldType::Text.to_rust(), "String");
733        assert_eq!(FieldType::Bool.to_rust(), "bool");
734        assert_eq!(FieldType::Int32.to_rust(), "i32");
735        assert_eq!(FieldType::Int64.to_rust(), "i64");
736        assert_eq!(FieldType::Float64.to_rust(), "f64");
737        assert_eq!(FieldType::Decimal.to_rust(), "Decimal");
738        assert_eq!(FieldType::Uuid.to_rust(), "Uuid");
739        assert_eq!(FieldType::DateTime.to_rust(), "DateTimeWithTimeZone");
740        assert_eq!(FieldType::Date.to_rust(), "Date");
741        assert_eq!(FieldType::Json.to_rust(), "Json");
742        assert_eq!(FieldType::Enum(vec!["A".into()]).to_rust(), "String");
743        assert_eq!(FieldType::File.to_rust(), "String");
744        assert_eq!(FieldType::Image.to_rust(), "String");
745    }
746
747    // ── FieldType::to_typescript ──────────────────────────────────────
748
749    #[test]
750    fn to_typescript_mappings() {
751        assert_eq!(FieldType::String.to_typescript(), "string");
752        assert_eq!(FieldType::Text.to_typescript(), "string");
753        assert_eq!(FieldType::Bool.to_typescript(), "boolean");
754        assert_eq!(FieldType::Int32.to_typescript(), "number");
755        assert_eq!(FieldType::Int64.to_typescript(), "number");
756        assert_eq!(FieldType::Float64.to_typescript(), "number");
757        assert_eq!(FieldType::Decimal.to_typescript(), "number");
758        assert_eq!(FieldType::Uuid.to_typescript(), "string");
759        assert_eq!(FieldType::DateTime.to_typescript(), "string");
760        assert_eq!(FieldType::Date.to_typescript(), "string");
761        assert_eq!(FieldType::Json.to_typescript(), "unknown");
762        assert_eq!(FieldType::Enum(vec![]).to_typescript(), "string");
763        assert_eq!(FieldType::File.to_typescript(), "string");
764        assert_eq!(FieldType::Image.to_typescript(), "string");
765    }
766
767    // ── FieldType::to_postgres ────────────────────────────────────────
768
769    #[test]
770    fn to_postgres_mappings() {
771        assert_eq!(FieldType::String.to_postgres(), "VARCHAR(255)");
772        assert_eq!(FieldType::Text.to_postgres(), "TEXT");
773        assert_eq!(FieldType::Bool.to_postgres(), "BOOLEAN");
774        assert_eq!(FieldType::Int32.to_postgres(), "INTEGER");
775        assert_eq!(FieldType::Int64.to_postgres(), "BIGINT");
776        assert_eq!(FieldType::Float64.to_postgres(), "DOUBLE PRECISION");
777        assert_eq!(FieldType::Decimal.to_postgres(), "DECIMAL");
778        assert_eq!(FieldType::Uuid.to_postgres(), "UUID");
779        assert_eq!(FieldType::DateTime.to_postgres(), "TIMESTAMPTZ");
780        assert_eq!(FieldType::Date.to_postgres(), "DATE");
781        assert_eq!(FieldType::Json.to_postgres(), "JSONB");
782        assert_eq!(FieldType::File.to_postgres(), "VARCHAR(512)");
783        assert_eq!(FieldType::Image.to_postgres(), "VARCHAR(512)");
784    }
785
786    // ── FieldType::to_sea_orm_column ──────────────────────────────────
787
788    #[test]
789    fn to_sea_orm_column_mappings() {
790        assert_eq!(FieldType::String.to_sea_orm_column(), "ColumnType::String(StringLen::N(255))");
791        assert_eq!(FieldType::Text.to_sea_orm_column(), "ColumnType::Text");
792        assert_eq!(FieldType::Bool.to_sea_orm_column(), "ColumnType::Boolean");
793        assert_eq!(FieldType::Int32.to_sea_orm_column(), "ColumnType::Integer");
794        assert_eq!(FieldType::Int64.to_sea_orm_column(), "ColumnType::BigInteger");
795        assert_eq!(FieldType::Float64.to_sea_orm_column(), "ColumnType::Double");
796        assert_eq!(FieldType::Decimal.to_sea_orm_column(), "ColumnType::Decimal(None)");
797        assert_eq!(FieldType::Uuid.to_sea_orm_column(), "ColumnType::Uuid");
798        assert_eq!(FieldType::DateTime.to_sea_orm_column(), "ColumnType::TimestampWithTimeZone");
799        assert_eq!(FieldType::Date.to_sea_orm_column(), "ColumnType::Date");
800        assert_eq!(FieldType::Json.to_sea_orm_column(), "ColumnType::JsonBinary");
801        assert_eq!(FieldType::File.to_sea_orm_column(), "ColumnType::String(StringLen::N(512))");
802        assert_eq!(FieldType::Image.to_sea_orm_column(), "ColumnType::String(StringLen::N(512))");
803    }
804
805    // ── FieldType::to_sea_orm_migration ───────────────────────────────
806
807    #[test]
808    fn to_sea_orm_migration_mappings() {
809        assert_eq!(FieldType::String.to_sea_orm_migration(), "string_len(255)");
810        assert_eq!(FieldType::Text.to_sea_orm_migration(), "text()");
811        assert_eq!(FieldType::Bool.to_sea_orm_migration(), "boolean()");
812        assert_eq!(FieldType::Int32.to_sea_orm_migration(), "integer()");
813        assert_eq!(FieldType::Int64.to_sea_orm_migration(), "big_integer()");
814        assert_eq!(FieldType::Float64.to_sea_orm_migration(), "double()");
815        assert_eq!(FieldType::Decimal.to_sea_orm_migration(), "decimal()");
816        assert_eq!(FieldType::Uuid.to_sea_orm_migration(), "uuid()");
817        assert_eq!(FieldType::DateTime.to_sea_orm_migration(), "timestamp_with_time_zone()");
818        assert_eq!(FieldType::Date.to_sea_orm_migration(), "date()");
819        assert_eq!(FieldType::Json.to_sea_orm_migration(), "json_binary()");
820        assert_eq!(FieldType::File.to_sea_orm_migration(), "string_len(512)");
821        assert_eq!(FieldType::Image.to_sea_orm_migration(), "string_len(512)");
822    }
823
824    // ── FieldType::to_shadcn ──────────────────────────────────────────
825
826    #[test]
827    fn to_shadcn_mappings() {
828        assert_eq!(FieldType::String.to_shadcn(), "Input");
829        assert_eq!(FieldType::Text.to_shadcn(), "Textarea");
830        assert_eq!(FieldType::Bool.to_shadcn(), "Switch");
831        assert_eq!(FieldType::Int32.to_shadcn(), "Input");
832        assert_eq!(FieldType::Uuid.to_shadcn(), "Input");
833        assert_eq!(FieldType::DateTime.to_shadcn(), "Input");
834        assert_eq!(FieldType::Json.to_shadcn(), "Textarea");
835        assert_eq!(FieldType::Enum(vec!["A".into()]).to_shadcn(), "Select");
836        assert_eq!(FieldType::File.to_shadcn(), "FileInput");
837        assert_eq!(FieldType::Image.to_shadcn(), "ImageInput");
838    }
839
840    // ── FieldType::input_type ─────────────────────────────────────────
841
842    #[test]
843    fn input_type_mappings() {
844        assert_eq!(FieldType::String.input_type(), "text");
845        assert_eq!(FieldType::Int32.input_type(), "number");
846        assert_eq!(FieldType::Float64.input_type(), "number");
847        assert_eq!(FieldType::Date.input_type(), "date");
848        assert_eq!(FieldType::DateTime.input_type(), "datetime-local");
849        assert_eq!(FieldType::File.input_type(), "file");
850        assert_eq!(FieldType::Image.input_type(), "file");
851        assert_eq!(FieldType::Bool.input_type(), "text");
852    }
853
854    // ── parse_entity: basic field ─────────────────────────────────────
855
856    #[test]
857    fn parse_entity_basic_field() {
858        let entity = parse_entity("Post", &["title:string".to_string()]).unwrap();
859        assert_eq!(entity.name, "Post");
860        assert_eq!(entity.fields.len(), 1);
861        assert_eq!(entity.fields[0].name, "title");
862        assert!(matches!(entity.fields[0].field_type, FieldType::String));
863        assert!(!entity.fields[0].optional);
864        assert!(entity.fields[0].relation.is_none());
865    }
866
867    // ── parse_entity: optional field ──────────────────────────────────
868
869    #[test]
870    fn parse_entity_optional_field() {
871        let entity = parse_entity("Post", &["bio:text?".to_string()]).unwrap();
872        assert_eq!(entity.fields.len(), 1);
873        assert!(entity.fields[0].optional);
874        assert!(matches!(entity.fields[0].field_type, FieldType::Text));
875    }
876
877    // ── parse_entity: belongs_to relation ─────────────────────────────
878
879    #[test]
880    fn parse_entity_belongs_to_relation() {
881        let entity = parse_entity("Post", &["author_id:uuid->User".to_string()]).unwrap();
882
883        // Field should exist with relation set
884        assert_eq!(entity.fields.len(), 1);
885        assert_eq!(entity.fields[0].name, "author_id");
886        assert!(matches!(entity.fields[0].field_type, FieldType::Uuid));
887        assert_eq!(entity.fields[0].relation.as_deref(), Some("User"));
888
889        // BelongsTo relation should be in relations vec
890        assert_eq!(entity.relations.len(), 1);
891        assert_eq!(entity.relations[0].target_entity, "User");
892        assert!(matches!(entity.relations[0].relation_type, RelationType::BelongsTo));
893        assert_eq!(entity.relations[0].fk_column.as_deref(), Some("author_id"));
894    }
895
896    // ── parse_entity: has_many relation ───────────────────────────────
897
898    #[test]
899    fn parse_entity_has_many_relation() {
900        let entity = parse_entity("User", &["posts:has_many->Post".to_string()]).unwrap();
901
902        // has_many does not create a field, only a relation
903        assert_eq!(entity.fields.len(), 0);
904        assert_eq!(entity.relations.len(), 1);
905        assert!(matches!(entity.relations[0].relation_type, RelationType::HasMany));
906        assert_eq!(entity.relations[0].target_entity, "Post");
907    }
908
909    // ── parse_entity: m2m relation ────────────────────────────────────
910
911    #[test]
912    fn parse_entity_m2m_relation() {
913        let entity = parse_entity("Post", &["tags:m2m->Tag".to_string()]).unwrap();
914
915        // m2m does not create a field, only a relation
916        assert_eq!(entity.fields.len(), 0);
917        assert_eq!(entity.relations.len(), 1);
918        assert!(matches!(entity.relations[0].relation_type, RelationType::ManyToMany));
919        assert_eq!(entity.relations[0].target_entity, "Tag");
920    }
921
922    // ── parse_entity: validation annotations ──────────────────────────
923
924    #[test]
925    fn parse_entity_validations() {
926        let entity = parse_entity("Post", &["title:string[min=3,max=100]".to_string()]).unwrap();
927        assert_eq!(entity.fields.len(), 1);
928        let validations = &entity.fields[0].validations;
929        assert_eq!(validations.len(), 2);
930        assert!(validations.contains(&ValidationRule::Min(3)));
931        assert!(validations.contains(&ValidationRule::Max(100)));
932    }
933
934    // ── parse_entity: searchable annotation ───────────────────────────
935
936    #[test]
937    fn parse_entity_searchable() {
938        let entity = parse_entity("Post", &["title:string[searchable]".to_string()]).unwrap();
939        assert_eq!(entity.fields.len(), 1);
940        assert!(entity.fields[0].searchable);
941    }
942
943    // ── parse_entity: multiple fields ─────────────────────────────────
944
945    #[test]
946    fn parse_entity_multiple_fields() {
947        let entity = parse_entity(
948            "Product",
949            &[
950                "title:string".to_string(),
951                "price:decimal".to_string(),
952                "description:text?".to_string(),
953                "category_id:uuid->Category".to_string(),
954                "tags:m2m->Tag".to_string(),
955            ],
956        )
957        .unwrap();
958
959        assert_eq!(entity.name, "Product");
960        // title, price, description, category_id are fields; tags (m2m) creates no field
961        assert_eq!(entity.fields.len(), 4);
962        // BelongsTo(Category) + ManyToMany(Tag)
963        assert_eq!(entity.relations.len(), 2);
964    }
965
966    // ── parse_entity: enum field via CLI ────────────────────────────────
967
968    #[test]
969    fn parse_entity_enum_field() {
970        let entity = parse_entity(
971            "Post",
972            &["status:enum(draft,published,archived)".to_string()],
973        )
974        .unwrap();
975        assert_eq!(entity.fields.len(), 1);
976        assert_eq!(entity.fields[0].name, "status");
977        assert!(matches!(
978            entity.fields[0].field_type,
979            FieldType::Enum(ref v) if v == &["draft", "published", "archived"]
980        ));
981    }
982
983    // ── parse_entity: invalid format ──────────────────────────────────
984
985    #[test]
986    fn parse_entity_invalid_format() {
987        assert!(parse_entity("Post", &["invalid_no_colon".to_string()]).is_err());
988    }
989
990    // ── parse_entity: unknown field type ──────────────────────────────
991
992    #[test]
993    fn parse_entity_unknown_field_type() {
994        assert!(parse_entity("Post", &["name:foobar".to_string()]).is_err());
995    }
996
997    // ── parse_validations: mixed rules ────────────────────────────────
998
999    #[test]
1000    fn parse_validations_mixed() {
1001        let rules = parse_validations("min=5,max=200,email,unique").unwrap();
1002        assert_eq!(rules.len(), 4);
1003        assert!(rules.contains(&ValidationRule::Min(5)));
1004        assert!(rules.contains(&ValidationRule::Max(200)));
1005        assert!(rules.contains(&ValidationRule::Email));
1006        assert!(rules.contains(&ValidationRule::Unique));
1007    }
1008
1009    #[test]
1010    fn parse_validations_regex() {
1011        let rules = parse_validations("regex=^[a-z]+$").unwrap();
1012        assert_eq!(rules.len(), 1);
1013        assert!(matches!(&rules[0], ValidationRule::Regex(r) if r == "^[a-z]+$"));
1014    }
1015
1016    #[test]
1017    fn parse_validations_empty() {
1018        let rules = parse_validations("").unwrap();
1019        assert!(rules.is_empty());
1020    }
1021
1022    #[test]
1023    fn parse_validations_invalid_regex_errors() {
1024        let result = parse_validations("regex=[invalid");
1025        assert!(result.is_err());
1026        assert!(result.unwrap_err().to_string().contains("Invalid regex pattern"));
1027    }
1028
1029    #[test]
1030    fn parse_validations_valid_regex_succeeds() {
1031        let rules = parse_validations("regex=^\\d{3}-\\d{4}$").unwrap();
1032        assert_eq!(rules.len(), 1);
1033        assert!(matches!(&rules[0], ValidationRule::Regex(r) if r == "^\\d{3}-\\d{4}$"));
1034    }
1035
1036    #[test]
1037    fn parse_validations_invalid_min_errors() {
1038        let result = parse_validations("min=abc");
1039        assert!(result.is_err());
1040        assert!(result.unwrap_err().to_string().contains("Invalid min value"));
1041    }
1042
1043    #[test]
1044    fn parse_validations_negative_max_errors() {
1045        let result = parse_validations("max=-5");
1046        assert!(result.is_err());
1047        assert!(result.unwrap_err().to_string().contains("Invalid max value"));
1048    }
1049
1050    #[test]
1051    fn parse_validations_invalid_max_errors() {
1052        let result = parse_validations("max=not_a_number");
1053        assert!(result.is_err());
1054        assert!(result.unwrap_err().to_string().contains("Invalid max value"));
1055    }
1056
1057    // ── parse_entity: optional belongs_to ─────────────────────────────
1058
1059    #[test]
1060    fn parse_entity_optional_belongs_to() {
1061        let entity = parse_entity("Post", &["category_id:uuid->Category?".to_string()]).unwrap();
1062        assert_eq!(entity.fields.len(), 1);
1063        assert!(entity.fields[0].optional);
1064        assert_eq!(entity.relations.len(), 1);
1065        assert!(entity.relations[0].optional);
1066    }
1067
1068    // ── parse_entity: optional FK with ? before -> ────────────────────
1069
1070    #[test]
1071    fn parse_entity_optional_fk_question_before_arrow() {
1072        // uuid?->Entity syntax (? before ->)
1073        let entity = parse_entity("Post", &["category_id:uuid?->Category".to_string()]).unwrap();
1074        assert_eq!(entity.fields.len(), 1);
1075        assert!(entity.fields[0].optional);
1076        assert_eq!(entity.fields[0].relation.as_deref(), Some("Category"));
1077        assert!(matches!(entity.fields[0].field_type, FieldType::Uuid));
1078        assert_eq!(entity.relations.len(), 1);
1079        assert!(entity.relations[0].optional);
1080    }
1081
1082    #[test]
1083    fn parse_entity_optional_fk_question_at_end() {
1084        // uuid->Entity? syntax (? at end — already worked)
1085        let entity = parse_entity("Post", &["category_id:uuid->Category?".to_string()]).unwrap();
1086        assert_eq!(entity.fields.len(), 1);
1087        assert!(entity.fields[0].optional);
1088    }
1089
1090    // ── parse_entity: validation + relation combined ──────────────────
1091
1092    #[test]
1093    fn parse_entity_validation_with_relation() {
1094        let entity = parse_entity("Post", &["author_id:uuid[required]->User".to_string()]).unwrap();
1095        assert_eq!(entity.fields.len(), 1);
1096        assert_eq!(entity.fields[0].relation.as_deref(), Some("User"));
1097        assert!(entity.fields[0].validations.contains(&ValidationRule::Required));
1098    }
1099}