Skip to main content

gize_core/
field.rs

1//! Model and field definitions parsed from the CLI (`name:String email:String ...`).
2
3use anyhow::{Context, Result, bail};
4
5/// The set of scalar field types Gize understands in the MVP.
6///
7/// Kept deliberately small and explicit (ADR-003 / ADR-011): each variant maps to a Rust
8/// type and a SQL column type. Unknown types are rejected early with a helpful error.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum FieldType {
11    String,
12    Bool,
13    I32,
14    I64,
15    F64,
16    Uuid,
17    DateTime,
18}
19
20impl FieldType {
21    /// Parse a field type from its CLI spelling (case-insensitive).
22    pub fn parse(raw: &str) -> Result<Self> {
23        let ty = match raw.to_ascii_lowercase().as_str() {
24            "string" | "str" => Self::String,
25            "bool" | "boolean" => Self::Bool,
26            "i32" | "int" => Self::I32,
27            "i64" | "bigint" | "long" => Self::I64,
28            "f64" | "float" | "double" => Self::F64,
29            "uuid" => Self::Uuid,
30            "datetime" | "timestamp" => Self::DateTime,
31            other => bail!(
32                "unknown field type `{other}` (supported: String, Bool, i32, i64, f64, Uuid, DateTime)"
33            ),
34        };
35        Ok(ty)
36    }
37
38    /// The idiomatic Rust type used in generated structs.
39    pub fn rust_type(self) -> &'static str {
40        match self {
41            Self::String => "String",
42            Self::Bool => "bool",
43            Self::I32 => "i32",
44            Self::I64 => "i64",
45            Self::F64 => "f64",
46            Self::Uuid => "uuid::Uuid",
47            Self::DateTime => "chrono::DateTime<chrono::Utc>",
48        }
49    }
50
51    /// Whether the mapped Rust type is `Copy`. Used by generators to decide between
52    /// `.bind(x)` (Copy) and `.bind(x.clone())` (owned), keeping generated code
53    /// clippy-clean (`clone_on_copy`).
54    pub fn is_copy(self) -> bool {
55        // `String` is the only non-Copy type in the MVP set; `uuid::Uuid` and
56        // `chrono::DateTime<Utc>` are both Copy.
57        !matches!(self, Self::String)
58    }
59
60    /// The PostgreSQL column type used in generated migrations (ADR-011).
61    pub fn sql_type(self) -> &'static str {
62        match self {
63            Self::String => "TEXT",
64            Self::Bool => "BOOLEAN",
65            Self::I32 => "INTEGER",
66            Self::I64 => "BIGINT",
67            Self::F64 => "DOUBLE PRECISION",
68            Self::Uuid => "UUID",
69            Self::DateTime => "TIMESTAMPTZ",
70        }
71    }
72}
73
74/// A single field of a model: `name:Type`.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct Field {
77    pub name: String,
78    pub ty: FieldType,
79}
80
81impl Field {
82    /// Parse one `name:Type` token.
83    pub fn parse(token: &str) -> Result<Self> {
84        let (name, ty) = token
85            .split_once(':')
86            .with_context(|| format!("field `{token}` must be in the form name:Type"))?;
87        if name.is_empty() {
88            bail!("field `{token}` has an empty name");
89        }
90        Ok(Self {
91            name: name.to_string(),
92            ty: FieldType::parse(ty)?,
93        })
94    }
95}
96
97/// A model: a name plus its fields, as produced by `gize make model Name f:T ...`.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct ModelSpec {
100    pub name: String,
101    pub fields: Vec<Field>,
102}
103
104impl ModelSpec {
105    /// Build a model spec from a name and raw `name:Type` tokens.
106    pub fn parse(name: impl Into<String>, tokens: &[String]) -> Result<Self> {
107        let fields = tokens
108            .iter()
109            .map(|t| Field::parse(t))
110            .collect::<Result<Vec<_>>>()?;
111        Ok(Self {
112            name: name.into(),
113            fields,
114        })
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn parses_a_field() {
124        let f = Field::parse("email:String").unwrap();
125        assert_eq!(f.name, "email");
126        assert_eq!(f.ty, FieldType::String);
127        assert_eq!(f.ty.rust_type(), "String");
128        assert_eq!(f.ty.sql_type(), "TEXT");
129    }
130
131    #[test]
132    fn rejects_unknown_type() {
133        assert!(Field::parse("x:Blob").is_err());
134    }
135
136    #[test]
137    fn rejects_malformed_token() {
138        assert!(Field::parse("notype").is_err());
139    }
140
141    #[test]
142    fn parses_a_model() {
143        let m = ModelSpec::parse(
144            "User",
145            &["name:String".to_string(), "active:bool".to_string()],
146        )
147        .unwrap();
148        assert_eq!(m.name, "User");
149        assert_eq!(m.fields.len(), 2);
150        assert_eq!(m.fields[1].ty, FieldType::Bool);
151    }
152}