entity_graph/commands/
init.rs

1//! `entity-graph init` — writes a starter `schema.json` into the current directory
2//! (or a path supplied via `--out`).
3
4use std::path::PathBuf;
5
6use anyhow::{bail, Context, Result};
7use serde_json::json;
8
9/// Arguments for the `init` subcommand (parsed by `clap` in main).
10#[derive(Debug, clap::Args)]
11pub struct InitArgs {
12    /// Directory to write `schema.json` into (defaults to `.`).
13    #[arg(short, long, default_value = ".")]
14    pub out: PathBuf,
15
16    /// Overwrite an existing `schema.json` without prompting.
17    #[arg(long, default_value_t = false)]
18    pub force: bool,
19}
20
21/// Execute the `init` subcommand.
22pub fn run(args: &InitArgs) -> Result<()> {
23    let dir = &args.out;
24    if !dir.exists() {
25        std::fs::create_dir_all(dir)
26            .with_context(|| format!("cannot create directory {}", dir.display()))?;
27    }
28
29    let dest = dir.join("schema.json");
30    if dest.exists() && !args.force {
31        bail!(
32            "{} already exists — pass --force to overwrite",
33            dest.display()
34        );
35    }
36
37    let schema = starter_schema();
38    let json = serde_json::to_string_pretty(&schema)
39        .context("failed to serialise starter schema")?;
40
41    std::fs::write(&dest, json)
42        .with_context(|| format!("cannot write {}", dest.display()))?;
43
44    println!("Created {}", dest.display());
45    println!();
46    println!("Next steps:");
47    println!("  1. Edit schema.json to define your entities.");
48    println!("  2. Run `entity-graph generate --target react --schema schema.json --out src/db`");
49
50    Ok(())
51}
52
53/// The content of the starter schema written by `init`.
54fn starter_schema() -> serde_json::Value {
55    json!({
56        "version": "1.0",
57        "entities": {
58            "user": {
59                "fields": {
60                    "id": { "type": "uuid", "primary": true, "required": true },
61                    "email": { "type": "string", "required": true, "unique": true },
62                    "name": { "type": "string", "required": true },
63                    "role": {
64                        "type": "enum",
65                        "required": false,
66                        "values": ["admin", "member", "viewer"],
67                        "default": "member"
68                    },
69                    "createdAt": { "type": "datetime", "required": false, "auto": true }
70                },
71                "relations": {
72                    "posts": { "type": "hasMany", "target": "post", "foreignKey": "userId" }
73                }
74            },
75            "post": {
76                "fields": {
77                    "id": { "type": "uuid", "primary": true, "required": true },
78                    "title": { "type": "string", "required": true },
79                    "body": { "type": "string", "required": false },
80                    "published": { "type": "boolean", "required": false, "default": false },
81                    "userId": { "type": "uuid", "required": true },
82                    "createdAt": { "type": "datetime", "required": false, "auto": true }
83                },
84                "relations": {
85                    "author": { "type": "belongsTo", "target": "user", "foreignKey": "userId" }
86                }
87            }
88        },
89        "config": {
90            "localFirst": { "engine": "pglite", "sync": "electric" },
91            "ai": { "mcp": true, "a2a": false }
92        }
93    })
94}
95
96// ── Tests ─────────────────────────────────────────────────────────────────────
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use tempfile::tempdir;
102
103    #[test]
104    fn creates_schema_json_in_temp_dir() {
105        let tmp = tempdir().unwrap();
106        let args = InitArgs { out: tmp.path().to_path_buf(), force: false };
107        run(&args).unwrap();
108
109        let dest = tmp.path().join("schema.json");
110        assert!(dest.exists(), "schema.json should have been created");
111
112        let content = std::fs::read_to_string(&dest).unwrap();
113        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
114        assert_eq!(parsed["version"], "1.0");
115        assert!(parsed["entities"]["user"].is_object());
116        assert!(parsed["entities"]["post"].is_object());
117    }
118
119    #[test]
120    fn refuses_to_overwrite_without_force() {
121        let tmp = tempdir().unwrap();
122        let args = InitArgs { out: tmp.path().to_path_buf(), force: false };
123        run(&args).unwrap();
124
125        // Second call without --force should fail.
126        let result = run(&args);
127        assert!(result.is_err(), "should have refused to overwrite");
128        let msg = result.unwrap_err().to_string();
129        assert!(msg.contains("already exists"), "error should mention already-exists: {msg}");
130    }
131
132    #[test]
133    fn force_flag_allows_overwrite() {
134        let tmp = tempdir().unwrap();
135        let first = InitArgs { out: tmp.path().to_path_buf(), force: false };
136        run(&first).unwrap();
137
138        let second = InitArgs { out: tmp.path().to_path_buf(), force: true };
139        run(&second).unwrap(); // should succeed
140    }
141
142    #[test]
143    fn creates_output_dir_if_absent() {
144        let tmp = tempdir().unwrap();
145        let nested = tmp.path().join("a/b/c");
146        let args = InitArgs { out: nested.clone(), force: false };
147        run(&args).unwrap();
148        assert!(nested.join("schema.json").exists());
149    }
150
151    #[test]
152    fn starter_schema_is_valid_json() {
153        let v = starter_schema();
154        // Round-trip through string serialisation.
155        let s = serde_json::to_string(&v).unwrap();
156        let _back: serde_json::Value = serde_json::from_str(&s).unwrap();
157    }
158}