entity_graph/commands/
generate.rs

1//! `entity-graph generate` — reads a schema JSON file (SDL IR) and emits
2//! TypeScript entity type definitions + a transport-registration stub.
3
4use std::path::PathBuf;
5
6use anyhow::{Context, Result};
7use convert_case::{Case, Casing};
8use serde::Serialize;
9use tera::{Context as TeraCtx, Tera};
10
11use crate::{
12    ir::{EntityGraphIR, IrEntity, RelationKind},
13    sdl_parser,
14    templates,
15};
16
17// ── CLI args ──────────────────────────────────────────────────────────────────
18
19/// Code-generation targets.
20#[derive(Debug, Clone, clap::ValueEnum)]
21pub enum Target {
22    /// Emit TypeScript types + a `registerEntityTransport` stub.
23    React,
24}
25
26/// Arguments for the `generate` subcommand (parsed by `clap` in main).
27#[derive(Debug, clap::Args)]
28pub struct GenerateArgs {
29    /// Code-generation target.
30    #[arg(short, long, value_enum, default_value = "react")]
31    pub target: Target,
32
33    /// Path to the SDL JSON schema (produced by `init` or `parseSdlJson`).
34    #[arg(short, long, default_value = "schema.json")]
35    pub schema: PathBuf,
36
37    /// Output directory for generated files.
38    #[arg(short, long, default_value = "generated")]
39    pub out: PathBuf,
40}
41
42// ── Template context types ─────────────────────────────────────────────────────
43
44/// Per-field data passed into Tera templates.
45#[derive(Debug, Serialize)]
46struct FieldCtx {
47    name: String,
48    ts_type: String,
49    optional: bool,
50    primary: bool,
51    auto: bool,
52}
53
54/// Per-relation data passed into Tera templates.
55#[derive(Debug, Serialize)]
56struct RelationCtx {
57    name: String,
58    kind: String,
59    target_pascal: String,
60    foreign_key: Option<String>,
61}
62
63/// Per-entity data passed into Tera templates.
64#[derive(Debug, Serialize)]
65struct EntityCtx {
66    pascal_name: String,
67    snake_name: String,
68    table: String,
69    primary_key: String,
70    fields: Vec<FieldCtx>,
71    relations: Vec<RelationCtx>,
72    has_auto_fields: bool,
73    /// Quoted comma-separated names of auto fields for Omit<>.
74    auto_field_names: String,
75}
76
77// ── Helpers ───────────────────────────────────────────────────────────────────
78
79fn build_entity_ctx(entity: &IrEntity) -> EntityCtx {
80    let fields: Vec<FieldCtx> = entity
81        .fields
82        .iter()
83        .map(|f| FieldCtx {
84            name: f.name.clone(),
85            ts_type: f.ts_type_string(),
86            optional: f.is_optional(),
87            primary: f.primary,
88            auto: f.auto,
89        })
90        .collect();
91
92    let auto_fields: Vec<&str> = entity
93        .fields
94        .iter()
95        .filter(|f| f.auto)
96        .map(|f| f.name.as_str())
97        .collect();
98    let has_auto_fields = !auto_fields.is_empty();
99    let auto_field_names = auto_fields
100        .iter()
101        .map(|n| format!("'{}'", n))
102        .collect::<Vec<_>>()
103        .join(" | ");
104
105    let relations: Vec<RelationCtx> = entity
106        .relations
107        .iter()
108        .map(|r| RelationCtx {
109            name: r.name.clone(),
110            kind: match r.kind {
111                RelationKind::BelongsTo => "belongsTo",
112                RelationKind::HasMany => "hasMany",
113                RelationKind::ManyToMany => "manyToMany",
114            }
115            .to_owned(),
116            target_pascal: r.target.to_case(Case::Pascal),
117            foreign_key: r.foreign_key.clone(),
118        })
119        .collect();
120
121    EntityCtx {
122        pascal_name: entity.name.to_case(Case::Pascal),
123        snake_name: entity.name.to_case(Case::Snake),
124        table: entity.table.clone(),
125        primary_key: entity.primary_key.clone(),
126        fields,
127        relations,
128        has_auto_fields,
129        auto_field_names,
130    }
131}
132
133/// Build the shared Tera context for all templates.
134fn build_tera_ctx(ir: &EntityGraphIR, schema_path: &str) -> TeraCtx {
135    let entities: Vec<EntityCtx> = ir.entities.iter().map(build_entity_ctx).collect();
136    let mut ctx = TeraCtx::new();
137    ctx.insert("entities", &entities);
138    ctx.insert("schema_path", schema_path);
139    ctx.insert("cli_version", env!("CARGO_PKG_VERSION"));
140    ctx
141}
142
143/// Render a Tera template string and return the output.
144fn render(template_name: &str, template_src: &str, ctx: &TeraCtx) -> Result<String> {
145    let mut tera = Tera::default();
146    tera.add_raw_template(template_name, template_src)
147        .with_context(|| format!("failed to parse template '{template_name}'"))?;
148    tera.render(template_name, ctx)
149        .with_context(|| format!("failed to render template '{template_name}'"))
150}
151
152// ── Entry point ───────────────────────────────────────────────────────────────
153
154/// Execute the `generate` subcommand.
155pub fn run(args: &GenerateArgs) -> Result<()> {
156    // 1. Load + parse the schema.
157    // Accept both the human-editable SDL source format (written by `init`) and
158    // the EntityGraphIR array format produced by `parseSdlJson()` in the TS SDL
159    // package. We try the IR format first; on failure fall back to SDL source.
160    let schema_str = std::fs::read_to_string(&args.schema)
161        .with_context(|| format!("cannot read schema from {}", args.schema.display()))?;
162
163    let ir: EntityGraphIR = {
164        // Attempt IR format first (entities is an array).
165        let ir_attempt: Result<EntityGraphIR> = serde_json::from_str(&schema_str)
166            .with_context(|| format!("IR parse attempt for {}", args.schema.display()));
167        match ir_attempt {
168            Ok(ir) if !ir.entities.is_empty() || ir.version == "1.0" => ir,
169            _ => {
170                // Fall back to SDL source format (entities is an object keyed by name).
171                sdl_parser::parse_sdl_source(&schema_str)
172                    .with_context(|| format!("invalid SDL JSON in {}", args.schema.display()))?
173            }
174        }
175    };
176
177    let schema_path = args.schema.to_string_lossy().into_owned();
178
179    // 2. Prepare output directory.
180    if !args.out.exists() {
181        std::fs::create_dir_all(&args.out)
182            .with_context(|| format!("cannot create output dir {}", args.out.display()))?;
183    }
184
185    let ctx = build_tera_ctx(&ir, &schema_path);
186
187    match args.target {
188        Target::React => generate_react(&args.out, &ctx)?,
189    }
190
191    Ok(())
192}
193
194fn generate_react(out: &std::path::Path, ctx: &TeraCtx) -> Result<()> {
195    // entity-types.ts
196    let types_src = render("entity-types.ts.tera", templates::ENTITY_TYPES_TS, ctx)?;
197    let types_path = out.join("entity-types.ts");
198    std::fs::write(&types_path, &types_src)
199        .with_context(|| format!("cannot write {}", types_path.display()))?;
200    println!("Wrote {}", types_path.display());
201
202    // register-transports.ts
203    let reg_src = render(
204        "register-transports.ts.tera",
205        templates::REGISTER_TRANSPORTS_TS,
206        ctx,
207    )?;
208    let reg_path = out.join("register-transports.ts");
209    std::fs::write(&reg_path, &reg_src)
210        .with_context(|| format!("cannot write {}", reg_path.display()))?;
211    println!("Wrote {}", reg_path.display());
212
213    // index.ts
214    let index_src = render("index.ts.tera", templates::INDEX_TS, ctx)?;
215    let index_path = out.join("index.ts");
216    std::fs::write(&index_path, &index_src)
217        .with_context(|| format!("cannot write {}", index_path.display()))?;
218    println!("Wrote {}", index_path.display());
219
220    println!();
221    println!("Done. {} entity/entities generated.", {
222        // entity count from context — re-parse to avoid extra dep
223        let dummy: serde_json::Value =
224            serde_json::to_value(ctx.get("entities").unwrap_or(&tera::Value::Null))
225                .unwrap_or_default();
226        dummy.as_array().map(|a| a.len()).unwrap_or(0)
227    });
228
229    Ok(())
230}
231
232// ── Tests ─────────────────────────────────────────────────────────────────────
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use crate::ir::{IrField, IrRelation, ScalarType};
238    use tempfile::tempdir;
239
240    fn sample_ir() -> EntityGraphIR {
241        EntityGraphIR {
242            version: "1.0".into(),
243            entities: vec![
244                crate::ir::IrEntity {
245                    name: "user".into(),
246                    table: "user".into(),
247                    primary_key: "id".into(),
248                    fields: vec![
249                        IrField {
250                            name: "id".into(),
251                            field_type: ScalarType::Uuid,
252                            primary: true,
253                            required: true,
254                            unique: true,
255                            default: None,
256                            values: None,
257                            auto: false,
258                        },
259                        IrField {
260                            name: "email".into(),
261                            field_type: ScalarType::String,
262                            primary: false,
263                            required: true,
264                            unique: true,
265                            default: None,
266                            values: None,
267                            auto: false,
268                        },
269                        IrField {
270                            name: "createdAt".into(),
271                            field_type: ScalarType::Datetime,
272                            primary: false,
273                            required: false,
274                            unique: false,
275                            default: None,
276                            values: None,
277                            auto: true,
278                        },
279                    ],
280                    relations: vec![IrRelation {
281                        name: "posts".into(),
282                        kind: RelationKind::HasMany,
283                        target: "post".into(),
284                        foreign_key: Some("userId".into()),
285                        through: None,
286                    }],
287                },
288            ],
289            config: Default::default(),
290        }
291    }
292
293    #[test]
294    fn build_entity_ctx_pascal_name() {
295        let ir = sample_ir();
296        let ctx = build_entity_ctx(&ir.entities[0]);
297        assert_eq!(ctx.pascal_name, "User");
298    }
299
300    #[test]
301    fn build_entity_ctx_auto_fields() {
302        let ir = sample_ir();
303        let ctx = build_entity_ctx(&ir.entities[0]);
304        assert!(ctx.has_auto_fields);
305        assert_eq!(ctx.auto_field_names, "'createdAt'");
306    }
307
308    #[test]
309    fn render_entity_types_contains_interface() {
310        let ir = sample_ir();
311        let ctx = build_tera_ctx(&ir, "schema.json");
312        let out = render("t.tera", templates::ENTITY_TYPES_TS, &ctx).unwrap();
313        assert!(out.contains("export interface User"), "expected interface: {out}");
314        assert!(out.contains("id: string"), "expected id field: {out}");
315        assert!(out.contains("email: string"), "expected email field: {out}");
316        // createdAt is optional (auto), and creates Omit
317        assert!(out.contains("CreateUserInput"), "expected CreateUserInput: {out}");
318    }
319
320    #[test]
321    fn render_register_transports_contains_register_call() {
322        let ir = sample_ir();
323        let ctx = build_tera_ctx(&ir, "schema.json");
324        let out = render("t.tera", templates::REGISTER_TRANSPORTS_TS, &ctx).unwrap();
325        assert!(
326            out.contains("registerEntityTransport"),
327            "expected register call: {out}"
328        );
329        assert!(out.contains("User"), "expected User type: {out}");
330    }
331
332    #[test]
333    fn generate_writes_files_to_outdir() {
334        let tmp = tempdir().unwrap();
335        let schema_path = tmp.path().join("schema.json");
336
337        // Write the sample IR as a JSON file.
338        let ir = sample_ir();
339        std::fs::write(&schema_path, serde_json::to_string(&ir).unwrap()).unwrap();
340
341        let out_dir = tmp.path().join("generated");
342        let args = GenerateArgs {
343            target: Target::React,
344            schema: schema_path.clone(),
345            out: out_dir.clone(),
346        };
347        run(&args).unwrap();
348
349        assert!(out_dir.join("entity-types.ts").exists(), "entity-types.ts");
350        assert!(
351            out_dir.join("register-transports.ts").exists(),
352            "register-transports.ts"
353        );
354        assert!(out_dir.join("index.ts").exists(), "index.ts");
355
356        let types = std::fs::read_to_string(out_dir.join("entity-types.ts")).unwrap();
357        assert!(types.contains("export interface User"));
358    }
359
360    #[test]
361    fn generate_errors_on_missing_schema() {
362        let tmp = tempdir().unwrap();
363        let args = GenerateArgs {
364            target: Target::React,
365            schema: tmp.path().join("missing.json"),
366            out: tmp.path().join("out"),
367        };
368        assert!(run(&args).is_err());
369    }
370}