Skip to main content

forge_codegen/
schema_json.rs

1//! Schema JSON emitter.
2//!
3//! Serializes `SchemaRegistry` to `forge.schema.json`, the public contract
4//! between Phase 1 (parser) and Phase 2 (per-language emitters). Community
5//! generators consume this file without depending on Forge's Rust crates.
6
7use forge_core::schema::{EnumDef, FieldDef, FunctionDef, RustType, SchemaRegistry, TableDef};
8use serde_json::{Value, json};
9
10/// Wire protocol version. Bumped on breaking schema format changes.
11const WIRE_VERSION: &str = "2";
12
13/// Forge framework version stamped into the schema.
14const FORGE_VERSION: &str = env!("CARGO_PKG_VERSION");
15
16pub fn emit(registry: &SchemaRegistry) -> Value {
17    json!({
18        "$schema": "https://forge-rs.dev/schema/v2.json",
19        "version": FORGE_VERSION,
20        "wire_version": WIRE_VERSION,
21        "types": emit_types(registry),
22        "functions": emit_functions(registry),
23    })
24}
25
26/// Serialize to a pretty-printed JSON string with trailing newline.
27pub fn emit_string(registry: &SchemaRegistry) -> Result<String, serde_json::Error> {
28    let value = emit(registry);
29    let mut output = serde_json::to_string_pretty(&value)?;
30    output.push('\n');
31    Ok(output)
32}
33
34fn emit_types(registry: &SchemaRegistry) -> Value {
35    let mut types = serde_json::Map::new();
36
37    let mut tables = registry.all_tables();
38    tables.sort_by(|a, b| a.struct_name.cmp(&b.struct_name));
39    for table in tables {
40        types.insert(table.struct_name.clone(), emit_table(&table));
41    }
42
43    let mut enums = registry.all_enums();
44    enums.sort_by(|a, b| a.name.cmp(&b.name));
45    for enum_def in enums {
46        types.insert(enum_def.name.clone(), emit_enum(&enum_def));
47    }
48
49    Value::Object(types)
50}
51
52fn emit_table(table: &TableDef) -> Value {
53    let fields: Vec<Value> = table.fields.iter().map(emit_field).collect();
54
55    let mut map = serde_json::Map::new();
56    map.insert(
57        "kind".into(),
58        json!(if table.is_dto { "dto" } else { "model" }),
59    );
60    map.insert("fields".into(), json!(fields));
61    if let Some(doc) = &table.doc {
62        map.insert("doc".into(), json!(doc));
63    }
64
65    Value::Object(map)
66}
67
68fn emit_field(field: &FieldDef) -> Value {
69    let mut map = serde_json::Map::new();
70    map.insert("name".into(), json!(field.name));
71    map.insert("type".into(), emit_rust_type(&field.rust_type));
72    map.insert("nullable".into(), json!(field.nullable));
73    if let Some(doc) = &field.doc {
74        map.insert("doc".into(), json!(doc));
75    }
76
77    Value::Object(map)
78}
79
80fn emit_enum(enum_def: &EnumDef) -> Value {
81    let variants: Vec<Value> = enum_def
82        .variants
83        .iter()
84        .map(|v| {
85            let mut map = serde_json::Map::new();
86            map.insert("name".into(), json!(v.name));
87            map.insert("value".into(), json!(v.sql_value));
88            if let Some(int_val) = v.int_value {
89                map.insert("int_value".into(), json!(int_val));
90            }
91            if let Some(doc) = &v.doc {
92                map.insert("doc".into(), json!(doc));
93            }
94            Value::Object(map)
95        })
96        .collect();
97
98    let mut map = serde_json::Map::new();
99    map.insert("kind".into(), json!("enum"));
100    map.insert("variants".into(), json!(variants));
101    if let Some(doc) = &enum_def.doc {
102        map.insert("doc".into(), json!(doc));
103    }
104
105    Value::Object(map)
106}
107
108fn emit_functions(registry: &SchemaRegistry) -> Value {
109    let mut functions = serde_json::Map::new();
110
111    let mut all_fns = registry.all_functions();
112    all_fns.sort_by(|a, b| a.name.cmp(&b.name));
113
114    for func in all_fns {
115        functions.insert(func.name.clone(), emit_function(&func));
116    }
117
118    Value::Object(functions)
119}
120
121fn emit_function(func: &FunctionDef) -> Value {
122    let args: Vec<Value> = func
123        .args
124        .iter()
125        .map(|arg| {
126            let mut map = serde_json::Map::new();
127            map.insert("name".into(), json!(arg.name));
128            map.insert("type".into(), emit_rust_type(&arg.rust_type));
129            if let Some(doc) = &arg.doc {
130                map.insert("doc".into(), json!(doc));
131            }
132            Value::Object(map)
133        })
134        .collect();
135
136    let mut map = serde_json::Map::new();
137    map.insert("kind".into(), json!(func.kind.as_str()));
138    map.insert("args".into(), json!(args));
139    map.insert("returns".into(), emit_rust_type(&func.return_type));
140    if let Some(doc) = &func.doc {
141        map.insert("doc".into(), json!(doc));
142    }
143
144    Value::Object(map)
145}
146
147fn emit_rust_type(rust_type: &RustType) -> Value {
148    match rust_type {
149        RustType::String => json!("string"),
150        RustType::I32 => json!({"base": "number", "format": "i32"}),
151        RustType::I64 => json!({"base": "number", "format": "i64"}),
152        RustType::F32 => json!({"base": "number", "format": "f32"}),
153        RustType::F64 => json!({"base": "number", "format": "f64"}),
154        RustType::Bool => json!("boolean"),
155        RustType::Uuid => json!({"base": "string", "format": "uuid"}),
156        RustType::Instant => json!({"base": "string", "format": "datetime"}),
157        RustType::LocalDate => json!({"base": "string", "format": "date"}),
158        RustType::LocalTime => json!({"base": "string", "format": "time"}),
159        RustType::Upload => json!("upload"),
160        RustType::Json => json!("any"),
161        RustType::Bytes => json!("bytes"),
162        RustType::Option(inner) => json!({"nullable": emit_rust_type(inner)}),
163        RustType::Vec(inner) => json!({"array": emit_rust_type(inner)}),
164        RustType::Custom(name) => json!({"$ref": name}),
165    }
166}
167
168#[cfg(test)]
169#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
170mod tests {
171    use super::*;
172    use forge_core::schema::{
173        EnumDef, EnumVariant, FieldDef, FunctionArg, FunctionDef, SchemaRegistry, TableDef,
174    };
175
176    #[test]
177    fn empty_registry_produces_valid_schema() {
178        let registry = SchemaRegistry::new();
179        let schema = emit(&registry);
180
181        assert_eq!(schema["wire_version"], "2");
182        assert!(schema["version"].is_string());
183        assert!(schema["types"].is_object());
184        assert!(schema["functions"].is_object());
185    }
186
187    #[test]
188    fn models_and_enums_appear_in_types() {
189        let registry = SchemaRegistry::new();
190
191        let mut table = TableDef::new("users", "User");
192        table.fields.push(FieldDef::new("id", RustType::Uuid));
193        table.fields.push(FieldDef::new("email", RustType::String));
194        registry.register_table(table);
195
196        let mut enum_def = EnumDef::new("Role");
197        enum_def.variants.push(EnumVariant::new("Admin"));
198        enum_def.variants.push(EnumVariant::new("Member"));
199        registry.register_enum(enum_def);
200
201        let schema = emit(&registry);
202
203        assert_eq!(schema["types"]["User"]["kind"], "model");
204        assert_eq!(schema["types"]["User"]["fields"][0]["name"], "id");
205        assert_eq!(schema["types"]["Role"]["kind"], "enum");
206        assert_eq!(schema["types"]["Role"]["variants"][0]["name"], "Admin");
207    }
208
209    #[test]
210    fn functions_appear_with_kind_and_args() {
211        let registry = SchemaRegistry::new();
212
213        let mut func = FunctionDef::query("get_user", RustType::Custom("User".into()));
214        func.args.push(FunctionArg::new("id", RustType::Uuid));
215        func.doc = Some("Fetch a user by ID".into());
216        registry.register_function(func);
217
218        let schema = emit(&registry);
219
220        let get_user = &schema["functions"]["get_user"];
221        assert_eq!(get_user["kind"], "query");
222        assert_eq!(get_user["args"][0]["name"], "id");
223        assert_eq!(get_user["doc"], "Fetch a user by ID");
224    }
225
226    #[test]
227    fn type_mapping_round_trips() {
228        assert_eq!(emit_rust_type(&RustType::String), json!("string"));
229        assert_eq!(emit_rust_type(&RustType::Bool), json!("boolean"));
230        assert_eq!(
231            emit_rust_type(&RustType::I32),
232            json!({"base": "number", "format": "i32"})
233        );
234        assert_eq!(
235            emit_rust_type(&RustType::Uuid),
236            json!({"base": "string", "format": "uuid"})
237        );
238        assert_eq!(
239            emit_rust_type(&RustType::Option(Box::new(RustType::String))),
240            json!({"nullable": "string"})
241        );
242        assert_eq!(
243            emit_rust_type(&RustType::Vec(Box::new(RustType::I32))),
244            json!({"array": {"base": "number", "format": "i32"}})
245        );
246        assert_eq!(
247            emit_rust_type(&RustType::Custom("User".into())),
248            json!({"$ref": "User"})
249        );
250    }
251
252    #[test]
253    fn dto_marked_correctly() {
254        let registry = SchemaRegistry::new();
255
256        let mut dto = TableDef::new("CreateUserArgs", "CreateUserArgs");
257        dto.is_dto = true;
258        dto.fields.push(FieldDef::new("name", RustType::String));
259        registry.register_table(dto);
260
261        let schema = emit(&registry);
262        assert_eq!(schema["types"]["CreateUserArgs"]["kind"], "dto");
263    }
264
265    #[test]
266    fn emit_string_has_trailing_newline() {
267        let registry = SchemaRegistry::new();
268        let output = emit_string(&registry).unwrap();
269        assert!(output.ends_with('\n'));
270        assert!(output.contains("wire_version"));
271    }
272
273    #[test]
274    fn deterministic_output() {
275        let registry = SchemaRegistry::new();
276
277        registry.register_function(FunctionDef::query("z_last", RustType::String));
278        registry.register_function(FunctionDef::query("a_first", RustType::String));
279        registry.register_function(FunctionDef::mutation("m_middle", RustType::I32));
280
281        let mut table_z = TableDef::new("zebras", "Zebra");
282        table_z.fields.push(FieldDef::new("id", RustType::Uuid));
283        registry.register_table(table_z);
284
285        let mut table_a = TableDef::new("apples", "Apple");
286        table_a.fields.push(FieldDef::new("id", RustType::Uuid));
287        registry.register_table(table_a);
288
289        let first = emit_string(&registry).unwrap();
290        let second = emit_string(&registry).unwrap();
291        assert_eq!(first, second, "Schema output must be deterministic");
292    }
293
294    #[test]
295    fn emit_rust_type_covers_every_numeric_and_temporal_variant() {
296        // Missing variants would emit `null` silently and break wire consumers.
297        assert_eq!(
298            emit_rust_type(&RustType::I64),
299            json!({"base": "number", "format": "i64"})
300        );
301        assert_eq!(
302            emit_rust_type(&RustType::F32),
303            json!({"base": "number", "format": "f32"})
304        );
305        assert_eq!(
306            emit_rust_type(&RustType::F64),
307            json!({"base": "number", "format": "f64"})
308        );
309        assert_eq!(
310            emit_rust_type(&RustType::Instant),
311            json!({"base": "string", "format": "datetime"})
312        );
313        assert_eq!(
314            emit_rust_type(&RustType::LocalDate),
315            json!({"base": "string", "format": "date"})
316        );
317        assert_eq!(
318            emit_rust_type(&RustType::LocalTime),
319            json!({"base": "string", "format": "time"})
320        );
321        assert_eq!(emit_rust_type(&RustType::Upload), json!("upload"));
322        assert_eq!(emit_rust_type(&RustType::Json), json!("any"));
323        assert_eq!(emit_rust_type(&RustType::Bytes), json!("bytes"));
324    }
325
326    #[test]
327    fn emit_rust_type_nests_option_and_vec_recursively() {
328        let ty = RustType::Option(Box::new(RustType::Vec(Box::new(RustType::Custom(
329            "Role".into(),
330        )))));
331        assert_eq!(
332            emit_rust_type(&ty),
333            json!({"nullable": {"array": {"$ref": "Role"}}})
334        );
335    }
336
337    #[test]
338    fn field_emits_nullable_flag_for_option_types() {
339        // FieldDef::new sets `nullable: true` whenever the rust_type is Option.
340        let field = FieldDef::new("nickname", RustType::Option(Box::new(RustType::String)));
341        let json = emit_field(&field);
342        assert_eq!(json["name"], "nickname");
343        assert_eq!(json["nullable"], true);
344        assert_eq!(json["type"], json!({"nullable": "string"}));
345    }
346
347    #[test]
348    fn field_emits_doc_only_when_present() {
349        let mut documented = FieldDef::new("email", RustType::String);
350        documented.doc = Some("Primary contact address".into());
351        let with_doc = emit_field(&documented);
352        assert_eq!(with_doc["doc"], "Primary contact address");
353
354        let plain = emit_field(&FieldDef::new("id", RustType::Uuid));
355        assert!(plain.as_object().unwrap().get("doc").is_none());
356    }
357
358    #[test]
359    fn enum_emits_int_value_and_variant_doc_when_set() {
360        let mut enum_def = EnumDef::new("Status");
361        let mut active = EnumVariant::new("Active");
362        active.int_value = Some(1);
363        active.doc = Some("Currently in use".into());
364        enum_def.variants.push(active);
365        enum_def.variants.push(EnumVariant::new("Archived"));
366
367        let json = emit_enum(&enum_def);
368        assert_eq!(json["variants"][0]["int_value"], 1);
369        assert_eq!(json["variants"][0]["doc"], "Currently in use");
370
371        let second = json["variants"][1].as_object().unwrap();
372        assert!(second.get("int_value").is_none());
373        assert!(second.get("doc").is_none());
374    }
375
376    #[test]
377    fn enum_emits_top_level_doc_when_set() {
378        let mut enum_def = EnumDef::new("Role");
379        enum_def.doc = Some("User permission tier".into());
380        enum_def.variants.push(EnumVariant::new("Admin"));
381
382        let json = emit_enum(&enum_def);
383        assert_eq!(json["doc"], "User permission tier");
384    }
385
386    #[test]
387    fn table_emits_top_level_doc_when_set() {
388        let mut table = TableDef::new("users", "User");
389        table.doc = Some("Account records".into());
390        table.fields.push(FieldDef::new("id", RustType::Uuid));
391
392        let json = emit_table(&table);
393        assert_eq!(json["doc"], "Account records");
394        assert_eq!(json["kind"], "model");
395    }
396
397    #[test]
398    fn function_with_no_args_emits_empty_array() {
399        let func = FunctionDef::query("ping", RustType::Bool);
400        let json = emit_function(&func);
401        assert_eq!(json["kind"], "query");
402        assert_eq!(json["args"], json!([]));
403        assert_eq!(json["returns"], json!("boolean"));
404    }
405
406    #[test]
407    fn types_and_functions_sort_alphabetically_in_string_output() {
408        let registry = SchemaRegistry::new();
409        registry.register_function(FunctionDef::query("z_last", RustType::String));
410        registry.register_function(FunctionDef::query("a_first", RustType::String));
411
412        let mut zebra = TableDef::new("zebras", "Zebra");
413        zebra.fields.push(FieldDef::new("id", RustType::Uuid));
414        registry.register_table(zebra);
415
416        let mut apple = TableDef::new("apples", "Apple");
417        apple.fields.push(FieldDef::new("id", RustType::Uuid));
418        registry.register_table(apple);
419
420        let output = emit_string(&registry).unwrap();
421
422        let a_pos = output.find("\"a_first\"").expect("a_first present");
423        let z_pos = output.find("\"z_last\"").expect("z_last present");
424        assert!(
425            a_pos < z_pos,
426            "functions must be sorted: a_first at {a_pos}, z_last at {z_pos}"
427        );
428
429        let apple_pos = output.find("\"Apple\"").expect("Apple present");
430        let zebra_pos = output.find("\"Zebra\"").expect("Zebra present");
431        assert!(
432            apple_pos < zebra_pos,
433            "types must be sorted: Apple at {apple_pos}, Zebra at {zebra_pos}"
434        );
435    }
436}